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
This commit is contained in:
@@ -30,6 +30,47 @@ type IdentityBundle struct {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user