Security and trust model
Openwake is a record. Its first security property is integrity, the record cannot be quietly altered, and its second is confidentiality for the small amount of private data it holds about customers. Most of the registry is public by design.
What we hold
| Data | Sensitivity | Protection |
|---|---|---|
| Vendor facts, quotes, snapshots | public by design | hash-chained ledger, signed entries, daily anchors |
| Watchlists (a customer's vendor list) | reveals their stack | org-scoped; DB encryption at rest |
| Emails, Slack webhook URLs | secrets | sealed at rest with AES-256-GCM (OPENWAKE_DATA_KEY); opened only at delivery |
| Counterparty checks | an org's agent activity | org-scoped; data classes only: payloads are never accepted, by API shape |
| API keys | secrets | stored as sha256; shown once |
| People | identity | provider subject (google:…, microsoft:…, email:…, org:<id>:…), sealed email, sha256(email) for lookups; no password, ever. Sign-in is OpenID Connect straight to Google / Microsoft / the org's own issuer (PKCE, nonce, ID token verified against the issuer's keys), or a six-digit email code (hashed, ten minutes, five tries) |
| Second factor | secrets | TOTP secret sealed; recovery codes hashed; enforced on every sign-in once enabled |
| Agent activity | record | kind, counterparty, data classes, outcome, policy verdict; detail sealed; every event is a ledger entry (agent_event), so the activity log is hash-chained and signed like checks |
| Org SSO | secrets | the org's OIDC client secret sealed; the email domain routes only after one verified sign-in |
| Sessions | secrets | a sealed {uid, iat, exp, sid} in an HttpOnly cookie, 30 days; the API verifies it with the same data key and never stores it |
The ledger
Every snapshot, fact set, change, document change and check is appended to one hash chain:
hash = sha256(prev_hash | kind | ref_id | payload_hash | created_at)
payload_hash = sha256(canonical JSON of the record)
signature = Ed25519(hash) with OPENWAKE_SIGNING_KEY
GET /v1/keys: the public key and the exact hash recipe.GET /v1/ledger/head: the current chain head.GET /v1/ledger/:kind/:ref: the entry for one record: anyone can recompute the hash and verify the signature offline.pnpm ledger:verify: walks the whole chain; exits non-zero on the first broken link.pnpm ledger:anchor: writesanchors/YYYY-MM-DD.jsonwith the head hash and signature. Commit that file. Public git history is the anchor: even the operator cannot rewrite it without it being visible.
Receipts
check_counterparty returns a self-contained receipt (version: openwake-receipt/1): the exact statement that was recorded (counterparty, data classes, decision, reasons, the fact set it was decided on and that fact set's own ledger entry), the ledger entry that includes it (seq, prev_hash, payload_hash, hash, signature, key_id, created_at), the public anchor that covers it when one exists, and the recipe. The caller stores it as its own evidence of what Openwake said, when.
Verify with nothing but the public key: no call to Openwake:
payload_hash == sha256(canonical(statement)) canonical = keys sorted recursively, no whitespace
hash == sha256(prev_hash|kind|ref_id|payload_hash|created_at)
Ed25519.verify(signature, hash-as-hex-bytes, public key for key_id)
pnpm receipt:verify receipt.json public-key.pem: does exactly that, offline, exit 0/1.POST /v1/receipts/verify: the same checks plus "is this entry still in the chain". An operator who rewrote history fails there and at the git anchors.
The shape follows the SCITT idea of a signed statement plus a transparency receipt; the content is what makes it Openwake's: the counterparty's cited data practices at decision time.
Keys
pnpm keys:generate prints OPENWAKE_SIGNING_KEY and OPENWAKE_DATA_KEY. Both are required in production; development falls back to ephemeral / fixed dev keys with a loud warning. Rotate the signing key by publishing the new public key alongside the old at /v1/keys (entries carry key_id).
Key rotation
- Signing key: generate a new pair, publish both public keys at
/v1/keys(entries carrykey_id), then switchOPENWAKE_SIGNING_KEY. Old entries stay verifiable with the old key forever; never re-sign history. - Data key: sealed values are bound to the key that sealed them. To rotate, open every sealed column with the old key and re-seal with the new one in a migration script, then switch
OPENWAKE_DATA_KEY. Losing the data key loses the secrets: back it up in your secret store. - Import-order note: the db package loads
.envfromenv.ts, imported first bycrypto.ts, so keys are present before any signing or sealing regardless of which module a script imports first.
Rules that never change
- The oracle takes data classes, never data.
- Facts are quoted or they are not facts.
- The ledger is append-only; corrections are new entries, never edits.
- Fail-open: Openwake is advisory. The caller's policy decides what happens when we are unreachable.