1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "bytes"
7 "fmt"
8 "net/mail"
9 "slices"
10 "strings"
11 "time"
12)
13
14// SignatureFormat is the protocol of a commit signature (ADR 0030).
15type SignatureFormat string
16
17// Signature formats. FormatNone is an unsigned commit.
18const (
19 FormatNone SignatureFormat = ""
20 FormatSSH SignatureFormat = "ssh"
21 FormatGPG SignatureFormat = "gpg"
22 // FormatX509 is a gpgsm (S/MIME) signature: recognized, never verified.
23 FormatX509 SignatureFormat = "x509"
24)
25
26// ParseSignatureFormat parses the text form of a key format, "ssh" or "gpg".
27// x509 keys cannot be trusted.
28func ParseSignatureFormat(s string) (SignatureFormat, error) {
29 switch f := SignatureFormat(strings.ToLower(strings.TrimSpace(s))); f {
30 case FormatSSH, FormatGPG:
31 return f, nil
32 }
33 return FormatNone, fmt.Errorf("%w: key format %q, want ssh or gpg", ErrInvalid, s)
34}
35
36// SignatureStatus is the outcome of checking a commit signature.
37type SignatureStatus string
38
39// Signature statuses. StatusUnchecked is the zero value: the commit was not
40// checked (or could not be read); it is never cached.
41const (
42 StatusUnchecked SignatureStatus = ""
43 // StatusUnsigned: the commit carries no signature.
44 StatusUnsigned SignatureStatus = "unsigned"
45 // StatusVerified: valid signature by a trusted key whose emails include
46 // the committer email.
47 StatusVerified SignatureStatus = "verified"
48 // StatusUnknownKey: well-formed signature by a key that is not trusted.
49 StatusUnknownKey SignatureStatus = "unknown_key"
50 // StatusBadSignature: malformed, or does not match the commit.
51 StatusBadSignature SignatureStatus = "bad_signature"
52 // StatusEmailMismatch: valid signature by a trusted key that is not
53 // trusted for the committer email.
54 StatusEmailMismatch SignatureStatus = "email_mismatch"
55 // StatusExpired: the GPG key or signature had expired.
56 StatusExpired SignatureStatus = "expired"
57 // StatusRevoked: the GPG key is revoked.
58 StatusRevoked SignatureStatus = "revoked"
59 // StatusUnsupported: a format ermite does not verify (x509).
60 StatusUnsupported SignatureStatus = "unsupported"
61)
62
63// CommitVerification is what ermite knows about the signature of a commit.
64type CommitVerification struct {
65 Format SignatureFormat
66 Status SignatureStatus
67 // Fingerprint is the fingerprint of the signing key when it could be
68 // read: "SHA256:<base64>" for SSH, 40 upper-case hex digits (or the
69 // 16-digit issuer key id when the key is unknown) for GPG.
70 Fingerprint string
71 // KeyID is the ID of the matching trusted key; empty when none matched.
72 KeyID string
73 // KeyTitle is the title of the matching trusted key.
74 KeyTitle string
75}
76
77// Signed reports whether the commit carries a signature.
78func (v CommitVerification) Signed() bool { return v.Format != FormatNone }
79
80// Limits of trusted keys.
81const (
82 MaxTrustedKeys = 100
83 MaxTrustedKeyEmails = 10
84 MaxTrustedKeyTitleLen = 100
85 MaxTrustedKeyBytes = 64 << 10
86 MaxCommitObjectBytes = 1 << 20
87 // MaxCommitObjectsBytes bounds the total size of the commit objects one
88 // ReadCommitObjects call returns.
89 MaxCommitObjectsBytes = 8 << 20
90 maxTrustedKeyEmailSize = 254
91)
92
93// TrustedKey is a public key whose signatures ermite accepts for the listed
94// committer emails (ADR 0030).
95type TrustedKey struct {
96 // ID is the key fingerprint: "SHA256:<base64>" for SSH, the primary key
97 // fingerprint in upper-case hex for GPG.
98 ID string
99 Format SignatureFormat
100 Title string
101 // PublicKey is the key as given: an authorized_keys line for SSH, an
102 // armored public key block for GPG.
103 PublicKey string
104 // Emails are the committer emails the key may sign for, lower-case.
105 Emails []string
106 CreatedAt time.Time
107 // Builtin marks the instance signing key (ADR 0022): listed, never stored
108 // nor deletable.
109 Builtin bool
110}
111
112// TrustsEmail reports whether the key may sign for the email (case-insensitive).
113func (k TrustedKey) TrustsEmail(email string) bool {
114 return slices.Contains(k.Emails, strings.ToLower(strings.TrimSpace(email)))
115}
116
117// ParseTrustedKeyTitle checks a key title: 1 to MaxTrustedKeyTitleLen bytes
118// once trimmed, no control characters.
119func ParseTrustedKeyTitle(s string) (string, error) {
120 s = strings.TrimSpace(s)
121 if s == "" || len(s) > MaxTrustedKeyTitleLen || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
122 return "", fmt.Errorf("%w: key title must be 1 to %d printable bytes", ErrInvalid, MaxTrustedKeyTitleLen)
123 }
124 return s, nil
125}
126
127// NormalizeKeyEmails lower-cases, trims, de-duplicates and checks emails:
128// 1 to MaxTrustedKeyEmails plain addresses ("a@b", no display name).
129func NormalizeKeyEmails(in []string) ([]string, error) {
130 out := make([]string, 0, len(in))
131 for _, e := range in {
132 e = strings.ToLower(strings.TrimSpace(e))
133 a, err := mail.ParseAddress(e)
134 if err != nil || a.Address != e || len(e) > maxTrustedKeyEmailSize {
135 return nil, fmt.Errorf("%w: email %q", ErrInvalid, e)
136 }
137 if !slices.Contains(out, e) {
138 out = append(out, e)
139 }
140 }
141 if len(out) == 0 || len(out) > MaxTrustedKeyEmails {
142 return nil, fmt.Errorf("%w: a key needs 1 to %d emails", ErrInvalid, MaxTrustedKeyEmails)
143 }
144 return out, nil
145}
146
147// SplitCommitSignature splits the raw content of a commit object (as stored,
148// without the "commit <size>\0" prefix) into the payload that was signed and
149// the signature, exactly as `git verify-commit` does for a SHA-1 repository
150// (checked against git 2.56 with a fake gpg.program):
151//
152// - in the header section only (up to the first empty line, so a message
153// cannot inject a header), every header whose name starts with "gpgsig"
154// (gpgsig, gpgsig-sha256, ...) is removed from the payload with its
155// continuation lines (lines that start with a space);
156// - the signature is the value of the "gpgsig " headers (name, one space,
157// value), with the leading space of each continuation line removed.
158// Several gpgsig headers are concatenated; their order and their line
159// endings are kept, and no line break is added;
160// - gpgsig-sha256 is the signature of SHA-256 repositories: it is never
161// the signature here, so a commit that only has it is unsigned (ok false).
162//
163// ok is false for an unsigned commit.
164func SplitCommitSignature(raw []byte) (payload, signature []byte, ok bool) {
165 const (
166 prefix = "gpgsig"
167 header = "gpgsig "
168 )
169 var pay, sig bytes.Buffer
170 pay.Grow(len(raw))
171 // inSig: the last header is a "gpgsig " one; skipping: the last header is
172 // any gpgsig* one, so its continuation lines are removed from the payload.
173 inSig, skipping, found := false, false, false
174 rest := raw
175 for len(rest) > 0 {
176 i := bytes.IndexByte(rest, '\n')
177 var line []byte
178 if i < 0 {
179 line, rest = rest, nil
180 } else {
181 line, rest = rest[:i+1], rest[i+1:]
182 }
183 if len(line) == 1 && line[0] == '\n' { // end of headers
184 pay.Write(line)
185 pay.Write(rest)
186 break
187 }
188 switch {
189 case skipping && line[0] == ' ':
190 if inSig {
191 sig.Write(line[1:])
192 }
193 case bytes.HasPrefix(line, []byte(prefix)):
194 skipping = true
195 inSig = bytes.HasPrefix(line, []byte(header))
196 if inSig {
197 found = true
198 sig.Write(line[len(header):])
199 }
200 default:
201 skipping, inSig = false, false
202 pay.Write(line)
203 }
204 }
205 if !found {
206 return nil, nil, false
207 }
208 return pay.Bytes(), sig.Bytes(), true
209}
210
211// SignatureFormatOf tells the format of an armored signature from its first
212// line; FormatNone when it is not recognized.
213func SignatureFormatOf(signature []byte) SignatureFormat {
214 switch {
215 case bytes.HasPrefix(signature, []byte("-----BEGIN SSH SIGNATURE-----")):
216 return FormatSSH
217 case bytes.HasPrefix(signature, []byte("-----BEGIN PGP SIGNATURE-----")):
218 return FormatGPG
219 case bytes.HasPrefix(signature, []byte("-----BEGIN SIGNED MESSAGE-----")):
220 return FormatX509
221 }
222 return FormatNone
223}