Auditor, log export, time and attestation
Evidence reports
| Report | Scope | Period | Signed by | |
|---|---|---|---|---|
| Event export | all partitions | 2026 Q3 | ROOST-26-0001 identity key | Download |
| Key attribute report | prod · payments | as of today | ROOST-26-0001 identity key | Download |
| Access review matrix | all | as of today | ROOST-26-0001 identity key | Download |
| Chain verification | all | last 90 days | verified by M. Lee (auditor) | 0 gaps |
Four controls that bank HSM tenders ask for, mapped onto Axowl. They sit on the device ledger above: a box is addressed by its ledger serial, and the box's own requests are signed with the attestation key the factory recorded in the ledger.
Roles: who may touch the audit log
| Role | How it is held | Audit log (read, export, verify) | Audit log (delete / retention purge) | Export targets (syslog / SNMP) | Time policy | Appoint / remove an Auditor |
|---|---|---|---|---|---|---|
| Auditor | Roost role Auditor , appointed by Owner quorum | ✅ | ✅ (passkey seal) | ✅ | read | — |
| Owner | Organization owner | ✅ until an Auditor exists, then ❌ AUDITOR_REQUIRED | ✅ until an Auditor exists, then ❌ | ✅ until an Auditor exists, then ❌ | ✅ | ✅ seals (majority of active Owners) |
| Admin | Organization admin | ✅ until an Auditor exists, then ❌ | ❌ | ❌ | read | ❌ |
| Member | Any other member | own records only (as before), ❌ once an Auditor exists | ❌ | read | read | ❌ |
| Security Officer · Operator · User | Roost roles on the box (ledger) | no effect on the audit log | — | — | — | — |
The rule is one switch with no separate flag: as soon as at least one Auditor is active, the organization audit log is Auditor-only. The rule applies to /api/v1/admin/audit query, query/detail, compact/urls, presign, content, verify/{logId}, stats/forensic and retention/purge-now. Aggregated dashboards (/stats, /analytics) are not log reads and stay as they were. Nobody — not the Owner, not Axowl operators — has an API that edits a sealed audit record; the only deletion is the retention purge, and once an Auditor exists it needs the Auditor's passkey seal.
An Auditor cannot be an Owner, a service account or a guest. The role is not assignable through the normal role or SCIM endpoints; only the quorum flow below writes it.
Auditor quorum — /api/org/{slug}/roost/auditors
| Method · Route | Purpose |
|---|---|
GET /auditors | { auditors[], candidates[], separationActive, ownerCount, requiredSeals, callerIsOwner, callerIsAuditor, pending[] } — any member. candidates[] and auditor emails are filled only for an Owner; other members see auditor names. |
POST /auditors/{connectedId}/assign | One Owner seal towards appointing. Body { stepUpAssertion } with purpose roost.auditor.change, subject RoostAuditorSubject(slug, "assign", connectedId) |
POST /auditors/{connectedId}/revoke | One Owner seal towards removing. Same body, action revoke |
POST /auditors/actions/{id}/cancel | Cancel a pending action (Owner) |
requiredSeals = activeOwners / 2 + 1 (one Owner → one seal). Each Owner counts once; a pending action expires after 7 days. Response { actionId, sealCount, requiredSeals, completed }. Errors: 403 OWNER_REQUIRED, 400 INVALID_AUDITOR_TARGET, 409 ALREADY_AUDITOR / NOT_AUDITOR, 401 STEPUP_REQUIRED / STEPUP_FAILED.
Audit events: roost.auditor.seal_recorded (RST050, one per seal, carries the seal proof), roost.auditor.assigned (RST051), roost.auditor.revoked (RST052).
Audit-log export — /api/org/{slug}/roost/log-export
| Method · Route | Purpose |
|---|---|
GET /log-export | Current targets; secrets come back as authKeySet / privKeySet / caPemSet booleans. Any member. |
PUT /log-export | Set targets. Owner — or the Auditor once one is appointed. Hosts must be reachable on the public internet: private, loopback and link-local addresses are refused (400 LOG_EXPORT_HOST_NOT_ALLOWED for an IP address; a name that resolves to one fails when a message is sent). |
POST /log-export/test | Body { target: "all" | "syslog" | "snmp" } → { results: [{ target, ok, error }] }. Each attempt is roost.log_export.test_sent (RST054). |
{
"syslog": { "enabled": true, "host": "siem.bank.example", "port": 6514, "protocol": "tls",
"serverCertSha256": "…64 hex…", "caPem": "-----BEGIN CERTIFICATE-----…", "facility": 10, "appName": "axowl-roost", "allOrgEvents": true },
"snmp": { "enabled": true, "host": "nms.bank.example", "port": 162, "engineId": "80004fb805617877", "userName": "axowl",
"authProtocol": "sha256", "authKey": "…", "privProtocol": "aes128", "privKey": "…", "kinds": ["Tamper", "Zeroize", "Degraded"] }
}
protocol:tls= RFC 5425 (octet-counting frames over TLS 1.2+) ·relp= RELP over TLS with per-message acknowledgement. There is no plain TCP or UDP.- Server trust: OS chain by default;
caPempins a private CA;serverCertSha256pins one certificate (wins over the chain). allOrgEvents:truesends every audit event of the organization,falseonlyroost.*events.- SNMPv3 only (USM).
authProtocolnone|sha1|sha256,privProtocolnone|aes128|aes256; privacy needs authentication. Keys are write-only and stored encrypted.kindsempty = every alarm.
SIEM JSON line (the syslog MSG; RFC 5424 header <PRI>1 TIMESTAMP HOST axowl-roost - RSTnnn [axowl@PEN org="…" event="…" code="…" serial="…"] MSG):
{"v":1,"ts":"2026-10-19T09:10:11.123Z","org":"<org guid>","event":"roost.alert.raised","code":"RST009","id":"<logId>",
"actor":{"cid":"<connectedId or system sentinel>","type":"system"},"target":{"type":"external","id":"ROOST-26-0001"},
"ip":null,"device":null,"data":{"Serial":"ROOST-26-0001","Kind":"Tamper","Severity":"Red","Summary":"mesh resistance out of band"}}
id is the audit logId — the same value GET /api/v1/admin/audit/verify/{logId} verifies, so a SIEM line can be tied back to the sealed record. Syslog severity: Red → 2, Yellow → 4, other Roost events → 5, everything else → 6.
SNMPv3 traps go only for alerts: roost.alert.raised (RST009) and roost.alert.cleared (RST010) from the alert evaluator (box status reports and box-sent alarms both pass through it), plus roost.box.attestation_mismatch (RST059, as key events Red) and roost.time.drift_exceeded (RST057, as key drift Red).
enterprise = configured enterprise OID (configurable, default 1.3.6.1.4.1.99999)
trap OID = <enterprise>.1.0.<key> key (alert item): chipTemp 1 · boardTemp 2 · v12 3 · v5 4 · v3v3 5 · battery 6 ·
mesh 7 · quorum 8 · fans 9 · psu 10 · link 11 · drift 12 · events 13 · other 99
varbinds = <enterprise>.2.1 serial · .2.2 key · .2.3 level (Red | Yellow | Normal = cleared | Gray) · .2.4 detail · .2.5 orgId ·
.2.6 eventType · .2.7 auditLogId · .2.8 occurredAt (ISO 8601 UTC)
The sender is the authoritative engine (engineId as configured, boots 1, engine time = process uptime); register user, keys and engine ID on the receiver.
Failures: the audit record is written first and is never held back by a slow or dead target. A failed delivery is logged, recorded as roost.log_export.delivery_failed (RST055, at most once per target per 10 minutes) and raises a SecurityAlert notification to every Owner. Target changes are roost.log_export.target_changed (RST053) without secrets.
Time policy — /api/org/{slug}/roost/time-policy
| Method · Route | Purpose |
|---|---|
GET /time-policy | { policy: { servers: [{ host, mode: "nts" | "ntp" }], maxDriftMs, refuseSigningOnDrift, staleAfterSeconds, updatedAt }, callerCanEdit } — any member |
PUT /time-policy | Owner. maxDriftMs 10–60000 (default 500), staleAfterSeconds 60–604800 (default 3600), up to 8 servers. Audit roost.time.policy_changed (RST056). |
GET /{serial}/status | { serial, lifecycleState, lastReportAt, time: { synced, driftMs, rtcOk, withinPolicy, stale, signingAllowed, maxDriftMs, refuseSigningOnDrift } } |
mode: "nts" is Network Time Security (RFC 8915, authenticated); plain ntp is accepted but the dashboard warns. The verdict is computed from the box's latest status report (ntpOk, driftMs, reportedAt in the ledger): synced = ntpOk && !stale, withinPolicy = |driftMs| ≤ maxDriftMs, signingAllowed = !refuseSigningOnDrift || (synced && withinPolicy). The same function answers the box (below), so the dashboard and the box never disagree.
Device identity and attestation — /api/org/{slug}/roost/{serial}/…
| Method · Route | Purpose |
|---|---|
GET /{serial}/identity | Identity payload + Axowl signature (below). Any member. 404 ROOST_DEVICE_NOT_FOUND. |
POST /{serial}/attestation/challenge | New nonce (32 hex, 10 minutes). 409 ATTESTATION_KEY_NOT_PROVISIONED when the ledger has no key; 429 above 5 open challenges. |
GET /{serial}/attestation?nonce= | The answered attestation for that nonce (or the latest without nonce), including the box's signed bytes and signature. |
GET /{serial}/attestations | Last 20 records. |
GET /api/roost/identity-public-key | Anonymous. { alg: "ES256", publicKeyPem, fingerprint } of the Axowl key that signs identities. |
GET /api/roost/reference-measurements | Anonymous. { components: [{ c, allowed: [sha256…] }] } — the published reference the server compares against. |
{
"identity": {
"typ": "roost-identity", "v": 1, "org": "acme",
"serial": "ROOST-26-0001", "model": "ROOST-3U", "attestationKid": "9f1c…", "deviceKeyFingerprint": "SHA256:…", "devicePublicKeyPem": "-----BEGIN PUBLIC KEY-----…",
"lifecycleState": "Active",
"measurements": [{ "c": "controller", "v": "1.1.0", "h": "…64 hex…" }, { "c": "voterkv260", "v": "bit-2026.3", "h": "…" }],
"measurementsHash": "…", "measurementsSource": "attestation", "referenceResult": "match",
"lastAttestationAt": "2026-10-19T08:00:00Z", "lastAttestationNonce": "…", "issuedAt": "2026-10-19T09:00:00Z"
},
"signed": true, "signature": "<base64url(payload)>.<base64url(r‖s)>", "signingKeyFingerprint": "SHA256:…", "alg": "ES256"
}
signature uses the re-arm ticket envelope: verify the P-256 signature over the base64url payload bytes with the identity public key; the decoded payload is the identity object. signed is false when no identity signing key is configured.
Measurements are { c: component, v: version, h: sha256 }, normalized (lower-case, sorted by component). referenceResult is match, mismatch or unverified (no reference published for a component). A mismatch records roost.box.attestation_mismatch (RST059) and raises alert key events Red through the alert evaluator (timeline, notification, SNMP trap); a later matching attestation clears it. Every answered attestation is roost.box.attestation_recorded (RST058) with the measurements hash.
Box-side API — /api/roost/boxes/{serial}/… (device-signed)
The box does not log in. It signs each request with the private half of the attestation key recorded in the ledger (written at the factory).
headers X-Roost-Box: <serial> X-Roost-Timestamp: <unix seconds> X-Roost-Signature: base64url(r‖s)
signed UTF-8 "ROOST-REQ-V1\n{METHOD}\n{PATH}\n{TIMESTAMP}\n{sha256hex(body)}" (empty body → sha256 of "")
window ±300 s; the same (serial, signature) is refused for 10 minutes (replay)
errors 401 BOX_HEADER_MISMATCH · BOX_NOT_REGISTERED · BOX_KEY_NOT_PROVISIONED · BOX_SIGNATURE_INVALID · BOX_REQUEST_REPLAYED · 403 BOX_RETIRED
| Method · Route | Purpose |
|---|---|
GET /policy | Time policy, the current verdict (signingAllowed) and the URLs of the reference measurements and identity key. Fetch at boot and periodically. |
POST /time-sync | { synced, driftMs, source, lastSyncUnix, stratum, authenticated, rtcOk } → updates the ledger's time fields; |driftMs| > maxDriftMs writes a TimeDrift event and roost.time.drift_exceeded (RST057, once per 10 min per box). Response time.signingAllowed is the instruction the box applies. |
GET /attestation/pending | Oldest open nonce, or 204. |
POST /attestation | { payload: base64url(JSON { typ: "roost-attest", serial, nonce, iat, measurements[] }), signature: base64url(r‖s over the payload bytes) }. Compared with the previous verified attestation (or the enrollment ceremony values) and the published reference. |
POST /alarms | { kind, severity?: "Red" | "Yellow" | "Info", summary?, payload?, ts? } with kind from the event list above. Alarm kinds map to alert keys (Tamper/Zeroize/Moved → events Red · PowerLoss/PsuFailed → psu · FanFailed → fans · BatteryLow → battery · TimeDrift → drift · Degraded → quorum · Recovered → clears events) and go through the alert evaluator: alert state, timeline event, roost.alert.raised / roost.alert.cleared, notifications, syslog and SNMP. Non-alarm kinds only add an Info timeline row. |
The signing conventions are P-256, SHA-256, IEEE P1363 r‖s and base64url without padding, the same as re-arm tickets.