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).
Opening Roost admin: the box admin code
Roost admin lives at roost.axowl.com/admin, which forwards to the Roost section of the Axowl console. Sign in as usual, then enter the serial of a bound box and the admin code printed on the card that ships with it. Unlike the product certificate number, the admin code is not used up: you enter it each time you open Roost admin. The console keeps the session token in memory only (no cookie, no browser storage), so reloading the page or waiting 30 minutes asks for the code again.
Once Axowl has issued an admin code for any bound box of an organization, every /api/org/{slug}/roost/* call of that organization needs the session token in the X-Roost-Admin-Session header (403 ROOST_ADMIN_SESSION_REQUIRED otherwise). The menu summary, binding and the air-gap binding file stay open without it. Organizations with no issued code work as before.
| Method · Route | Who | Effect |
|---|---|---|
GET /api/org/{slug}/roost/admin-session | Members who can list boxes | { required, valid, expiresAt } — whether the organization requires an admin code and whether the token you sent is live |
POST /api/org/{slug}/roost/admin-session | Members who can read the box | Body { serial, code } (dashes and spaces ignored). Returns { token, expiresAt, serial }, valid for 30 minutes for this organization and user. 403 ADMIN_CODE_INVALID with remaining attempts — the answer never says whether the serial or the code was wrong; 409 ADMIN_CODE_NOT_ISSUED; 429 TOO_MANY_ATTEMPTS after 5 wrong codes per user in 15 minutes. Every wrong code is recorded on the box timeline (AdminCodeRejected) |
POST /api/platform/roost/devices/{serial}/admin-code | Axowl operator | Issues or reissues the code. Returns { serial, code, issuedAt, reissued } once; only a SHA-256 hash is stored. Reissuing stops the old code immediately. Recorded on the box timeline (AdminCodeIssued) |
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.