Files
felhom-agent/internal/escrow/identity.go
T
admin 0c22b9bbf3 v0.77.0: fork-4 — escrow the offsite restic repo password under R
IdentityBundle gains ResticRepoPassword (rides existing age-under-R
WrapIdentityBundle; custody spike febdc56 proved a recovered value opens the
real repo). POST /escrow/stage-secret (withGuest, scopedFromBody) transiently
stages the controller-pushed password (0600, atomic, NEVER logged), which the
escrow-create ceremony auto-injects then wipes. Adds AttachResticPassword +
StagedResticPasswordPath + WipeStagedResticPassword; EscrowStagePath injectable
for tests. Tests: bundle carries pw byte-exact + not-in-blob + wrong-R fails
closed; stage 0600 + non-secret ack + cross-guest 403 + value-not-in-log.
Additive; PBS-K escrow untouched. NOT yet live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 14:56:09 +02:00

170 lines
7.8 KiB
Go

package escrow
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
// PBS-native scrypt path. The identity bundle `{tunnel_token, pbs_token}` is arbitrary secret bytes
// (not a PBS key), so it is wrapped under the SAME recovery code `R` with **age** (`age -p`: scrypt
// + ChaCha20-Poly1305 — a vetted passphrase-AEAD, not hand-rolled). Same two-factor, zero-knowledge
// shape as the K-escrow: the blob is opaque without `R`; `R` is the only out-of-band secret. The
// K-escrow + the 10C `Consume` path are UNTOUCHED — this is purely additive. Proven by the slice-10D
// identity-restore spike (documentation/tests/slice10d-identity-restore-spike-findings.md).
//
// age is a runtime dependency for the identity path (analogous to proxmox-backup-client for K).
var ageBinary = "/usr/bin/age"
// IdentityBundle is the box's recoverable identity — the secrets a re-enrolling box needs to come
// back "as host X". Carried only inside the R-wrapped blob; never stored or logged in the clear.
type IdentityBundle struct {
TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token
PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish)
// WGPrivateKey is the offsite WG tunnel private key (S3; base64, 32 bytes). OPTIONAL: escrow
// blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR
// falls back to fresh-key re-registration, which keeps the box's /32 (hub S2 re-key-in-place).
WGPrivateKey string `json:"wg_private_key,omitempty"`
// ResticRepoPassword is the offsite restic repo password (fork-4). OPTIONAL: escrow blobs created
// before fork-4 lack it and CANNOT be retro-fitted (R is never retained). It is the DATA key for the
// offsite tier — irreplaceable (unlike the SFTP access key, which is regenerable at DR). The
// controller's atomicity gate ensures no offsite ciphertext exists until this is escrowed.
ResticRepoPassword string `json:"restic_repo_password,omitempty"`
}
// StagedResticPasswordPath is the well-known 0600 file where the controller-pushed restic repo password
// is transiently staged (by the local API) for the escrow-create ceremony to pick up, then wiped. A fixed
// path so the local-API writer and the CLI ceremony reader agree without threading config through.
func StagedResticPasswordPath() string {
return filepath.Join("/var/lib/felhom-agent", "escrow-stage", "restic_repo_password")
}
// WipeStagedResticPassword removes the staged restic password (called by the ceremony after a successful
// escrow-create — the secret now lives only inside the R-wrapped blob). A missing file is a clean no-op.
func WipeStagedResticPassword() error {
if err := os.Remove(StagedResticPasswordPath()); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("escrow: wipe staged restic password: %w", err)
}
return nil
}
// AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle
// when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
// is a clean no-attach (pre-fork-4 behavior, byte-compatible bundle).
func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
raw, err := os.ReadFile(stagePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("escrow: reading staged restic password: %w", err)
}
pw := strings.TrimSpace(string(raw))
if pw == "" {
return false, fmt.Errorf("escrow: staged restic password file %s is empty", stagePath)
}
b.ResticRepoPassword = pw
return true, nil
}
// AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3
// escrow-create auto-inject). Returns whether it attached. The VALUE is validated (base64, 32
// bytes) but never logged by callers — log the field NAME only. A missing key file is a clean
// no-attach (pre-S3 behavior, byte-compatible bundle); a corrupt one is an error (the operator
// should know their escrow would silently lack a live identity).
func AttachWGKey(b *IdentityBundle, keyPath string) (bool, error) {
raw, err := os.ReadFile(keyPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("escrow: reading wg key file: %w", err)
}
s := strings.TrimSpace(string(raw))
dec, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(dec) != 32 {
return false, fmt.Errorf("escrow: wg key file %s is corrupt (not 32-byte base64)", keyPath)
}
b.WGPrivateKey = s
return true, nil
}
// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and
// returns the opaque blob. `R` is fed via the pty (2 prompts: passphrase + confirm); the plaintext
// and ciphertext flow as files, so only `R` touches the tty (never logged).
func WrapIdentity(ctx context.Context, bundle []byte, recoveryCode string) ([]byte, error) {
if len(bundle) == 0 {
return nil, fmt.Errorf("escrow: WrapIdentity needs a non-empty bundle")
}
if recoveryCode == "" {
return nil, fmt.Errorf("escrow: WrapIdentity needs the recovery code (R)")
}
work, err := os.MkdirTemp("", "felhom-idesc-")
if err != nil {
return nil, fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work)
in, out := filepath.Join(work, "bundle"), filepath.Join(work, "blob")
if err := os.WriteFile(in, bundle, 0o600); err != nil {
return nil, fmt.Errorf("escrow: stage bundle: %w", err)
}
// `age -p -o <out> <in>` prompts the passphrase + confirm (2) and writes the armored blob.
if err := runWithPassphrase(ctx, recoveryCode, 2, ageBinary, "-p", "-a", "-o", out, in); err != nil {
return nil, fmt.Errorf("escrow: identity wrap (age -p): %w", err)
}
return os.ReadFile(out)
}
// UnwrapIdentity recovers the bundle bytes from an age blob with `R`. A WRONG R fails CLOSED at the
// scrypt KDF (`age -d` nonzero exit, no plaintext emitted) — never a plausible-but-wrong bundle.
func UnwrapIdentity(ctx context.Context, blob []byte, recoveryCode string) ([]byte, error) {
if len(blob) == 0 {
return nil, fmt.Errorf("escrow: UnwrapIdentity needs a non-empty blob")
}
if recoveryCode == "" {
return nil, fmt.Errorf("escrow: UnwrapIdentity needs the recovery code (R)")
}
work, err := os.MkdirTemp("", "felhom-idesc-")
if err != nil {
return nil, fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work)
in, out := filepath.Join(work, "blob"), filepath.Join(work, "bundle")
if err := os.WriteFile(in, blob, 0o600); err != nil {
return nil, fmt.Errorf("escrow: stage blob: %w", err)
}
// `age -d -o <out> <in>` prompts the passphrase (1).
if err := runWithPassphrase(ctx, recoveryCode, 1, ageBinary, "-d", "-o", out, in); err != nil {
return nil, fmt.Errorf("escrow: the recovery code did not unwrap the identity escrow (wrong recovery code, or a corrupt blob): %w", err)
}
return os.ReadFile(out)
}
// WrapIdentityBundle marshals + wraps an IdentityBundle under R.
func WrapIdentityBundle(ctx context.Context, b IdentityBundle, recoveryCode string) ([]byte, error) {
raw, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("escrow: marshal identity bundle: %w", err)
}
return WrapIdentity(ctx, raw, recoveryCode)
}
// UnwrapIdentityBundle unwraps + parses an IdentityBundle (slice 10D.3 restore-mode consumption).
func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string) (IdentityBundle, error) {
raw, err := UnwrapIdentity(ctx, blob, recoveryCode)
if err != nil {
return IdentityBundle{}, err
}
var b IdentityBundle
if err := json.Unmarshal(raw, &b); err != nil {
return IdentityBundle{}, fmt.Errorf("escrow: recovered identity bundle is malformed: %w", err)
}
return b, nil
}