Files
bible/rules/patterns/release-signing/contract.md

4.6 KiB

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.

// 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:

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

// 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 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.