8561dc8a5c
The root ceremony uploaded the new package before the new code was claimed, and the hub overwrites the current package when the sealed secrets are unchanged: a customer who started the ceremony again and walked away kept a written-down code that opened nothing. Json mode (the FELHOM_ESCROW argv, unchanged) now returns the package (CeremonyOutput v2, PendingUpload); the daemon uploads it at POST /escrow/ceremony/claim before handing out the code. A failed upload is a 502 with the code still claimable; TTL and a new start wait for an in-flight claim upload; no uploader is a 503. Text mode still uploads at once. No sudoers change. Tests TestEscrowCeremony_R978_* (localapi) and the cmd-side defer/delivery/wiring tests, red-proved.
60 lines
3.1 KiB
Go
60 lines
3.1 KiB
Go
package escrow
|
|
|
|
import "encoding/json"
|
|
|
|
// Controller-driven ceremony contract (v0.88.0, TASK 2026-07-13; mechanics validated by
|
|
// felhom.eu/documentation/audits/SPIKE-controller-escrow-2026-07-13.md). The agent's local API
|
|
// re-invokes the agent binary as root via `sudo -n` with ONE fixed argument vector; sudoers
|
|
// matches that vector byte-for-byte (spike §2.2: any alteration — value, extra flag, order,
|
|
// config path — is refused), so the argv below is the SINGLE SOURCE OF TRUTH shared by the
|
|
// exec (localapi), the capability manifest entry, and (byte-identically) the FELHOM_ESCROW
|
|
// sudoers line. Never build it with flag helpers and never normalize `--` to `-` — Go's flag
|
|
// package would accept either spelling, sudoers only the literal one.
|
|
|
|
// CeremonyBinary is the installed agent binary path the sudoers line pins.
|
|
const CeremonyBinary = "/usr/local/bin/felhom-agent"
|
|
|
|
// ceremonyArgv is the fixed vector. --config is pinned explicitly: `sudo -n` env_reset strips
|
|
// FELHOM_AGENT_CONFIG, and the pin closes env-injection of an alternate config (spike probe (e)).
|
|
var ceremonyArgv = []string{
|
|
"--config", "/etc/felhom-agent/agent.json",
|
|
"--selftest=escrow-create",
|
|
"--upload",
|
|
"--output=json",
|
|
}
|
|
|
|
// CeremonyArgs returns a fresh copy of the fixed argv (callers must not be able to mutate the
|
|
// shared source).
|
|
func CeremonyArgs() []string {
|
|
out := make([]string, len(ceremonyArgv))
|
|
copy(out, ceremonyArgv)
|
|
return out
|
|
}
|
|
|
|
// CeremonyOutput is the --output=json wire contract (version 1): the ONE JSON object json mode
|
|
// emits on stdout — nothing else lands there; every human/info line goes to stderr. RecoveryCode
|
|
// is the only secret field: the consumer must extract it, hand it to the one-shot claim holder,
|
|
// and zero both the parsed struct and the raw stdout buffer. (Best-effort — Go's GC may hold
|
|
// stale copies; the discipline still shrinks the exposure window.)
|
|
type CeremonyOutput struct {
|
|
Version int `json:"version"`
|
|
RecoveryCode string `json:"recovery_code"`
|
|
KeyFingerprint string `json:"key_fingerprint"`
|
|
EntropyBits float64 `json:"entropy_bits"`
|
|
BlobBytes int `json:"blob_bytes"`
|
|
IdentityBlobBytes int `json:"identity_blob_bytes"`
|
|
ResticPwSealed bool `json:"restic_pw_sealed"`
|
|
Uploaded bool `json:"uploaded"`
|
|
// PendingUpload (version 2, R-978) is the hub upload body — the R-wrapped blobs (ciphertext), the key
|
|
// fingerprint and the sealed password's hash — that json mode hands back INSTEAD of uploading. The agent
|
|
// daemon uploads it only when the customer claims the new recovery code, so a ceremony that is started
|
|
// and abandoned never replaces the package the customer's previous code opens. Not a secret without R,
|
|
// but never logged. Uploaded is false whenever this is set.
|
|
PendingUpload json.RawMessage `json:"pending_upload,omitempty"`
|
|
}
|
|
|
|
// CeremonyOutputVersion is the current CeremonyOutput.Version value. 2 (R-978): json mode no longer
|
|
// uploads; it returns PendingUpload. The daemon refuses any other version (a binary/daemon mismatch
|
|
// fails the ceremony instead of losing a package).
|
|
const CeremonyOutputVersion = 2
|