v0.23.0: device-ROLE classification + tiered storage-wipe gate (user-data customer-confirmable; system/backup operator-only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:29:18 +02:00
parent 9e3513557f
commit 15f7529a1c
10 changed files with 759 additions and 106 deletions
+78 -2
View File
@@ -53,7 +53,11 @@ const (
ReasonPendingSignature RefuseReason = "pending_signature" // destructive, no/again-needed signature
ReasonRejected RefuseReason = "rejected" // signature failed authz verification
ReasonRoleDenied RefuseReason = "role_denied" // signer role not authorized for this op class
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature is for a different action
ReasonBindingMismatch RefuseReason = "binding_mismatch" // signature/confirmation is for a different action/device
// Storage-wipe customer-confirmable tier (user-data only).
ReasonCustomerConfirmed RefuseReason = "customer_confirmed" // allowed by the customer's durable-id-bound confirmation
ReasonPendingConfirmation RefuseReason = "pending_confirmation" // user-data wipe awaiting the customer's confirmation
)
// Decision is the gate verdict.
@@ -94,6 +98,9 @@ type AuditRecord struct {
Reason RefuseReason
KeyID string // matched signer's key id, when signed
Nonce string // the op nonce, when signed
// DurableID is the device's durable id on a customer-confirmable storage wipe — the load-bearing
// audit detail ("who/what/when + the durable id") so a customer-authorized wipe is attributable.
DurableID string
}
// Gate is the reversibility gate: it sits in front of the per-guest queue's executor
@@ -172,6 +179,75 @@ func (g *Gate) Authorize(intent Intent, signed *SignedOp) Decision {
return d
}
// StorageWipeAuthz is the input to AuthorizeStorageWipe. Role is the agent's AUTHORITATIVE
// classification (StorageRole* in classify.go) — never a caller's claim. DeviceDurableID is the
// durable id the AGENT re-resolved for the device. Confirmed/ConfirmDurableID are the customer's
// informed-confirmation, honored ONLY for user-data and ONLY when ConfirmDurableID matches the agent's
// DeviceDurableID (a confirmation for one disk can't wipe another).
type StorageWipeAuthz struct {
HostID string
Role string // StorageRoleSystem | StorageRoleBackup | StorageRoleUserData
TargetID string // storage target identity for the operator-signed (system/backup) binding
DeviceDurableID string // agent-re-resolved durable id of the device ("" if unresolvable)
Confirmed bool
ConfirmDurableID string
}
// AuthorizeStorageWipe tiers a data-bearing storage wipe by the agent's role verdict:
//
// - user-data → CUSTOMER-CONFIRMABLE: allowed iff the request carries an explicit customer
// confirmation BOUND to the device's durable id (the agent re-resolved DeviceDurableID and it
// matches ConfirmDurableID). No operator signature. The wipe is recorded in the customer-visible
// audit log with the durable id.
// - system / backup / anything else → the standard DESTRUCTIVE path (operator-signature /
// pending_signature). The Confirmed flag is DELIBERATELY IGNORED: no customer confirmation can
// wipe the appliance's system storage or the backup safety-net. A compromised controller
// asserting confirmed:true is refused here BY ROLE — role is the agent's, never the caller's.
//
// signed is the operator signature for the system/backup path (nil on the inline format path → always
// pending_signature; the signed completion runs via the signed-jobs runner).
func (g *Gate) AuthorizeStorageWipe(in StorageWipeAuthz, signed *SignedOp) Decision {
if in.Role == StorageRoleUserData {
if !in.Confirmed {
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonPendingConfirmation}
g.recordWipe(in, d)
return d
}
// Durable-id binding: the confirmation must name THIS exact device (re-resolved by the agent).
// An unresolved/empty id fails safe (refuse), and a mismatch can't be retargeted to another disk.
if in.DeviceDurableID == "" || in.ConfirmDurableID == "" || in.ConfirmDurableID != in.DeviceDurableID {
d := Decision{Allowed: false, Disposition: CustomerConfirmable, Reason: ReasonBindingMismatch}
g.recordWipe(in, d)
return d
}
d := Decision{Allowed: true, Disposition: CustomerConfirmable, Reason: ReasonCustomerConfirmed}
g.recordWipe(in, d)
return d
}
// system / backup / unknown → operator-signature path. Confirmed is NOT consulted.
intent := IntentForStorageDestructive(ClassStorageWipe, in.HostID, in.TargetID, nil, SourceOneShotJob)
return g.Authorize(intent, signed)
}
// recordWipe audits a customer-confirmable storage-wipe decision (allowed or refused) with the
// device's durable id — the customer-visible "who/what/when + durable id" record.
func (g *Gate) recordWipe(in StorageWipeAuthz, d Decision) {
g.audit.Record(AuditRecord{
Time: time.Now().UTC(),
Class: ClassStorageWipe,
HostID: in.HostID,
GuestID: in.TargetID,
Source: SourceOneShotJob,
Disposition: d.Disposition,
Allowed: d.Allowed,
Reason: d.Reason,
DurableID: in.DeviceDurableID,
})
g.logger.Info("gate decision (storage wipe)",
"role", in.Role, "disposition", d.Disposition, "allowed", d.Allowed,
"reason", d.Reason, "durable_id", in.DeviceDurableID)
}
// roleAuthorizes enforces the doc 04 §4 two-key role model: the cold recovery key
// authorizes ONLY key-rotation re-pins; the operational key authorizes ordinary
// destructive ops AND planned key-rotation.
@@ -278,7 +354,7 @@ func (s SlogAudit) Record(rec AuditRecord) {
s.Logger.Info("audit: gate decision",
"class", rec.Class, "host", rec.HostID, "guest", rec.GuestID, "source", rec.Source,
"disposition", rec.Disposition, "allowed", rec.Allowed, "reason", rec.Reason,
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce))
"key_id", rec.KeyID, "nonce", auditNonce(rec.Nonce), "durable_id", rec.DurableID)
}
// auditNonce shortens a nonce for the log (full nonce is high-cardinality; a prefix is