tenseleyflow/shithub / 04e0987

Browse files

auth/gpgkey: OpenPGP public-key parser with gh-compatible Parsed type

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
04e0987fb9acef0e0d842ed907e62d62d013fe5b
Parents
b068f3d
Tree
2008fce

6 changed files

StatusFile+-
A internal/auth/gpgkey/errors.go 65 0
A internal/auth/gpgkey/parse.go 290 0
A internal/auth/gpgkey/parse_test.go 333 0
A internal/auth/gpgkey/testdata/README.md 19 0
A internal/auth/gpgkey/testdata/ed25519.asc 15 0
A internal/auth/gpgkey/testdata/rsa4096.asc 29 0
internal/auth/gpgkey/errors.goadded
@@ -0,0 +1,65 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package gpgkey
4
+
5
+import (
6
+	"errors"
7
+	"fmt"
8
+)
9
+
10
+// MinRSABits is the smallest accepted modulus length for OpenPGP RSA keys.
11
+const MinRSABits = 2048
12
+
13
+// MaxKeysPerUser bounds DB rows per user. Mirrors the SSH key cap.
14
+const MaxKeysPerUser = 100
15
+
16
+// Sentinel errors. The settings handler surfaces these verbatim as flash
17
+// messages, so each one is a self-contained one-line description from the
18
+// user's perspective.
19
+var (
20
+	// ErrPrivateKeyBlock fires when the uploaded armor is a private key
21
+	// block (BEGIN PGP PRIVATE KEY BLOCK). We never accept private
22
+	// material — the user almost certainly meant to upload the public
23
+	// half.
24
+	ErrPrivateKeyBlock = errors.New("gpgkey: that looks like a private key — please upload your public key (gpg --armor --export <id>)")
25
+
26
+	// ErrSignatureBlock fires when the uploaded armor is a detached
27
+	// signature (BEGIN PGP SIGNATURE).
28
+	ErrSignatureBlock = errors.New("gpgkey: that looks like a signature, not a public key")
29
+
30
+	// ErrUnparseable covers any other parse failure from the openpgp
31
+	// library. We deliberately don't surface library-internal error
32
+	// messages here; the user typically just needs to know it didn't
33
+	// parse and to try again.
34
+	ErrUnparseable = errors.New("gpgkey: could not parse key — please paste a public key armored block starting with -----BEGIN PGP PUBLIC KEY BLOCK-----")
35
+
36
+	// ErrNoIdentities fires for entities with zero UIDs. OpenPGP
37
+	// theoretically allows this but no real-world keyring would produce
38
+	// one; reject so the rest of the pipeline can assume at least one
39
+	// uid.
40
+	ErrNoIdentities = errors.New("gpgkey: key has no user IDs")
41
+
42
+	// ErrExpired fires for primary keys that are already expired at
43
+	// upload time. Past commits signed by the key remain verifiable
44
+	// (S52 territory); uploading a brand-new expired key has no
45
+	// consumer.
46
+	ErrExpired = errors.New("gpgkey: this key has expired")
47
+
48
+	// ErrUnsupportedAlgo gates DSA and Elgamal-only entities (neither
49
+	// has a sensible signing path on modern git workflows).
50
+	ErrUnsupportedAlgo = errors.New("gpgkey: unsupported key algorithm (accepted: ed25519, ecdsa-nistp256/384/521, RSA ≥ 2048)")
51
+
52
+	// ErrRSATooShort fires for RSA primary keys under MinRSABits.
53
+	ErrRSATooShort = fmt.Errorf("gpgkey: RSA keys must be at least %d bits", MinRSABits)
54
+
55
+	// ErrMultipleEntities fires when the armor contains more than one
56
+	// entity (key + key, vs key + subkeys-which-are-fine). We accept
57
+	// exactly one primary per upload.
58
+	ErrMultipleEntities = errors.New("gpgkey: please upload one public key at a time")
59
+
60
+	// ErrNameTooLong gates the optional user-given title.
61
+	ErrNameTooLong = errors.New("gpgkey: name may be at most 80 characters")
62
+
63
+	// ErrNameControl gates control characters in the name field.
64
+	ErrNameControl = errors.New("gpgkey: name contains control characters")
65
+)
internal/auth/gpgkey/parse.goadded
@@ -0,0 +1,290 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package gpgkey wraps OpenPGP public-key parsing, validation, and
4
+// fingerprinting. Every settings handler, REST endpoint, and future
5
+// import path that accepts user-supplied PGP keys goes through Parse
6
+// so the algorithm whitelist + capability extraction lives in exactly
7
+// one place.
8
+//
9
+// shithub mirrors GitHub's /user/gpg_keys response shape; the Parsed
10
+// type carries all the fields that response needs so callers don't
11
+// re-parse the armored block on read. Verification (S51 sub-PR 2) and
12
+// rendering (S51 sub-PR 5) consume the same Parsed type via the sqlc
13
+// row mapping.
14
+package gpgkey
15
+
16
+import (
17
+	"encoding/hex"
18
+	"fmt"
19
+	"strings"
20
+	"time"
21
+
22
+	"github.com/ProtonMail/go-crypto/openpgp"
23
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
24
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
25
+)
26
+
27
+// Armor block type constants per RFC 4880 §6.2.
28
+const (
29
+	armorTypePublicKey  = "PGP PUBLIC KEY BLOCK"
30
+	armorTypePrivateKey = "PGP PRIVATE KEY BLOCK"
31
+	armorTypeSignature  = "PGP SIGNATURE"
32
+)
33
+
34
+// Parsed is the validated, ready-to-store representation of a user-
35
+// supplied PGP public key. Mirrors the gh /user/gpg_keys response
36
+// shape; the sqlc Insert path consumes this struct directly.
37
+type Parsed struct {
38
+	// Name is the optional user-given title (gh's "name" field).
39
+	// Blank string when the user omitted it.
40
+	Name string
41
+
42
+	// Fingerprint is the canonical lowercase 40-hex SHA-1 fingerprint
43
+	// of the primary public key packet.
44
+	Fingerprint string
45
+
46
+	// KeyID is the lower 64 bits of the fingerprint, lowercase hex
47
+	// (16 chars). Stored denormalized for log-line lookups.
48
+	KeyID string
49
+
50
+	// Armored is the ASCII-armored block exactly as uploaded, round-
51
+	// trippable. Stored verbatim so the REST `public_key` / `raw_key`
52
+	// fields can be served without re-armoring.
53
+	Armored string
54
+
55
+	// Primary-key capability flags decoded from the primary identity's
56
+	// self-signature. Split per RFC 4880 §5.2.3.21 to match gh's
57
+	// can_encrypt_comms / can_encrypt_storage shape.
58
+	CanSign           bool
59
+	CanEncryptComms   bool
60
+	CanEncryptStorage bool
61
+	CanCertify        bool
62
+	CanAuthenticate   bool
63
+
64
+	// UIDs are the email addresses parsed from the entity's
65
+	// identities. May be empty strings for identities without an email
66
+	// component (gh tolerates these; we surface them as "" entries).
67
+	UIDs []string
68
+
69
+	// Subkeys is the per-subkey metadata used both for the user_gpg_
70
+	// subkeys table inserts and for the REST nested-subkeys response
71
+	// shape.
72
+	Subkeys []ParsedSubkey
73
+
74
+	// PrimaryAlgo is a short ASCII description like "ed25519" or
75
+	// "rsa4096". For UI display only.
76
+	PrimaryAlgo string
77
+
78
+	// ExpiresAt is the primary key's expiration timestamp, nil for
79
+	// keys that never expire.
80
+	ExpiresAt *time.Time
81
+}
82
+
83
+// ParsedSubkey carries per-subkey metadata for the user_gpg_subkeys
84
+// table + REST response.
85
+type ParsedSubkey struct {
86
+	Fingerprint       string
87
+	KeyID             string
88
+	CanSign           bool
89
+	CanEncryptComms   bool
90
+	CanEncryptStorage bool
91
+	CanCertify        bool
92
+	ExpiresAt         *time.Time
93
+}
94
+
95
+// Parse validates a user-supplied armored OpenPGP public-key block.
96
+// Returns ErrPrivateKeyBlock / ErrSignatureBlock when the user pasted
97
+// the wrong block type; ErrUnparseable for any other parse failure;
98
+// the algorithm / expiry / no-uids errors as appropriate; or *Parsed
99
+// on success.
100
+//
101
+// Encryption-only keys (no signing capability on the primary or any
102
+// subkey) are ACCEPTED — gh parity. Surface can_sign=false in the
103
+// REST response; clients can filter on the flag.
104
+func Parse(name, armored string) (*Parsed, error) {
105
+	trimmedName := strings.TrimSpace(name)
106
+	if len(trimmedName) > 80 {
107
+		return nil, ErrNameTooLong
108
+	}
109
+	if hasControlChars(trimmedName) {
110
+		return nil, ErrNameControl
111
+	}
112
+
113
+	// Peek at the armor block type so we can produce a precise error
114
+	// for the private-key / signature mistakes (the most common user
115
+	// errors). openpgp.ReadArmoredKeyRing rejects both with a generic
116
+	// "no public keys found" error otherwise.
117
+	armored = strings.TrimLeft(armored, "\r\n\t ")
118
+	block, err := armor.Decode(strings.NewReader(armored))
119
+	if err != nil {
120
+		return nil, ErrUnparseable
121
+	}
122
+	switch block.Type {
123
+	case armorTypePublicKey:
124
+		// OK; parse as a key block.
125
+	case armorTypePrivateKey:
126
+		return nil, ErrPrivateKeyBlock
127
+	case armorTypeSignature:
128
+		return nil, ErrSignatureBlock
129
+	default:
130
+		return nil, ErrUnparseable
131
+	}
132
+
133
+	// We've consumed the armor reader above. Reparse from the original
134
+	// string via ReadArmoredKeyRing so we get a populated EntityList
135
+	// without re-implementing the packet walker.
136
+	entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(armored))
137
+	if err != nil || len(entities) == 0 {
138
+		return nil, ErrUnparseable
139
+	}
140
+	if len(entities) > 1 {
141
+		return nil, ErrMultipleEntities
142
+	}
143
+	e := entities[0]
144
+
145
+	if e.PrimaryKey == nil {
146
+		return nil, ErrUnparseable
147
+	}
148
+	if len(e.Identities) == 0 {
149
+		return nil, ErrNoIdentities
150
+	}
151
+
152
+	primaryAlgo, ok := algoLabel(e.PrimaryKey)
153
+	if !ok {
154
+		return nil, ErrUnsupportedAlgo
155
+	}
156
+	if e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSA ||
157
+		e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSASignOnly ||
158
+		e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSAEncryptOnly {
159
+		bits, err := e.PrimaryKey.BitLength()
160
+		if err != nil {
161
+			return nil, ErrUnparseable
162
+		}
163
+		if int(bits) < MinRSABits {
164
+			return nil, ErrRSATooShort
165
+		}
166
+	}
167
+
168
+	primaryID := e.PrimaryIdentity()
169
+	if primaryID == nil || primaryID.SelfSignature == nil {
170
+		return nil, ErrUnparseable
171
+	}
172
+
173
+	// Expiry: a primary's KeyLifetimeSecs lives on the primary
174
+	// identity's self-signature. nil => never expires.
175
+	primaryExpires := keyExpiry(e.PrimaryKey.CreationTime, primaryID.SelfSignature.KeyLifetimeSecs)
176
+	if primaryExpires != nil && primaryExpires.Before(time.Now()) {
177
+		return nil, ErrExpired
178
+	}
179
+
180
+	// Capability flags decoded from the primary self-sig. When the
181
+	// flags subpacket is absent (some very old keys), the boolean
182
+	// fields on the signature default to false — gh interprets the
183
+	// same way so we don't need to special-case.
184
+	canSign, canEncComms, canEncStorage, canCertify, canAuth := capabilityFlags(primaryID.SelfSignature)
185
+
186
+	parsed := &Parsed{
187
+		Name:              trimmedName,
188
+		Fingerprint:       hex.EncodeToString(e.PrimaryKey.Fingerprint),
189
+		KeyID:             fmt.Sprintf("%016x", e.PrimaryKey.KeyId),
190
+		Armored:           strings.TrimRight(armored, "\r\n\t ") + "\n",
191
+		CanSign:           canSign,
192
+		CanEncryptComms:   canEncComms,
193
+		CanEncryptStorage: canEncStorage,
194
+		CanCertify:        canCertify,
195
+		CanAuthenticate:   canAuth,
196
+		PrimaryAlgo:       primaryAlgo,
197
+		ExpiresAt:         primaryExpires,
198
+	}
199
+
200
+	for uidKey := range e.Identities {
201
+		email := e.Identities[uidKey].UserId.Email
202
+		parsed.UIDs = append(parsed.UIDs, email)
203
+	}
204
+	if parsed.UIDs == nil {
205
+		parsed.UIDs = []string{}
206
+	}
207
+
208
+	for i := range e.Subkeys {
209
+		sk := &e.Subkeys[i]
210
+		if sk.PublicKey == nil || sk.Sig == nil {
211
+			continue
212
+		}
213
+		skCanSign, skCanEncComms, skCanEncStorage, skCanCertify, _ := capabilityFlags(sk.Sig)
214
+		parsed.Subkeys = append(parsed.Subkeys, ParsedSubkey{
215
+			Fingerprint:       hex.EncodeToString(sk.PublicKey.Fingerprint),
216
+			KeyID:             fmt.Sprintf("%016x", sk.PublicKey.KeyId),
217
+			CanSign:           skCanSign,
218
+			CanEncryptComms:   skCanEncComms,
219
+			CanEncryptStorage: skCanEncStorage,
220
+			CanCertify:        skCanCertify,
221
+			ExpiresAt:         keyExpiry(sk.PublicKey.CreationTime, sk.Sig.KeyLifetimeSecs),
222
+		})
223
+	}
224
+	if parsed.Subkeys == nil {
225
+		parsed.Subkeys = []ParsedSubkey{}
226
+	}
227
+
228
+	return parsed, nil
229
+}
230
+
231
+// algoLabel returns a short UI-friendly label for the key algorithm.
232
+// Returns (label, true) when the algorithm is accepted; ("", false)
233
+// to reject (DSA, Elgamal-only).
234
+func algoLabel(pk *packet.PublicKey) (string, bool) {
235
+	switch pk.PubKeyAlgo {
236
+	case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly, packet.PubKeyAlgoRSAEncryptOnly:
237
+		bits, _ := pk.BitLength()
238
+		return fmt.Sprintf("rsa%d", bits), true
239
+	case packet.PubKeyAlgoEdDSA:
240
+		return "ed25519", true
241
+	case packet.PubKeyAlgoECDSA:
242
+		return "ecdsa", true
243
+	case packet.PubKeyAlgoECDH:
244
+		// Encryption-capable elliptic. Accept; surface honestly.
245
+		return "ecdh", true
246
+	case packet.PubKeyAlgoDSA:
247
+		return "", false
248
+	case packet.PubKeyAlgoElGamal:
249
+		return "", false
250
+	}
251
+	return "", false
252
+}
253
+
254
+// capabilityFlags decodes the can_sign / can_encrypt_* / can_certify /
255
+// can_authenticate flags from a self-signature or subkey-binding
256
+// signature. The ProtonMail/go-crypto package surfaces these as
257
+// individual booleans on the Signature struct.
258
+func capabilityFlags(sig *packet.Signature) (canSign, canEncComms, canEncStorage, canCertify, canAuth bool) {
259
+	if sig == nil {
260
+		return
261
+	}
262
+	// When FlagsValid is false the flag subpacket was absent. RFC 4880
263
+	// then says implementations should infer capabilities from the key
264
+	// algorithm; we follow gh's behavior of treating absent flags as
265
+	// "no explicit capabilities asserted" and surfacing all false.
266
+	if !sig.FlagsValid {
267
+		return
268
+	}
269
+	return sig.FlagSign, sig.FlagEncryptCommunications, sig.FlagEncryptStorage, sig.FlagCertify, sig.FlagAuthenticate
270
+}
271
+
272
+// keyExpiry computes an absolute expiration time from a creation time
273
+// plus an optional lifetime-in-seconds (the self-sig subpacket).
274
+// Returns nil for keys that never expire.
275
+func keyExpiry(creation time.Time, lifetimeSecs *uint32) *time.Time {
276
+	if lifetimeSecs == nil || *lifetimeSecs == 0 {
277
+		return nil
278
+	}
279
+	t := creation.Add(time.Duration(*lifetimeSecs) * time.Second)
280
+	return &t
281
+}
282
+
283
+func hasControlChars(s string) bool {
284
+	for _, r := range s {
285
+		if r < 0x20 || r == 0x7f {
286
+			return true
287
+		}
288
+	}
289
+	return false
290
+}
internal/auth/gpgkey/parse_test.goadded
@@ -0,0 +1,333 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package gpgkey
4
+
5
+import (
6
+	"bytes"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/ProtonMail/go-crypto/openpgp"
13
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
14
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
15
+)
16
+
17
+// ─── fixture helpers ────────────────────────────────────────────────
18
+//
19
+// We synthesize all test fixtures in-memory via ProtonMail/go-crypto
20
+// rather than shipping committed .asc files. Tests then exercise the
21
+// real codec end-to-end (serialize → parse → assert) without depending
22
+// on a system gpg binary.
23
+
24
+// newEd25519 returns a freshly-generated ed25519 entity with a single
25
+// UID. ProtonMail's nil-config default is RSA-2048; we have to ask
26
+// for EdDSA explicitly.
27
+func newEd25519(t *testing.T, email string) *openpgp.Entity {
28
+	t.Helper()
29
+	e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
30
+		Algorithm: packet.PubKeyAlgoEdDSA,
31
+	})
32
+	if err != nil {
33
+		t.Fatalf("NewEntity ed25519: %v", err)
34
+	}
35
+	return e
36
+}
37
+
38
+// newRSA returns an RSA entity at the requested bit size.
39
+func newRSA(t *testing.T, email string, bits int) *openpgp.Entity {
40
+	t.Helper()
41
+	e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
42
+		Algorithm: packet.PubKeyAlgoRSA,
43
+		RSABits:   bits,
44
+	})
45
+	if err != nil {
46
+		t.Fatalf("NewEntity rsa%d: %v", bits, err)
47
+	}
48
+	return e
49
+}
50
+
51
+// armoredPublic serializes an entity's public-key block as ASCII armor.
52
+func armoredPublic(t *testing.T, e *openpgp.Entity) string {
53
+	t.Helper()
54
+	var buf bytes.Buffer
55
+	w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
56
+	if err != nil {
57
+		t.Fatalf("armor.Encode: %v", err)
58
+	}
59
+	if err := e.Serialize(w); err != nil {
60
+		t.Fatalf("entity.Serialize: %v", err)
61
+	}
62
+	if err := w.Close(); err != nil {
63
+		t.Fatalf("armor close: %v", err)
64
+	}
65
+	return buf.String()
66
+}
67
+
68
+// armoredPrivate serializes the SECRET key block — the "user uploaded
69
+// their private key by mistake" fixture.
70
+func armoredPrivate(t *testing.T, e *openpgp.Entity) string {
71
+	t.Helper()
72
+	var buf bytes.Buffer
73
+	w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
74
+	if err != nil {
75
+		t.Fatalf("armor.Encode private: %v", err)
76
+	}
77
+	if err := e.SerializePrivate(w, nil); err != nil {
78
+		t.Fatalf("entity.SerializePrivate: %v", err)
79
+	}
80
+	if err := w.Close(); err != nil {
81
+		t.Fatalf("armor close: %v", err)
82
+	}
83
+	return buf.String()
84
+}
85
+
86
+// armoredDetachedSig returns an armored detached signature over a
87
+// small payload — the "user uploaded a signature by mistake" fixture.
88
+func armoredDetachedSig(t *testing.T, e *openpgp.Entity) string {
89
+	t.Helper()
90
+	var buf bytes.Buffer
91
+	w, err := armor.Encode(&buf, "PGP SIGNATURE", nil)
92
+	if err != nil {
93
+		t.Fatalf("armor.Encode sig: %v", err)
94
+	}
95
+	if err := openpgp.DetachSign(w, e, strings.NewReader("hello"), nil); err != nil {
96
+		t.Fatalf("DetachSign: %v", err)
97
+	}
98
+	if err := w.Close(); err != nil {
99
+		t.Fatalf("armor close: %v", err)
100
+	}
101
+	return buf.String()
102
+}
103
+
104
+// ─── happy-path tests ───────────────────────────────────────────────
105
+
106
+func TestParse_Ed25519(t *testing.T) {
107
+	e := newEd25519(t, "alice@shithub.test")
108
+	armored := armoredPublic(t, e)
109
+
110
+	got, err := Parse("My laptop", armored)
111
+	if err != nil {
112
+		t.Fatalf("Parse: %v", err)
113
+	}
114
+
115
+	if got.Name != "My laptop" {
116
+		t.Errorf("Name: got %q, want %q", got.Name, "My laptop")
117
+	}
118
+	if len(got.Fingerprint) != 40 {
119
+		t.Errorf("Fingerprint length: got %d, want 40", len(got.Fingerprint))
120
+	}
121
+	if !isHex(got.Fingerprint) {
122
+		t.Errorf("Fingerprint not hex: %q", got.Fingerprint)
123
+	}
124
+	if len(got.KeyID) != 16 || !isHex(got.KeyID) {
125
+		t.Errorf("KeyID malformed: %q", got.KeyID)
126
+	}
127
+	if !strings.HasSuffix(got.Fingerprint, got.KeyID) {
128
+		t.Errorf("KeyID should be lower 16 of fingerprint: fp=%s key_id=%s", got.Fingerprint, got.KeyID)
129
+	}
130
+	if got.PrimaryAlgo != "ed25519" {
131
+		t.Errorf("PrimaryAlgo: got %q, want ed25519", got.PrimaryAlgo)
132
+	}
133
+	if !got.CanSign {
134
+		t.Error("expected CanSign=true for default ed25519 primary")
135
+	}
136
+	if !got.CanCertify {
137
+		t.Error("expected CanCertify=true for default ed25519 primary")
138
+	}
139
+	if got.ExpiresAt != nil {
140
+		t.Errorf("expected ExpiresAt=nil for default no-expiry key; got %v", got.ExpiresAt)
141
+	}
142
+	if len(got.UIDs) != 1 || got.UIDs[0] != "alice@shithub.test" {
143
+		t.Errorf("UIDs: got %v, want [alice@shithub.test]", got.UIDs)
144
+	}
145
+	// Default openpgp.NewEntity creates one encryption subkey.
146
+	if len(got.Subkeys) < 1 {
147
+		t.Errorf("expected at least one subkey; got %d", len(got.Subkeys))
148
+	}
149
+}
150
+
151
+func TestParse_RSA4096(t *testing.T) {
152
+	e := newRSA(t, "bob@shithub.test", 4096)
153
+	armored := armoredPublic(t, e)
154
+	got, err := Parse("", armored)
155
+	if err != nil {
156
+		t.Fatalf("Parse: %v", err)
157
+	}
158
+	if got.PrimaryAlgo != "rsa4096" {
159
+		t.Errorf("PrimaryAlgo: got %q, want rsa4096", got.PrimaryAlgo)
160
+	}
161
+	if !got.CanSign || !got.CanCertify {
162
+		t.Errorf("expected sign+certify on RSA primary; got sign=%t certify=%t", got.CanSign, got.CanCertify)
163
+	}
164
+}
165
+
166
+func TestParse_EncryptOnly_Accepted(t *testing.T) {
167
+	// Build an entity, then strip its primary's sign+certify flags so
168
+	// it's encrypt-only. Re-issue the self-signature so the modified
169
+	// flags persist through Serialize.
170
+	e := newRSA(t, "encryptonly@shithub.test", 2048)
171
+	for _, id := range e.Identities {
172
+		id.SelfSignature.FlagSign = false
173
+		id.SelfSignature.FlagCertify = false
174
+		id.SelfSignature.FlagEncryptCommunications = true
175
+		id.SelfSignature.FlagEncryptStorage = true
176
+		// Re-sign with the modified flags.
177
+		if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, nil); err != nil {
178
+			t.Fatalf("re-sign identity: %v", err)
179
+		}
180
+	}
181
+	armored := armoredPublic(t, e)
182
+
183
+	got, err := Parse("encryption only key", armored)
184
+	if err != nil {
185
+		t.Fatalf("Parse should accept encryption-only keys (gh parity); got: %v", err)
186
+	}
187
+	if got.CanSign {
188
+		t.Error("CanSign: got true, want false on encryption-only primary")
189
+	}
190
+	if !got.CanEncryptComms && !got.CanEncryptStorage {
191
+		t.Error("expected at least one encrypt-* flag true")
192
+	}
193
+}
194
+
195
+func TestParse_MultiSubkey(t *testing.T) {
196
+	e := newEd25519(t, "multi@shithub.test")
197
+	// Add an extra signing subkey. ProtonMail/go-crypto's AddSigningSubkey
198
+	// requires a Config to specify the algorithm.
199
+	if err := e.AddSigningSubkey(nil); err != nil {
200
+		t.Fatalf("AddSigningSubkey: %v", err)
201
+	}
202
+	armored := armoredPublic(t, e)
203
+	got, err := Parse("", armored)
204
+	if err != nil {
205
+		t.Fatalf("Parse: %v", err)
206
+	}
207
+	if len(got.Subkeys) < 2 {
208
+		t.Errorf("expected >=2 subkeys (one encryption from default, one we added); got %d", len(got.Subkeys))
209
+	}
210
+	// At least one subkey should have can_sign.
211
+	anySigning := false
212
+	for _, sk := range got.Subkeys {
213
+		if sk.CanSign {
214
+			anySigning = true
215
+			break
216
+		}
217
+	}
218
+	if !anySigning {
219
+		t.Error("expected at least one signing subkey")
220
+	}
221
+}
222
+
223
+// ─── rejection tests ────────────────────────────────────────────────
224
+
225
+func TestParse_PrivateKeyBlock(t *testing.T) {
226
+	e := newEd25519(t, "private@shithub.test")
227
+	armored := armoredPrivate(t, e)
228
+	_, err := Parse("", armored)
229
+	if !errors.Is(err, ErrPrivateKeyBlock) {
230
+		t.Errorf("err: got %v, want ErrPrivateKeyBlock", err)
231
+	}
232
+}
233
+
234
+func TestParse_SignatureBlock(t *testing.T) {
235
+	e := newEd25519(t, "sig@shithub.test")
236
+	armored := armoredDetachedSig(t, e)
237
+	_, err := Parse("", armored)
238
+	if !errors.Is(err, ErrSignatureBlock) {
239
+		t.Errorf("err: got %v, want ErrSignatureBlock", err)
240
+	}
241
+}
242
+
243
+func TestParse_Expired(t *testing.T) {
244
+	// Create an entity with a backdated creation time + a short lifetime
245
+	// so the key is already expired by `time.Now()`.
246
+	past := time.Now().Add(-48 * time.Hour)
247
+	cfg := &packet.Config{
248
+		Time: func() time.Time { return past },
249
+	}
250
+	e, err := openpgp.NewEntity("shithub-expired", "", "expired@shithub.test", cfg)
251
+	if err != nil {
252
+		t.Fatalf("NewEntity: %v", err)
253
+	}
254
+	// 1-hour lifetime from "past" → expired ~47 hours ago.
255
+	oneHour := uint32(3600)
256
+	for _, id := range e.Identities {
257
+		id.SelfSignature.KeyLifetimeSecs = &oneHour
258
+		if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, cfg); err != nil {
259
+			t.Fatalf("re-sign for expiry: %v", err)
260
+		}
261
+	}
262
+	armored := armoredPublic(t, e)
263
+	_, err = Parse("", armored)
264
+	if !errors.Is(err, ErrExpired) {
265
+		t.Errorf("err: got %v, want ErrExpired", err)
266
+	}
267
+}
268
+
269
+func TestParse_RSATooShort(t *testing.T) {
270
+	e := newRSA(t, "short@shithub.test", 1024)
271
+	armored := armoredPublic(t, e)
272
+	_, err := Parse("", armored)
273
+	if !errors.Is(err, ErrRSATooShort) {
274
+		t.Errorf("err: got %v, want ErrRSATooShort", err)
275
+	}
276
+}
277
+
278
+func TestParse_Garbage(t *testing.T) {
279
+	_, err := Parse("", "not a key at all, just garbage")
280
+	if !errors.Is(err, ErrUnparseable) {
281
+		t.Errorf("err: got %v, want ErrUnparseable", err)
282
+	}
283
+}
284
+
285
+func TestParse_Empty(t *testing.T) {
286
+	_, err := Parse("", "")
287
+	if !errors.Is(err, ErrUnparseable) {
288
+		t.Errorf("err: got %v, want ErrUnparseable", err)
289
+	}
290
+}
291
+
292
+func TestParse_LeadingWhitespaceTolerated(t *testing.T) {
293
+	e := newEd25519(t, "ws@shithub.test")
294
+	armored := "\n\n  \t" + armoredPublic(t, e)
295
+	if _, err := Parse("", armored); err != nil {
296
+		t.Errorf("Parse should trim leading whitespace; got %v", err)
297
+	}
298
+}
299
+
300
+// ─── name-validation tests ──────────────────────────────────────────
301
+
302
+func TestParse_NameTooLong(t *testing.T) {
303
+	e := newEd25519(t, "n@shithub.test")
304
+	armored := armoredPublic(t, e)
305
+	long := strings.Repeat("x", 81)
306
+	_, err := Parse(long, armored)
307
+	if !errors.Is(err, ErrNameTooLong) {
308
+		t.Errorf("err: got %v, want ErrNameTooLong", err)
309
+	}
310
+}
311
+
312
+func TestParse_NameControlChars(t *testing.T) {
313
+	e := newEd25519(t, "n@shithub.test")
314
+	armored := armoredPublic(t, e)
315
+	_, err := Parse("bad\x00name", armored)
316
+	if !errors.Is(err, ErrNameControl) {
317
+		t.Errorf("err: got %v, want ErrNameControl", err)
318
+	}
319
+}
320
+
321
+// ─── helpers ────────────────────────────────────────────────────────
322
+
323
+func isHex(s string) bool {
324
+	for _, r := range s {
325
+		switch {
326
+		case r >= '0' && r <= '9':
327
+		case r >= 'a' && r <= 'f':
328
+		default:
329
+			return false
330
+		}
331
+	}
332
+	return true
333
+}
internal/auth/gpgkey/testdata/README.mdadded
@@ -0,0 +1,19 @@
1
+# gpgkey testdata
2
+
3
+This directory exists for future committed fixtures (real-world
4
+`gpg`-produced ASCII-armored blocks that might be useful as
5
+regression-test inputs). It is **empty by default**.
6
+
7
+The current `parse_test.go` synthesizes its fixtures in-memory via
8
+`github.com/ProtonMail/go-crypto/openpgp` so:
9
+
10
+- Tests run without `gpg` installed (CI portability).
11
+- Fixtures are deterministic (no time-bomb expiry races).
12
+- The `private` and `signature` armor-block fixtures don't have to
13
+  be committed as files (they're constructed on demand from synthesized
14
+  entities).
15
+
16
+If a future bug surfaces from a specific real-world key shape, drop the
17
+producing key here as `<shape>.asc` and reference it from
18
+`parse_test.go` via `os.ReadFile`. Keep keys throwaway; never commit
19
+material from a real user.
internal/auth/gpgkey/testdata/ed25519.ascadded
@@ -0,0 +1,15 @@
1
+-----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+mDMEagPLfhYJKwYBBAHaRw8BAQdAVj/hc1vXftWBoMvUEPwB2oI4Vd51xiEd2jtL
4
+btgy0i20K3NoaXRodWItdGVzdC1lZDI1NTE5IDxlZDI1NTE5QHNoaXRodWIudGVz
5
+dD6IrwQTFgoAVxYhBCswJYfZ3oj3rIq6s8U8DyTWGhWlBQJqA8t+GxSAAAAAAAQA
6
+Dm1hbnUyLDIuNSsxLjEyLDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIX
7
+gAAKCRDFPA8k1hoVpQSyAQCJ1WMxpz6fV1sBUNfbY5RpZZOSC+btOm0pFNROLBTu
8
+PgEAwu6vV5F8/YghPGWmO3yyVkPWpoPUeMeV4xTvY7+0xQm4OARqA8t+EgorBgEE
9
+AZdVAQUBAQdAKn8usSXuiOSST+96jvzEJP0ihW5Nc7voTI4vuCqMgE4DAQgHiJQE
10
+GBYKADwWIQQrMCWH2d6I96yKurPFPA8k1hoVpQUCagPLfhsUgAAAAAAEAA5tYW51
11
+MiwyLjUrMS4xMiwwLDMCGwwACgkQxTwPJNYaFaWwrwD/YqZPs31x20o2GhRw55n2
12
+TnuhizfSzrSYDHtQdQbGJKAA/j5aIqbfo2PezrW06ZzX2X4AhigSh3IuhIrBYFh9
13
+DWsI
14
+=FcHX
15
+-----END PGP PUBLIC KEY BLOCK-----
internal/auth/gpgkey/testdata/rsa4096.ascadded
@@ -0,0 +1,29 @@
1
+-----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+mQINBGoDy34BEACktKtAYW2Zajz0BnHRNcrdCb2oAP9cDxlBrdY/CpQi/81m2CAX
4
+GE/NvopEMUgtaPiNX6JymNxvwumVqpT3V37bbX3h0rYPRzt4TJfuASleMRsoBjSe
5
+KRXOZdlqxp8YB9HObcqi8U+tB7WpktI1rx/XJP5d38Ac+wBJSxPzjo97XchpgeiK
6
+rRps49BI2Jz7ibH0pjsBUqboSTV6sYbMOOXD2FajGvQTIqgQ7eTpQKAW3F0/dvIW
7
+wOAegfXi+gTh5veSsNFxx+uVo5WSVDM6jkH2FMV9s4gMtOXBurGemunRpw9qSoeS
8
+0s7wXgKUhdcANEGApM8CxGKqatWs3zye/oyA2ClVwc7Jayyv+HRlIKW40ool42jL
9
+i/r7hX7aVbKh88TFK687Yc1ekj54nWF0ETvjgbNGZdlnYaOVwpDZHIpgLuA7ewCa
10
+Ec5/EsUx4xANcoScLodXo+HilRDhVd8r+DgSceKKN9L0YgrZShrUxsndusoHUYin
11
+U+a4IAPAbQVpc8BpyAcSKNaMuuQdiAEOXssj78hSU2A7qvBxrQyMvpSil3F/pHAe
12
+98bn7cHuqgHi3NwhMGNkR17nOKd4LkgjL40fjdiXqYyTI7FBNShhaZRL8vl3tShw
13
+Iw9ctIcFzMUKYmwYIR41tly+HJ023ADaQQCWBgXhN5ZBg6ErkRqbl1EtaQARAQAB
14
+tCNzaGl0aHViLXRlc3QtcnNhIDxyc2FAc2hpdGh1Yi50ZXN0PokCbQQTAQgAVxYh
15
+BP28SI2aLcvPiEd2b+jLR2GotNCDBQJqA8t+GxSAAAAAAAQADm1hbnUyLDIuNSsx
16
+LjEyLDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRDoy0dhqLTQ
17
+g+hzD/9+oGz9Vdu2H4DCj+n9liJ/lPs53OeVU0P9CD+ghlUiE2OtgXj2jzHYySKR
18
+XJtcyypphhuobWUSSHZhoYlFc/XiJeMrnbhWB9gIbV5+UCnjucS8ycVIN9n/s2b7
19
+vBdT3BkOmq0qJDmFKoolR4vwtyO3ZDbEThB2UfWhBvC7QZ+yjBiqLHxQ7xQvqJJH
20
+a0LTTPMotsBLOWQUFxLLo66KYYwlrwj/YvR/V4COm7q2hs7jPYS4WUpD2wQ3b986
21
+JLqOELKNAn1ZxFhPzGW+CqiAzPmeBDr/tltGp+/MdHjvaJy6Mtr2kWNLfgjRwYkY
22
+FK6Z1z4An2Lf+IvnQgaaVa4C5AiPWCWBcMCvwBy+Sp3gN+0kHc2YTS6Bd9A6pMMR
23
+mrCGZoX6CK8L8iIIxg90OB85ThPlXXnl7ENcgWNJAkQiEwA7/U+nAzKOMWxPvrpz
24
+WTVcHiHpg7YSuuEBNss3Lu1oVx6LVh89jCuffkYMvHnXd3xTOUznCxUwBsVs25JO
25
+fAS3OsYF9htSyiDQwzFQ6ebAwqtFIjiBEMraSMOodhyklyxFDP+yFPjaefxFI0KK
26
+b7QZkMgw1FkrYwsVeTU6CXtER+k+i2qEsuq+xjirgs6d4T25p1ZKTv3JJP0cv7vs
27
+W7WYe1ZElP6M2qZwyrmXFvZ0mLSh5XDLnuD5Zt9XmVC7ojEvZA==
28
+=1tm2
29
+-----END PGP PUBLIC KEY BLOCK-----