Binding and replacement
Every write needs the organization Owner and a passkey seal bound to the exact action (403 OWNER_REQUIRED for other members; 401 STEPUP_REQUIRED / STEPUP_FAILED when the seal is missing or bound to something else). Request the challenge from POST /api/passkey/stepup/options with the purpose and subject shown. Every write is recorded as an audit event; the ledger rows themselves are not sealed, the events are.
| Method · Route | Purpose · subject | Effect · audit event |
|---|---|---|
POST /api/org/{slug}/roost/bind/challenge | — (no seal; Owner) | Verifies productCert against the hash recorded at the factory; returns { serial, nonce, expiresAt, payloadToSign, attestationKeyRegistered }. Only a box Axowl shipped to this organization can be bound. 403 BIND_REJECTED for every refusal — unknown serial, wrong number, a box shipped to another organization or not shipped yet, or a box already bound; the answer never says which. 429 after 10 attempts in 10 minutes |
POST /api/org/{slug}/roost/bind | roost.bind · RoostBindSubject(slug, serial, nonce) | Verifies boxSignature (the box signs payloadToSign with its attestation key), moves the box to Enrolling, starts an enrollment ceremony. roost.device.bound, roost.device.lifecycle_changed |
POST /api/org/{slug}/roost/pairs | roost.pair · RoostPairSubject(slug, "new", "create", serialA+serialB) | Creates the pair. roost.pair.registered |
POST /api/org/{slug}/roost/pairs/{id}/replacement | roost.pair · RoostPairSubject(slug, pairId, action, serial) | One replacement step in order: degraded → stage → register → verify-envelope → remove → resealed. roost.pair.replacement_step (+ lifecycle and envelope events) |
POST /api/org/{slug}/roost/pairs/{id}/envelopes | roost.pair · RoostPairSubject(slug, pairId, "envelope", serial+":"+fingerprint) | Records a DB key envelope (Issued). roost.envelope.issued |
POST /api/org/{slug}/roost/envelopes/{id}/verify | roost.pair · RoostPairSubject(slug, pairId, "verify", envelopeId) | Verified. roost.envelope.issued with verified: true |
POST /api/org/{slug}/roost/envelopes/{id}/revoke | roost.pair · RoostPairSubject(slug, pairId, "revoke", envelopeId) | Revoked. roost.envelope.revoked |
POST /api/org/{slug}/roost/enrollments/{id}/advance | roost.enrollment · RoostEnrollmentSubject(slug, serial, enrollmentId) (last step only) | ArrivalCheck {transportSealOk, shockIndicatorOk} → MeasurementCompare {actualMeasurements} (compared per target with the factory record; mismatch fails) → QuorumRegistration {keyHolders, fidoKeys, axowlTicketRef, stepUpAssertion} → Completed, box Active. roost.enrollment.completed, roost.device.lifecycle_changed |
Binding, step by step
- Axowl ships the box to the organization that bought it. Only that organization can bind it, and its Roost menu shows Connect a box until the box is bound.
- The Owner enters the serial and the product certificate number from the card shipped with the box →
/bind/challengereturns a one-time number andpayloadToSign(base64url of{"v":1,"typ":"roost-bind","serial","nonce","org"}). - On the box controller:
bind_client.py sign <payloadToSign>— the box signs the bytes with its attestation key (ECDSA P-256 / SHA-256,r‖s, base64url). The attestation public key was recorded at the factory. - The Owner pastes the signature and seals →
/bind. The seal subject carries the serial and the one-time number, so a seal cannot be reused for another box or another attempt. The number works once and expires after 10 minutes.
A certificate number alone cannot bind a box (a leaked card photo is not enough), and a box signature alone cannot either (the Owner must seal).
Replacement
A failed box is never opened on site. The pair runs 2-of-3 (degraded), a new box is bound and staged as a transient third (stage), the customer and Axowl register it together — three physical keys, Owner quorum seal, Axowl ticket (register, axowlTicketRef required) — the new box's envelope is verified against the pair (verify-envelope), the old box is removed, set to RmaReturned, its envelopes revoked and a Replacement case moved to FactoryReseal (remove), and finally resealed closes the case. Steps out of order are refused with 409 REPLACEMENT_STEP_OUT_OF_ORDER.