MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+176
View File
@@ -0,0 +1,176 @@
package pgp
import (
"bytes"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/textproto"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
// Entity is a MIME entity: its own part-level headers plus its body — deliberately
// the same shape as smime.Entity, so webui's compose/read handlers can pass the same
// value between either package's Encrypt/Decrypt without conversion glue.
type Entity struct {
Headers []string
Body []byte
}
func (e Entity) bytes() []byte {
var buf bytes.Buffer
for _, h := range e.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(e.Body)
return buf.Bytes()
}
// parseEntity splits raw bytes (headers, a blank line, then body) back into an
// Entity — used to recover the inner MIME entity after Decrypt.
func parseEntity(raw []byte) Entity {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
sep := 4
if idx < 0 {
idx = bytes.Index(raw, []byte("\n\n"))
sep = 2
}
if idx < 0 {
return Entity{Body: raw}
}
var headers []string
for _, line := range strings.Split(string(raw[:idx]), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
headers = append(headers, line)
}
return Entity{Headers: headers, Body: raw[idx+sep:]}
}
func headerValue(headers []string, name string) string {
for _, h := range headers {
if i := strings.Index(h, ":"); i >= 0 && strings.EqualFold(strings.TrimSpace(h[:i]), name) {
return strings.TrimSpace(h[i+1:])
}
}
return ""
}
// EncryptEntity wraps entity's bytes as an RFC 3156 PGP/MIME multipart/encrypted
// structure, encrypted to recipients. Pass every recipient's public key, including
// the sender's own, so a copy kept in Sent stays readable — mirrors
// smime.Encrypt's same convention.
func EncryptEntity(entity Entity, recipients []*openpgp.Entity) (Entity, error) {
if len(recipients) == 0 {
return Entity{}, errors.New("pgp: no recipient keys provided")
}
var armored bytes.Buffer
aw, err := armor.Encode(&armored, "PGP MESSAGE", nil)
if err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
if err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if _, err := pt.Write(entity.bytes()); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if err := pt.Close(); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if err := aw.Close(); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
ctrlPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"application/pgp-encrypted"},
"Content-Transfer-Encoding": {"7bit"},
})
if err != nil {
return Entity{}, err
}
if _, err := ctrlPart.Write([]byte("Version: 1\r\n")); err != nil {
return Entity{}, err
}
dataPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {`application/octet-stream; name="encrypted.asc"`},
"Content-Disposition": {`inline; filename="encrypted.asc"`},
"Content-Transfer-Encoding": {"7bit"},
})
if err != nil {
return Entity{}, err
}
if _, err := dataPart.Write(armored.Bytes()); err != nil {
return Entity{}, err
}
if err := mw.Close(); err != nil {
return Entity{}, err
}
return Entity{
Headers: []string{
fmt.Sprintf(`Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="%s"`, mw.Boundary()),
},
Body: body.Bytes(),
}, nil
}
// DecryptEntity reverses EncryptEntity, decrypting with unlockedIdentity (its
// private key material must already be unlocked via UnlockPrivateKey — this
// function never takes a passphrase itself).
func DecryptEntity(entity Entity, unlockedIdentity *openpgp.Entity) (Entity, error) {
ct := headerValue(entity.Headers, "Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil || mediaType != "multipart/encrypted" {
return Entity{}, errors.New("pgp: not a multipart/encrypted message")
}
if !strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
return Entity{}, fmt.Errorf("pgp: unsupported multipart/encrypted protocol %q", params["protocol"])
}
boundary := params["boundary"]
if boundary == "" {
return Entity{}, errors.New("pgp: missing multipart boundary")
}
mr := multipart.NewReader(bytes.NewReader(entity.Body), boundary)
// First part is the application/pgp-encrypted control part ("Version: 1") — not
// needed, the actual ciphertext is the second part.
if _, err := mr.NextPart(); err != nil {
return Entity{}, fmt.Errorf("pgp: read control part: %w", err)
}
dataPart, err := mr.NextPart()
if err != nil {
return Entity{}, fmt.Errorf("pgp: read data part: %w", err)
}
armoredCiphertext, err := io.ReadAll(dataPart)
if err != nil {
return Entity{}, err
}
block, err := armor.Decode(bytes.NewReader(armoredCiphertext))
if err != nil {
return Entity{}, fmt.Errorf("pgp: invalid armored ciphertext: %w", err)
}
md, err := openpgp.ReadMessage(block.Body, openpgp.EntityList{unlockedIdentity}, nil, defaultConfig())
if err != nil {
return Entity{}, fmt.Errorf("pgp: decrypt: %w", err)
}
plaintext, err := io.ReadAll(md.UnverifiedBody)
if err != nil {
return Entity{}, fmt.Errorf("pgp: decrypt: %w", err)
}
return parseEntity(plaintext), nil
}
+175
View File
@@ -0,0 +1,175 @@
// Package pgp implements OpenPGP key generation/import and message encryption for
// the webmail client's PGP encryption feature. Deliberately encryption-only: this
// codebase uses S/MIME (internal/smime) for signing, PGP only for confidentiality
// (an explicit design split) — no signature generation or verification code lives
// here.
//
// Uses github.com/ProtonMail/go-crypto/openpgp, the actively maintained replacement
// for the deprecated (and explicitly "unsafe by design", per its own doc comment)
// golang.org/x/crypto/openpgp.
package pgp
import (
"bytes"
"errors"
"fmt"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
// defaultConfig pins AES-256 (the library defaults to AES-128) — same "always pick
// the strong option explicitly" posture as internal/smime. RSA-2048 (the library's
// own zero-value default when Config.RSABits is unset) matches this codebase's
// existing key-size convention (internal/smime, internal/tlsutil both use RSA-2048).
func defaultConfig() *packet.Config {
return &packet.Config{DefaultCipher: packet.CipherAES256}
}
// GenerateKeyPair creates a fresh RSA-2048 OpenPGP keypair for email, protects the
// private key material with passphrase (the library's own native S2K passphrase
// protection, part of the OpenPGP private-key packet format itself — no separate
// wrapping layer needed, unlike internal/smime's hand-rolled scrypt+AES-GCM), and
// returns both halves ASCII-armored.
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
cfg := defaultConfig()
entity, err := openpgp.NewEntity(email, "", email, cfg)
if err != nil {
return nil, nil, fmt.Errorf("pgp: generate key: %w", err)
}
if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil {
return nil, nil, fmt.Errorf("pgp: protect private key: %w", err)
}
if publicArmor, err = serializePublic(entity); err != nil {
return nil, nil, err
}
if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
// ImportPrivateKey parses an ASCII-armored private key export (e.g. from `gpg
// --export-secret-keys --armor`). If it isn't already passphrase-protected,
// passphrase is used to protect it before storing (same posture as generate — never
// store an unprotected private key). If it's already protected, passphrase must be
// the one that already unlocks it — verified here (by actually unlocking it) so a
// wrong passphrase is caught at import time rather than silently producing a
// permanently unusable stored key.
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
entity, err := readArmoredEntity(armoredData)
if err != nil {
return nil, nil, err
}
if entity.PrivateKey == nil {
return nil, nil, errors.New("pgp: no private key found in the uploaded file")
}
cfg := defaultConfig()
if entity.PrivateKey.Encrypted {
if err := UnlockPrivateKey(entity, passphrase); err != nil {
return nil, nil, fmt.Errorf("pgp: wrong passphrase for the imported key: %w", err)
}
}
// Re-encrypt (or encrypt for the first time) with passphrase — verified live
// that decrypting and re-encrypting the same in-memory Entity, then serializing
// without signing, round-trips correctly.
if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil {
return nil, nil, fmt.Errorf("pgp: protect private key: %w", err)
}
if publicArmor, err = serializePublic(entity); err != nil {
return nil, nil, err
}
if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
// ParsePublicKey parses an ASCII-armored public key block — used for a contact's
// key, added by hand (PGP has no signature here to auto-capture a contact from the
// way S/MIME does).
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
return readArmoredEntity(armoredData)
}
// ParsePrivateKey parses a stored (already passphrase-protected) armored private
// key back into an Entity, still locked — call UnlockPrivateKey with the passphrase
// before using it to decrypt anything.
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
return readArmoredEntity(armoredData)
}
// UnlockPrivateKey decrypts entity's primary private key AND every subkey's private
// key with passphrase — the actual encryption-capable key lives on a subkey in
// modern OpenPGP layout (confirmed via a live round-trip test), so both must be
// unlocked before Decrypt can use entity as a recipient key.
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("pgp: %w", err)
}
}
for _, sk := range entity.Subkeys {
if sk.PrivateKey != nil && sk.PrivateKey.Encrypted {
if err := sk.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("pgp: %w", err)
}
}
}
return nil
}
// Fingerprint returns entity's primary key fingerprint as uppercase hex, for
// display — distinguishing keys beyond just their user-supplied label.
func Fingerprint(entity *openpgp.Entity) string {
return fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint)
}
func readArmoredEntity(armoredData []byte) (*openpgp.Entity, error) {
block, err := armor.Decode(bytes.NewReader(armoredData))
if err != nil {
return nil, fmt.Errorf("pgp: invalid armored data: %w", err)
}
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
if err != nil {
return nil, fmt.Errorf("pgp: parse key: %w", err)
}
return entity, 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
}
// serializePrivateWithoutSigning uses SerializePrivateWithoutSigning, not
// SerializePrivate — the latter re-signs identities/subkeys using the private key
// as a crypto.Signer, which panics once the key material is encrypted (confirmed
// via a live round-trip test; SerializePrivate is only safe to call before
// EncryptPrivateKeys, which isn't a option here since every caller wants the
// already-protected key serialized).
func serializePrivateWithoutSigning(entity *openpgp.Entity, cfg *packet.Config) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.SerializePrivateWithoutSigning(w, cfg); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+248
View File
@@ -0,0 +1,248 @@
package pgp
import (
"bytes"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
// serializeUnencryptedPrivate serializes an as-yet-unprotected private key — only
// safe to call before EncryptPrivateKeys (see the gotcha documented on
// serializePrivateWithoutSigning in identity.go). No production code path needs
// this (every stored key goes through EncryptPrivateKeys first); it exists here
// purely to simulate a genuinely unprotected "gpg --export-secret-keys" output for
// TestImportPrivateKeyUnencrypted.
func serializeUnencryptedPrivate(entity *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.SerializePrivate(w, nil); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func testEntity() Entity {
return Entity{
Headers: []string{"Content-Type: text/plain; charset=utf-8"},
Body: []byte("hello world\r\nsecond line\r\n"),
}
}
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
pubPEM, privPEM, err := GenerateKeyPair("alice@example.com", "correct horse battery staple")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
if bytes.Contains(privPEM, []byte("correct horse")) {
t.Fatal("stored private key armor should not contain the plaintext passphrase")
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatalf("EncryptEntity: %v", err)
}
if ct := headerValue(encrypted.Headers, "Content-Type"); ct == "" {
t.Fatal("expected a Content-Type header on the encrypted entity")
}
if bytes.Contains(encrypted.Body, orig.Body) {
t.Fatal("encrypted body should not contain the plaintext")
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if err := UnlockPrivateKey(identity, "correct horse battery staple"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body)
}
if headerValue(decrypted.Headers, "Content-Type") != headerValue(orig.Headers, "Content-Type") {
t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers)
}
}
func TestUnlockPrivateKeyWrongPassphraseFails(t *testing.T) {
_, privPEM, err := GenerateKeyPair("alice@example.com", "right passphrase")
if err != nil {
t.Fatal(err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if err := UnlockPrivateKey(identity, "wrong passphrase"); err == nil {
t.Fatal("expected the wrong passphrase to fail")
}
}
func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) {
senderPub, senderPriv, err := GenerateKeyPair("sender@example.com", "sender pass")
if err != nil {
t.Fatal(err)
}
recipPub, recipPriv, err := GenerateKeyPair("recipient@example.com", "recipient pass")
if err != nil {
t.Fatal(err)
}
senderPubEntity, _ := ParsePublicKey(senderPub)
recipPubEntity, _ := ParsePublicKey(recipPub)
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{senderPubEntity, recipPubEntity})
if err != nil {
t.Fatal(err)
}
senderIdentity, _ := ParsePrivateKey(senderPriv)
if err := UnlockPrivateKey(senderIdentity, "sender pass"); err != nil {
t.Fatal(err)
}
senderCopy, err := DecryptEntity(encrypted, senderIdentity)
if err != nil {
t.Fatalf("sender DecryptEntity: %v", err)
}
if !bytes.Equal(senderCopy.Body, orig.Body) {
t.Fatal("sender's own copy did not decrypt to the original body")
}
recipIdentity, _ := ParsePrivateKey(recipPriv)
if err := UnlockPrivateKey(recipIdentity, "recipient pass"); err != nil {
t.Fatal(err)
}
recipCopy, err := DecryptEntity(encrypted, recipIdentity)
if err != nil {
t.Fatalf("recipient DecryptEntity: %v", err)
}
if !bytes.Equal(recipCopy.Body, orig.Body) {
t.Fatal("recipient's copy did not decrypt to the original body")
}
}
func TestImportPrivateKeyUnencrypted(t *testing.T) {
// Simulate a raw, not-yet-passphrase-protected export by generating a key and
// serializing it before EncryptPrivateKeys is ever called.
entity, err := openpgp.NewEntity("bob@example.com", "", "bob@example.com", defaultConfig())
if err != nil {
t.Fatal(err)
}
unprotectedArmor, err := serializeUnencryptedPrivate(entity)
if err != nil {
t.Fatal(err)
}
pubPEM, privPEM, err := ImportPrivateKey(unprotectedArmor, "new passphrase")
if err != nil {
t.Fatalf("ImportPrivateKey: %v", err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if !identity.PrivateKey.Encrypted {
t.Fatal("expected the imported key to be encrypted after import")
}
if err := UnlockPrivateKey(identity, "new passphrase"); err != nil {
t.Fatalf("expected the new passphrase to unlock the imported key: %v", err)
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity after import: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatal("round trip through an imported unencrypted key failed")
}
}
func TestImportPrivateKeyAlreadyEncrypted(t *testing.T) {
_, existingArmor, err := GenerateKeyPair("carol@example.com", "original passphrase")
if err != nil {
t.Fatal(err)
}
if _, _, err := ImportPrivateKey(existingArmor, "wrong passphrase"); err == nil {
t.Fatal("expected import with the wrong passphrase for an already-encrypted key to fail")
}
pubPEM, privPEM, err := ImportPrivateKey(existingArmor, "original passphrase")
if err != nil {
t.Fatalf("ImportPrivateKey with the correct passphrase: %v", err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if err := UnlockPrivateKey(identity, "original passphrase"); err != nil {
t.Fatalf("expected the original passphrase to still unlock after re-import: %v", err)
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity after re-import: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatal("round trip through a re-imported already-encrypted key failed")
}
}
func TestFingerprintIsStableAndNonEmpty(t *testing.T) {
pubPEM, _, err := GenerateKeyPair("alice@example.com", "pass")
if err != nil {
t.Fatal(err)
}
entity, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
fp := Fingerprint(entity)
if len(fp) == 0 {
t.Fatal("expected a non-empty fingerprint")
}
entity2, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
if Fingerprint(entity2) != fp {
t.Fatal("expected the fingerprint to be stable across re-parses of the same key")
}
}