# 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`.