diff --git a/rules/patterns/release-signing/contract.md b/rules/patterns/release-signing/contract.md new file mode 100644 index 0000000..7268c11 --- /dev/null +++ b/rules/patterns/release-signing/contract.md @@ -0,0 +1,149 @@ +# Contract: Release Signing + +Version: 1.0 + +## Purpose + +Ed25519 asymmetric signing for Go release binaries. +Guarantees that a binary accepted by a running application was produced by a trusted developer. +Applies to any Go binary that is distributed or supports self-update. + +--- + +## Key Management + +Public keys are stored in the centralized keys repository: `git.mchus.pro/mchus/keys` + +``` +keys/ + developers/ + .pub ← raw Ed25519 public key, base64-encoded, one line per developer + scripts/ + keygen.sh ← generates keypair + sign-release.sh ← signs a binary + verify-signature.sh ← verifies locally +``` + +Public keys are safe to commit. Private keys stay on each developer's machine — never committed, never shared. + +**Adding a developer:** add their `.pub` file → commit → rebuild affected releases. +**Removing a developer:** delete their `.pub` file → commit → rebuild releases. +Previously signed binaries with their key remain valid (already distributed), but they cannot sign new releases. + +--- + +## Multi-Key Trust Model + +A binary is accepted if its signature verifies against **any** of the embedded trusted public keys. +This mirrors the SSH `authorized_keys` model. + +- One developer signs a release with their private key → produces one `.sig` file. +- The binary trusts all active developers — any of them can make a valid release. +- Signature format: raw 64-byte Ed25519 signature (not PEM, not armored). + +--- + +## Embedding Keys at Build Time + +Public keys are injected via `-ldflags` at release build time — not hardcoded at compile time. +This allows adding/removing developers without changing application source code. + +```go +// internal/updater/trust.go + +// trustedKeysRaw is injected at build time via -ldflags. +// Format: base64(key1):base64(key2):... +// Empty string = dev build, updates disabled. +var trustedKeysRaw string + +func trustedKeys() ([]ed25519.PublicKey, error) { + if trustedKeysRaw == "" { + return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled") + } + var keys []ed25519.PublicKey + for _, enc := range strings.Split(trustedKeysRaw, ":") { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + if err != nil || len(b) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid trusted key: %w", err) + } + keys = append(keys, ed25519.PublicKey(b)) + } + return keys, nil +} +``` + +Release build script injects all current developer keys: + +```sh +# scripts/build-release.sh +KEYS=$(paste -sd: /path/to/keys/developers/*.pub) +go build \ + -ldflags "-s -w -X /internal/updater.trustedKeysRaw=${KEYS}" \ + -o dist/-linux-amd64 \ + ./cmd/ +``` + +Dev build (no `-ldflags` injection): `trustedKeysRaw` is empty → updates disabled, binary works normally. + +--- + +## Signature Verification (stdlib only, no external tools) + +Use `crypto/ed25519` from Go standard library. No third-party dependencies. + +```go +// internal/updater/trust.go + +func verifySignature(binaryPath, sigPath string) error { + keys, err := trustedKeys() + if err != nil { + return err // dev build or misconfiguration + } + data, err := os.ReadFile(binaryPath) + if err != nil { + return fmt.Errorf("read binary: %w", err) + } + sig, err := os.ReadFile(sigPath) + if err != nil { + return fmt.Errorf("read signature: %w", err) + } + for _, key := range keys { + if ed25519.Verify(key, data, sig) { + return nil // any trusted key accepts → pass + } + } + return fmt.Errorf("signature verification failed: no trusted key matched") +} +``` + +Rejection behavior: log as WARNING, continue with current binary. Never crash, never block operation. + +--- + +## Release Asset Convention + +Every release must attach two files to the Gitea release: + +``` +-linux-amd64 ← the binary +-linux-amd64.sig ← raw 64-byte Ed25519 signature +``` + +Signing: + +```sh +sh keys/scripts/sign-release.sh dist/-linux-amd64 +``` + +Both files are uploaded to the Gitea release as downloadable assets. + +--- + +## Rules + +- Never hardcode public keys as string literals in source code — always use ldflags injection. +- Never commit private keys (`.key` files) anywhere. +- A binary built without ldflags injection must work normally — it just cannot perform verified updates. +- Signature verification failure must be a silent logged warning, not a crash or user-visible error. +- Use `crypto/ed25519` (stdlib) only — no external signing libraries. +- `.sig` file contains raw 64 bytes (not base64, not PEM). Produced by `openssl pkeyutl -sign -rawin`.