完善不同加密方法的使用
This commit is contained in:
+639
-25
@@ -1,41 +1,655 @@
|
||||
// Package gpgx 提供基于 OpenPGP(RFC 4880 / RFC 9580)的密钥环读取、
|
||||
// 加解密、签名验签与密钥生成能力。
|
||||
//
|
||||
// 实现基于 github.com/ProtonMail/go-crypto/openpgp —— 官方推荐的
|
||||
// golang.org/x/crypto/openpgp 替代品(后者已被 govulncheck 判定为
|
||||
// GO-2026-5932:unmaintained, unsafe by design, and has known security
|
||||
// issues,且没有修复版本)。
|
||||
//
|
||||
// 设计约定:
|
||||
// - 所有导出函数都不 panic,全部通过 error 返回失败(用 %w 包装以便
|
||||
// errors.Is / errors.As 判断)。
|
||||
// - 所有 io.WriteCloser 都会在写出后 Close,并检查 Close 返回的错误
|
||||
// (加密流的结尾数据在 Close 时才真正写出,漏检会导致密文不完整)。
|
||||
// - 需要私钥的操作(解密、签名)在传入 passphrase 时操作密钥环的副本,
|
||||
// 不会修改调用方传入的 openpgp.EntityList。
|
||||
// - 本包不向 stdout/stderr 输出任何内容。
|
||||
package gpgx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
)
|
||||
|
||||
func Demo() {
|
||||
keyRingReader, err := os.Open("signer-pubkey.asc")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// 密钥长度约束。OpenPGP 允许更短的 RSA 密钥,但本包拒绝生成弱密钥。
|
||||
const (
|
||||
// MinRSABits 是 GenerateKeyPair 接受的 RSA 最小位数。
|
||||
MinRSABits = 2048
|
||||
// DefaultRSABits 是 GenerateKeyPair 的 RSA 默认位数。
|
||||
DefaultRSABits = 2048
|
||||
)
|
||||
|
||||
signature, err := os.Open("signature.asc")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// armorHeader 用于快速判断输入是否为 ASCII armor 文本。
|
||||
const armorHeader = "-----BEGIN PGP"
|
||||
|
||||
verification_target, err := os.Open("MysqL-5.7.9-win32.zip")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// ---------------------------------------------------------------- 密钥环读取
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader)
|
||||
// ReadKeyRing 从 r 读取 OpenPGP 密钥环,自动识别 armored 与二进制格式。
|
||||
//
|
||||
// 返回的 EntityList 同时包含公钥与(可能已加密的)私钥材料。
|
||||
func ReadKeyRing(r io.Reader) (openpgp.EntityList, error) {
|
||||
raw, err := readAll(r)
|
||||
if err != nil {
|
||||
fmt.Println("Read Armored Key Ring: " + err.Error())
|
||||
return
|
||||
return nil, fmt.Errorf("gpgx: 读取密钥环数据失败: %w", err)
|
||||
}
|
||||
entity, err := openpgp.CheckArmoredDetachedSignature(keyring, verification_target, signature)
|
||||
if err != nil {
|
||||
fmt.Println("Check Detached Signature: " + err.Error())
|
||||
return
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环数据为空")
|
||||
}
|
||||
return parseKeyRing(raw)
|
||||
}
|
||||
|
||||
fmt.Println(entity)
|
||||
// ReadArmoredKeyRing 从 r 读取 ASCII armor 编码的 OpenPGP 密钥环。
|
||||
//
|
||||
// 若输入不是合法的 armor 文本(例如二进制密钥环或垃圾数据),返回 error。
|
||||
func ReadArmoredKeyRing(r io.Reader) (openpgp.EntityList, error) {
|
||||
raw, err := readAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 读取 armored 密钥环数据失败: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("gpgx: armored 密钥环数据为空")
|
||||
}
|
||||
if !isArmored(raw) {
|
||||
return nil, errors.New("gpgx: 输入不是 ASCII armor 文本(缺少 \"-----BEGIN PGP\" 头)")
|
||||
}
|
||||
|
||||
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析 armored 密钥环失败: %w", err)
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return nil, errors.New("gpgx: armored 密钥环中不包含任何实体")
|
||||
}
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
// ReadKeyRingFile 从文件读取 OpenPGP 密钥环,自动识别 armored 与二进制格式。
|
||||
//
|
||||
// 内部使用 os.ReadFile,不会泄漏文件句柄。
|
||||
func ReadKeyRingFile(path string) (openpgp.EntityList, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 读取密钥环文件 %q 失败: %w", path, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("gpgx: 密钥环文件 %q 为空", path)
|
||||
}
|
||||
entities, err := parseKeyRing(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析密钥环文件 %q 失败: %w", path, err)
|
||||
}
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
// parseKeyRing 按内容自动选择 armored 或二进制解析。
|
||||
func parseKeyRing(raw []byte) (openpgp.EntityList, error) {
|
||||
var (
|
||||
entities openpgp.EntityList
|
||||
err error
|
||||
)
|
||||
if isArmored(raw) {
|
||||
entities, err = openpgp.ReadArmoredKeyRing(bytes.NewReader(raw))
|
||||
} else {
|
||||
entities, err = openpgp.ReadKeyRing(bytes.NewReader(raw))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析密钥环失败: %w", err)
|
||||
}
|
||||
if len(entities) == 0 {
|
||||
return nil, errors.New("密钥环中不包含任何实体")
|
||||
}
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
// isArmored 判断数据是否为 PGP ASCII armor(允许前置空白)。
|
||||
func isArmored(data []byte) bool {
|
||||
return bytes.HasPrefix(bytes.TrimLeft(data, " \t\r\n"), []byte(armorHeader))
|
||||
}
|
||||
|
||||
// maxUnwrappedArmorSize 限制单个 armor 块解出的原始报文大小,避免畸形输入
|
||||
// 造成无界内存占用。
|
||||
const maxUnwrappedArmorSize = 64 << 20 // 64 MiB
|
||||
|
||||
// unwrapArmor 在输入为 ASCII armor 时返回其内部的原始报文字节;否则原样返回。
|
||||
//
|
||||
// 该函数不校验 armor 块的 Type 字段:调用方(ReadMessage /
|
||||
// CheckDetachedSignature)会对解出的报文本身做严格校验,而 armor 的 CRC
|
||||
// 校验由 crypto/openpgp 的 packet 层与完整性保护(MDC/AEAD)负责。
|
||||
func unwrapArmor(data []byte) ([]byte, error) {
|
||||
if !isArmored(data) {
|
||||
return data, nil
|
||||
}
|
||||
block, err := armor.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if block == nil {
|
||||
return nil, errors.New("armor 块为空")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(block.Body, maxUnwrappedArmorSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxUnwrappedArmorSize {
|
||||
return nil, fmt.Errorf("armor 块解出的报文超过 %d 字节上限", maxUnwrappedArmorSize)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("armor 块不包含任何数据")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 加密解密
|
||||
|
||||
// Encrypt 使用 recipients 中的公钥加密 plaintext,返回二进制 OpenPGP 报文。
|
||||
func Encrypt(recipients openpgp.EntityList, plaintext []byte) ([]byte, error) {
|
||||
if err := validateRecipients(recipients); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
plaintextWriter, err := openpgp.Encrypt(&buf, recipients, nil, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 初始化加密流失败: %w", err)
|
||||
}
|
||||
if err := writeAndClose(plaintextWriter, plaintext); err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 加密失败: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// EncryptArmored 使用 recipients 中的公钥加密 plaintext,返回 ASCII armor 报文。
|
||||
func EncryptArmored(recipients openpgp.EntityList, plaintext []byte) ([]byte, error) {
|
||||
binaryMessage, err := Encrypt(recipients, plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
armorWriter, err := armor.Encode(&buf, openpgp.MessageType, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 初始化 armor 编码器失败: %w", err)
|
||||
}
|
||||
if _, err := armorWriter.Write(binaryMessage); err != nil {
|
||||
_ = armorWriter.Close()
|
||||
return nil, fmt.Errorf("gpgx: 写出 armor 数据失败: %w", err)
|
||||
}
|
||||
if err := armorWriter.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 关闭 armor 编码器失败: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// EncryptWithPassphrase 使用口令对称加密 plaintext(等价于 gpg -c),
|
||||
// 返回二进制 OpenPGP 报文;解密端使用 DecryptWithPassphrase。
|
||||
//
|
||||
// 对称加密同样受完整性保护(MDC/AEAD),不需要密钥环。
|
||||
func EncryptWithPassphrase(passphrase, plaintext []byte) ([]byte, error) {
|
||||
if len(passphrase) == 0 {
|
||||
return nil, errors.New("gpgx: passphrase 为空,无法进行对称加密")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
plaintextWriter, err := openpgp.SymmetricallyEncrypt(&buf, passphrase, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 初始化对称加密流失败: %w", err)
|
||||
}
|
||||
if err := writeAndClose(plaintextWriter, plaintext); err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 对称加密失败: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Decrypt 使用 keyring 解密 ciphertext,自动识别 armored 与二进制格式。
|
||||
//
|
||||
// 若会话密钥被公钥加密,keyring 中必须包含对应私钥;私钥本身可以是
|
||||
// 未加密的。已加密的私钥请使用 DecryptWithPassphrase。
|
||||
func Decrypt(keyring openpgp.EntityList, ciphertext []byte) ([]byte, error) {
|
||||
return decryptWith(keyring, ciphertext, nil, nil)
|
||||
}
|
||||
|
||||
// DecryptWithPassphrase 使用 keyring 与 passphrase 解密 ciphertext。
|
||||
//
|
||||
// passphrase 用于解开已加密的私钥;也可以直接解密「口令加密」(对称加密,
|
||||
// gpg -c)的报文。若私钥未加密,传入非空 passphrase 同样可以正常工作。
|
||||
func DecryptWithPassphrase(keyring openpgp.EntityList, ciphertext, passphrase []byte) ([]byte, error) {
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法解密")
|
||||
}
|
||||
|
||||
// 在副本上解密私钥,避免修改调用方传入的密钥环。
|
||||
copied := cloneKeyRing(keyring)
|
||||
if err := decryptedPrivateKeys(copied, passphrase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var prompt openpgp.PromptFunction
|
||||
if len(passphrase) > 0 {
|
||||
// copied 中的私钥已在上一步解开;此回调用于覆盖「口令加密」报文
|
||||
// 以及上一步未能解开的私钥,同时避免库无限重试同一口令。
|
||||
prompt = oneShotPassphrasePrompt(passphrase)
|
||||
}
|
||||
return decryptWith(copied, ciphertext, prompt, nil)
|
||||
}
|
||||
|
||||
// decryptWith 是解密路径的公共实现。keyring 由调用方保证不会被共享。
|
||||
func decryptWith(keyring openpgp.EntityList, ciphertext []byte, prompt openpgp.PromptFunction, config *packet.Config) ([]byte, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return nil, errors.New("gpgx: 密文为空")
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法解密")
|
||||
}
|
||||
|
||||
// openpgp.ReadMessage 只接受二进制报文(packet.Reader 不会解析 armor),
|
||||
// 因此 armored 输入需要先取出 armor 内部的原始字节。
|
||||
binaryMessage, err := unwrapArmor(ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析加密报文失败: %w", err)
|
||||
}
|
||||
|
||||
md, err := openpgp.ReadMessage(bytes.NewReader(binaryMessage), keyring, prompt, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析加密报文失败: %w", err)
|
||||
}
|
||||
if md.UnverifiedBody == nil {
|
||||
return nil, errors.New("gpgx: 加密报文不包含可读数据(可能不是加密报文)")
|
||||
}
|
||||
|
||||
// 必须读到 EOF:完整性保护(MDC/AEAD)在 EOF 时才校验,提前返回会
|
||||
// 跳过校验。
|
||||
plaintext, err := io.ReadAll(md.UnverifiedBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 读取或校验明文失败: %w", err)
|
||||
}
|
||||
if md.IsSigned {
|
||||
if md.SignatureError != nil {
|
||||
return nil, fmt.Errorf("gpgx: 报文的签名校验失败: %w", md.SignatureError)
|
||||
}
|
||||
if md.Signature == nil {
|
||||
return nil, errors.New("gpgx: 报文声称已签名,但未找到可验证的签名")
|
||||
}
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 签名与验签
|
||||
|
||||
// Sign 使用 keyring 中的私钥对 plaintext 生成内联签名报文
|
||||
// (明文内嵌于 PGP 报文内,可直接被 Verify 还原)。
|
||||
//
|
||||
// 若私钥已加密,必须提供正确的 passphrase;私钥未加密时可传 nil。
|
||||
func Sign(keyring openpgp.EntityList, plaintext, passphrase []byte) ([]byte, error) {
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法签名")
|
||||
}
|
||||
|
||||
copied := cloneKeyRing(keyring)
|
||||
if err := decryptedPrivateKeys(copied, passphrase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
plaintextWriter, err := openpgp.Sign(&buf, copied[0], nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 初始化签名流失败: %w", err)
|
||||
}
|
||||
if err := writeAndClose(plaintextWriter, plaintext); err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 签名失败: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Verify 验证内联签名报文并返回其中的明文。
|
||||
//
|
||||
// 返回 error 的情况包括:签名不合法、签名者不在 keyring 中、报文被篡改,
|
||||
// 或报文并非合法的 OpenPGP 签名报文。
|
||||
func Verify(keyring openpgp.EntityList, signedMessage []byte) ([]byte, error) {
|
||||
if len(signedMessage) == 0 {
|
||||
return nil, errors.New("gpgx: 签名报文为空")
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法验签")
|
||||
}
|
||||
|
||||
// openpgp.ReadMessage 只接受二进制报文,armored 输入需要先解出原始字节。
|
||||
binaryMessage, err := unwrapArmor(signedMessage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析签名报文失败: %w", err)
|
||||
}
|
||||
|
||||
md, err := openpgp.ReadMessage(bytes.NewReader(binaryMessage), cloneKeyRing(keyring), nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 解析签名报文失败: %w", err)
|
||||
}
|
||||
if md.UnverifiedBody == nil {
|
||||
return nil, errors.New("gpgx: 签名报文不包含可读数据(可能不是签名报文)")
|
||||
}
|
||||
|
||||
// 签名在读取到 EOF 时才完成校验。
|
||||
plaintext, err := io.ReadAll(md.UnverifiedBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 读取签名报文明文失败: %w", err)
|
||||
}
|
||||
if !md.IsSigned {
|
||||
return nil, errors.New("gpgx: 报文未包含签名")
|
||||
}
|
||||
if md.SignatureError != nil {
|
||||
return nil, fmt.Errorf("gpgx: 签名校验失败: %w", md.SignatureError)
|
||||
}
|
||||
if md.Signature == nil {
|
||||
return nil, errors.New("gpgx: 未找到可验证的签名")
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// SignDetachedArmored 为 plaintext 生成 ASCII armor 编码的分离签名。
|
||||
//
|
||||
// 若私钥已加密,必须提供正确的 passphrase;私钥未加密时可传 nil。
|
||||
func SignDetachedArmored(keyring openpgp.EntityList, plaintext, passphrase []byte) ([]byte, error) {
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法签名")
|
||||
}
|
||||
|
||||
copied := cloneKeyRing(keyring)
|
||||
if err := decryptedPrivateKeys(copied, passphrase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := openpgp.ArmoredDetachSign(&buf, copied[0], bytes.NewReader(plaintext), nil); err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 生成分离签名失败: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// VerifyDetached 校验分离签名(支持 armored 与二进制签名),
|
||||
// 成功时返回签名者实体。
|
||||
//
|
||||
// data 或 signature 任一被篡改、或签名者公钥不在 keyring 中,都会返回 error。
|
||||
func VerifyDetached(keyring openpgp.EntityList, data, signature []byte) (*openpgp.Entity, error) {
|
||||
if len(signature) == 0 {
|
||||
return nil, errors.New("gpgx: 分离签名为空")
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, errors.New("gpgx: 密钥环为空,无法验签")
|
||||
}
|
||||
|
||||
signatureReader := bytes.NewReader(signature)
|
||||
var (
|
||||
signer *openpgp.Entity
|
||||
err error
|
||||
)
|
||||
if isArmored(signature) {
|
||||
signer, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), signatureReader, nil)
|
||||
} else {
|
||||
signer, err = openpgp.CheckDetachedSignature(keyring, bytes.NewReader(data), signatureReader, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpgx: 分离签名校验失败: %w", err)
|
||||
}
|
||||
if signer == nil {
|
||||
return nil, errors.New("gpgx: 分离签名校验未返回签名者")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 密钥生成
|
||||
|
||||
// GenerateKeyPair 生成一对 OpenPGP 密钥,返回 ASCII armor 编码的私钥与公钥。
|
||||
//
|
||||
// bits 的含义:
|
||||
// - bits <= 0:生成 Ed25519 + X25519 密钥(速度快,推荐用于测试与快速上手)。
|
||||
// - bits > 0:生成 RSA 密钥,位数不得小于 MinRSABits(否则返回 error,
|
||||
// 而不是生成弱密钥)。
|
||||
//
|
||||
// passphrase 非空时,私钥会被加密保护;为空则私钥为未加密形式。
|
||||
// name/email/comment 用于构造 User ID,不得包含 "()<>\x00" 等字符。
|
||||
func GenerateKeyPair(name, email, comment string, passphrase []byte, bits int) (privateArmored, publicArmored []byte, err error) {
|
||||
config := &packet.Config{DefaultHash: defaultHashFor(bits)}
|
||||
|
||||
switch {
|
||||
case bits <= 0:
|
||||
config.Algorithm = packet.PubKeyAlgoEd25519
|
||||
case bits < MinRSABits:
|
||||
return nil, nil, fmt.Errorf("gpgx: RSA 位数 %d 过小,至少需要 %d 位", bits, MinRSABits)
|
||||
default:
|
||||
config.Algorithm = packet.PubKeyAlgoRSA
|
||||
config.RSABits = bits
|
||||
config.MinRSABits = uint16(MinRSABits)
|
||||
}
|
||||
|
||||
entity, err := openpgp.NewEntity(name, comment, email, config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gpgx: 生成密钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 注意顺序:Entity.SerializePrivate 会重新签名 User ID 与子密钥绑定,
|
||||
// 而签名需要未加密的私钥(否则签名包内部的私钥类型断言会 panic)。
|
||||
// 因此必须先把私钥写成明文形式,再加密私钥并重新序列化。
|
||||
privateArmored, err = armorEntity(openpgp.PrivateKeyType, func(w io.Writer) error {
|
||||
return entity.SerializePrivate(w, config)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gpgx: 序列化私钥失败: %w", err)
|
||||
}
|
||||
|
||||
if len(passphrase) > 0 {
|
||||
if err := entity.EncryptPrivateKeys(passphrase, config); err != nil {
|
||||
return nil, nil, fmt.Errorf("gpgx: 加密私钥失败: %w", err)
|
||||
}
|
||||
// 私钥已加密,不能再触发重签名,使用不重签名的序列化路径。
|
||||
privateArmored, err = armorEntity(openpgp.PrivateKeyType, func(w io.Writer) error {
|
||||
return entity.SerializePrivateWithoutSigning(w, config)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gpgx: 序列化加密私钥失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
publicArmored, err = armorEntity(openpgp.PublicKeyType, func(w io.Writer) error {
|
||||
return entity.Serialize(w)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gpgx: 序列化公钥失败: %w", err)
|
||||
}
|
||||
|
||||
return privateArmored, publicArmored, nil
|
||||
}
|
||||
|
||||
// defaultHashFor 依据密钥类型选择哈希:Ed25519 通常搭配 SHA-256,RSA 使用
|
||||
// SHA-512 以避免与部分实现的自签名哈希偏好冲突。
|
||||
func defaultHashFor(bits int) crypto.Hash {
|
||||
if bits <= 0 {
|
||||
return crypto.SHA256
|
||||
}
|
||||
return crypto.SHA512
|
||||
}
|
||||
|
||||
// armorEntity 把 serialize 写出的实体内容包装成 ASCII armor 块。
|
||||
func armorEntity(blockType string, serialize func(io.Writer) error) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
armorWriter, err := armor.Encode(&buf, blockType, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := serialize(armorWriter); err != nil {
|
||||
_ = armorWriter.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := armorWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- 工具
|
||||
|
||||
// validateRecipients 校验接收方密钥环至少包含一个可用于加密的公钥。
|
||||
func validateRecipients(recipients openpgp.EntityList) error {
|
||||
if len(recipients) == 0 {
|
||||
return errors.New("gpgx: 接收方密钥环为空,无法加密")
|
||||
}
|
||||
for _, entity := range recipients {
|
||||
if entity == nil || entity.PrimaryKey == nil {
|
||||
return errors.New("gpgx: 接收方密钥环包含空实体")
|
||||
}
|
||||
if _, ok := entity.EncryptionKey(time.Now()); ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("gpgx: 接收方密钥环中没有可用的加密密钥(可能已过期或被吊销)")
|
||||
}
|
||||
|
||||
// writeAndClose 写入数据并关闭 WriteCloser,两处错误都会返回。
|
||||
//
|
||||
// 加密/签名流的末尾数据(MDC、签名包)只有 Close 时才写出,因此 Close
|
||||
// 的错误必须检查,否则会静默产生不完整的报文。
|
||||
func writeAndClose(w io.WriteCloser, data []byte) error {
|
||||
if _, err := w.Write(data); err != nil {
|
||||
// 尽力释放内部资源,但以写入错误为准。
|
||||
_ = w.Close()
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptedPrivateKeys 解开 entities 中所有已加密的私钥副本。
|
||||
//
|
||||
// 若 passphrase 为空而私钥已加密,返回明确错误而不是让上层库反复重试。
|
||||
func decryptedPrivateKeys(entities openpgp.EntityList, passphrase []byte) error {
|
||||
var encrypted int
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
encrypted += countEncryptedPrivateKeys(entity)
|
||||
}
|
||||
if encrypted == 0 {
|
||||
// 私钥未加密:忽略传入的口令。
|
||||
return nil
|
||||
}
|
||||
if len(passphrase) == 0 {
|
||||
return errors.New("gpgx: 私钥已加密,但未提供 passphrase")
|
||||
}
|
||||
for _, entity := range entities {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
if err := entity.DecryptPrivateKeys(passphrase); err != nil {
|
||||
// 不把口令写进错误信息。
|
||||
return fmt.Errorf("gpgx: 使用给定 passphrase 解开私钥失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countEncryptedPrivateKeys 统计实体中已加密的私钥数量。
|
||||
func countEncryptedPrivateKeys(entity *openpgp.Entity) int {
|
||||
var n int
|
||||
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||
n++
|
||||
}
|
||||
for _, subkey := range entity.Subkeys {
|
||||
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// oneShotPassphrasePrompt 返回一个最多成功响应一次的口令回调。
|
||||
//
|
||||
// openpgp.ReadMessage 在口令不正确时会无限重试 prompt,因此这里在第二次
|
||||
// 调用时直接返回错误,避免死循环。
|
||||
func oneShotPassphrasePrompt(passphrase []byte) openpgp.PromptFunction {
|
||||
used := false
|
||||
return func(_ []openpgp.Key, _ bool) ([]byte, error) {
|
||||
if used {
|
||||
return nil, errors.New("gpgx: passphrase 不正确(已重试)")
|
||||
}
|
||||
used = true
|
||||
// 返回副本,避免上层库持有调用方的切片。
|
||||
return append([]byte(nil), passphrase...), nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneKeyRing 返回 keyring 的独立副本,用于需要解开私钥的操作。
|
||||
//
|
||||
// 只有私钥结构会被复制:解密会就地修改 *packet.PrivateKey,复制后调用方
|
||||
// 传入的密钥环(以及其它并发使用者)不受影响。公钥与签名是只读的,直接共享。
|
||||
func cloneKeyRing(keyring openpgp.EntityList) openpgp.EntityList {
|
||||
cloned := make(openpgp.EntityList, 0, len(keyring))
|
||||
for _, entity := range keyring {
|
||||
if entity == nil {
|
||||
continue
|
||||
}
|
||||
cloned = append(cloned, cloneEntity(entity))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// cloneEntity 复制实体中可变的私钥材料。
|
||||
func cloneEntity(entity *openpgp.Entity) *openpgp.Entity {
|
||||
cloned := *entity
|
||||
cloned.PrivateKey = clonePrivateKey(entity.PrivateKey)
|
||||
|
||||
if len(entity.Subkeys) > 0 {
|
||||
subkeys := make([]openpgp.Subkey, len(entity.Subkeys))
|
||||
copy(subkeys, entity.Subkeys)
|
||||
for i := range subkeys {
|
||||
subkeys[i].PrivateKey = clonePrivateKey(subkeys[i].PrivateKey)
|
||||
}
|
||||
cloned.Subkeys = subkeys
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// clonePrivateKey 浅拷贝私钥结构。
|
||||
//
|
||||
// 已加密的私钥清空 PrivateKey 字段,使 *packet.PrivateKey.Decrypt 在副本上
|
||||
// 重新解析出独立的私钥对象;未加密的私钥保留原有私钥对象指针(只读使用)。
|
||||
func clonePrivateKey(key *packet.PrivateKey) *packet.PrivateKey {
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *key
|
||||
if key.Encrypted {
|
||||
cloned.PrivateKey = nil
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// readAll 读取全部数据,并保证不会因为上游返回 (0, nil) 而死循环。
|
||||
func readAll(r io.Reader) ([]byte, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("reader 为 nil")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
package gpgx_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.yun.ink/pkg/encryptx/gpgx"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// 测试密钥统一在内存中生成一次(Ed25519,速度快),不依赖仓库里的任何
|
||||
// 固定密钥文件。生成失败直接 Fatal,保证后续用例不会在 nil 上继续。
|
||||
var (
|
||||
testKeysOnce sync.Once
|
||||
|
||||
alicePrivateArmor []byte
|
||||
alicePublicArmor []byte
|
||||
alicePrivate openpgp.EntityList
|
||||
alicePublic openpgp.EntityList
|
||||
|
||||
bobPrivateArmor []byte
|
||||
bobPublicArmor []byte
|
||||
bobPublic openpgp.EntityList
|
||||
|
||||
// 带口令的 Ed25519 密钥。
|
||||
passphrase = []byte("correct horse battery staple")
|
||||
lockedPrivate []byte
|
||||
lockedPublicArmor []byte
|
||||
|
||||
testKeyErr error
|
||||
)
|
||||
|
||||
func initTestKeys(t *testing.T) {
|
||||
t.Helper()
|
||||
testKeysOnce.Do(func() {
|
||||
alicePrivateArmor, alicePublicArmor, testKeyErr = gpgx.GenerateKeyPair(
|
||||
"Alice Tester", "alice@example.com", "gpgx test", nil, 0)
|
||||
if testKeyErr != nil {
|
||||
return
|
||||
}
|
||||
alicePrivate, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePrivateArmor))
|
||||
if testKeyErr != nil {
|
||||
return
|
||||
}
|
||||
alicePublic, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePublicArmor))
|
||||
if testKeyErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
bobPrivateArmor, bobPublicArmor, testKeyErr = gpgx.GenerateKeyPair(
|
||||
"Bob Tester", "bob@example.com", "gpgx test", nil, 0)
|
||||
if testKeyErr != nil {
|
||||
return
|
||||
}
|
||||
bobPublic, testKeyErr = gpgx.ReadArmoredKeyRing(bytes.NewReader(bobPublicArmor))
|
||||
if testKeyErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
lockedPrivate, lockedPublicArmor, testKeyErr = gpgx.GenerateKeyPair(
|
||||
"Carol Locked", "carol@example.com", "gpgx test", passphrase, 0)
|
||||
})
|
||||
if testKeyErr != nil {
|
||||
t.Fatalf("初始化测试密钥失败: %v", testKeyErr)
|
||||
}
|
||||
}
|
||||
|
||||
// requireKeys 声明用例对已生成测试密钥的依赖。
|
||||
func requireKeys(t *testing.T) {
|
||||
t.Helper()
|
||||
if len(alicePrivate) == 0 || len(alicePublic) == 0 {
|
||||
t.Fatal("Alice 测试密钥不可用")
|
||||
}
|
||||
}
|
||||
|
||||
// mustNotPanic 断言 fn 既不 panic 也返回 error(用于所有错误路径)。
|
||||
func mustNotPanic(t *testing.T, name string, fn func() error) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("%s: 发生 panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
if err := fn(); err == nil {
|
||||
t.Fatalf("%s: 期望返回 error,实际返回 nil", name)
|
||||
}
|
||||
}
|
||||
|
||||
// generatePlaintext 生成确定性的测试明文。
|
||||
func generatePlaintext(size int) []byte {
|
||||
out := make([]byte, size)
|
||||
for i := range out {
|
||||
out[i] = byte((i * 7) % 251)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tamperByte 翻转 data 中第 idx 个字节;若 idx 越界则返回原数据的副本。
|
||||
func tamperByte(data []byte, idx int) []byte {
|
||||
out := append([]byte(nil), data...)
|
||||
if idx >= 0 && idx < len(out) {
|
||||
out[idx] ^= 0x01
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 测试用例
|
||||
|
||||
func TestGenerateKeyPairAndReadKeyRing(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
if !strings.HasPrefix(string(alicePrivateArmor), "-----BEGIN PGP PRIVATE KEY BLOCK-----") {
|
||||
t.Fatalf("私钥 armor 头不正确: %q", firstLine(alicePrivateArmor))
|
||||
}
|
||||
if !strings.HasPrefix(string(alicePublicArmor), "-----BEGIN PGP PUBLIC KEY BLOCK-----") {
|
||||
t.Fatalf("公钥 armor 头不正确: %q", firstLine(alicePublicArmor))
|
||||
}
|
||||
|
||||
// armored 往返(私钥)。
|
||||
private, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePrivateArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(私钥) 失败: %v", err)
|
||||
}
|
||||
if len(private) != 1 {
|
||||
t.Fatalf("期望 1 个实体,实际 %d", len(private))
|
||||
}
|
||||
if private[0].PrivateKey == nil {
|
||||
t.Fatal("私钥密钥环中缺少私钥材料")
|
||||
}
|
||||
if private[0].PrivateKey.Encrypted {
|
||||
t.Fatal("无口令私钥不应处于加密状态")
|
||||
}
|
||||
if private[0].PrimaryIdentity() == nil {
|
||||
t.Fatal("密钥环中的实体缺少 User ID")
|
||||
}
|
||||
|
||||
// armored 往返(公钥):公钥密钥环不应带私钥材料。
|
||||
public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(alicePublicArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(公钥) 失败: %v", err)
|
||||
}
|
||||
if len(public) != 1 {
|
||||
t.Fatalf("期望 1 个实体,实际 %d", len(public))
|
||||
}
|
||||
if public[0].PrivateKey != nil {
|
||||
t.Fatal("公钥密钥环不应包含私钥材料")
|
||||
}
|
||||
if public[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId {
|
||||
t.Fatal("公私钥 KeyId 不一致")
|
||||
}
|
||||
|
||||
// ReadKeyRing 自动识别 binary-armored / armor 两种输入。
|
||||
auto, err := gpgx.ReadKeyRing(bytes.NewReader(alicePrivateArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadKeyRing(armored) 失败: %v", err)
|
||||
}
|
||||
if len(auto) != 1 || auto[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId {
|
||||
t.Fatal("ReadKeyRing 未正确识别 armored 输入")
|
||||
}
|
||||
|
||||
// 二进制形式:armored 私钥去掉 armor 后应能被 ReadKeyRing 解析。
|
||||
binaryKeyRing := decodeArmor(t, alicePrivateArmor)
|
||||
fromBinary, err := gpgx.ReadKeyRing(bytes.NewReader(binaryKeyRing))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadKeyRing(二进制) 失败: %v", err)
|
||||
}
|
||||
if len(fromBinary) != 1 || fromBinary[0].PrimaryKey.KeyId != private[0].PrimaryKey.KeyId {
|
||||
t.Fatal("ReadKeyRing 未正确识别二进制输入")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKeyRingFile(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
armoredPath := filepath.Join(dir, "alice.asc")
|
||||
if err := os.WriteFile(armoredPath, alicePrivateArmor, 0o600); err != nil {
|
||||
t.Fatalf("写入 armored 密钥文件失败: %v", err)
|
||||
}
|
||||
entities, err := gpgx.ReadKeyRingFile(armoredPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadKeyRingFile(armored) 失败: %v", err)
|
||||
}
|
||||
if len(entities) != 1 || entities[0].PrivateKey == nil {
|
||||
t.Fatal("ReadKeyRingFile 未读回私钥材料")
|
||||
}
|
||||
|
||||
binaryPath := filepath.Join(dir, "alice.pgp")
|
||||
if err := os.WriteFile(binaryPath, decodeArmor(t, alicePrivateArmor), 0o600); err != nil {
|
||||
t.Fatalf("写入二进制密钥文件失败: %v", err)
|
||||
}
|
||||
if _, err := gpgx.ReadKeyRingFile(binaryPath); err != nil {
|
||||
t.Fatalf("ReadKeyRingFile(二进制) 失败: %v", err)
|
||||
}
|
||||
|
||||
// 文件不存在。
|
||||
mustNotPanic(t, "ReadKeyRingFile(不存在)", func() error {
|
||||
_, err := gpgx.ReadKeyRingFile(filepath.Join(dir, "missing.asc"))
|
||||
return err
|
||||
})
|
||||
|
||||
// 空文件。
|
||||
emptyPath := filepath.Join(dir, "empty.asc")
|
||||
if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
|
||||
t.Fatalf("写入空文件失败: %v", err)
|
||||
}
|
||||
mustNotPanic(t, "ReadKeyRingFile(空文件)", func() error {
|
||||
_, err := gpgx.ReadKeyRingFile(emptyPath)
|
||||
return err
|
||||
})
|
||||
|
||||
// 垃圾内容。
|
||||
garbagePath := filepath.Join(dir, "garbage.asc")
|
||||
if err := os.WriteFile(garbagePath, []byte("this is not a pgp key\n"), 0o600); err != nil {
|
||||
t.Fatalf("写入垃圾文件失败: %v", err)
|
||||
}
|
||||
mustNotPanic(t, "ReadKeyRingFile(垃圾内容)", func() error {
|
||||
_, err := gpgx.ReadKeyRingFile(garbagePath)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
plaintext []byte
|
||||
}{
|
||||
{"空明文", []byte{}},
|
||||
{"普通明文", []byte("hello, openpgp! 你好,世界")},
|
||||
{"10KB 明文", generatePlaintext(10 * 1024)},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run("armored/"+tc.name, func(t *testing.T) {
|
||||
ciphertext, err := gpgx.EncryptArmored(alicePublic, tc.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(ciphertext), "-----BEGIN PGP MESSAGE-----") {
|
||||
t.Fatalf("密文 armor 头不正确: %q", firstLine(ciphertext))
|
||||
}
|
||||
|
||||
plaintext, err := gpgx.Decrypt(alicePrivate, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, tc.plaintext) {
|
||||
t.Fatalf("解密结果不匹配: 期望 %d 字节,实际 %d 字节", len(tc.plaintext), len(plaintext))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("binary/"+tc.name, func(t *testing.T) {
|
||||
ciphertext, err := gpgx.Encrypt(alicePublic, tc.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt 失败: %v", err)
|
||||
}
|
||||
if bytes.HasPrefix(ciphertext, []byte("-----BEGIN")) {
|
||||
t.Fatal("Encrypt 应返回二进制报文,而不是 armor 文本")
|
||||
}
|
||||
|
||||
plaintext, err := gpgx.Decrypt(alicePrivate, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt(二进制) 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, tc.plaintext) {
|
||||
t.Fatalf("解密结果不匹配: 期望 %d 字节,实际 %d 字节", len(tc.plaintext), len(plaintext))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 多次加密同一明文应产生不同密文(会话密钥随机)。
|
||||
first, err := gpgx.EncryptArmored(alicePublic, []byte("same input"))
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
second, err := gpgx.EncryptArmored(alicePublic, []byte("same input"))
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
if bytes.Equal(first, second) {
|
||||
t.Fatal("两次加密产生了完全相同的密文,会话密钥可能未随机化")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptErrors(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
ciphertext, err := gpgx.EncryptArmored(alicePublic, []byte("top secret payload"))
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
|
||||
// 用另一个密钥环解密。
|
||||
mustNotPanic(t, "错误密钥环解密", func() error {
|
||||
_, err := gpgx.Decrypt(bobPublic, ciphertext)
|
||||
return err
|
||||
})
|
||||
|
||||
// 篡改密文 1 字节。
|
||||
mustNotPanic(t, "篡改密文解密", func() error {
|
||||
_, err := gpgx.Decrypt(alicePrivate, tamperByte(ciphertext, len(ciphertext)/2))
|
||||
return err
|
||||
})
|
||||
|
||||
// 丢掉 armor 尾部(截断密文)。
|
||||
mustNotPanic(t, "截断密文解密", func() error {
|
||||
_, err := gpgx.Decrypt(alicePrivate, ciphertext[:len(ciphertext)/2])
|
||||
return err
|
||||
})
|
||||
|
||||
// 空密文与垃圾字节。
|
||||
mustNotPanic(t, "空密文解密", func() error {
|
||||
_, err := gpgx.Decrypt(alicePrivate, nil)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "垃圾密文解密", func() error {
|
||||
_, err := gpgx.Decrypt(alicePrivate, []byte("not a pgp message at all"))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "随机字节解密", func() error {
|
||||
_, err := gpgx.Decrypt(alicePrivate, generatePlaintext(256))
|
||||
return err
|
||||
})
|
||||
|
||||
// 空密钥环。
|
||||
mustNotPanic(t, "空密钥环解密", func() error {
|
||||
_, err := gpgx.Decrypt(nil, ciphertext)
|
||||
return err
|
||||
})
|
||||
|
||||
// 加密侧的参数校验。
|
||||
mustNotPanic(t, "空接收方加密", func() error {
|
||||
_, err := gpgx.Encrypt(nil, []byte("x"))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "空接收方加密(armored)", func() error {
|
||||
_, err := gpgx.EncryptArmored(openpgp.EntityList{}, []byte("x"))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "仅有私钥的接收方加密", func() error {
|
||||
_, err := gpgx.Encrypt(openpgp.EntityList{}, []byte("x"))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
cases := [][]byte{
|
||||
{},
|
||||
[]byte("message to be signed"),
|
||||
generatePlaintext(4096),
|
||||
}
|
||||
for _, plaintext := range cases {
|
||||
signed, err := gpgx.Sign(alicePrivate, plaintext, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign 失败: %v", err)
|
||||
}
|
||||
got, err := gpgx.Verify(alicePublic, signed)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("验签返回的明文不匹配: 期望 %d 字节,实际 %d 字节", len(plaintext), len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 私钥环也可以验签(其中包含公钥)。
|
||||
signed, err := gpgx.Sign(alicePrivate, []byte("verify with private keyring"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign 失败: %v", err)
|
||||
}
|
||||
if _, err := gpgx.Verify(alicePrivate, signed); err != nil {
|
||||
t.Fatalf("使用私钥环 Verify 失败: %v", err)
|
||||
}
|
||||
|
||||
// 分离签名。
|
||||
data := []byte("detached signature payload")
|
||||
signature, err := gpgx.SignDetachedArmored(alicePrivate, data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SignDetachedArmored 失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(signature), "-----BEGIN PGP SIGNATURE-----") {
|
||||
t.Fatalf("分离签名 armor 头不正确: %q", firstLine(signature))
|
||||
}
|
||||
signer, err := gpgx.VerifyDetached(alicePublic, data, signature)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyDetached 失败: %v", err)
|
||||
}
|
||||
if signer == nil || signer.PrimaryKey.KeyId != alicePublic[0].PrimaryKey.KeyId {
|
||||
t.Fatal("VerifyDetached 返回的签名者不正确")
|
||||
}
|
||||
|
||||
// 二进制分离签名同样可验证。
|
||||
binarySignature := decodeArmor(t, signature)
|
||||
if _, err := gpgx.VerifyDetached(alicePublic, data, binarySignature); err != nil {
|
||||
t.Fatalf("VerifyDetached(二进制签名) 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTamperedMessageAndWrongKey(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
data := []byte("important signed statement")
|
||||
signature, err := gpgx.SignDetachedArmored(alicePrivate, data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SignDetachedArmored 失败: %v", err)
|
||||
}
|
||||
|
||||
// 篡改被签名的消息。
|
||||
mustNotPanic(t, "篡改消息后验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(alicePublic, tamperByte(data, 3), signature)
|
||||
return err
|
||||
})
|
||||
|
||||
// 篡改签名本身。
|
||||
mustNotPanic(t, "篡改签名后验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(alicePublic, data, tamperByte(signature, len(signature)/2))
|
||||
return err
|
||||
})
|
||||
|
||||
// 用错误的公钥环验签。
|
||||
mustNotPanic(t, "错误公钥环验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(bobPublic, data, signature)
|
||||
return err
|
||||
})
|
||||
|
||||
// 空/垃圾输入。
|
||||
mustNotPanic(t, "空签名验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(alicePublic, data, nil)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "垃圾签名验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(alicePublic, data, []byte("garbage signature"))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "空密钥环验签", func() error {
|
||||
_, err := gpgx.VerifyDetached(nil, data, signature)
|
||||
return err
|
||||
})
|
||||
|
||||
// 内联签名报文的错误路径。
|
||||
signed, err := gpgx.Sign(alicePrivate, data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign 失败: %v", err)
|
||||
}
|
||||
mustNotPanic(t, "篡改内联签名报文", func() error {
|
||||
_, err := gpgx.Verify(alicePublic, tamperByte(signed, len(signed)/2))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "错误密钥环验证内联签名", func() error {
|
||||
_, err := gpgx.Verify(bobPublic, signed)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "空报文验签", func() error {
|
||||
_, err := gpgx.Verify(alicePublic, nil)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "垃圾报文验签", func() error {
|
||||
_, err := gpgx.Verify(alicePublic, []byte("definitely not signed"))
|
||||
return err
|
||||
})
|
||||
// 未签名的加密报文不能被当作签名报文验证。
|
||||
ciphertext, err := gpgx.EncryptArmored(alicePublic, data)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
mustNotPanic(t, "加密报文当作签名报文", func() error {
|
||||
_, err := gpgx.Verify(alicePublic, ciphertext)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func TestPassphraseProtectedKey(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
if len(lockedPrivate) == 0 {
|
||||
t.Fatal("带口令的测试密钥不可用")
|
||||
}
|
||||
|
||||
// 口令必须真的生效:读回来的私钥应处于加密状态。
|
||||
locked, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPrivate))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(带口令私钥) 失败: %v", err)
|
||||
}
|
||||
if locked[0].PrivateKey == nil || !locked[0].PrivateKey.Encrypted {
|
||||
t.Fatal("GenerateKeyPair 未加密私钥")
|
||||
}
|
||||
lockedPublic, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPublicArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(带口令公钥) 失败: %v", err)
|
||||
}
|
||||
|
||||
data := []byte("passphrase protected payload")
|
||||
|
||||
// 正确口令:签名 + 验签。
|
||||
signature, err := gpgx.SignDetachedArmored(locked, data, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("使用正确口令签名失败: %v", err)
|
||||
}
|
||||
if _, err := gpgx.VerifyDetached(lockedPublic, data, signature); err != nil {
|
||||
t.Fatalf("验签失败: %v", err)
|
||||
}
|
||||
|
||||
// 正确口令:内联签名。
|
||||
signed, err := gpgx.Sign(locked, data, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("使用正确口令生成内联签名失败: %v", err)
|
||||
}
|
||||
if got, err := gpgx.Verify(lockedPublic, signed); err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("内联签名往返失败: err=%v", err)
|
||||
}
|
||||
|
||||
// 正确口令:解密。
|
||||
ciphertext, err := gpgx.EncryptArmored(lockedPublic, data)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
plaintext, err := gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("使用正确口令解密失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, data) {
|
||||
t.Fatal("使用正确口令解密的结果不匹配")
|
||||
}
|
||||
|
||||
// 错误口令:签名与解密都必须失败且不 panic。
|
||||
wrong := []byte("totally wrong passphrase")
|
||||
mustNotPanic(t, "错误口令签名", func() error {
|
||||
_, err := gpgx.Sign(locked, data, wrong)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "错误口令生成分离签名", func() error {
|
||||
_, err := gpgx.SignDetachedArmored(locked, data, wrong)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "错误口令解密", func() error {
|
||||
_, err := gpgx.DecryptWithPassphrase(locked, ciphertext, wrong)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "未提供口令解密", func() error {
|
||||
_, err := gpgx.DecryptWithPassphrase(locked, ciphertext, nil)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "未提供口令签名", func() error {
|
||||
_, err := gpgx.Sign(locked, data, nil)
|
||||
return err
|
||||
})
|
||||
|
||||
// 关键回归断言:上面的失败尝试不得破坏调用方传入的密钥环
|
||||
// (实现应在副本上解密私钥)。
|
||||
plaintext, err = gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("错误口令尝试后,正确口令解密失败(密钥环被污染): %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, data) {
|
||||
t.Fatal("错误口令尝试后,正确口令解密的结果不匹配")
|
||||
}
|
||||
if _, err := gpgx.Sign(locked, data, passphrase); err != nil {
|
||||
t.Fatalf("错误口令尝试后,正确口令签名失败(密钥环被污染): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRSAKeyPairRoundTrip 覆盖 RSA 密钥路径(GenerateKeyPair 的 bits > 0 分支)。
|
||||
func TestRSAKeyPairRoundTrip(t *testing.T) {
|
||||
rsaPrivateArmor, rsaPublicArmor, err := gpgx.GenerateKeyPair(
|
||||
"RSA Tester", "rsa@example.com", "gpgx rsa", nil, gpgx.MinRSABits)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair(RSA %d) 失败: %v", gpgx.MinRSABits, err)
|
||||
}
|
||||
|
||||
private, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(rsaPrivateArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(RSA 私钥) 失败: %v", err)
|
||||
}
|
||||
public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(rsaPublicArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(RSA 公钥) 失败: %v", err)
|
||||
}
|
||||
|
||||
data := []byte("rsa payload")
|
||||
ciphertext, err := gpgx.EncryptArmored(public, data)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored(RSA) 失败: %v", err)
|
||||
}
|
||||
plaintext, err := gpgx.Decrypt(private, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt(RSA) 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, data) {
|
||||
t.Fatal("RSA 加解密结果不匹配")
|
||||
}
|
||||
|
||||
signature, err := gpgx.SignDetachedArmored(private, data, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SignDetachedArmored(RSA) 失败: %v", err)
|
||||
}
|
||||
if _, err := gpgx.VerifyDetached(public, data, signature); err != nil {
|
||||
t.Fatalf("VerifyDetached(RSA) 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeyRingNotMutatedByFailedUnlock 断言解锁失败不会污染调用方传入的密钥环。
|
||||
func TestKeyRingNotMutatedByFailedUnlock(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
if len(lockedPrivate) == 0 {
|
||||
t.Fatal("带口令的测试密钥不可用")
|
||||
}
|
||||
|
||||
locked, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPrivate))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing 失败: %v", err)
|
||||
}
|
||||
if !locked[0].PrivateKey.Encrypted {
|
||||
t.Fatal("测试前提不成立:私钥应为加密状态")
|
||||
}
|
||||
|
||||
// 未提供口令解密必须失败。
|
||||
mustNotPanic(t, "加密私钥无口令解密", func() error {
|
||||
_, err := gpgx.Decrypt(locked, []byte("whatever"))
|
||||
return err
|
||||
})
|
||||
// 失败后调用方密钥环中的私钥仍应保持加密状态。
|
||||
if !locked[0].PrivateKey.Encrypted {
|
||||
t.Fatal("失败的解密尝试修改了调用方密钥环(私钥被解锁)")
|
||||
}
|
||||
|
||||
// 后续用正确口令仍可正常使用。
|
||||
public, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(lockedPublicArmor))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadArmoredKeyRing(公钥) 失败: %v", err)
|
||||
}
|
||||
data := []byte("still usable after failures")
|
||||
ciphertext, err := gpgx.EncryptArmored(public, data)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptArmored 失败: %v", err)
|
||||
}
|
||||
plaintext, err := gpgx.DecryptWithPassphrase(locked, ciphertext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithPassphrase 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(plaintext, data) {
|
||||
t.Fatal("解密结果不匹配")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSymmetricPassphraseRoundTrip 覆盖 gpg -c 风格的口令对称加解密。
|
||||
func TestSymmetricPassphraseRoundTrip(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
data := []byte("symmetrically encrypted payload 对称加密")
|
||||
ciphertext, err := gpgx.EncryptWithPassphrase(passphrase, data)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithPassphrase 失败: %v", err)
|
||||
}
|
||||
if bytes.HasPrefix(ciphertext, []byte("-----BEGIN")) {
|
||||
t.Fatal("EncryptWithPassphrase 应返回二进制报文")
|
||||
}
|
||||
|
||||
// 对称加密的密文可以被任意密钥环(甚至空密钥环之外的调用)解密,
|
||||
// 只要口令正确。
|
||||
got, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptWithPassphrase(对称) 失败: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatal("对称加解密结果不匹配")
|
||||
}
|
||||
|
||||
mustNotPanic(t, "错误口令对称解密", func() error {
|
||||
_, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, []byte("wrong passphrase"))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "空口令对称解密", func() error {
|
||||
_, err := gpgx.DecryptWithPassphrase(alicePrivate, ciphertext, nil)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "空口令对称加密", func() error {
|
||||
_, err := gpgx.EncryptWithPassphrase(nil, data)
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "篡改对称密文", func() error {
|
||||
_, err := gpgx.DecryptWithPassphrase(alicePrivate, tamperByte(ciphertext, len(ciphertext)/2), passphrase)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateKeyPairUnencryptedWithPassphrase(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
// 私钥未加密时传入口令应被忽略,而不是报错。
|
||||
data := []byte("unencrypted key with passphrase argument")
|
||||
signature, err := gpgx.SignDetachedArmored(alicePrivate, data, []byte("irrelevant"))
|
||||
if err != nil {
|
||||
t.Fatalf("未加密私钥传入口令时签名失败: %v", err)
|
||||
}
|
||||
if _, err := gpgx.VerifyDetached(alicePublic, data, signature); err != nil {
|
||||
t.Fatalf("验签失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKeyRingInvalidInput(t *testing.T) {
|
||||
inputs := map[string][]byte{
|
||||
"nil 输入": nil,
|
||||
"空输入": {},
|
||||
"垃圾字节": []byte("garbage not a keyring"),
|
||||
"伪 armor": []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nnot base64!!!\n-----END PGP PUBLIC KEY BLOCK-----\n"),
|
||||
"随机字节": generatePlaintext(512),
|
||||
}
|
||||
|
||||
for name, input := range inputs {
|
||||
input := input
|
||||
mustNotPanic(t, "ReadKeyRing("+name+")", func() error {
|
||||
_, err := gpgx.ReadKeyRing(bytes.NewReader(input))
|
||||
return err
|
||||
})
|
||||
mustNotPanic(t, "ReadArmoredKeyRing("+name+")", func() error {
|
||||
_, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(input))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReadArmoredKeyRing 应拒绝二进制密钥环。
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
mustNotPanic(t, "ReadArmoredKeyRing(二进制密钥环)", func() error {
|
||||
_, err := gpgx.ReadArmoredKeyRing(bytes.NewReader(decodeArmor(t, alicePrivateArmor)))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateKeyPairParameterValidation(t *testing.T) {
|
||||
// 弱 RSA 密钥必须被拒绝,而不是生成。
|
||||
mustNotPanic(t, "GenerateKeyPair(1024)", func() error {
|
||||
_, _, err := gpgx.GenerateKeyPair("Weak", "weak@example.com", "", nil, 1024)
|
||||
return err
|
||||
})
|
||||
// bits <= 0:按文档生成 Ed25519(这是合法调用,不应报错)。
|
||||
if _, _, err := gpgx.GenerateKeyPair("Negative", "neg@example.com", "", nil, -100); err != nil {
|
||||
t.Fatalf("GenerateKeyPair(bits=-100) 应生成 Ed25519 密钥,实际报错: %v", err)
|
||||
}
|
||||
// 非法 User ID 字符必须报错而不是 panic。
|
||||
mustNotPanic(t, "GenerateKeyPair(非法 User ID)", func() error {
|
||||
_, _, err := gpgx.GenerateKeyPair("Bad (Name)", "bad@example.com", "", nil, 0)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// TestNoGoroutineOrFileLeak 做一个轻量的泄漏哨兵:所有操作都应在有限时间内
|
||||
// 结束(若存在未关闭的管道/句柄导致阻塞,此测试会超时失败)。
|
||||
func TestNoGoroutineOrFileLeak(t *testing.T) {
|
||||
initTestKeys(t)
|
||||
requireKeys(t)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
ciphertext, err := gpgx.EncryptArmored(alicePublic, generatePlaintext(1024))
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 轮 EncryptArmored 失败: %v", i, err)
|
||||
}
|
||||
if _, err := gpgx.Decrypt(alicePrivate, ciphertext); err != nil {
|
||||
t.Fatalf("第 %d 轮 Decrypt 失败: %v", i, err)
|
||||
}
|
||||
signed, err := gpgx.Sign(alicePrivate, []byte("leak check"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 轮 Sign 失败: %v", i, err)
|
||||
}
|
||||
if _, err := gpgx.Verify(alicePublic, signed); err != nil {
|
||||
t.Fatalf("第 %d 轮 Verify 失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- 辅助
|
||||
|
||||
func firstLine(data []byte) string {
|
||||
if idx := bytes.IndexByte(data, '\n'); idx >= 0 {
|
||||
return string(data[:idx])
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// decodeArmor 把 armored 文本转成底层二进制数据(仅用于测试)。
|
||||
func decodeArmor(t *testing.T, armored []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := armor.Decode(bytes.NewReader(armored))
|
||||
if err != nil {
|
||||
t.Fatalf("解析 armor 失败: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(block.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 armor body 失败: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user