mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-13 23:30:37 +01:00
286 lines
9.8 KiB
Go
286 lines
9.8 KiB
Go
// Package pgp provides PGP key generation and RFC 3156 (PGP/MIME) encryption for
|
|
// outgoing mail, using github.com/ProtonMail/go-crypto — the maintained fork of
|
|
// golang.org/x/crypto/openpgp, which its own doc comment calls deprecated and
|
|
// "unsafe by design". This package is encryption-only: no PGP signature generation
|
|
// or verification (S/MIME, internal/smime, handles signing).
|
|
package pgp
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp"
|
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
|
)
|
|
|
|
func defaultConfig() *packet.Config {
|
|
return &packet.Config{
|
|
DefaultCipher: packet.CipherAES256, // library default is AES-128
|
|
RSABits: 2048,
|
|
}
|
|
}
|
|
|
|
// GenerateKeyPair creates a new RSA-2048 keypair for email, protecting the private key
|
|
// with passphrase using OpenPGP's own native S2K format — no extra app-layer wrapping
|
|
// needed (unlike internal/smime's key_pem, which is encrypted at rest by the caller).
|
|
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
|
config := defaultConfig()
|
|
entity, err := openpgp.NewEntity(email, "", email, config)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("generate entity: %w", err)
|
|
}
|
|
if err := lockEntity(entity, passphrase); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
publicArmor, err = serializePublic(entity)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
privateArmor, err = serializePrivate(entity, config)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return publicArmor, privateArmor, nil
|
|
}
|
|
|
|
func lockEntity(entity *openpgp.Entity, passphrase string) error {
|
|
if err := entity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
|
return fmt.Errorf("lock primary key: %w", err)
|
|
}
|
|
for _, sub := range entity.Subkeys {
|
|
if sub.PrivateKey == nil {
|
|
continue
|
|
}
|
|
if err := sub.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
|
|
return fmt.Errorf("lock subkey: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := entity.Serialize(w); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func serializePrivate(entity *openpgp.Entity, config *packet.Config) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Must use SerializePrivateWithoutSigning: SerializePrivate re-signs identities,
|
|
// which requires the (now-encrypted) private key and fails once it's locked.
|
|
if err := entity.SerializePrivateWithoutSigning(w, config); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// ImportPrivateKey parses an armored private key (already passphrase-protected, e.g.
|
|
// exported from GnuPG) and re-serializes its public/private halves in our storage form.
|
|
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
|
|
entity, err := ParsePrivateKey(armoredData)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// Verify the passphrase actually unlocks it before accepting the import.
|
|
if err := UnlockPrivateKey(entity, passphrase); err != nil {
|
|
return nil, nil, fmt.Errorf("passphrase does not unlock key: %w", err)
|
|
}
|
|
publicArmor, err = serializePublic(entity)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
privateArmor = armoredData
|
|
return publicArmor, privateArmor, nil
|
|
}
|
|
|
|
// ParsePublicKey reads a single armored public key.
|
|
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
|
|
return parseEntity(armoredData)
|
|
}
|
|
|
|
// ParsePrivateKey reads a single armored private key. The key remains locked
|
|
// (Encrypted) until UnlockPrivateKey is called with its passphrase.
|
|
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
|
|
return parseEntity(armoredData)
|
|
}
|
|
|
|
func parseEntity(armoredData []byte) (*openpgp.Entity, error) {
|
|
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armoredData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse key: %w", err)
|
|
}
|
|
if len(entities) == 0 {
|
|
return nil, fmt.Errorf("no key found in armored data")
|
|
}
|
|
return entities[0], nil
|
|
}
|
|
|
|
// UnlockPrivateKey decrypts the primary key and every subkey using passphrase.
|
|
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
|
|
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
|
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
|
return fmt.Errorf("unlock primary key: %w", err)
|
|
}
|
|
}
|
|
for _, sub := range entity.Subkeys {
|
|
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
|
if err := sub.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
|
return fmt.Errorf("unlock subkey: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fingerprint returns the entity's primary key fingerprint as uppercase hex.
|
|
func Fingerprint(entity *openpgp.Entity) string {
|
|
return strings.ToUpper(fmt.Sprintf("%x", entity.PrimaryKey.Fingerprint))
|
|
}
|
|
|
|
// EncryptEntity produces an RFC 3156 (PGP/MIME) armored encrypted message for the given
|
|
// recipients' public keys.
|
|
func EncryptEntity(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
aw, err := armor.Encode(&buf, "PGP MESSAGE", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
if _, err := pt.Write(raw); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pt.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := aw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// DecryptEntity opens an armored PGP message using an already-unlocked identity
|
|
// (see UnlockPrivateKey).
|
|
func DecryptEntity(armored []byte, unlockedIdentity *openpgp.Entity) ([]byte, error) {
|
|
block, err := armor.Decode(bytes.NewReader(armored))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode armor: %w", err)
|
|
}
|
|
keyring := openpgp.EntityList{unlockedIdentity}
|
|
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read message: %w", err)
|
|
}
|
|
return io.ReadAll(md.UnverifiedBody)
|
|
}
|
|
|
|
// ---- Whole-message MIME wrapping (RFC 3156 multipart/encrypted) ----
|
|
|
|
// EncryptMIME wraps a complete raw MIME message (headers + body, as produced by
|
|
// internal/email's buildMIMEMessage) in an RFC 3156 multipart/encrypted structure: the
|
|
// original Content-Type + body are PGP-encrypted as one opaque unit for recipients, and
|
|
// all other top-level headers (From, To, Subject, Date, Message-ID, ...) are preserved.
|
|
// Unlike SignMIME's CMS wrapping, no CRLF/boundary canonicalization concern applies here —
|
|
// the encrypted blob is opaque to any downstream MIME parser, so decryption returns exactly
|
|
// what was encrypted regardless of a trailing CRLF.
|
|
func EncryptMIME(raw []byte, recipients []*openpgp.Entity) ([]byte, error) {
|
|
topLines, entity, err := splitMIMEEntity(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encrypted, err := EncryptEntity(entity, recipients)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
boundary := fmt.Sprintf("pgp_enc_%x", time.Now().UnixNano())
|
|
|
|
var out bytes.Buffer
|
|
for _, l := range topLines {
|
|
out.WriteString(l + "\r\n")
|
|
}
|
|
fmt.Fprintf(&out, "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"%s\"\r\n\r\n", boundary)
|
|
out.WriteString("--" + boundary + "\r\n")
|
|
out.WriteString("Content-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n")
|
|
out.WriteString("--" + boundary + "\r\n")
|
|
out.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n")
|
|
out.WriteString("Content-Description: OpenPGP encrypted message\r\n")
|
|
out.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n\r\n")
|
|
out.Write(encrypted)
|
|
out.WriteString("\r\n--" + boundary + "--\r\n")
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// entityHeaderNames are the headers that describe a MIME entity's own content (as opposed
|
|
// to the surrounding message envelope) and so must travel INSIDE the encrypted part, not
|
|
// stay behind as a stray top-level header of the wrapper message.
|
|
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
|
|
|
|
// splitMIMEEntity splits a raw RFC 5322 message into the top-level headers with the entity
|
|
// headers removed, and the "entity" being protected — its own Content-Type/Content-Transfer-
|
|
// Encoding/Content-Disposition headers plus blank line plus body.
|
|
func splitMIMEEntity(raw []byte) (topLines []string, entity []byte, err error) {
|
|
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
|
if idx < 0 {
|
|
return nil, nil, errors.New("no header/body separator found in message")
|
|
}
|
|
headerBlock := string(raw[:idx])
|
|
body := raw[idx+4:]
|
|
rest := strings.Split(headerBlock, "\r\n")
|
|
|
|
var entityLines []string
|
|
for _, name := range entityHeaderNames {
|
|
var val string
|
|
val, rest = extractHeader(rest, name)
|
|
if val != "" {
|
|
entityLines = append(entityLines, val)
|
|
}
|
|
}
|
|
if len(entityLines) == 0 {
|
|
return nil, nil, errors.New("no Content-Type header found in message")
|
|
}
|
|
entity = append([]byte(strings.Join(entityLines, "\r\n")+"\r\n\r\n"), body...)
|
|
return rest, entity, nil
|
|
}
|
|
|
|
// extractHeader pulls the named header (plus any folded continuation lines) out of lines,
|
|
// returning its full value and the remaining lines with it removed.
|
|
func extractHeader(lines []string, name string) (value string, rest []string) {
|
|
prefix := strings.ToLower(name) + ":"
|
|
for i, l := range lines {
|
|
if strings.HasPrefix(strings.ToLower(l), prefix) {
|
|
value = l
|
|
j := i + 1
|
|
for j < len(lines) && (strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) {
|
|
value += "\r\n" + lines[j]
|
|
j++
|
|
}
|
|
rest = append(append([]string{}, lines[:i]...), lines[j:]...)
|
|
return value, rest
|
|
}
|
|
}
|
|
return "", lines
|
|
}
|