.klock is a small JSON file that holds a session secret. Whoever holds| Where | What |
|---|---|
outh/internal/klock/klock.go | The Go reference: the file, Create / Adopt / Ensure / Load, and the rotating id (ID). |
outh/internal/grant/grant.go | Go grant signing and checking (Sign, Verify). |
outh/cmd/outh/main.go | The outh command: $OUTH_SESSION_SECRET and outh spnego. |
src/klock/format.ts, src/klock/path.ts | The one parser for versions 1 and 2 (parseKlock / serializeKlock) and the one path resolver (resolveKlockPath). |
src/klock-io.ts | Reading and writing a .klock on disk for the TypeScript readers and writers: the 0600 check, the atomic write, and a local klock's urlSecretRef. |
src/cli/basic/commands/passwordless.ts | The prorm passwordless … and prorm klock show commands, writeKlock / writeLocalKlock, and the TS Klock type. |
src/cli/basic/commands/local.ts | prorm db init and prorm db serve. |
src/store-key.ts | StoreKey, which reads a cloud .klock for the ORM, and readLocalKlock. |
src/local-klock.ts, src/local-klock-scope.ts | LocalKlock, LocalKlockGate and serveLocalKlockGate, and the gate's scope check. |
src/transfer/transfer-klock.ts | TransferKlock, the transfer certificate. |
src/transfer/local-auth.ts | The TypeScript port of the Go rotating id. |
api/security/routes/passwordless.js, api/security/controllers/passwordless.ts, api/database/passwordless.ts | The control plane's side: sessions, grant checking, revocation. |
orders)prorm passwordless revoke marks the session revoked, the file stops| Cloud passwordless session | Local klock | Transfer report klock | |
|---|---|---|---|
| Made by | prorm passwordless issue, prorm roles grant (control plane mints the secret) | prorm db init (secret generated on your machine) | prorm transfer create for a job with a local endpoint (control plane mints it) |
| Where it lives | A .klock file (default ./.klock) | A .klock file with "kind": "local" (version 2) or "local": true (version 1) | Printed once, or stored in the agent config ~/.prorm/transfer-agent.json (0600). It is not a .klock file. |
| Who checks it | The control plane (/provision/databases/:id/passwordless/{exchange,query}) | The gate you run yourself: prorm db serve / serveLocalKlockGate on 127.0.0.1:7070 | The control plane (verifyKlockReport), for status reports on that one job |
| What is presented | A signed grant: b64url(body).b64url(sig) plus the session uuid | A single-use request token (kl2.…), or the rotating 50-character session id: Authorization: Klock <token> | An HMAC over <jobId>.<status>.<window>: Authorization: Klock <sig> or x-transfer-klock |
| HMAC domain | outh/grant/v1 | outh/local-gate/v2 (request token), outh/session-id/v1 (session id) | transfer/report/v1 (and outh/transfer-cert/v1 for the job's certificate) |
| Scope | The session's ceiling: ops × tables (and columns for a role) | Optional: the klock's scope (ops × tables). Without one, a valid token may run any SQL through the gate | Reporting status for one job |
| Revoked by | prorm passwordless revoke, a session expiry, or deleting the role | Replacing the file's secret and restarting the gate | Not separately revocable. It only works for its own job's reports. |
.klock file..klock (cloud session) | API key | CLI login token | |
|---|---|---|---|
| What it is | A 32-byte HMAC secret the server also holds (encrypted with SECRETS_KEY) | prk_<8 hex>.<secret>, stored server-side only as a keyed hash | A JWT the API issues for your user (prorm login) |
| Sent on each request? | No. Only a signature over the request is sent. | Yes, as x-api-key | Yes, as Authorization: Bearer |
| Acts as | A role on one database, limited by the session's ceiling | One database, at one privilege (owner/admin/readwrite/readonly), optionally bound to a role | You: every database and every account action you may perform |
| Lifetime | Until revoked, or until the optional --expires | Until revoked, or until the optional --expires | 30 days by default (1–90) |
| Stored | A file, mode 0600 | Wherever the program keeps its configuration | The OS keychain, or ~/.prorm/config.json (0600) |
| What a leak exposes | A copied file: everything the session allows, until it is revoked (see security properties). A captured grant: one op on one table, for its window and the next one. | Everything the key allows, until it is revoked | Your account, until the token expires or its session is ended |
.klock when the holder should never have a.klock is pretty-printed JSON. Both implementations write it with0600.klock.Create and klock.Adopt change only thewriteKlock for passwordless issue and roles
grant, writeLocalKlock for db init) also write atomically: a 0600 tempfsync, then rename over the target, so atls orkerberos block never survives into a new session. A TypeScript writerkind interactive,device or guest), a file with an integrity tag, and a version newer| Field | Type | Go klock.Klock | TS Klock | Meaning |
|---|---|---|---|---|
version | number | yes | yes (1 or 2) | Format version. The TypeScript readers accept 1 and 2 through one parser (parseKlock); a newer version is shown read-only by klock show and refused by everything else. |
kind | string | – | yes (v2) | Version 2: what the file is for. service is a database session, local a local klock; interactive, device and guest are CLI device identities. |
mode | string | yes | yes | Always written as "0600". It is a record only: no reader checks it. The file's real permission bits are what get checked. |
secret | string | yes | yes | The session secret, base64. The Go Create function and prorm write it unpadded (standard alphabet). The API hands it over padded. Readers accept both. Writers write exactly 32 bytes; the TypeScript readers accept 16–64 bytes and warn under 32. |
api | string | yes | yes | The control plane URL. passwordless issue and roles grant fill it in from your CLI config. |
database | string | yes | yes | The database's uuid on prod-orm. |
databaseId | number | – | yes | The database's numeric id, which the grant routes are addressed by. passwordless issue and roles grant write it. passwordless query/check and StoreKey use it when it is there, and the uuid otherwise. |
sessionId | string | yes | yes | The session's uuid on the control plane: which row the secret belongs to. This is not the rotating session id described under the cryptography. |
role | string | yes | yes | The role name the holder signs as. |
entId | string | yes | yes | The role's entitlement id (ent_id in a grant). Written by roles grant, and by passwordless issue when the API returns one. |
tls | object | yes | yes | { ca, cert, key, servername }, all PEM. Present when the session was issued with --auth tls. |
kerberos | object | yes | yes | { realm, principal, service, kdc, keytab }. Present when the session was issued with --auth kerberos. keytab is base64 and is a second secret. |
local | boolean | no | yes | true marks a local klock. There is no cloud session. |
dialect | string | no | yes | Local klock: the engine (sqlite, postgres, mysql, mariadb). |
url | string | no | yes | Local klock: the connection URL, without its password. db init strips the password and writes it to the .env beside the klock (see urlSecretRef). A version 1 file written before this change may still carry the password. The readers use it and warn. |
urlSecretRef | string | no | yes | Local klock: where the url's password is. env:<NAME> is read from the environment, then from the .env in the klock's directory. keychain:<service>/<account> is read from the OS keychain. When there is no url, the reference holds the whole URL. |
scope | object | no | yes | Local klock: what the gate may run, { "ops": ["read"], "tables": ["orders"] }. Both keys are optional. See the gate's scope. |
storage | string | no | yes | Local klock: the SQLite file path. |
version, mode and secret is optional in both types.passwordless issue / roles grant: version (1), mode, secret,api, database, databaseId, sessionId, role, and entId when theretls or kerberos when --auth asked for them.db init: version (2), mode, kind (local), secret, dialect,storage, or url plus urlSecretRef when the URL had aouth (Create / Adopt): version, mode and secret in a newsecret changes.prorm roles grant. The secret is a fake: it isEXAMPLE-ONLY-NOT-A-REAL-SECRET!!.{
"version": 1, // format version; readers refuse anything else
"mode": "0600", // informational; the file's real mode is what is checked
"secret": "RVhBTVBMRS1PTkxZLU5PVC1BLVJFQUwtU0VDUkVUISE", // 32 bytes, base64, unpadded
"api": "https://api.prod-orm.io", // where passwordless query / StoreKey send requests
"database": "0b6f2c1e-8d4a-4f7e-9a51-3c2d7e6b9f10", // the database uuid
"sessionId": "5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62", // the session row's uuid, sent with every grant
"role": "support_ro", // the role the grant is signed as
"entId": "c7a4e2d1-9b3f-4a6e-8d5c-2e1f0a9b8c73" // the role's uuid, signed into every grant as ent_id
}prorm db init app:{
"version": 2,
"mode": "0600",
"kind": "local",
"secret": "RVhBTVBMRS1PTkxZLU5PVC1BLVJFQUwtU0VDUkVUISE",
"dialect": "sqlite",
"storage": "/home/ada/project/app.db"
}prorm db init shop --docker --dialect postgres. The password is not in it:./.env as PRORM_KLOCK_DB_PASSWORD=…. A klock at another path,gate.klock, uses PRORM_KLOCK_DB_PASSWORD_GATE.{
"version": 2,
"mode": "0600",
"kind": "local",
"secret": "RVhBTVBMRS1PTkxZLU5PVC1BLVJFQUwtU0VDUkVUISE",
"dialect": "postgres",
"url": "postgres://prorm@localhost:5432/shop",
"urlSecretRef": "env:PRORM_KLOCK_DB_PASSWORD"
}$OUTH_KLOCK, then ./.klock in the current directory. A leading ~/ is| Reader | Explicit path |
|---|---|
prorm passwordless query / check, prorm klock show | --klock / -k |
prorm db serve | the positional [klock] argument |
StoreKey.fromKlock(file?), readLocalKlock(file?), LocalKlock.fromFile(file?), LocalKlockGate.fromFile(file?) | the file argument |
outh spnego | --klock |
passwordless issue, roles
grant and db init write to --klock when you give it, then to$OUTH_KLOCK, and otherwise to ./.klock (resolveKlockPath insrc/klock/path.ts). outh [--klock <file>] <dsn> writes to the sameklock.ResolvePath in Go). outh --out-dir <dir> <dsn><dir>/.klock instead; --out-dir cmd/outh restores the old.klock that another account on themode & 0o077 !== 0). 0600 and 0400 pass. 0640 and 0644klock.LoadFile) refuses with<path> is mode 0644; it must be 0600, as nobody else may read the session secret.<path> is mode 0644; it must be 0600 …. Theprocess.platform === 'win32', because POSIX mode bits do not describechmod 600 .klock, not by relaxing the check. A$ prorm passwordless check --klock ~/support/.klock
✗ /home/ada/support/.klock is mode 0644; it must be 0600 — nobody else may read the session secret
$ chmod 600 ~/support/.klock
$ prorm passwordless check --klock ~/support/.klock
✓ /home/ada/support/.klock signs for support_ro on database 0b6f2c1e-8d4a-4f7e-9a51-3c2d7e6b9f10
ceiling: read on orders, customers
this grant expires 9/26/2026, 10:00:30 AM[!WARNING]
On Windows no reader checks the mode, so nothing warns you about a file in a
shared or synced folder. Set an NTFS ACL that grants only your account read
access.
.klock never belongs in a repository or an image layer. Add it to.gitignore:*.klockStoreKey.from({ … }).prorm audit code flags any committed file whose name ends in .klock asSEC-003. It is critical for a v1 file andgit ls-files lists. Outside a git repository, any .klock that is present.env file: git rm --cached it, ignore it, and rotate the secret,.klock it finds,KLOCK-001), a v1 secret where v2 isKLOCK-003), and a file otherKLOCK-007).prorm audit klock runs them on your own klock files too, and probes your[!CAUTION] Untracking is not enough git rm --cached .klockstops the file from being tracked, but every earlier
commit still holds the secret. Anyone who cloned the repository before the
fix can still sign with it. Revoke the session and issue a new
one; a v1 secret cannot be changed in place.
$ git rm --cached .klock && echo '*.klock' >> .gitignore
$ prorm passwordless revoke 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 --db app
✓ Revoked 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62.
$ prorm passwordless issue --role support_ro --read --label "ada laptop" --db app| Domain | Used for |
|---|---|
outh/session-id/v1 | The rotating session id |
outh/grant/v1 | A cloud grant |
transfer/report/v1 | A transfer status report |
outh/transfer-cert/v1 | A transfer certificate |
floor(unix_seconds / 30), and both sides compute it from their own clocks.klock compute the same id without talking to each other, and the idcounter = uint64_be(floor(unix_seconds / 30)) 8 bytes
H1 = HMAC-SHA256(secret, "" ‖ "outh/session-id/v1" ‖ 0x00 ‖ counter ‖ 0x01)
H2 = HMAC-SHA256(secret, H1 ‖ "outh/session-id/v1" ‖ 0x00 ‖ counter ‖ 0x02)
id = base64url_nopad( (H1 ‖ H2)[0:48] )[0:50]src/transfer/local-auth.ts holds the TypeScript implementation (sessionId,verify, counter, expiresAt). verify(secret, id, at?, skew = 1) acceptsskew windows before it, and comparesLocalKlockGate.authorize uses that default. The look-back neververify checks only theRangeError there with a skew of 1 orprorm klock show prints the current id (windowId) andwindowEnds). The local gate is the place where theAuthorization: Klock <token>:LocalKlock.token() returns. It is differentnonce = 16 random bytes
mac = HMAC-SHA256(secret, "outh/local-gate/v2" ‖ 0x00 ‖ uint64_be(window) ‖ nonce)
token = "kl2." window "." base64url(nonce) "." base64url(mac)outh derives too and whichLocalKlock.sessionId() returns. The gate admits it once per window.401 with the reasonthe token has already been used. The seen-set lives in the gate process: anew LocalKlockGate(secret, conn, { reuseV1Tokens: true }) restores theouth/internal/klock/vector_test.goTestVectorParity) and tests/local-auth.test.ts (VECTOR), so this is thefloor(0 / 30) = 0, as 8 big-endian bytes:00 00 00 00 00 00 00 00.outh/session-id/v1, then 00, then the counter, then 01:6f7574682f73657373696f6e2d69642f7631 00 0000000000000000 01cfac2a41565e86aa6c12def3cde8a370568a21f2f0fe2751db387bd801f187e000, counter, and02.fda0e5806bf712a655fa96b674b940b7b6167c4cb72b9322c7543bcc791f511cfda0e5806bf712a655fa96b674b940b7).z6wqQVZehqpsEt7zzeijcFaKIfLw_idR2zh72AHxh-D9oOWAa_cSplX6lrZ0uUC3.z6wqQVZehqpsEt7zzeijcFaKIfLw_idR2zh72AHxh-D9oOWAa_import { Migrate } from 'ts-prorm-orm';
const zero = Buffer.alloc(32);
const id = Migrate.sessionId(zero, 0);
// id === 'z6wqQVZehqpsEt7zzeijcFaKIfLw_idR2zh72AHxh-D9oOWAa_'
// The checking side, at any real time: the id for now verifies.
const now = Date.now();
const ok = Migrate.verify(zero, Migrate.sessionId(zero, now), now); // trueKlock{Version: 1, Secret: base64.RawStdEncoding.EncodeToString(make([]byte, 32))}.ID(time.Unix(0, 0))grant = { "role": …, "ent_id": …, "scope": { "ops": [...], "tables": [...] }, "window": counter }
body = canonical JSON of grant
sig = HMAC-SHA256(secret, "outh/grant/v1" ‖ 0x00 ‖ body)
credential = base64url_nopad(body) + "." + base64url_nopad(sig)role, ent_id, scope, window. Insidescope, ops comes before tables.ops (read and/or write) and tables are sorted, so the order intables is omitted when it is empty. An absent tables means everyent_id is always present, and is "" when the session has none.StoreKey sign exactly one op and one table per request: theexpiresAt). The grant's role must equal theent_id the grant's must matchroles grant, the ceiling is the role's scope as it is now.reporter, ent_id ent_1,orders, in window 0. This example was computed by hand and checkedgrant.Sign. Unlike the id vector, no test pins it:body {"role":"reporter","ent_id":"ent_1","scope":{"ops":["read"],"tables":["orders"]},"window":0}
credential eyJyb2xlIjoicmVwb3J0ZXIiLCJlbnRfaWQiOiJlbnRfMSIsInNjb3BlIjp7Im9wcyI6WyJyZWFkIl0sInRhYmxlcyI6WyJvcmRlcnMiXX0sIndpbmRvdyI6MH0.N-wb072PEdAPfDiN3UBGHBmDw0QdU7DVVoUvdRUEG_wsignV1Grant and verifyV1Grant from ts-prorm-orm/klock produce and checkimport { signV1Grant, verifyV1Grant } from 'ts-prorm-orm/klock';
const secret = new Uint8Array(32); // the zero secret, as above
const { body, credential } = await signV1Grant(secret, {
role: 'reporter',
ent_id: 'ent_1',
scope: { ops: ['read'], tables: ['orders'] }, // sorted and ordered for you
window: 0,
});
// credential === 'eyJyb2xlIjoicmVwb3J0ZXIi…N-wb072PEdAPfDiN3UBGHBmDw0QdU7DVVoUvdRUEG_w'
await verifyV1Grant(secret, credential, 29); // window 0: the grant comes back
await verifyV1Grant(secret, credential, 59); // window 1: still accepted
await verifyV1Grant(secret, credential, 1000); // throws: the grant expired
console.log(body);POST /provision/databases/:id/passwordless/query takes{ sessionId, credential, query }. …/exchange takes{ sessionId, credential, op?, table? } and answers with the ceiling withoutprorm transfer agent) reports a local job'sHMAC-SHA256(secret, "transfer/report/v1" ‖ 0x00 ‖ "<jobId>.<status>.<window>"),Authorization: Klock <sig>, and the API'sverifyKlockReport accepts the current or the previous window. Only the jobmanifestDigest, bytes and error) are not. This is unchangedprorm transfer create … --local --klock-v2TransferKlock, the secret behind a job's certificate, reads the same.klock formats as everything else: TransferKlock.fromKlock(textOrObject)TransferKlock.fromFile(path?) take version 1 or 2. TransferKlock.create().klock cannot beSECRETS_KEY, not hashed, because it has.klock is the credential. The file is not bound to a device.api, database, sessionId,role and entId are plain JSON. Changing role only makes grants failapi (or setting $OUTH_API_URL) sendsscope in the gate klock, a valid token may run any SQL on theSELECT does. Give the gate's connection a database role127.0.0.1 by default. If you bind--host, tokens cross the network in thedb init contains the connection, but not the.env beside it (urlSecretRef), whichdb init warns about if it does not look git-ignored. The klock is stillnew LocalKlock(secret), or a file with just version, kind: "local"local: true) and secret. No command writes a client-only file. Thedb init --docker writes still names the password inurl; re-run db init to move it.--auth tls--auth kerberos must, on every …/passwordless/query, also present aAuthorization: Negotiate) for the session's principal..klock, so they only help if they are/query. It does not apply to /exchange, sopasswordless check succeeds on a copied file. Revoking a Kerberos session--label, and the narrowest scope that--read, --tables, or a narrow role).--expires for anything temporary.0600, out of git, out of images, and out of synced folders.--auth tls or --auth kerberos when the holder can keep the extraprorm passwordless list forlastUsed you do not expect..klock with mode 0600. The API call is the same one the CLI makes; if$ prorm passwordless issue --db app --role reporter --read --tables orders --label "nightly report" --expires 30 --klock ~/reports/.klock
✓ Session 9f3a7c52-1d4e-4b8a-9c6f-2e7d1a0b5c34 as reporter: read on orders
Wrote /home/ada/reports/.klock (0600).
Try it: prorm passwordless query '{"select":{"from":"…","limit":1}}' --klock /home/ada/reports/.klock$ prorm roles grant support_ro --db app --label "ada laptop" --klock ~/support/.klock{
"session": {
"id": "9f3a7c52-1d4e-4b8a-9c6f-2e7d1a0b5c34",
"role": "reporter",
"entId": null,
"scope": { "ops": ["read"], "tables": ["orders"] },
"label": "nightly report",
"auth": "secret",
"principal": null,
"tlsFingerprint": null,
"poolId": null,
"lastUsedAt": null,
"expiresAt": "2026-10-26T10:00:00.000Z",
"revokedAt": null,
"createdAt": "2026-09-26T10:00:00.000Z"
},
"secret": "RVhBTVBMRS1PTkxZLU5PVC1BLVJFQUwtU0VDUkVUISE=",
"secretSeenOnce": true,
"windowSeconds": 30
}201, and secret is neverpasswordless issue (aliases passwordless new, klock issue) takes--db, --role (required), --read, --write, --tables, --label,--expires (ISO date or number of days), --klock and --auth. You must--read and --write.roles grant <role> takes --db, --label (required), --expires,--klock and --auth.--auth secret|tls|kerberos adds a certificate or a principal and keytab to--klock - prints the secret instead of writing a file. You cannot combine--auth tls or --auth kerberos, because that material only fits.klock.POST /provision/databases/:id/passwordless andPOST /provision/databases/:id/roles/:roleId/grant. Both return 201 withsecret in the body, and neither will return it again. Both refuse with503 when SECRETS_KEY is not configured.outh. outh <dsn> settles its .klock at the resolved path--klock, then $OUTH_KLOCK, then ./.klock; or <dir>/.klock with--out-dir <dir>):$OUTH_SESSION_SECRET, it calls klock.Ensure, which keeps ancrypto/rand.$OUTH_SESSION_SECRET set, it calls klock.Adopt, which replacesprorm db init writes a local klock beside a new database:--klock - skips writing the file.passwordless check (alias klock check) proves the file against theklock show (alias passwordless klock) worksfile, database, sessionId, role, entId, api,window, windowId and windowEnds. Both commands accept--json.passwordless query (aliases klock query, pq) and passwordless checkent_id from the file. The flags--database, --session, --role and --ent-id, and the environment$OUTH_DATABASE, $OUTH_SESSION_ID and $OUTH_API_URL, overrideStoreKey.fromKlock() reads a cloud .klock and signs everyimport { Prorm, StoreKey } from 'ts-prorm-orm';
const key = StoreKey.fromKlock('~/support/.klock'); // or ./.klock, or $OUTH_KLOCK
const db = new Prorm({ dialect: 'postgres', storage: key });
console.log(String(key)); // [StoreKey <database uuid> as support_ro] — never the secretStoreKey.fromKlock reads a version 1 file, or a version 2 file of kindservice that carries a secret. It requires database, sessionId,role and entId in the file, and a secret of 16–64 bytes (it warns underqueryUrl is addressed by the file's databaseId when there isStoreKey.from({ …, databaseId }) does theouth spnego --klock <file> --service <spn> for$OUTH_BIN if outh is not on your PATH).prorm db serve (alias db gate) runsserveLocalKlockGate in front of the database that a gate klock names. The127.0.0.1:7070. Clients present a request token (orimport { LocalKlock, LocalKlockGate, serveLocalKlockGate } from 'ts-prorm-orm';
// The gate: holds the secret and the connection.
const gate = LocalKlockGate.fromFile('./.klock');
const server = serveLocalKlockGate(gate, { port: 7070, host: '127.0.0.1' });
// A client: holds only the secret.
const client = new LocalKlock(process.env.KLOCK_SECRET ?? '');
const res = await fetch('http://127.0.0.1:7070/query', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Klock ${client.token()}` },
body: JSON.stringify({ sql: 'SELECT 1' }),
});
console.log(res.status, await res.json()); // 200 { rows: [...] }, or 401
server.close();
await gate.close();GET /healthz (open) and POST /query with a{ "sql": "…" } body (at most 256 KiB by default), which returns { rows }.401, and SQL outside the403. A gate klock whose url has its password inurlSecretRef needs that variable in the gate's environment, or the .env[!WARNING] Keep the gate on loopback
The gate speaks plain HTTP.prorm db serve --host 0.0.0.0puts every
request token on the network in the clear, and one read in transit can be
used once before the real request arrives. Put a TLS proxy in front, or keep
clients on the same machine.
scope. db init does not write one; add it to{ "scope": { "ops": ["read"], "tables": ["orders", "public.customers"] } }ops lists what the gate may run: read (SELECT, WITH, VALUES,EXPLAIN, and SHOW/DESCRIBE/PRAGMA without an assignment) andwrite (everything else, and any read that contains a writing keyword suchINSERT, DELETE, INTO or FOR UPDATE).tables lists the tables a statement may name after FROM, JOIN,INTO, UPDATE or TABLE. Names are compared case-insensitively. A bareorders) matches orders in any schema; a qualified entrypublic.customers) matches only that. CTE names and aliases are notPRAGMA, CREATE INDEX,GRANT) is refused when tables is set.LocalKlockGate.fromFile picks the scope up from the file;new LocalKlockGate(secret, conn, { scope }) takes it directly, andgate.permits(sql) / checkScope(scope, sql) check a statement withoutreadLocalKlock() is the non-passwordless shortcut. It opens theimport { Prorm, readLocalKlock, SqlDialect } from 'ts-prorm-orm';
// ./.klock, or $OUTH_KLOCK, or a path. `dialect` comes back as a plain string.
const { dialect, ...connection } = readLocalKlock();
const local = new Prorm({ dialect: dialect as SqlDialect, ...connection });passwordless revoke (alias passwordless rm) callsDELETE /provision/databases/:id/passwordless/:sessionId. The row is keptrevokedAt set, so it stays auditable. The next grant signed with thatThat session cannot be used. For a Kerberos session,roles grant can be revoked the same way. Deleting the roleprorm db init --url … --klock ./.klock replaces the file) and restartdb serve, which reads the secret once, at startup.$ prorm passwordless issue --db app --role support_ro --read --tables orders,customers --label "ada laptop" --klock ~/support/.klock
✓ Session 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 as support_ro: read on orders, customers
Wrote /home/ada/support/.klock (0600).
Try it: prorm passwordless query '{"select":{"from":"…","limit":1}}' --klock /home/ada/support/.klock
$ prorm klock show --klock ~/support/.klock
file /home/ada/support/.klock
version 1
kind service
database 0b6f2c1e-8d4a-4f7e-9a51-3c2d7e6b9f10
databaseId 42
sessionId 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62
role support_ro
entId
api https://api.prod-orm.io
window 59680560
windowId 7l4w_wuUdYQ1UpjeWoDltUm4U1E49WzcPS82sa16L7Mdz4eTDg
windowEnds 2026-09-26T10:00:30.000Z
$ prorm passwordless query '{"select":{"from":"orders","limit":2}}' --klock ~/support/.klock
grant: read on orders, window 59680560 · session 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 · no credential sent
id customer_id total status
1001 17 49.90 shipped
1002 23 120.00 pending
2 row(s) in 14ms
$ prorm passwordless list --db app
session role label scope lastUsed expires revoked
5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 support_ro ada laptop read on orders,customers 8s ago never
$ prorm passwordless revoke 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 --db app
✓ Revoked 5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62.
$ prorm passwordless query '{"select":{"from":"orders","limit":2}}' --klock ~/support/.klock
✗ That session cannot be usedwindowId is what the example secret derives for 10:00:00 UTC on 26statement), whichpasswordless issue or roles grant).klock. Write it beside the old file, not over it,$ prorm roles grant support_ro --db app --label "ada laptop (2026-09)" --klock ~/support/.klock.new$ mv ~/support/.klock.new ~/support/.klock
$ prorm passwordless check --klock ~/support/.klockprorm passwordless revoke <old-session-uuid>.prorm passwordless list shows which one it is by its label andlastUsed.[!IMPORTANT]
If the old file may have leaked, revoke first and accept the gap. The
credential rotation runbook covers the
ordering.
POST /security/klock/sessions/:id/rotate), and its shared secret droppedklock.ID (Go),Migrate.sessionId (src/transfer/local-auth.ts) and prorm klock showTestVectorParity in Go and VECTOR intests/local-auth.test.ts. If you change one, change the other.grant.Sign / grant.Verify, the CLI'ssignGrant, StoreKey.sign and the API's verify use the same domain,outh/testdata/klock-vectors.json (v1grant) pin the exact body and MAC,<, >, & and non-ASCII characters; thesignGrant and StoreKey.sign are tested against them.version, thelocal, dialect, url, urlSecretRef, storagescope. Go does not use them, and keeps them on rewrite.kl2.…) and its scope: only the TypeScript gateLocalKlock implement them. A Go client presents the session id.the grant expired about 1m0s ago in Go andThe grant expired about 60 seconds ago from the API.explainKlockError(err) from ts-prorm-orm/klock finds the entry for| Message | Reference |
|---|---|
<path> is mode 0644; it must be 0600, as nobody else may read the session secret (Go) · <path> is mode 0644; it must be 0600 — nobody else may read the session secret (CLI, StoreKey) · … — nobody else may read the secret (readLocalKlock) · <path> is mode 0644; it must be 0600 (LocalKlock, LocalKlockGate) | klock_mode_insecure |
<path> is version 3; this build understands versions 1 and 2 · <path>: version must be a positive integer, not undefined | klock_version_unsupported |
<path> is a klock of kind interactive, not a database session (also device, guest) | klock_wrong_kind |
<path> holds no secret; a v2 session without one signs with its device key, which … does not do yet | klock_secret_missing |
<path> is not a local klock | klock_wrong_kind |
<path> is a cloud session klock, not a local one — use StoreKey.fromKlock() for it | klock_wrong_kind |
this klock has no connection; it is a client credential, not a gate · <path> names no dialect | klock_wrong_kind |
<path> holds no secret | klock_secret_missing |
<path>: the secret is 8 bytes; a session secret is 32 (CLI and other file readers) · A StoreKey secret is 32 bytes (16–64 accepted); this one decodes to 8 | klock_secret_length |
<path>: the secret is 16 bytes, under 32; rotate it (prorm klock rotate) (a warning: the CLI prints it, the library emits a KlockWarning) | klock_secret_length |
the secret decodes to 16 bytes; a session secret is 32 | klock_secret_length |
<path> holds a klock of kind interactive (a device identity); write this one elsewhere with --klock <file> · <path> is sealed with an integrity tag … | klock_overwrite_refused |
<path>: the database password is not in the file (urlSecretRef env:NAME); set $NAME, or keep the .env that \prorm db init` wrote beside the klock` | klock_secret_ref_unresolved |
OUTH_SESSION_SECRET: the session secret to adopt decodes to 8 bytes; a key is at least 32 · … is not valid base64 | klock_secret_length |
the session secret is not valid base64 | klock_secret_length |
a local klock secret is 32 bytes; this one is 0 | klock_secret_length |
No .klock at <path>. Issue one with … · no .klock at <path>; pass --klock <file>, or issue one with … · No .klock at <path>. Make one with \prorm db init`.` | klock_not_found |
the .klock does not say which database, session, role; pass --database --session --role | klock_coordinates_missing |
A StoreKey needs sessionId — a .klock written by prorm carries it | klock_coordinates_missing |
The grant expired about 60 seconds ago (API) · the grant expired about 1m0s ago (Go) | v1_window_expired |
The grant is for a window that has not started | v1_window_not_started |
The signature does not match this session (API) · the signature does not match this .klock (Go) | v1_signature_mismatch |
The body carries more than the grant it decodes to | v1_grant_not_canonical |
That session cannot be used | v1_session_unusable |
That session is for a different database | v1_wrong_database |
The grant is for a different role than the session · … different role id than the session | v1_role_mismatch |
The grant exceeds the session: … · This grant does not permit … · This session … | v1_beyond_session |
The role this session was granted no longer exists | v1_role_gone |
This session presents a client certificate; none arrived. … · The client certificate is not this session's. · This session presents a Kerberos ticket; … · The Kerberos ticket is not this session's. | v1_client_cert_required |
<path> holds no Kerberos identity; issue the session with --auth kerberos | v1_kerberos_refused |
present a valid klock token: Authorization: Klock <token> (HTTP 401 from db serve), with a reason | gate_auth_refused |
refused: this gate does not permit writes · … does not permit <table> · … which tables the statement touches cannot be told (HTTP 403 from db serve) | gate_scope_refused |
Passwordless access needs SECRETS_KEY to be configured · Granting a role needs SECRETS_KEY to be configured (HTTP 503) | secrets_key_missing |
verify accepts whatever secret the session rowpasswordless issueroles grant now record the numeric databaseId beside the uuid, andpasswordless query/check and StoreKey address/passwordless/{exchange,query} by it. A .klock written before that hasStoreKey builtdatabaseId) sends the uuid. A control plane without the uuid/idThat session is for a different database. Re-issue the file,--database <numeric id>, $OUTH_DATABASE, or databaseId.| v1 | v2 | |
|---|---|---|
| What the client holds | A 32-byte HMAC secret, which the server also holds | An Ed25519 signing key and an X25519 sealing key, which only the device holds |
| What says what it may do | The session row on the server | A grant: a compact JWS the API signed, naming the capabilities, the limits and the device key |
| What is sent | An HMAC over the grant body for one 30-second window | Authorization: Klock <grant> plus an RFC 9421 signature over the request, with a single-use nonce |
| A copied file | Is the credential, until revoked | Carries no private key when the keys are in the keychain, so it cannot sign |
| Replay | A captured v1 grant works for about 60 seconds | The nonce is single-use for unsafe methods and admin requests |
| Who can verify offline | Nobody | Anyone holding the pinned root key |
outh/grant/v1 HMAC grant, the transfer report MAC andAuthorization: Klock <id> at the local gate. A v1 file stays v1 until you runprorm klock upgrade. Nothing upgrades a file| Where | What |
|---|---|
src/klock/format.ts, types.ts, klock-file.schema.json | The v2 file: parse, validate, serialise, the integrity tag |
src/klock/keys.ts | The pinned roots (KLOCK_ROOTS) and keyset verification |
src/klock/jws.ts | Strict Ed25519 JWS, grants (signGrant, verifyGrant) |
src/klock/httpsig.ts | RFC 9421 request signatures and Content-Digest |
src/klock/seal.ts | klock-seal-v1: secrets sealed to a device |
src/klock/canonical.ts, thumbprint.ts | Canonical JSON and RFC 7638 thumbprints |
src/klock/agent/* | The device agent: keys, the file on disk, the held session, the capability guard, sync and the push channel |
api/security/controllers/klock.ts, api/controllers/klockGrants.ts | The API: the Klock scheme, sessions, step-up, guest grants, and everything the API signs |
api/klock/** | The klock service at klock.prod-orm.io |
outh/testdata/klock-vectors.json | The shared vectors Go and TypeScript both test against |
"version": 2 and a kind:kind | What it is | Where it lives | Needs device |
|---|---|---|---|
interactive | A person on the CLI or the local MCP server | ~/.prorm/klock/<profile>.klock | yes |
device | A trusted machine: a long-lived session, opened with step-up | ~/.prorm/klock/<profile>.klock | yes |
guest | An anonymous device key with the public set | ~/.prorm/klock/<profile>.klock | yes |
service | A database session. With a secret, it is the v1-compatible form that klock upgrade writes | a project .klock | yes |
local | A local klock written by prorm db init | a project .klock | no; it uses secret only |
<profile> is --profile or $PRORM_PROFILE, and default otherwiseprofileKlockPath in src/klock/path.ts). The CLI records the path asklock in ~/.prorm/config.json.| Field | Meaning |
|---|---|
kind | One of the kinds above. |
klock | The klock service's URL, https://klock.prod-orm.io by default. |
device.id | dev_…, assigned when the device enrols. Absent while it is pending. |
device.sign | { alg: "Ed25519", pub, thumbprint, priv? }. pub is the 32-byte public key, b64url. thumbprint is its RFC 7638 thumbprint (43 characters), which is the device's name everywhere. |
device.seal | { alg: "X25519", pub, priv?, binding }. binding is a JWS by the signing key over {sealPub, deviceId}, so the signing key vouches for the sealing key. |
device.keyStore | keychain or file. priv is in the file only when this is file. |
device.cert | The device certificate the API signed (a compact JWS). |
session | { id: "ks_<uuid>", grant, grantSeq, syncSeq, syncedAt, checkInBy }. The grant is the API-signed JWS. |
providers | Per-provider state (tls, kerberos, files). Private material is referenced (keyRef, keytabRef), not stored, unless the key store is file. |
integrity | An HMAC tag over the rest of the file. Present exactly when the key store is keychain. |
secret, database, databaseId, sessionId, role,entId, tls, kerberos, local, dialect, url, urlSecretRef,storage, scope) keep their v1 meaning and shape, so StoreKey and outh{
"version": 2,
"mode": "0600",
"kind": "interactive",
"api": "https://api.prod-orm.io", // must be the origin of the grant's iss
"klock": "https://klock.prod-orm.io",
"device": {
"id": "dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3",
"sign": { "alg": "Ed25519", "pub": "11qYAYKx…", "thumbprint": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k" },
"seal": { "alg": "X25519", "pub": "hSDwCYkw…", "binding": "eyJhbGciOiJFZERTQSJ9.eyJ…" },
"keyStore": "keychain", // no priv in the file
"cert": "eyJhbGciOiJFZERTQSIsImtpZCI6ImctMjAyNjA5LTNmYTEiLCJ0eXAiOiJrbG9jay1kZXZpY2Urandzin0.…"
},
"session": {
"id": "ks_5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62",
"grant": "eyJhbGciOiJFZERTQSIsImtpZCI6ImctMjAyNjA5LTNmYTEiLCJ0eXAiOiJrbG9jay1ncmFudCtqd3MifQ.…",
"grantSeq": 17,
"syncSeq": 42,
"syncedAt": "2026-09-26T10:00:00Z",
"checkInBy": "2026-09-26T11:00:00Z"
},
"integrity": "3q2-7wAAAAB…"
}parseKlock) enforces the shape: a v2 file other than localdevice; a keychain-backed device must carry an integrity tagpriv; a file-backed one must carry both priv keys and no tag;session.id must be ks_<uuid>; the grant, the seal binding and theprorm klock show and refused by everything else.src/klock/agent/devicekey.ts generates both key pairs on the device andprorm-klock;sign:<profile>:<id>, seal:<profile>:<id> andintegrity:<id>, where <id> is the device id once enrolled andpending:<thumbprint> before;security -i with the command on stdin, and Linuxsecret-tool store with the secret on stdin, so a key never appears inPRORM_NO_KEYCHAIN=1, or a failed write), thepriv, keyStore becomes file,integrity isb64url(HMAC-SHA256(k, canonical(file without "integrity"))), where k is acomputeIntegrity / verifyIntegrity insrc/klock/format.ts). It catches an edit to a field the grant does notapi.klock.tamper-detected, which is reported at theKLOCK_ROOTS (src/klock/keys.ts), which the CLI, the MCPouth/internal/klockv2/roots.go, and a test holds the two equal.typ: klock-keyset+jws):{ seq, iat, expires, keys: [{ kid, use, x, nbf, exp }] }. It lives at mostuse: "grant", kid g-YYYYMM-<4 hex>. This is the API'sKLOCK_GRANT_KEY. It signs grants, device certificates, enrolmentuse: "klock", kid k-YYYYMM-<4 hex>. This is the klock service's key.KLOCK_ROOTS is empty until the production keyset ceremony. With nothing503 klock_v2_unavailable, andprorm login falls back to the legacy token. Development and staging pinPRORM_KLOCK_ROOTS, a JSON array of {kid, x}. AGET /.well-known/klock-keys.jsonapplication/jose, with a Klock-Keyset-Seq header), and the klockGET /v2/keys. Before a keyset is published, both503 no_keyset, as do the MCP404.~/.prorm/klock/keyset.json, with the highest seqseq is refused as a rollback.kid the cache does not have.KLOCK_GRANT_KEY is set, thegrant key. Otherwise nothing is signed.GET /.well-known/klock-configuration tells a client whether v2 is ready:{ v2, api, klock, keysetUrl, revocationsUrl, sessionKinds, capabilitiesVersion }.$ curl -s https://api.prod-orm.io/.well-known/klock-configuration
{"v2":true,"api":"https://api.prod-orm.io","klock":"https://klock.prod-orm.io","keysetUrl":"https://api.prod-orm.io/.well-known/klock-keys.json","revocationsUrl":"https://api.prod-orm.io/security/klock/revocations","sessionKinds":["interactive","device","guest"],"capabilitiesVersion":1}v2 is false until the API can sign (the three conditions above), and the keyset URL answers503 no_keyset until one is published.{"alg":"EdDSA","kid":"root-2026","typ":"klock-keyset+jws"}): one grant key{
"seq": 1,
"iat": 1790416800,
"expires": 1798106400,
"keys": [
{ "kid": "g-202609-3fa1", "use": "grant", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "nbf": 1790416800, "exp": 1798106400 },
{ "kid": "k-202609-7c2e", "use": "klock", "x": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw", "nbf": 1790416800, "exp": 1798106400 }
]
}x is the b64url Ed25519 public key. expires is 89 days after iat,kid names one of the grant keys.kid: clients pick it up on their next sync, and{"alg":"EdDSA","kid":"g-…","typ":"klock-grant+jws"}.GrantPayload in src/klock/types.ts):| Claim | Meaning |
|---|---|
v, iss, aud, jti | 2, the API's origin, ["api","mcp","klock"], and a unique id |
principal | { type: "user" | "guest" | "service", id, name? }. For a guest, id is the device thumbprint. |
tier, caps, dormant | The resolved tier, the capability ceiling (sorted), and the admin capabilities that need a step-up. See Capabilities. |
adminUntil | When a step-up's admin capabilities lapse. |
teamId, teamRole, roles, rolesTruncated | The current team context, and up to 50 team memberships. |
sessionId, kind, binding | ks_<uuid>, the session kind, and device. |
resource | For a service session: the database, store or transfer job it is bound to, and its owner. |
limits | The session's limits (see sessions). |
net, netRef | Compact network rules, at most 2 KB, or a flag saying to fetch them. |
iat, nbf, exp | Issue time, not-before, and when the grant is dead for everything. |
checkInBy | At or before exp: the last moment an offline verifier may honour gated capabilities. |
deviceKeyThumbprint, deviceId, grantSeq | The device the grant is bound to, and its sequence number. |
interactive grant, as prorm login receives it. TheresolveCapabilities insrc/capabilities.ts gives an account on the usage plan whose session is indeveloper. The API also puts the device's public keydevicePub), so a verifier can check the request signature{
"v": 2,
"iss": "https://api.prod-orm.io",
"aud": ["api", "mcp", "klock"],
"jti": "0f6c2b8e-5a1d-4e9c-b7a3-2d4f6e8a0c1b",
"principal": { "type": "user", "id": "b3e1c9d2-7f4a-4a8e-9c6b-1d2e3f4a5b6c", "name": "ada" },
"tier": "team",
"caps": [
"access-rule:read", "access-rule:write", "access:read", "access:write",
"account:read", "account:write", "audit:read", "behaviour:read",
"behaviour:write", "billing:manage", "billing:read", "catalog:read",
"chat:manage", "data:read", "data:write", "device:manage", "device:read",
"docs:read", "files:receive", "files:send", "kerberos:manage", "key:read",
"key:write", "klock:sync", "local:use", "mcp:clients", "migration:apply",
"migration:write", "project:read", "project:write", "reference:read",
"resource:delete", "resource:operate", "resource:provision",
"resource:read", "scaffold:use", "session:manage", "session:read",
"sso:manage", "team:create", "team:manage-members", "team:read"
],
"dormant": [],
"teamId": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"teamRole": "developer",
"roles": { "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f": "developer" },
"sessionId": "ks_5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62",
"kind": "interactive",
"binding": "device",
"limits": {
"maxDurationSec": 2592000,
"idleTimeoutSec": 604800,
"concurrent": 10,
"evict": "lru",
"checkInEverySec": 3600,
"grantTtlSec": 86400,
"stepUpTtlSec": null,
"rotateEverySec": null
},
"iat": 1790416800,
"nbf": 1790416800,
"exp": 1790503200,
"checkInBy": 1790420400,
"deviceKeyThumbprint": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k",
"deviceId": "dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3",
"grantSeq": 17,
"devicePub": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}exp (24 hours, thegrantTtlSec); an offline check honours caps only until checkInBy (onecheckInEverySec); dormant is empty because this account holds nodeviceKeyThumbprint.verifyGrant (src/klock/jws.ts) checks, in order:alg must be EdDSA and is checked before any key isalg, kid and typ. Every part istyp is klock-grant+jws, and the kid is a current grant key of anbf…exp.iss and aud, when the caller asks.deviceKeyThumbprint is this device's thumbprint.src/klock/agent/session.ts):| When | Status | What is honoured offline |
|---|---|---|
now ≥ exp | expired | nothing: the session is dead |
idle longer than limits.idleTimeoutSec | idle | the public set only; sync needed |
now > checkInBy | check-in-due | the public set only; sync needed |
| otherwise | active | the grant's caps; admin capabilities only until adminUntil |
Authorization: Klock <grant JWS>
Content-Digest: sha-256=:<base64 SHA-256 of the body; the empty body hashes "">:
Klock-Client: prorm-cli/4.9.2 (darwin; node/22)
Signature-Input: kl=("@method" "@authority" "@path" "@query" "content-digest" "authorization" "klock-client")
;created=1727340000;expires=1727340060;nonce="<b64url 16 bytes>"
;keyid="<device thumbprint>";alg="ed25519";tag="klock-v2"
Signature: kl=:<base64 Ed25519 signature over the RFC 9421 signature base>:idempotency-key and x-prorm-team are added to the covered components@query is ? when there is no query.verifyKlockRequest in src/klock/httpsig.ts) requires all of:created is within ±60 seconds of now, and expires has not passed;keyid is the grant's deviceKeyThumbprint;tag is klock-v2 and alg is ed25519;Content-Digest matches the body, recomputed;authenticateKlock), the nonce is then claimed single-use forDeviceGuard Durable Object, for every unsafe methodPOST, PUT, PATCH, DELETE) and for every request made with a liveadmin:* capability. After that the API loads the session row. The row mustlimits.idleTimeoutSec or past its maximum duration. Its device thumbprint401 clock_skew with aKlock-Server-Time: <unix seconds> header. The CLI (klockFetch) stores theauthenticateKlock in api/security/controllers/klock.ts):403 capability_missing or401 step_up_required when thesignKlockRequest and verifyKlockRequest from ts-prorm-orm/klock are theauthorization and klock-client), theimport {
HttpSigError,
klockCrypto,
signKlockRequest,
thumbprint,
verifyKlockRequest,
} from 'ts-prorm-orm/klock';
const c = klockCrypto();
const seed = c.randomBytes(32); // a device's Ed25519 seed; the CLI keeps it in the keychain
const devicePub = await c.ed25519PublicKey(seed);
const keyid = await thumbprint(devicePub); // what the grant calls deviceKeyThumbprint
const req = {
method: 'POST',
url: 'https://api.prod-orm.io/provision/databases/42/passwordless/query',
headers: {
authorization: 'Klock eyJhbGciOiJFZERTQSJ9.…', // the grant
'klock-client': 'nightly-report/1.0',
} as Record<string, string>,
body: JSON.stringify({ sessionId: '5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62', query: { select: { from: 'orders', limit: 5 } } }),
};
const now = Math.floor(Date.now() / 1000);
const signed = await signKlockRequest(req, { seed, keyid, created: now });
// signed['content-digest'] sha-256=:TEeYBAZx…:
// signed['signature-input'] kl=("@method" "@authority" "@path" "@query" "content-digest"
// "authorization" "klock-client");created=…;expires=…;nonce="…";
// keyid="…";alg="ed25519";tag="klock-v2"
// signed.signature kl=:…:
const sent = {
...req,
headers: {
...req.headers,
'content-digest': signed['content-digest'],
'signature-input': signed['signature-input'],
signature: signed.signature,
},
};
// The server's side: the public key and thumbprint come from the verified grant.
const ok = await verifyKlockRequest(sent, { publicKey: devicePub, keyid, now });
console.log(ok.nonce); // the API claims this nonce for 120 seconds
// Change one byte of the body and the digest no longer matches.
try {
await verifyKlockRequest({ ...sent, body: sent.body.replace('5', '5000') }, { publicKey: devicePub, keyid, now });
} catch (e) {
if (e instanceof HttpSigError) console.log(e.code); // 'bad-digest'
}HttpSigError.code is clock-skew when created is more than 60 secondsnow, and one of bad-digest, bad-signature, wrong-key, expired,bad-tag, bad-params, missing-component or malformed otherwise. The APIclock-skew into clock_skew and every other code intosignature_invalid.klock-seal-v1src/klock/seal.ts): an ephemeral X25519 key, HKDF-SHA256 with the infoklock/seal/v1, a NUL and a context, then AES-256-GCM with the contextkerberos/keytab/<principal>, db/secret/<sessionUuid>,files/key/<transferId>), so a sealed keytab cannot be opened as a file key.stateDigest, the RPC argument digest, and the v1src/klock/canonical.ts, Goouth/internal/canon): keys sorted by UTF-16 code unit, no whitespace,JSON.stringify escapes them (so <, >, &,outh/testdata/klock-vectors.json pins canonical JSON, v1 grants, the sessionsessions ledger for every kind. GET /security/sessionsDELETE /security/sessions/:id revokes any of them.| Kind | Who | Binding | Opened by | Built |
|---|---|---|---|---|
interactive | A person on the CLI or the local MCP server | device | prorm login, through the console | yes |
device | A trusted machine | device | prorm login --device, with a fresh step-up | yes |
service | A database host, CI runner or on-device transfer agent | device | prorm klock upgrade, the owner's …/passwordless/enrol + the host's …/redeem, the console, or prorm transfer create --klock-v2 | yes; see service sessions |
guest | Nobody: an anonymous device key | device | prorm login --guest | yes; there is no session row |
delegated | One MCP client acting for you | bearer | prorm mcp clients create | yes; see MCP |
browser, cli-legacy | The console, and pre-v2 CLI tokens | bearer | as before | unchanged |
SESSION_DEFAULTS in src/capabilities.ts). Plans may| Kind (tier) | Maximum duration | Idle timeout | Concurrent | Check in every | Grant lifetime | Step-up lasts |
|---|---|---|---|---|---|---|
| guest | 24 h | 2 h | 1 per device; 5 new per IP an hour | 24 h | 24 h | – |
| interactive (user, team) | 30 d | 7 d | 10 per account, least recently used evicted | 1 h | 24 h | – |
| interactive (admin) | 12 h | 30 min | 3 per account, least recently used evicted | 5 min | 12 h | 15 min |
| device | none | 90 d | 3 (free, starter), 10 (usage), 25 (membership, pro); refused above that | 24 h | 7 d | – |
| service | none | 180 d | 100 per database, refused above that | 24 h | 7 d | never admin |
| delegated | 30 d | 7 d | 5 per account, least recently used evicted | – | – (bearer) | never admin |
HARD_LIMITS) cap an interactive session at 90 days, anlru revokesevicted, audited). A kindrefuse answers 409session_limit with the list to choose| Route | Auth | Does |
|---|---|---|
POST /security/klock/sessions | the console (browser session), or an existing klock session | Open an interactive or device session: { kind, devicePub, sealPub, sealBinding, hostname?, platform?, label?, teamUuid?, state, confirmCode, stepUp? }. Returns the grant, the device id and certificate, and the keyset. A legacy CLI token gets 403 console_only. |
POST /security/klock/sessions/:id/refresh | the session itself (Klock; an expired grant may still ask) | Check in: the idle and duration checks, then the next grant |
POST /security/klock/sessions/:id/step-up | the session itself | { totp }, or { password } from a browser without a second factor. A Klock caller gets a new grant with adminUntil. |
POST /security/klock/sessions/:id/rotate | the session itself (Klock) | { devicePub, proof, sealPub?, sealBinding? }. proof is a JWS by the new key over {sessionId, from, to}. A new session carries on, and the old one ends after five minutes. |
POST /security/klock/sessions/:id/freeze, /unfreeze | owner (unfreeze needs a fresh step-up) | A frozen session answers 423 session_frozen to everything |
POST /security/klock/guest | device-signed, no account | A guest grant: 5 per IP an hour, 1 per device a day |
GET /security/klock/revocations?since=<seq> | public | The API-signed revocation feed |
prorm step-up). They then stay live forstepUpTtlSec (15 minutes), after which they are dormant again. A route that401step_up_required, and the CLI offersdevice session and unfreezing atoo_many_attempts). SeePOST /security/lockdown (with step-up) revokesincludeServices), suspends its devices, and refuses every request for 15/security/recovery/start, then /complete) gives akind: "service") stands for exactly one subject: apasswordless:use for atransfer:report for a transfer job. It is never admin, evenadmin:*, nothing dormant, tier user. Theprincipal: { type: "service", id: <thumbprint> }resource: { kind: "database" | "transfer-job", id: <uuid> }. On any403 service_session_scope; itPOST /security/klock/sessions/:id/refresh and /rotate.checkInBy), and a grant lives 7 days. An optional hard deadline,rotateBy (limits.rotateBy in the grant), ends the session, and itscheckInBy has passed (check_in_required).GET /security/sessions with kind: "service" and subject: {kind, id}.DELETE …/passwordless/:sid) revokes the service row (reasonsubject-revoked). "Sign out everywhere" (DELETE /security/sessions), aincludeServices: true (or ?includeServices=1 on the DELETE) to endsessions row, and v1 transfer jobs keep thetransfer/report/v1 MAC.| Route | Who | Does |
|---|---|---|
POST /provision/databases/:id/passwordless/:sid/bind | the owner (access:write) | Binds an existing v1 session to the holder's key: { signPub, sealPub, sealBinding, hostname?, platform?, protocol?, rotateBy? | rotateEverySec? }. protocol is v1+v2 (the default: the secret keeps working) or v2 (the secret is refused with 403 device_signature_required). Binding again with the same key answers 200 rebound and may tighten to v2; another key gets 409 already_bound. prorm klock upgrade calls it. |
POST /provision/databases/:id/passwordless/enrol | the owner (access:write) | Issues a session that is v2 from birth: { role, scope, label?, entId?, expiresAt?, rotateBy? }. Answers with an enrolment ticket, never a secret. The session answers nothing until the ticket is redeemed. The MCP tool prorm_issue_passwordless_binding calls it. |
POST /provision/databases/:id/passwordless/redeem | the host, no user auth | Redeems the ticket with the host's keys: { ticket, signPub, sealPub, sealBinding, hostname?, platform? }. The ticket is API-signed, lasts 15 minutes, works once and names one database. |
POST /security/klock/sessions with kind: "service" | the console, or the owner's klock session | resource: { kind: "database", id } with role, scope, expiresAt? and compat?: "v1" (which makes it v1+v2 and returns the secret once); or resource: { kind: "transfer-job", id } for a local job's agent (resource:operate; 409 already_bound while an agent is live). A legacy CLI token gets 403 console_only. |
POST /provision/databases/:id/passwordless/{exchange,query}Authorization: Klock <service grant>.{ sessionId, query } or { sessionId, scope?, table?, op? },credential. A freshly upgraded file that has not synced a grant yetAuthorization: Klock device=<thumbprint> and the key inKlock-Device-Pub; it cannot check in or rotate until it holds a grant.not_bound (the session iswrong_subject,signature_invalid, nonce_replayed, session_revoked,device_key_mismatch and grant_expired. GET …/passwordless shows eachprotocol and whether it is bound.src/klock/providers/db.ts holds the device sideopenServiceKlock, serviceFetch, passwordlessQueryV2,passwordlessExchangeV2, and assertServiceCheckIn, the gate rule). It ists-prorm-orm/klock export. StoreKey sendsprorm klock upgrade on anprorm klock init
--service with the owner's enrolment ticket (host setup).prorm klock agent keeps it checked in.prorm transfer create … --local --klock-v2 creates the job withklock: "v2": no klockSecret is returned, the agent gets a device key and a~/.prorm/transfer-agent/<job>.klock. prorm transfer agent then signs eachPOST /transfer/jobs/:jobId/events whole, with RFC 9421, so every field of the403 not_bound. Agents without a klock file keep reporting with the v1 MAC.503 klock_unconfigured when the API has no klock service| Route | Does |
|---|---|
GET /security/devices | Your devices, the current one marked. ?all=1 includes revoked ones. |
GET /security/devices/:id | The device, its sessions, and its last 50 audit lines. |
PATCH /security/devices/:id | Rename: { label }, 1–120 characters. |
DELETE /security/devices/:id | Revoke. Its sessions end first, the device and its credentials go on the revocation feed, and it is nudged. |
POST /security/devices/:id/suspend, /reinstate | A suspended device gets no grant at sync, and its sessions are frozen (device-suspended) so its current grant stops working too. Reinstate reverses both. |
POST /security/devices/:id/wipe | Ends its sessions, suspends it, and queues an API-signed wipe command for its next sync. Best effort, and it removes klock material only: the klock files, keychain items and cached keysets. |
POST /security/devices/:id/rotate-key | Queues a rotate-key command for the device's next sync. |
POST /security/devices/pairings/approve | Approve a pairing code (below). |
device:read and changing needs device:manage, in every404 device_not_found, thewipe command waits for its next sync.$ prorm devices list
id label platform status key lastSync country current
dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3 ada-mbp darwin active keychain 2m ago GB *
dev_01J8W2H3J4K5L6M7N8P9Q0R1S2 ada-x1 linux active keychain 3d ago GB
$ prorm devices wipe dev_01J8W2H3J4K5L6M7N8P9Q0R1S2 --reason "laptop stolen"
Wipe dev_01J8W2H3J4K5L6M7N8P9Q0R1S2? Its klock files and keys are deleted when it next syncs. [y/N] y
✓ dev_01J8W2H3J4K5L6M7N8P9Q0R1S2: wipe requested.$ prorm devices revoke dev_01J8W2H3J4K5L6M7N8P9Q0R1S2 --reason "laptop stolen"
Revoke dev_01J8W2H3J4K5L6M7N8P9Q0R1S2? Every session on it ends now. [y/N] y
✓ dev_01J8W2H3J4K5L6M7N8P9Q0R1S2: revoked.DELETE and POST claim theirdevice:manage-members useGET /teams/:teamId/devices and DELETE /teams/:teamId/devices/:deviceId,session:manage-members use GET /teams/:teamId/sessions andDELETE /teams/:teamId/sessions/:sid. Acting on a member's device or sessionreason (reason_required), issupport scope use GET /admin/users/:id/devices,DELETE /admin/users/:id/devices/:deviceId,POST /admin/users/:id/devices/:deviceId/wipe andDELETE /admin/users/:id/sessions/:sid. Each change needs a fresh step-upalert, and emailsGET /admin/klock/devices gives the fleet counts.prorm login opens an interactive session bound to this machine's key. Thestate and the deviceconfirm_code_mismatch. A parkedklock_downgrade); prorm login --legacy does it on purpose.acme, then a look at$ prorm login --team acme
Confirm code: 7QK2MD (the console shows the same code — approve only if they match)
Sign in in the browser to connect this CLI:
https://app.prod-orm.io/#/cli?port=53117&state=Xk9…&host=ada-mbp&v=2&dpk=11qYAYKx…&spk=hSDwCYkw…&sb=eyJhbGciOiJFZERTQSJ9…&cc=7QK2MD&team=acme
✓ Signed in as ada (ada@example.com) on this device (dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3).
klock v2 interactive session · key in the macOS Keychain · ~/.prorm/klock/default.klock
Check in by 9/26/2026, 11:00:00 AM; the session ends 9/27/2026, 10:00:00 AM.
$ prorm whoami --team acme
ada <ada@example.com>
tier team — acme as developer · plan usage
session interactive · device-bound · dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3 · key in the OS keychain
check in by 9/26/2026, 11:00:00 AM (in 59m)
expires 9/27/2026, 10:00:00 AM
klock active · ~/.prorm/klock/default.klock
teams acme (developer)
42 capabilities (api); --caps lists them
API https://api.prod-orm.io
$ prorm klock status
~/.prorm/klock/default.klock — version 2, interactive
device dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3 · key in the OS keychain
integrity ok
session ks_5e1d9c3a-2b7f-4e8d-a6c4-1f0e9b8d7a62 · interactive · active
tier team
check in by 9/26/2026, 11:00:00 AM (in 59m)
expires 9/27/2026, 10:00:00 AM
synced 40s ago · grant 1 · sync 0[!WARNING] Match the code before you approve
The console shows the code it recomputed from the link. If the two differ,
or you did not runprorm loginjust now, someone sent you a crafted link
for their own device key. Close the tab; approving would sign their
machine in as you.
whoami asks the API (--offline answers--caps and --limits show the detail); klock statusprorm step-up, and$ prorm step-up --totp 123456
✓ Stepped up; admin capabilities are live until 9/26/2026, 10:17:00 AM.POST /v2/sync, device-signed. The CLI syncs when prorm klock sync runs,syncedAt + nextSyncAfter has passed or checkInBy is$ prorm klock sync
✓ Synced (seq 43); the session is active, check in by 9/26/2026, 12:00:00 PM.
$ prorm klock sync --json
{
"syncSeq": 44,
"deviceStatus": "active",
"session": "active",
"checkInBy": "2026-09-26T12:00:00.000Z",
"dropped": null,
"commands": [],
"rejectedCommands": [],
"nextSyncAfter": 900
}klock sync says so, tellsprorm login, and exits with the authentication status. AsyncSeq only rises. A device thatahead), or one below what itrollback, for example a restored or cloned klocksyncSeq andklock.sync-rollback.logout, wipe, rotate-key, resync) are applied onlytyp: klock-command+jws) verifies. The klocknextSyncAfter is 15 minutesstateDigest is the canonical SHA-256 of what the device holds. When itApiInternal.compiledRules: the owner'sgrantError: "access_denied" instead of a grant, and its credentialsremove updates. The sync log records denied, the event chaindevice.access-denied (mirrored to the audit log asklock.device.access-denied), and the API records an access.denied auditgrantError: "policy_unavailable" and no grant; the device keeps its current503 policy_unavailable.POST /v2/enrol checks403 access_denied, no device is made and/v2/credentials/* applies the same check to thecli for a person's device,agent for a service device). Rules with appliesTo do not decide a syncaccess.would-deny.location.new-country,location.new-asn, location.outside, impossible-travel,device.unknown, device.new, device.file-key, agent.tamper,sync.stale (more than 24 hours since the last sync) and sync.rollback.ApiInternal.recordFindings) before the grantfreeze or revoke means that same sync getsgrantError: "session_frozen" or "session_revoked".ACCESS_RULES_CACHE_MS (30 seconds bypolicy is null for a suspended device, when the device did not"policy": {
"version": 123456789, // changes whenever a rule or profile does
"local": { "profiles": [ /* one LocalPolicy per profile: { name, hours?, capabilities?, rates?, on?, escalation? } */ ] },
"profiles": [ { "name": "office", "mode": "enforce", "scope": "account:42" } ]
}policy.local is advisory. The agent runsevaluateLocal once per entry of policy.local.profiles against thehours.outside,cap.outside-profile, …) go into the next sync's report.findings, and it maycredentials list carries updates from every provider. The| Provider | The device reports | The answer carries |
|---|---|---|
db | providers.db: its service bindings | install for a binding the ledger has for this key that the device does not report; remove (reason: "revoked" or "rebound") for one it reports that the ledger no longer binds to this key |
tls | providers.tls = { serial, notAfter, renew?: { csr, spki, csrAttestation } } | install, renew (when a CSR was sent), remove for a revoked certificate, or error |
kerberos | providers.kerberos = { principal, kvno, renew?: {} } | renew (a rotated keytab) when asked past renewAfter, remove for a revoked principal#kvno |
GET /v2/channel, a{type: "sync-now" | "revoked" | "transfer-offered"}. A nudge carries norevoked does not end the session by itself; it triggers a sync,nextSyncAfter.prorm-klock, at klock.prod-orm.io) is a Cloudflareprorm-klock), a Durable Object per deviceDeviceChannel) and an R2 bucket for the file-transfer relayprorm-klock-relay, bound as RELAY), which only ever holds ciphertext. Itsapi/klock/** and its configuration api/wrangler.klock.jsonc.KlockInternal RPC entrypoint of a service binding, and klock reaches theApiInternal. Neither has an HTTP route. Each call alsosvc:api or svc:klock,typ: klock-rpc+jws) over the method, a digest of the arguments, the timeseq, and keepsklock keyApiInternal.certifyDevice and the first grant fromApiInternal.mintGrant, and klock verifies both before it stores or passes| Route | State |
|---|---|
POST /v2/enrol | live |
POST /v2/sync | live |
GET /v2/channel | live: a signed WebSocket upgrade, nudges only |
GET /v2/keys, GET /v2/revocations?since= | live: public mirrors of API-signed documents |
POST /v2/pair/start, GET /v2/pair/:id | live: pairing |
GET /v2/credentials, POST /v2/credentials/:provider, POST /v2/credentials/:id/renew, DELETE /v2/credentials/:id | live: credentials (tls, kerberos) |
/v2/transfers/… | live: file transfer; 503 klock_unconfigured while the RELAY bucket is not bound |
404, and a known path with another method is 405.triggers.crons: ["17 * * * *"], hourly)klock_sync_log rows older than 30 days, spent RPC noncesklock: pruned N sync log rows, ….401 bad_signature, the signature does not verify against an enrolled device
key. For an unknown key the service still checks the signature, against aunknown_device appears onlyclock_skew,signature_expired, bad_digest, signature_malformed) keep their own codes./security/devices…) lives on the API, not on klock. Forprorm login --pair (or prorm devices pair).POST /v2/pair/start, signed by the key beingKXQ7-M2PD (8 base32 characters), a prorm://pair?… QR payload, and a<console>/#/pair?p=…&c=… for a phone camera. The pairingprorm devices approve <code>
[--kind device|interactive] (the same command as devices pair <code>),POST /security/devices/pairings/approve. Approving a device kind needs409 pairing_kind_mismatch). Approvals areGET /v2/pair/:id,202 pending until approved, then 200 with the200 expired.$ prorm login --pair
Pairing code: KXQ7-M2PD
On a signed-in device: prorm devices approve KXQ7-M2PD (or the console, Security → Devices)
prorm://pair?p=pair_01J9Z4A5B6C7D8E9F0G1H2J3K4&c=KXQ7-M2PD&t=Zx3mQ8vL
✓ Signed in as ada (ada@example.com) on this device (dev_01J9Z4P7Q8R9S0T1V2W3X4Y5Z6).
klock v2 device session · key in the system keyring · ~/.prorm/klock/default.klock
Check in by 9/27/2026, 10:05:00 AM; the session ends 10/3/2026, 10:05:00 AM.$ prorm step-up --totp 123456
✓ Stepped up.
$ prorm devices approve KXQ7-M2PD
✓ Approved dev_01J9Z4P7Q8R9S0T1V2W3X4Y5Z6: the new device is signing in.login --pair asks for a device session, so the approval needs a freshprorm devices pair --kind interactive on the new device and approve withprorm devices approve <code> --kind interactive.pairing_used); anpairing_not_found andpairing_expired. A poll404 as an unknown pairing, so only the[!CAUTION] Never approve a code someone read out to you
Whoever holds the new device gets the session, with your account's
capabilities. Approve only a code on a screen you can see yourself.
prorm klock cp sends files from one device to another, end to end$ prorm klock cp ./dump.sql ./schema/ device:ada-x1
✓ Offered 2 file(s), 48213504 bytes, to ada-x1: transfer xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6.
They accept it with `prorm klock accept <id>`; it expires in 24 hours.
$ prorm klock cp ./logs support: --reason "ticket 4411"$ prorm klock inbox
id from files bytes status expires
xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6 ada-mbp 2 48213504 ready 9/27/2026, 10:05:00 AM
$ prorm klock accept xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6
Transfer xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6 from ada on ada-mbp:
dump.sql 48210000 bytes
schema/users.sql 3504 bytes
Into ~/Downloads/prorm-klock/xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6
Accept and write these files? [y/N] y
✓ Wrote 2 file(s) into ~/Downloads/prorm-klock/xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6.
$ prorm klock receipt xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6
Transfer xfer_01J9Z6B7C8D9E0F1G2H3J4K5M6: completed
✓ Receipt verified: completed at 2026-09-26T10:07:12.000Z, 48213504 bytes in 12 chunk(s), manifest 3f9a0c1d2e4b….| Command | Does |
|---|---|
prorm klock cp <files…> device:<name|id>[:<dir>] | Send files or directories to a device, optionally into a subdirectory of its inbox. --max-bytes sets your own cap (default 2 GB). |
prorm klock cp <files…> support: | Send to the support drop. Needs --reason. |
prorm klock inbox [--sent] | What waits for this device; --sent lists what this device sent, with status. |
prorm klock accept <id> [--into DIR] [--force] [--keep-mode] [--max-bytes N] | Verify, then write the files. The global --yes skips the prompt. --json prints {id, into, files, receipt}. |
prorm klock refuse <id> | Decline. The ciphertext is deleted and the sender's receipt says refused. It needs no capability. The sender cancels with the same route. |
prorm klock receipt <id> | The transfer's status and, once it has ended, the klock service's signed receipt, verified against the keyset's klock key. |
klock accept checks:~/Downloads/prorm-klock/<id>/ by--into). Absolute paths, .., symlinks and device files are--force, and nothing is made--keep-mode.typ: klock-transfer-receipt+jws) signed by the klockcompleted, refused,cancelled or expired), the bytes and chunks, and digests of the manifestklock receipt needs files:send or files:receive.filesPolicy) on every offer.| To | The sender needs | The recipient needs |
|---|---|---|
| your own device | files:send | files:receive |
| a teammate's device | files:team, in a team you are both active in (roles owner, admin, operator); the team's plan pays | files:receive |
support: | files:send and --reason | the staff drop device's session holds admin:support or admin:dev |
| any device, as staff | admin:support or admin:dev (live only after step-up) and a reason; audited at level alert | only acceptance |
files:* is removed fromdevice_suspended,device_revoked). A transfer whosetransfer_sender_revoked). Atransfer_recipient_refused,transfer_not_found.| Limit | Value | Over it |
|---|---|---|
| Per transfer | the plan's perTransferBytes: 2 GB on usage, 10 GB on membership and pro; the relay's hard cap is 10 GB | 413 transfer_too_large |
| Per day | the plan's perDayBytes, over your offers in the last 24 hours: 10 GB on usage, 100 GB on membership and pro | 429 transfer_quota_exceeded, with Retry-After |
| Waiting for one device | 50 live transfers | transfer_quota_exceeded |
| In flight from one device | 20 | transfer_quota_exceeded |
| Manifest | 10,000 files; the offer body at most 1 MiB; the same name rules as the client | transfer_manifest_invalid |
| Lifetime | 24 hours | transfer_expired |
klock-seal-v1, contextfiles/key/<id>) to the recipient's X25519 key. The sender checks that keytransfer_chunk_corrupt andtransfer_chunk_exists), eachnonce_reused), and a grant past itscheckInBy is refusedcheck_in_required; runprorm klock sync).transfer.offered, .sent, .accepted,.completed, .refused, .cancelled, .expired, .denied,.chunk-refused, .tamper-refused and .chunk-corrupt are events onklock.prod-orm.io, RFC 9421 device-signed, withAuthorization: Klock <grant>. The shared protocol is ints-prorm-orm/klock (checkManifest, encryptChunk, decryptChunk,sealedChunkBytes, verifyTransferReceipt, MANIFEST_TYP,TRANSFER_RECEIPT_TYP).| Route | Who | Body and answer |
|---|---|---|
POST /v2/transfers | sender | { to: {deviceId} | {device} | {drop: "support"}, manifest[], totalBytes, chunks, chunkSize, reason? } → 201 { id, recipient: { deviceId, signPub, sealPub, sealBinding, deviceCert, label }, expiresAt, chunks, chunkSize } |
PUT /v2/transfers/:id/chunks/:n | sender | { ct } |
POST /v2/transfers/:id/complete | sender | { manifestSig, wrappedKey, chunks } → { id, status: "ready" } |
POST /v2/transfers/:id/complete | recipient | { received: true } → { id, status: "completed", receipt } |
GET /v2/transfers[?box=sent] | either | { transfers: [...] } |
GET /v2/transfers/:id | either end | status, times, receipt (and the manifest, for the recipient) |
POST /v2/transfers/:id/accept | recipient | → { id, from: { deviceId, signPub, deviceCert, label, user }, manifest, manifestSig, wrappedKey, chunks, … } |
GET /v2/transfers/:id/chunks/:n | recipient, after accept | → { ct } |
DELETE /v2/transfers/:id | either end | → { id, status: "refused" | "cancelled", receipt } |
[!NOTE] Until the relay is bound
Every/v2/transfersroute answers503 klock_unconfigureduntil the
operator binds theRELAYbucket, and the CLI says file transfer is coming
soon (nothing is sent or written).support:answers the same untilSUPPORT_DROP_DEVICE_IDis set. See the
klock deploy runbook.
support: goes to one configured staff device rather than a/v2/credentials, both issued so that no| Route | Does |
|---|---|
GET /v2/credentials | This device's credentials: id, provider, status, serial or principal, validity, fingerprint |
POST /v2/credentials/:provider | Issue. tls takes { csr, spki, csrAttestation }; kerberos takes {} (any field is a 400). Answers 201 { credential, delivery }. |
POST /v2/credentials/:id/renew | Renew (TLS: a new CSR) or rotate (Kerberos: a new kvno) |
DELETE /v2/credentials/:id | Revoke, with an optional { reason } |
device_suspended. Issue is checkedaccess_denied,policy_unavailable). A plan withoutprovider_unavailable,capability_required, and a missingauthority_unavailable.prorm-klock, account tls:<deviceId>). A devicefile keeps it in the 0600 klock, underproviders.tls.key.CN=device/<deviceId>,URI:urn:prorm:device:<deviceId>, and an attestation: theklock/csr/v1\n<b64url(sha256(SubjectPublicKeyInfo))>.klock:sync (a person's device) orpasswordless:use (a database host's service session).auth: tls,{ kind: "cert", ref: <sha256 fingerprint> } and its binding isremove. There is no--file points at another klock, for example a database host's./.klock:| Command | Does |
|---|---|
prorm klock certs / prorm klock certs list [--file F] [--json] | This device's certificates: id, status, serial, validity and fingerprint; * marks the installed one |
prorm klock certs request [--file F] | Make a key, send the CSR, install the certificate |
prorm klock certs renew [id] [--if-due] [--file F] | A fresh key and a new leaf; the old one is superseded. --if-due does nothing until two thirds of the life has passed, for a cron. |
prorm klock certs revoke [id] [--reason R] [--file F] | Revoke a certificate (it goes onto the feed); removed locally if it is the installed one |
$ prorm klock certs request
✓ Certificate cred_01J9Z7C8D9E0F1G2H3J4K5M6N7 issued, valid until 9/26/2026, 10:12:00 PM.
subject device/dev_01J9Z3K4M5N6P7Q8R9S0T1V2W3
serial 4f1c9a0e2b7d8c36
fingerprint 8d2e…
Its key is in the OS keychain; it never leaves this machine. Renewal is due 9/26/2026, 6:12:00 PM.
$ prorm klock certs renew --if-due
Not due yet.klock agent renews at sync, sending a new CSRproviders.tls.renew, so a host rarely needs the command. A database host--auth tls.api/kerberos) atPOST [/{team}]/v1/csr, with the bearer AUTHORITY_TOKEN or the optionalKLOCK_AUTHORITY_TOKEN, which is accepted on that route only. It refuses a422 (csr_invalid,csr_key_type, csr_subject_mismatch) and returns no key. The olderPOST /v1/certificates, which made the client key on the server, is--auth tls.interactive or device session, not a service orsession/<uuid>@<REALM>, in itsPROD-ORM.IO.klock-seal-v1, contextkerberos/keytab/<principal>, the full principal with its realm). The klock0x0502, as kadmin ktadd writeskeytab_invalid.prorm-klock, accountkeytab:<deviceId>, with providers.kerberos.keytabRef =
"keychain:prorm-klock/keytab:<deviceId>"), or, for a keyStore: "file"providers.kerberos.keytab with keytabRef: "file".renewAfter is 30 days after issue. Rotating sets a new randomprincipal#kvno that has been revoked is answered with remove at sync.kerberos:manage and klock:sync in the session's ceiling, a paidseal_binding_invalid409 kerberos_realm_missing;POST /teams/:teamId/kerberos.| Command | Does |
|---|---|
prorm klock kerberos / prorm klock kerberos status [--file F] | The principal, its realm and kvno, and when it renews |
prorm klock kerberos issue [--file F] | Get this device's principal, its keytab sealed to this device |
prorm klock kerberos rotate [--file F] | A new key (kvno) for the principal |
prorm klock kerberos revoke <id> [--reason R] [--file F] | Remove the principal from the KDC and put it on the feed (prorm klock certs list --json shows the ids) |
outh spnego reads providers.kerberos, opens the box with the device's sealthe delivered keytab: keytab: is kvno N, not M; run prorm klock sync.[!NOTE]
A ticket from a device principal verifies at the authority assession: <uuid>, where the uuid is the credential's. The API does not yet
map device principals to klock sessions for Negotiate authentication. The
development authority (authority -dev) returns bytes that are not a
keytab, so v2 issuance refuses them withauthority_unavailable; use a real
KDC to try it.
$ prorm klock keygen
✓ Created device key for ./.klock.
thumbprint NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs
public key 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo
seal key hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr6BqzPLRr2eT0
Private key in the OS keychain; it never leaves this machine.prorm klock keygen [file] [--rotate] writes a new v2 kind: "service" klock--json givesaction: created | rotated | existing and the sealBinding). --rotateprorm klock rotate). A v1 file is refused with a pointer toprorm klock upgrade. The default file is ./.klock, or $OUTH_KLOCK.POST …/passwordless/enrol, the console,prorm_issue_passwordless_binding) and gives it to the host:$ prorm klock init --service --ticket "$ENROL_TICKET"
✓ ./.klock: bound as dev_01J9Z8D9E0F1G2H3J4K5M6N7P8 to session 7c0e… on orders-db.
Service session ks_2b7f…; check in by 9/27/2026, 10:00:00 AM — `prorm klock agent` keeps it current.prorm klock init --service [--ticket T] [--database ID] [--force] [file]POST /provision/databases/:id/passwordless/redeem with{ticket, signPub, sealPub, sealBinding, hostname, platform}. No account$PRORM_ENROL_TICKET, and --database defaults to the one the ticketkind: "service" klock with the enrolled device, theks_…) and its grant, and no secret: the session is--ticket later and the same key (or one from klock keygen) is reused.--force (exit 2), and a refusedticket_invalid,ticket_expired, ticket_used) leaves no bound file.$ prorm klock agentprorm klock agent [file] [--once] [--no-channel] [--json] is the$OUTH_KLOCK, else this profile's session klock, else ./.klock; it mustnextSyncAfter, clamped to betweencheckInBy minus 10 minutes. A push-channel nudge asks for a sync at once;checkInBy is within 10 minutes, it callsPOST <api>/security/klock/sessions/:id/refresh, device-signed with thesession_revoked,device_revoked or session_unknown stops the agent.providers.db (subject, service session,policy.local from the sync answer (seelogout, wipe and a revocation stop the agent (exitrotate-key rotates the key when thisprorm klock
rotate". A command that does not verify is ignored and logged.[klock agent] <iso time> …). SIGTERM or SIGINT stop it cleanly with exit--once runs one round (sync, check-in, renewals) and exits: 0 if all of--json it prints the round's report. It suits cron, systemd timers--no-channel syncs on schedule only.src/klock/providers,api/klock/providers).| Provider | State | What it does |
|---|---|---|
session | built | Grant refresh, revocations and commands, through sync |
db | built | Service sessions: device-signed passwordless queries and transfer-agent reports, with install and remove updates at sync; the v1 HMAC stays accepted beside it |
tls | built | Short-lived client certificates for a P-256 key made on the device, from an attested CSR; bound by fingerprint; renewed at two thirds of their life |
kerberos | built | A principal per device, the keytab sealed to the device's X25519 key; klock never stores it; rotated by kvno |
files | built | End-to-end encrypted file transfer; live once the operator binds the relay bucket |
ssh | later | Short-lived OpenSSH user certificates for bastions |
webauthn | later | Passkeys for step-up |
oidc, signing, secrets, attest | later | Workload identity for CI, code-signing certificates, sealed secret delivery, and hardware-backed keys with attestation |
prorm klock upgrade [file] [--drop-secret] [--no-bind] rewrites a v1.klock as v2, in place, keeping every field:POST /provision/databases/:id/passwordless/:sid/bind, which the owner'saccess:write). --no-bind onlyversion: 2, kind: service, keeping secret, so outh,v1+v2).session holds the service session idks_…), its grant, grantSeq and checkInBy, so the file can check inklock agent) and rotate. api is set when the fileklock status--json gains sessionId and protocolv1+v2 or v2).--drop-secret makes the session v2-only, server first: it binds withprotocol: "v2", so the server refuses the v1 secret from then ondevice_signature_required),secret from the file. On a file already upgraded it200 rebound and tightens--no-bind.$ prorm klock upgrade ./ops/.klock --drop-secret
✓ ~/app/ops/.klock: version 1 → 2 (service).
Device key in the OS keychain; bound as dev_01J9Z9E0F1G2H3J4K5M6N7P8Q9.
Service session ks_4a2c… (v2): `prorm klock agent` keeps it checked in.prorm klock upgrade runs again. Until theKlock device=<thumbprint>; seedb init) is alreadykind: local, and needs no upgrade.prorm audit klock finds v1 files that still hold a secret where v2 isKLOCK-003); see the security toolkit.klock keygen,klock init --service) and the klock agent daemon, and the klock servicesupport: transfers go to one configured staff device, not a pool, andalert emails are not sent for findings klock raises at sync; they arerotate-key command for a klock other.klock as the ORM's storageprorm db up, and db init's local klockSEC-003, committed .klock files, and prorm audit klock with the KLOCK-00x checks