detached fda3c2b

sshsig.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package sshsig signs the commits ermite creates with the instance key
4// (ADR 0022): an ed25519 key, in the SSH signature format Git reads with
5// gpg.format=ssh. The private key lives in memory only.
6package sshsig
7
8import (
9	"crypto/ed25519"
10	"crypto/sha512"
11	"encoding/base64"
12	"errors"
13	"fmt"
14	"strings"
15
16	"golang.org/x/crypto/ssh"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22const (
23	// Namespace is the SSHSIG namespace of Git signatures.
24	Namespace = "git"
25	// HashAlgorithm is the hash of the message in the signed data.
26	HashAlgorithm = "sha512"
27
28	magic   = "SSHSIG"
29	version = 1
30
31	armorBegin = "-----BEGIN SSH SIGNATURE-----"
32	armorEnd   = "-----END SSH SIGNATURE-----"
33	armorWidth = 70
34)
35
36// Signer signs with one ed25519 key. It is safe for concurrent use.
37type Signer struct {
38	signer ssh.Signer
39	pub    domain.SigningPublicKey
40}
41
42var _ ports.CommitSigner = (*Signer)(nil)
43
44// New builds a signer from the 32-byte seed of an ed25519 key. The error does
45// not contain the seed.
46func New(seed []byte) (*Signer, error) {
47	if len(seed) != ed25519.SeedSize {
48		return nil, fmt.Errorf("signing key seed has %d bytes, want %d", len(seed), ed25519.SeedSize)
49	}
50	signer, err := ssh.NewSignerFromSigner(ed25519.NewKeyFromSeed(seed))
51	if err != nil {
52		return nil, fmt.Errorf("build the signing key: %w", err)
53	}
54	pk := signer.PublicKey()
55	return &Signer{
56		signer: signer,
57		pub: domain.SigningPublicKey{
58			AuthorizedKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))),
59			Fingerprint:   ssh.FingerprintSHA256(pk),
60		},
61	}, nil
62}
63
64// PublicKey returns the public key as an authorized_keys line, and its
65// SHA-256 fingerprint.
66func (s *Signer) PublicKey() domain.SigningPublicKey { return s.pub }
67
68// signedData is what the signature covers after the magic (PROTOCOL.sshsig).
69type signedData struct {
70	Namespace     string
71	Reserved      string
72	HashAlgorithm string
73	Hash          string
74}
75
76// sigBlob is the signature file (PROTOCOL.sshsig), after the magic.
77type sigBlob struct {
78	Version       uint32
79	PublicKey     string
80	Namespace     string
81	Reserved      string
82	HashAlgorithm string
83	Signature     string
84}
85
86// signatureField is the "signature" string of an SSHSIG: an SSH signature.
87type signatureField struct {
88	Format string
89	Blob   string
90}
91
92// Sign signs payload, the text of a commit object, and returns the armored
93// signature for its gpgsig header.
94func (s *Signer) Sign(payload []byte) (string, error) {
95	sum := sha512.Sum512(payload)
96	// The magic is a fixed 6-byte preamble, not a length-prefixed string.
97	msg := append([]byte(magic), ssh.Marshal(signedData{
98		Namespace: Namespace, HashAlgorithm: HashAlgorithm, Hash: string(sum[:]),
99	})...)
100
101	sig, err := s.signer.Sign(nil, msg)
102	if err != nil {
103		return "", errors.New("sign the commit")
104	}
105	blob := append([]byte(magic), ssh.Marshal(sigBlob{
106		Version:       version,
107		PublicKey:     string(s.signer.PublicKey().Marshal()),
108		Namespace:     Namespace,
109		HashAlgorithm: HashAlgorithm,
110		Signature:     string(ssh.Marshal(signatureField{Format: sig.Format, Blob: string(sig.Blob)})),
111	})...)
112	return armor(blob), nil
113}
114
115// armor wraps the signature file in its PEM-like armor, 70 columns wide.
116func armor(blob []byte) string {
117	enc := base64.StdEncoding.EncodeToString(blob)
118	var b strings.Builder
119	b.WriteString(armorBegin + "\n")
120	for len(enc) > armorWidth {
121		b.WriteString(enc[:armorWidth] + "\n")
122		enc = enc[armorWidth:]
123	}
124	b.WriteString(enc + "\n" + armorEnd + "\n")
125	return b.String()
126}

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