feat(release-signing): add Ed25519 multi-key release signing contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
149
rules/patterns/release-signing/contract.md
Normal file
149
rules/patterns/release-signing/contract.md
Normal file
@@ -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/
|
||||||
|
<name>.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 <module>/internal/updater.trustedKeysRaw=${KEYS}" \
|
||||||
|
-o dist/<binary>-linux-amd64 \
|
||||||
|
./cmd/<binary>
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<binary>-linux-amd64 ← the binary
|
||||||
|
<binary>-linux-amd64.sig ← raw 64-byte Ed25519 signature
|
||||||
|
```
|
||||||
|
|
||||||
|
Signing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh keys/scripts/sign-release.sh <developer-name> dist/<binary>-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`.
|
||||||
Reference in New Issue
Block a user