Two interlocking pieces shipped together: 1. Strict Ed25519 signature verification on URL-fetched apps artifacts. Every URL the apps cascade resolves must publish a corresponding <url>.sig (raw 64-byte Ed25519 signature). The fetcher rejects on any failure (sig 404, transport error, wrong key, tampered body) and the resolver falls back to the embedded copy. The trusted public key is OPERATOR-CONFIGURED via --apps-pubkey / ZDDC_APPS_PUBKEY (PEM file path). No baked-in default — same posture as TLS certificates. Operators using zddc.varasys.io's canonical channels download pubkey.pem from there and configure the local path. Operators with their own signing infrastructure pass their own public key. Build pipeline (./build) gains sign_release_artifacts: walks dist/release-output/ after promote and produces an Ed25519 .sig alongside every real file. ZDDC_SIGNING_KEY=~/.config/zddc-signing/ key.pem (mode 0600). Symlinks skip — the .sig at the symlink target is what counts. Test coverage: parse-PEM round-trip, malformed/wrong-type PEM rejection, valid-signature accept, tampered-body reject, wrong-key reject, malformed-signature reject, end-to-end fetch+sign+verify, fetch-rejects-tampered, fetch-rejects-missing-sig, fetch-rejects- wrong-key. Existing fetch tests updated to use signed-fixture helpers. 2. Dev Helm chart mounts production data READ-ONLY and layers an OverlayFS writable scratch on top. Prod data is the lowerdir; dev's writes (form submissions, archive index state, .zddc edits) land in upperdir; main container sees the merged read-write view at $ZDDC_ROOT. Setup runs in a privileged init container; main container runs unprivileged. Solves the dev-replica-on-shared- dataset problem at the filesystem layer with no zddc-server code change. Docs: env-var tables in zddc/README.md and AGENTS.md gain a ZDDC_APPS_PUBKEY row. The Federal-readiness gap analysis "Code-signed apps: URL fetches" subsection is rewritten as "what's currently in place" instead of "what would need to be added," with a forward pointer to per-entry signed_by: (multi-key) and Sigstore as the federally-acceptable evolution. The website "Verify your downloads" section + the embedded pubkey gone — but the website needs separate updates landing in zddc-website to publish pubkey.pem and add the verify section. Pending in that repo's commit. Production binary unchanged at 13.1 MB. All 11 Go test packages green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 lines
2.5 KiB
Go
73 lines
2.5 KiB
Go
package apps
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// LoadPubKey reads a PEM-encoded SubjectPublicKeyInfo (the format
|
|
// `openssl pkey -pubout` emits) from path and returns the underlying
|
|
// Ed25519 public key.
|
|
//
|
|
// Operators distribute and configure this key explicitly — same posture
|
|
// as the TLS certificate: zddc-server bakes nothing in. Customers
|
|
// running against zddc.varasys.io's release channel download the
|
|
// canonical key from zddc.varasys.io/pubkey.pem and pass the local
|
|
// path via --apps-pubkey or ZDDC_APPS_PUBKEY. Customers running their
|
|
// own signing infrastructure pass their own public key instead.
|
|
//
|
|
// Returns a descriptive error for missing files, malformed PEM, wrong
|
|
// PEM type, or non-Ed25519 keys. Callers (cmd/zddc-server's startup
|
|
// path) treat any error as fatal — refusing to start with a misconfigured
|
|
// apps-pubkey is the right posture.
|
|
func LoadPubKey(path string) (ed25519.PublicKey, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read apps-pubkey from %s: %w", path, err)
|
|
}
|
|
return ParsePubKeyPEM(data)
|
|
}
|
|
|
|
// ParsePubKeyPEM is LoadPubKey's content-only variant. Useful when the
|
|
// PEM bytes come from somewhere other than disk (test fixtures, etc.).
|
|
func ParsePubKeyPEM(pemBytes []byte) (ed25519.PublicKey, error) {
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block found")
|
|
}
|
|
if block.Type != "PUBLIC KEY" {
|
|
return nil, fmt.Errorf("unexpected PEM type %q (want PUBLIC KEY)", block.Type)
|
|
}
|
|
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse PKIX: %w", err)
|
|
}
|
|
edPub, ok := pub.(ed25519.PublicKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("public key is not Ed25519 (got %T)", pub)
|
|
}
|
|
return edPub, nil
|
|
}
|
|
|
|
// VerifyEd25519 checks that sig is a valid Ed25519 signature of body
|
|
// produced with the private key matching pub. Returns nil on success
|
|
// or a descriptive error otherwise.
|
|
//
|
|
// sig must be exactly 64 bytes (the raw Ed25519 signature format
|
|
// produced by `openssl pkeyutl -sign -rawin`).
|
|
func VerifyEd25519(pub ed25519.PublicKey, body, sig []byte) error {
|
|
if pub == nil {
|
|
return errors.New("no public key configured")
|
|
}
|
|
if len(sig) != ed25519.SignatureSize {
|
|
return fmt.Errorf("signature has wrong length: %d (want %d)", len(sig), ed25519.SignatureSize)
|
|
}
|
|
if !ed25519.Verify(pub, body, sig) {
|
|
return errors.New("signature does not verify against trusted public key")
|
|
}
|
|
return nil
|
|
}
|