detached fda3c2b

signature.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close