1. guides
prorm
  • prorm documentation
  • Known test gaps
  • Architecture
  • Core concepts
  • Database types & coverage
  • Getting started
  • Going further
  • compliance
    • Compliance
    • The security toolkit: `prorm audit`
    • Checklists by regulation
    • Declaring personal data
    • Evidence bundles
    • Evidence
    • Frameworks: `@Security`
    • Jurisdictions
    • Platform policies
    • Protecting data
    • The regulation dataset
    • Residency and cross-border transfers
    • Retention & integrity
    • Status
    • Subject rights
  • decorators
    • prorm with decorators
    • Models with decorators
    • Querying a decorated model
    • Relations with decorators
  • diagrams
    • Diagram generators
    • Chen diagram
    • Class diagram
    • Dependency diagram
    • ER diagram
    • Flow diagram
    • From your models to a diagram
    • Gantt diagram
    • Index diagram
    • Migration diagram
    • Model diagram
    • Package diagram
    • Crow’s-foot diagram
    • Schema documentation
    • Sequence diagram
    • State diagram
    • Tree diagram
  • dialects
    • SQL dialects
    • AlloyDB Dialect
    • AlloyDB Omni Dialect
    • Amazon Athena Dialect
    • Aurora DSQL Dialect
    • Amazon Aurora MySQL Dialect
    • Amazon Aurora PostgreSQL Dialect
    • Azure Database for MySQL Dialect
    • Azure Database for PostgreSQL Dialect
    • Azure SQL Database Dialect
    • Azure SQL Managed Instance Dialect
    • Google BigQuery Dialect
    • Citus Dialect
    • ClickHouse dialect
    • Cloud Authentication (`cloud-auth`)
    • Cloud SQL for SQL Server Dialect
    • Cloud SQL for MySQL Dialect
    • Cloud SQL for PostgreSQL Dialect
    • CockroachDB Dialect
    • Azure Cosmos DB for PostgreSQL Dialect
    • CrateDB Dialect
    • Cloudflare D1 Dialect
    • Databricks dialect
    • IBM Db2 Dialect
    • Dolt Dialect
    • Apache Doris Dialect
    • Druid Dialect
    • DuckDB Dialect
    • Exasol dialect
    • Microsoft Fabric Data Warehouse Dialect
    • Firebird Dialect
    • Greenplum Dialect
    • SAP HANA Dialect
    • MySQL HeatWave Dialect
    • Azure Data Explorer (Kusto) Dialect — T-SQL
    • MariaDB Dialect
    • Materialize Dialect
    • MonetDB Dialect
    • MotherDuck Dialect
    • Microsoft SQL Server Dialect
    • MySQL Dialect
    • Neon Dialect
    • OceanBase Dialect
    • openGauss Dialect
    • Oracle Dialect
    • Oracle Autonomous Database Dialect
    • ParadeDB Dialect
    • PolarDB Dialect
    • PostgreSQL Dialect
    • Presto Dialect
    • QuestDB Dialect
    • Aurora RDS Data API Dialect (`rdsdata`)
    • Amazon RDS for Db2 Dialect
    • Amazon RDS for MariaDB Dialect
    • Amazon RDS for SQL Server Dialect
    • Amazon RDS for MySQL Dialect
    • Amazon RDS for Oracle Dialect
    • Amazon RDS for PostgreSQL Dialect
    • Amazon Redshift
    • Amazon Redshift Data API Dialect
    • RisingWave Dialect
    • rqlite Dialect
    • SingleStore Dialect
    • Snowflake Dialect
    • Google Cloud Spanner Dialect
    • Spanner (PostgreSQL interface) Dialect
    • SQLite Dialect
    • StarRocks Dialect
    • Supabase Dialect
    • Azure Synapse Analytics (dedicated SQL pool)
    • Teradata Dialect
    • TiDB Dialect
    • TimescaleDB Dialect
    • Trino Dialect
    • Turso / libSQL Dialect
    • Vertica dialect
    • Vitess Dialect
    • Yellowbrick Dialect
    • YugabyteDB Dialect
  • guides
    • Access rules and behaviour profiles
    • ArangoDB (AQL)
    • Associations
    • Audit levels and escalation
    • Audit logging
    • In the browser
    • Bulk operations
    • Caching
    • Capabilities, tiers and team roles
    • CLI
    • Cloudflare
    • Compliance & data protection
    • Connection Pooling & Performance
    • Security in the console
    • Couchbase: structured N1QL queries
    • Data types
    • Decorators
    • Eager loading
    • Enums and constants
    • Error handling & error types
    • Extensions
    • External fields
    • Foreign data wrappers
    • Graph databases
    • Hooks & lifecycle events
    • Indexes & constraints
    • InfluxDB: structured Flux pipelines
    • klock-errors
    • klock — the `.klock` file
    • Local databases (`prorm db up`)
    • Structured log search — Splunk, Graylog, Papertrail, Sumo Logic
    • Logging
    • Grafana Loki: structured LogQL queries
    • MCP server
    • Migrate — the data transfer subsystem
    • Migrations & Schema Sync
    • Models from an existing database
    • Defining models
    • Multiple databases
    • OrientDB, Neptune and TigerGraph
    • Passwords
    • Importing a Prisma schema
    • Structured PromQL and MetricsQL
    • Query firewall
    • QueryInterface
    • Query operators reference
    • Query optimization
    • Composing find options with `pipe()` and `Q`
    • Query safety: the fail-closed WHERE guard
    • Querying
    • Raw Queries & Query Building
    • Read replicas
    • Schema diffing & migration generation
    • Views, triggers & other schema objects
    • Scopes & default scopes
    • Seeders
    • SPARQL / RDF triplestores
    • SQL constants
    • SQL features
    • SQL function builders
    • SQLite advanced features
    • StoreKey — a prod-orm database as the storage
    • Streaming results
    • Terraform
    • Transactions & Isolation Levels
    • Type mapping: legacy and modern
    • TypeScript types
    • Database users, roles & privileges
    • Validation & constraints
    • Virtual & Computed Fields
    • Writing docs
  • javascript
    • prorm in JavaScript
    • Models in JavaScript
    • Querying in JavaScript
    • Relations in JavaScript
  • runbooks
    • Operational runbooks
    • Connection failures
    • Credential rotation
    • Failover
    • Incident checklist
    • Deploying klock v2
    • Migration rollback
  • running-databases
    • Running databases in Docker
    • Running prorm under Bun in Docker
    • Running Cassandra in Docker with prorm
    • Running ClickHouse in Docker with prorm
    • Running CockroachDB in Docker with prorm
    • Running prorm under Deno in Docker
    • Running Elasticsearch in Docker with prorm
    • Running MariaDB in Docker with prorm
    • Running MongoDB in Docker with prorm
  • prorm control plane
    • Introduction
    • Authentication
    • Errors
    • Getting started
      • Service
        • Is it up
        • This description
    • Account
      • Sign-up and verification
        • Create an account
        • Exchange credentials for a token
        • Verify with the emailed code
        • Verify by clicking the emailed link
        • Name the parent or guardian
        • What a consent link is about
        • Give consent for a child's account
        • Ask the parent or guardian again
        • Send a new code and link
      • Family
        • Everyone this account is responsible for
        • One child, in detail
        • Turn a limit on or off
      • Profile
        • Who am I
        • Change the account
        • Every address on the account
        • Add an address
        • Confirm an address with the code mailed to it
        • Mail a fresh code to an unconfirmed address
        • Make an address primary
        • Remove an address
        • What this caller may do
    • Security
      • Sessions
        • Everywhere this account is signed in
        • Sign out everywhere else
        • Sign one out
        • A token for the CLI on a named machine
        • A token for the customer console, from the staff one
        • Park a sign-in for the CLI to collect
        • The CLI collecting its sign-in
        • The team's members' live sessions
        • Sign a member's session out
        • Sign an account out everywhere
        • Everywhere the account is signed in
        • Sign an account's session out
      • Single sign-on
        • Begin a single sign-on
        • The provider's return
        • Link the signed-in account to a team's provider
        • Which team an address signs in to
        • The team's single sign-on, as a member sees it
        • Set the team's OpenID Connect provider up, or change it
        • Remove the provider
        • The team's domain claims and the DNS record each needs
        • Prove a claimed domain
      • MFA and passwords
        • Start enrolling a second factor
        • Prove the app has it
        • Remove the second factor
        • Ask for a reset link
        • Set a new password with the emailed token
      • Klock
        • Open a device-bound klock session
        • Check in, for the next grant
        • Step up the session asking
        • Rotate the device key
        • Freeze a session
        • Unfreeze a session
        • A guest grant
        • The revocation feed
        • The published keyset
        • What this control plane supports
      • Devices
        • Your devices, or a team's
        • Approve a new device's pairing code
        • One device
        • Rename a device
        • Revoke a device
        • Suspend a device
        • Reinstate a suspended device
        • Wipe a device
        • Ask a device to rotate its key
        • The team's members' devices
        • Revoke a member's device
        • An account's devices
        • Revoke an account's device
        • Wipe an account's device
        • The device fleet
      • Access rules and behaviour
        • The access rules that bear on you
        • Add an access rule
        • This request, as the rules see it
        • One access rule
        • Remove an access rule
        • The behaviour profiles that bear on you
        • Add or change a behaviour profile
        • The profile presets
        • Your behaviour findings
        • Acknowledge a finding
        • One behaviour profile
        • Remove a behaviour profile
        • The team's access rules
        • Add a team access rule
        • Remove a team access rule
        • The team's behaviour profiles
        • Add or change a team behaviour profile
        • Remove a team behaviour profile
        • What the team's profiles caught
        • Acknowledge a team finding
      • Recovery and lockdown
        • Ask for a recovery link
        • Recover access with the emailed code
        • Is the account locked down
        • Lock the account down
        • Release a lockdown
        • Lock a member down
        • Release a member's lockdown
      • MCP tokens
        • Make a delegated token for one MCP client
        • The account's delegated tokens
        • Revoke a delegated token
        • What a delegated token may do now
        • Run one MCP tool with a delegated token
        • Run a device-signed MCP tool call relayed by the MCP Worker
      • Kerberos
        • Give the team its own Kerberos realm
        • The team's realm
        • Remove the team's realm
    • Logging and monitoring
      • Audit log
        • What has been done to the account
        • What has happened to this account
        • The account's audit trail
      • Operations
        • Everything that has been asked of this database
        • One, with its steps
        • Stop it
        • Put a failed one back in the queue
        • Every operation, across all accounts
      • Metrics and events
        • Every measurement, for a graph
        • Who did what to it
    • Billing
      • Plans and pricing
        • What the plans allow
        • The rate card
        • What one database would cost
        • What this account is paying for, itemised
      • Subscriptions and invoices
        • What this month's invoice will say
        • What this account is on
        • What it has been charged
        • Start a Stripe Checkout session
        • Open the customer portal
        • Stripe's events
        • Buy more connection pools
      • Team billing
        • The team's billing account
        • What the team has been charged
        • What this month's invoice will say
        • The team's cards
        • Add a card, through a Stripe-hosted setup session
        • Make a card the team's default
        • Remove a card
        • Start the team paying
        • Open the Stripe portal on the team's customer
        • Change the team's plan
        • Who receives the team's bills
        • How the team pays for transfers
    • Support
      • Support chat
        • My conversations
        • Start a conversation
        • Every support conversation, open first
        • A ticket for the socket
        • One conversation, with who is in it and who is connected
        • Rename a group; open or close a support conversation
        • The lines, oldest first
        • Say something
        • Add somebody
        • Mark read
    • Administration
      • Users
        • Every account
        • One account, with everything hanging off it
        • Grant or revoke admin
        • Verify an account by hand
        • Lock or unlock sign-in
        • Allow or hold provisioning
        • Send the verification mail again
        • Set the plan by hand
      • Platform
        • Every database and store, across all accounts
        • The counts worth putting on a dashboard
      • Staff and permissions
        • What kind of admin the caller is, and which scopes they hold
        • The admin presets
        • Every admin, and the scopes each resolves to
        • Make an account an admin of a kind
        • Change an admin's kind, or a custom admin's scopes
        • Revoke admin
      • Team administration
        • Every team, with its owner and size
        • One team in full — members with their accounts, and projects
        • Hand the team to another member
        • Put an account in the team, active at once
        • Change a member's role
        • Remove a member
      • Assumed identities
        • Assume an identity — act as a customer account
        • The identities the caller holds open
        • End an assumed identity
      • Support inbox
        • The platform's addresses
        • Add a platform address
        • One address, with its counts
        • Change an address's label, routing, or whether it accepts mail
        • Remove an address that holds no mail
        • Make, adopt or repair the address's routing rule
        • Every admin's personal address, made where missing
        • File a raw message, as if it had arrived
        • The inbox
        • One message, whole
        • Mark a message
        • Hand a message to an admin, or take it back
        • Leave an internal note
        • Reply, from the address the message came to
        • What is waiting
    • Databases
      • Databases
        • Data stores
          • Every data store on the account
          • Provision a data store
          • Change a data store that is already running
          • One data store
          • Deprovision it
          • Report what it is using
          • How to reach a data store
        • Connections and credentials
          • The api keys on a database
          • Issue a key
          • Revoke a key
          • The engine users we created
          • Create a user inside the database
          • Give a user a new password
          • Drop a user
          • How the database is reached
          • Set how it is reached
          • Revoke every api key on it
          • How to connect to it
          • The ssh keys that open its bastion
          • Add a key, or have one made
          • The private half of a generated key, once
          • Revoke an ssh key
        • Schema and objects
          • The SQL objects we put in it
          • Create one, and apply it
          • What is in the database, as the engine reports it
          • One table
          • What it would run, without running it
          • Redefine an object in place
          • Drop it
          • The extensions installed in it
        • Migrations
          • A plan compiled to statements, recording nothing
          • The migrations recorded for it
          • Record one
          • Apply everything recorded and not yet run
          • Apply everything pending, oldest version first
        • Lifecycle
          • Grow or shrink it
          • Stop it accepting connections
          • Start it again
          • Move it to another region
        • Backups and replicas
          • Take a backup
          • The backups it has
          • Restore one into it
          • What has been restored into it, and from where
          • Its read copies
          • Add one
        • Policies
          • The catalog
          • One policy
          • What this database is held to
          • Switch a policy on, or change its knobs
          • Switch it off
          • Mark the policies reviewed
          • Evaluate the periodic policies now
          • Who is behind a credential
        • Passwordless
          • The database's passwordless sessions
          • Issue a passwordless session (v1)
          • Revoke a passwordless session
          • Bind a session to a device key
          • Issue a v2 session for a host to bind
          • Redeem an enrolment ticket on the host
          • Check a passwordless credential
          • Run one described query under a passwordless credential
        • Database roles
          • The database's roles
          • Define a role
          • One role
          • Change what a role permits
          • Delete a role, and revoke every key granted it
          • Grant a role, which mints a key
          • Who holds a role
          • The api keys bound to a role
        • Pools
          • A database's connection pools, and how busy each is
          • Open another pool
          • Rename a pool or change its size
          • Close a pool
        • Data plane
          • Run a query against a database
          • Several statements, in order, over one connection
          • Is this database answering
          • What the proxy itself is holding
        • Transfers
          • The strategies this account's tier may use
          • Price a transfer without creating it
          • The account's transfer jobs, newest first
          • Create a transfer job
          • One job, and whether its certificate holds
          • Cancel an unfinished job
          • Start a created job, confirming its price
          • Report a job's status (the runner, or the owner's side of a local run)
        • Every database on the account
        • Provision a database
        • Change a database that is already running
        • One database
        • Deprovision it
        • Read or write it
        • Report what it is using
        • The engines this API will provision
        • Where a managed database can be put
    • Teams
      • Teams
        • Members
          • Who is on it
          • Invite somebody
          • Accept an invitation
          • Change somebody's role
          • Remove somebody
        • Projects
          • The projects a team runs
          • Start one
          • Rename a project, or move its environment
          • Delete a project
          • One project, and what is in it
          • Put a database or store in it
          • What is in a project
          • Take one out
        • The teams this account is on
        • Start one
        • The twelve roles, and what each may do
        • Rename a team
        • Close a team
        • One team
        • Hand the team to somebody else
        • Leave a team
    • Schemas
      • Error
      • ValidationErrors
      • Token
      • PolicyCategory
      • PolicySeverity
      • PolicyAction
      • PolicyTrigger
      • PolicyParam
      • Policy
      • PolicyCatalog
      • PolicyAssignment
      • PolicyOutcome
      • PolicyFiring
      • CredentialTrace
      • Relation
      • Restrictions
      • ChildSummary
      • SignupRequest
      • SigninRequest
      • UpdateMeRequest
      • User
      • Address
      • GeoPoint
      • D1Region
      • Usage
      • UsageReport
      • Resource
      • Database
      • DataStore
      • NewDatabase
      • NewDataStore
      • QueryRequest
      • DatabasePrivilege
      • ApiKey
      • DbUser
      • Access
      • AccessRequest
      • DatabaseObject
      • NewDatabaseObject
      • NewMigration
      • SchemaOperation
      • Migration
      • Operation
      • Session
      • AdminPowers
      • Admin
      • AuditEntry
      • TeamRole
      • Conversation
      • ChatMessage
      • TeamSso
      • Team
      • TeamMember
      • SshKey
      • ConnectionInfo
      • InboxAddress
      • InboxMailbox
      • InboxMessageSummary
      • InboxMessage
      • InboxNote
      • Page
      • Quote
      • Plan
      • ApiError
      • CapabilityRefusal
      • KlockErrorCode
      • PageWindow
      • SessionView
      • KlockStepUpRequest
      • KlockOpenRequest
      • KlockOpenedSession
      • UpdateMeResult
      • CapabilitiesView
      • SessionLimits
      • Device
      • DeviceListed
      • DeviceTeamRef
      • DeviceCommand
      • DeviceAuditLine
      • DeviceActionRequest
      • StaffActionRequest
      • ManagedSession
      • SessionRevoked
      • AccessRule
      • AccessRuleRequest
      • BehaviourProfile
      • BehaviourProfileRequest
      • BehaviourProfileSaved
      • BehaviourFinding
      • BehaviourFindingAck
      • StepUpProof
      • Removed
      • MemberLockdownRequest
      • McpClient
      • McpDelegatedView
      • McpToolCall
      • McpForwardRequest
      • PasswordlessScope
      • RoleScope
      • PasswordlessSession
      • PasswordlessTlsMaterial
      • PasswordlessKerberosMaterial
      • ServiceDeviceKeys
      • ServiceSession
      • DatabaseRole
      • TransferEndpoint
      • TransferJobRequest
      • TransferQuote
      • TransferStrategy
      • TransferJob
      • TransferJobAccess
      • Pool
      • PoolAllowance
      • BillingSubscription
      • Invoice
      • BillingContact
      • PaymentMethod
      • UpcomingCharges
      • TeamBilling
      • TeamKerberosRealm
      • TeamSsoDomain
      • TeamSsoResponse
      • StoreConnection
      • BatchFailure
      • ProxyStatus
  1. guides

klock — the `.klock` file

A .klock is a small JSON file that holds a session secret. Whoever holds
it can reach a database without a database password: instead of sending a
credential, the holder uses the secret to sign what it wants for the current
thirty-second window. The other side holds the same secret and checks the
signature. The secret itself never crosses the wire.
This page describes klock as it works today. The first half is format
version 1: the file, the cryptography, what the file does and does not
protect, how one is issued, used and revoked, where the Go and TypeScript
implementations agree, and the errors you can hit. The second half,
klock v2, covers the device-bound version: device keys, the
signed grant, request signing, sessions, sync and the klock service. The v1
code:
WhereWhat
outh/internal/klock/klock.goThe Go reference: the file, Create / Adopt / Ensure / Load, and the rotating id (ID).
outh/internal/grant/grant.goGo grant signing and checking (Sign, Verify).
outh/cmd/outh/main.goThe outh command: $OUTH_SESSION_SECRET and outh spnego.
src/klock/format.ts, src/klock/path.tsThe one parser for versions 1 and 2 (parseKlock / serializeKlock) and the one path resolver (resolveKlockPath).
src/klock-io.tsReading 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.tsThe prorm passwordless … and prorm klock show commands, writeKlock / writeLocalKlock, and the TS Klock type.
src/cli/basic/commands/local.tsprorm db init and prorm db serve.
src/store-key.tsStoreKey, which reads a cloud .klock for the ORM, and readLocalKlock.
src/local-klock.ts, src/local-klock-scope.tsLocalKlock, LocalKlockGate and serveLocalKlockGate, and the gate's scope check.
src/transfer/transfer-klock.tsTransferKlock, the transfer certificate.
src/transfer/local-auth.tsThe TypeScript port of the Go rotating id.
api/security/routes/passwordless.js, api/security/controllers/passwordless.ts, api/database/passwordless.tsThe control plane's side: sessions, grant checking, revocation.

What klock is#

A database credential is a thing you send. If it leaks, whoever has it can
connect directly, and to cut them off you change the password on the engine,
which breaks everybody else who uses it. klock removes the credential from the
client:
The database password stays on the server, in a pool the server owns.
The client holds only the secret.
The client signs a request for one thing (for example, read orders)
in one thirty-second window. The server checks the signature, checks that
the request is within what the session may do, and then runs the query.
Revoking access is a database row, not a password change. When
prorm passwordless revoke marks the session revoked, the file stops
working at once and nobody else is affected.

The three variants#

The name "klock" is used for three related secrets. They share the idea (an
HMAC-SHA256 secret, a 30-second window) but not their files or wire formats.
Cloud passwordless sessionLocal klockTransfer report klock
Made byprorm 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 livesA .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 itThe control plane (/provision/databases/:id/passwordless/{exchange,query})The gate you run yourself: prorm db serve / serveLocalKlockGate on 127.0.0.1:7070The control plane (verifyKlockReport), for status reports on that one job
What is presentedA signed grant: b64url(body).b64url(sig) plus the session uuidA 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 domainouth/grant/v1outh/local-gate/v2 (request token), outh/session-id/v1 (session id)transfer/report/v1 (and outh/transfer-cert/v1 for the job's certificate)
ScopeThe 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 gateReporting status for one job
Revoked byprorm passwordless revoke, a session expiry, or deleting the roleReplacing the file's secret and restarting the gateNot separately revocable. It only works for its own job's reports.
Most of this page is about the first two. They are the variants that use a
.klock file.

How it differs from API keys and the CLI login token#

.klock (cloud session)API keyCLI login token
What it isA 32-byte HMAC secret the server also holds (encrypted with SECRETS_KEY)prk_<8 hex>.<secret>, stored server-side only as a keyed hashA 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-keyYes, as Authorization: Bearer
Acts asA role on one database, limited by the session's ceilingOne database, at one privilege (owner/admin/readwrite/readonly), optionally bound to a roleYou: every database and every account action you may perform
LifetimeUntil revoked, or until the optional --expiresUntil revoked, or until the optional --expires30 days by default (1–90)
StoredA file, mode 0600Wherever the program keeps its configurationThe OS keychain, or ~/.prorm/config.json (0600)
What a leak exposesA 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 revokedYour account, until the token expires or its session is ended
Use the CLI token to administer, an API key for a server program that you
trust with a credential, and a .klock when the holder should never have a
credential at all.

The file#

A .klock is pretty-printed JSON. Both implementations write it with
two-space indentation and a trailing newline, and create it with mode 0600.
Every writer, Go and TypeScript, writes atomically and keeps the fields
it does not know
. In Go, klock.Create and klock.Adopt change only the
secret of an existing file and write everything else back. The TypeScript
writers (writeKlock for passwordless issue and roles grant, writeLocalKlock for db init) also write atomically: a 0600 temp
file in the same directory, fsync, then rename over the target, so a
reader never sees half a file and the mode is set before the secret is
written. They also keep the fields they do not know: they load the
existing file, change only their own fields, and write the rest back. A
writer replaces all of its own fields, so an older session's tls or
kerberos block never survives into a new session. A TypeScript writer
refuses to overwrite a version 2 device identity (kind interactive,
device or guest), a file with an integrity tag, and a version newer
than 2.

Fields#

FieldTypeGo klock.KlockTS KlockMeaning
versionnumberyesyes (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.
kindstring–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.
modestringyesyesAlways written as "0600". It is a record only: no reader checks it. The file's real permission bits are what get checked.
secretstringyesyesThe 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.
apistringyesyesThe control plane URL. passwordless issue and roles grant fill it in from your CLI config.
databasestringyesyesThe database's uuid on prod-orm.
databaseIdnumber–yesThe 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.
sessionIdstringyesyesThe session's uuid on the control plane: which row the secret belongs to. This is not the rotating session id described under the cryptography.
rolestringyesyesThe role name the holder signs as.
entIdstringyesyesThe role's entitlement id (ent_id in a grant). Written by roles grant, and by passwordless issue when the API returns one.
tlsobjectyesyes{ ca, cert, key, servername }, all PEM. Present when the session was issued with --auth tls.
kerberosobjectyesyes{ realm, principal, service, kdc, keytab }. Present when the session was issued with --auth kerberos. keytab is base64 and is a second secret.
localbooleannoyestrue marks a local klock. There is no cloud session.
dialectstringnoyesLocal klock: the engine (sqlite, postgres, mysql, mariadb).
urlstringnoyesLocal 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.
urlSecretRefstringnoyesLocal 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.
scopeobjectnoyesLocal klock: what the gate may run, { "ops": ["read"], "tables": ["orders"] }. Both keys are optional. See the gate's scope.
storagestringnoyesLocal klock: the SQLite file path.
Every field except version, mode and secret is optional in both types.
Go reads a file that has the TS-only fields without complaint, and writes
them back unchanged when it rewrites the file.
Which fields appear in practice:
passwordless issue / roles grant: version (1), mode, secret,
api, database, databaseId, sessionId, role, and entId when there
is one, plus tls or kerberos when --auth asked for them.
db init: version (2), mode, kind (local), secret, dialect,
and either storage, or url plus urlSecretRef when the URL had a
password.
outh (Create / Adopt): version, mode and secret in a new
file. Over an existing file, only secret changes.

An annotated example#

A cloud session issued with prorm roles grant. The secret is a fake: it is
the base64 of the 32 ASCII bytes EXAMPLE-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
}
A local klock written by prorm db init app:
{
  "version": 2,
  "mode": "0600",
  "kind": "local",
  "secret": "RVhBTVBMRS1PTkxZLU5PVC1BLVJFQUwtU0VDUkVUISE",
  "dialect": "sqlite",
  "storage": "/home/ada/project/app.db"
}
A local klock for a server engine, written by
prorm db init shop --docker --dialect postgres. The password is not in it:
it is in ./.env as PRORM_KLOCK_DB_PASSWORD=…. A klock at another path,
for example 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"
}

Where it is looked up#

Every reader resolves the path the same way: an explicit path wins, then
$OUTH_KLOCK, then ./.klock
in the current directory. A leading ~/ is
expanded in TypeScript.
ReaderExplicit path
prorm passwordless query / check, prorm klock show--klock / -k
prorm db servethe positional [klock] argument
StoreKey.fromKlock(file?), readLocalKlock(file?), LocalKlock.fromFile(file?), LocalKlockGate.fromFile(file?)the file argument
outh spnego--klock
Writers use the same order, in both languages. passwordless issue, roles grant and db init write to --klock when you give it, then to
$OUTH_KLOCK, and otherwise to ./.klock (resolveKlockPath in
src/klock/path.ts). outh [--klock <file>] <dsn> writes to the same
resolved path (klock.ResolvePath in Go). outh --out-dir <dir> <dsn>
writes <dir>/.klock instead; --out-dir cmd/outh restores the old
placement inside the outh module.

The 0600 rule#

The secret is the credential, so a .klock that another account on the
machine can read is treated as leaked. Every reader checks the file's
permission bits before parsing it and refuses the file if any group or other
bit is set
(mode & 0o077 !== 0). 0600 and 0400 pass. 0640 and 0644
fail.
Go (klock.LoadFile) refuses with
<path> is mode 0644; it must be 0600, as nobody else may read the session secret.
TypeScript refuses with <path> is mode 0644; it must be 0600 …. The
wording after the dash depends on the reader. For the full messages, see
troubleshooting.
Windows: the TypeScript readers skip the check when
process.platform === 'win32', because POSIX mode bits do not describe
who can read a file there. Protect the file with an NTFS ACL instead. The
Go check has no platform exception.
Fix a refused file with chmod 600 .klock, not by relaxing the check. A
file that was readable by others may already have been copied, so treat it as
leaked and rotate the secret.
$ 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.

Keeping it out of git#

A .klock never belongs in a repository or an image layer. Add it to
.gitignore:
*.klock
Ship it to a container by mounting it, or pass the fields through a
secret store and build the key with StoreKey.from({ … }).
prorm audit code flags any committed file whose name ends in .klock as
SEC-003. It is critical for a v1 file and
for any file holding a secret or private key material, and medium for a v2
file whose device key is in the key store, which holds no secret but still
names the device and its session. The rule fires for a file that
git ls-files lists. Outside a git repository, any .klock that is present
is flagged, with medium confidence. The remediation is the same as for a
committed .env file: git rm --cached it, ignore it, and rotate the secret,
because it stays in the history.
The same scan also runs the local klock checks on every .klock it finds,
committed or not: a key held in the file
(KLOCK-001), a v1 secret where v2 is
available (KLOCK-003), and a file other
users can read (KLOCK-007).
prorm audit klock runs them on your own klock files too, and probes your
account.
[!CAUTION] Untracking is not enough
git rm --cached .klock stops 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

The cryptography#

klock uses HMAC-SHA256 and nothing else: no public keys and no
encryption on the client. Every HMAC begins with a domain string and a NUL
byte
, so a MAC computed for one purpose can never be valid for another:
DomainUsed for
outh/session-id/v1The rotating session id
outh/grant/v1A cloud grant
transfer/report/v1A transfer status report
outh/transfer-cert/v1A transfer certificate
Time is divided into 30-second windows. The window counter is
floor(unix_seconds / 30), and both sides compute it from their own clocks
without exchanging anything.

The rotating session id#

The id is a function of the secret and the window, so two parties holding the
same .klock compute the same id without talking to each other, and the id
changes by itself every 30 seconds. Because the checker can derive the
expected value, it does not need to store the ids it has issued. This is why
the id is derived rather than random.
One SHA-256 digest is 32 bytes, which is only 43 base64url characters, so two
HMAC blocks are chained:
counter = 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) accepts
the id for the current window and for skew windows before it, and compares
every candidate in constant time. With the default skew, an id is accepted
until 60 seconds after its window starts, so for 30 to 60 seconds after it was
derived. LocalKlockGate.authorize uses that default. The look-back never
goes below window 0: at the very start of the epoch verify checks only the
windows that exist (it used to throw a RangeError there with a skew of 1 or
more), and a time before the epoch is simply not valid.
Because the id is the same for everyone in a window, the gate admits each id
once per window (see the local gate's tokens).
In the cloud flow, the rotating id is not what authenticates a request;
the grant does. prorm klock show prints the current id (windowId) and
when its window ends (windowEnds). The local gate is the place where the
id itself is the credential.

The local gate's tokens#

A gate admits two kinds of token in Authorization: Klock <token>:
The request token, which LocalKlock.token() returns. It is different
on every call:
nonce = 16 random bytes
mac   = HMAC-SHA256(secret, "outh/local-gate/v2" ‖ 0x00 ‖ uint64_be(window) ‖ nonce)
token = "kl2." window "." base64url(nonce) "." base64url(mac)
The gate accepts it in its own window and the next one (the same 60-second
tolerance as the session id), and admits each nonce once.
The v1 session id above, which outh derives too and which
LocalKlock.sessionId() returns. The gate admits it once per window.
A client that sends more than one request in 30 seconds uses request
tokens.
The gate keeps an in-memory seen-set of the tokens it has admitted, and
forgets each one once its window has passed. A captured token therefore
cannot be replayed, and a replay gets 401 with the reason
the token has already been used. The seen-set lives in the gate process: a
restarted gate starts empty, and two gates behind one address do not share
one. new LocalKlockGate(secret, conn, { reuseV1Tokens: true }) restores the
old behaviour of admitting a session id any number of times within its
window, for clients that cannot yet send request tokens.

Worked example: the pinned test vector#

The secret is 32 zero bytes and the time is the Unix epoch, so the counter is 0.
The expected id is pinned in both outh/internal/klock/vector_test.go
(TestVectorParity) and tests/local-auth.test.ts (VECTOR), so this is the
value both languages must produce.
1.
Counter. floor(0 / 30) = 0, as 8 big-endian bytes:
00 00 00 00 00 00 00 00.
2.
Block 1 input (28 bytes): the empty previous block, then
outh/session-id/v1, then 00, then the counter, then 01:
6f7574682f73657373696f6e2d69642f7631 00 0000000000000000 01
3.
H1 = HMAC-SHA256(32 zero bytes, block 1 input):
cfac2a41565e86aa6c12def3cde8a370568a21f2f0fe2751db387bd801f187e0
4.
Block 2 input (60 bytes): H1, then the same domain, 00, counter, and
02.
5.
H2 = HMAC-SHA256(32 zero bytes, block 2 input):
fda0e5806bf712a655fa96b674b940b7b6167c4cb72b9322c7543bcc791f511c
6.
Take 48 bytes. H1 ‖ H2 is 64 bytes. Keep the first 48: all of H1 and
the first 16 bytes of H2 (fda0e5806bf712a655fa96b674b940b7).
7.
Encode. base64url without padding gives 64 characters:
z6wqQVZehqpsEt7zzeijcFaKIfLw_idR2zh72AHxh-D9oOWAa_cSplX6lrZ0uUC3.
8.
Truncate to 50:
z6wqQVZehqpsEt7zzeijcFaKIfLw_idR2zh72AHxh-D9oOWAa_
To reproduce it from TypeScript:
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); // true
In Go, Klock{Version: 1, Secret: base64.RawStdEncoding.EncodeToString(make([]byte, 32))}.ID(time.Unix(0, 0))
returns the same string.

Grants#

A cloud request carries a grant: what the holder wants, signed for one
window.
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)
The canonical JSON is the one detail both sides must get exactly right:
Keys are always in the order role, ent_id, scope, window. Inside
scope, ops comes before tables.
ops (read and/or write) and tables are sorted, so the order in
which a caller listed them does not change the signature.
tables is omitted when it is empty. An absent tables means every
table.
ent_id is always present, and is "" when the session has none.
The CLI and StoreKey sign exactly one op and one table per request: the
intent of the query being sent.
A server that verifies a grant checks the signature (in constant time, before
it trusts anything in the body). It then re-canonicalises the parsed grant and
checks that re-signing it gives the same MAC. This catches a body that carries
extra fields. Finally it checks the window. The current window and the
previous one are accepted
, because a request signed at 29.9 seconds can
arrive in the next window. Anything older is refused as expired, and anything
newer as "a window that has not started".
The control plane then checks the session itself. It must exist and be live
(not revoked, not past expiresAt). The grant's role must equal the
session's role, and when the session has an ent_id the grant's must match
it. The grant's scope must also sit within the session's ceiling. For a
session made with roles grant, the ceiling is the role's scope as it is now.
An example made with the zero secret, role reporter, ent_id ent_1,
reading orders, in window 0. This example was computed by hand and checked
against Go's grant.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_w
signV1Grant and verifyV1Grant from ts-prorm-orm/klock produce and check
exactly this. The verifier takes the time in Unix seconds and accepts the
grant's window and the one after it:
import { 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);
What happens to a grant between the holder and the database:
POST /provision/databases/:id/passwordless/query takes
{ sessionId, credential, query }. …/exchange takes
{ sessionId, credential, op?, table? } and answers with the ceiling without
running anything. Neither route requires a user token.

Transfer reports#

An on-device transfer agent (prorm transfer agent) reports a local job's
status without a session token. It signs
HMAC-SHA256(secret, "transfer/report/v1" ‖ 0x00 ‖ "<jobId>.<status>.<window>"),
sends it base64url as Authorization: Klock <sig>, and the API's
verifyKlockReport accepts the current or the previous window. Only the job
id, the status and the window are signed. The other fields of the report body
(such as manifestDigest, bytes and error) are not. This is unchanged
for v1 jobs. A job created with prorm transfer create … --local --klock-v2
uses a v2 service session instead, which signs the whole request (method,
path and a digest of the body); see v2 transfer reports.
TransferKlock, the secret behind a job's certificate, reads the same
.klock formats as everything else: TransferKlock.fromKlock(textOrObject)
and TransferKlock.fromFile(path?) take version 1 or 2. TransferKlock.create()
makes a 32-byte secret. The constructor accepts an existing secret of 16–64
bytes and records a warning under 32.

Security properties#

What the design gives you:
No database credential on the client in the cloud variant. The engine's
password never leaves the control plane, so a leaked .klock cannot be
replayed into a direct connection to the engine.
The secret is never sent. Only MACs cross the wire. The control plane
stores the secret encrypted with SECRETS_KEY, not hashed, because it has
to recompute the MAC. No route returns the secret after issue.
Short-lived requests. A captured grant is good only for the op and table
it names, within its window and the next one: at most about 60 seconds.
The control plane has no replay cache for v1 grants, so within that time
one can be replayed. The local gate does have one (see
the local gate's tokens).
Scope is enforced server-side, against the session's ceiling, before a
connection is taken.
Revocation takes effect at once, on the next request.
What it does not give you:
A copied .klock is the credential. The file is not bound to a device.
Anyone who obtains a copy can do everything the session allows, from any
machine, until the session is revoked or expires. The only protection
the file itself has is the 0600 check, which stops other local accounts from
reading it (and is skipped on Windows). It does nothing against a backup, a
synced folder, a container image, or a process running as you.
The non-secret fields are not signed. api, database, sessionId,
role and entId are plain JSON. Changing role only makes grants fail
the server's role check. Changing api (or setting $OUTH_API_URL) sends
queries and their short-lived grants to another host.
The local gate's scope is opt-in, and it is a guard, not a sandbox.
Without a scope in the gate klock, a valid token may run any SQL on the
gate's connection. With one, the gate checks the statement's shape (see
the gate's scope), but it cannot see what a function
called from a SELECT does. Give the gate's connection a database role
with only the rights you mean.
The local gate speaks plain HTTP on 127.0.0.1 by default. If you bind
it to another interface with --host, tokens cross the network in the
clear. A token that is read in transit and never delivered can still be
used once, within its window.
A local klock written by db init contains the connection, but not the
password.
The password is in the .env beside it (urlSecretRef), which
db init warns about if it does not look git-ignored. The klock is still
the gate's file: a client of the gate needs only the secret,
new LocalKlock(secret), or a file with just version, kind: "local"
(or local: true) and secret. No command writes a client-only file. The
compose file that db init --docker writes still names the password in
plain text. A version 1 local klock written before this change may still
carry the password in url; re-run db init to move it.
TLS and Kerberos as proof of identity. A session issued with --auth tls
or --auth kerberos must, on every …/passwordless/query, also present a
client certificate whose SHA-256 fingerprint matches the session's, or a
Kerberos ticket (Authorization: Negotiate) for the session's principal.
Then the signature alone is not enough. However, the certificate key and the
keytab are stored in the same .klock, so they only help if they are
protected separately: a copy of the whole file carries them too. The
identity check applies to /query. It does not apply to /exchange, so
passwordless check succeeds on a copied file. Revoking a Kerberos session
also deletes its principal from the KDC.
See StoreKey.
Recommended handling:
1.
Issue one session per holder, with --label, and the narrowest scope that
works (--read, --tables, or a narrow role).
2.
Set --expires for anything temporary.
3.
Keep the file 0600, out of git, out of images, and out of synced folders.
Mount it at runtime.
4.
Use --auth tls or --auth kerberos when the holder can keep the extra
material somewhere other than the file.
5.
Revoke on any doubt, and re-issue. Watch prorm passwordless list for
sessions whose lastUsed you do not expect.

Lifecycle#

Issue#

Cloud. Both commands call the API, receive the secret once, and write
the .klock with mode 0600. The API call is the same one the CLI makes; if
you call it yourself, you write the file:
$ 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
A session whose ceiling is a role, read live on every request:
$ 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
}
The request is authenticated like any other account call (a klock v2
session, or a bearer token). The answer is 201, and secret is never
returned again.
passwordless 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
give at least one of --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
the file.
--klock - prints the secret instead of writing a file. You cannot combine
it with --auth tls or --auth kerberos, because that material only fits
in a .klock.
The API routes are POST /provision/databases/:id/passwordless and
POST /provision/databases/:id/roles/:roleId/grant. Both return 201 with
secret in the body, and neither will return it again. Both refuse with
503 when SECRETS_KEY is not configured.
With outh. outh <dsn> settles its .klock at the resolved path
(--klock, then $OUTH_KLOCK, then ./.klock; or <dir>/.klock with
--out-dir <dir>):
Without $OUTH_SESSION_SECRET, it calls klock.Ensure, which keeps an
existing file or creates one with a fresh 32-byte secret from
crypto/rand.
With $OUTH_SESSION_SECRET set, it calls klock.Adopt, which replaces
the secret
in the file with that one (at least 32 bytes) and keeps every
other field. This is how outh takes on a secret the API
issued. An unusable value stops the run instead of falling back to a
generated secret.
The secret travels as an environment variable, not a flag, so that it stays
out of the process list.
Local. prorm db init writes a local klock beside a new database:
--klock - skips writing the file.

Verify#

passwordless check (alias klock check) proves the file against the
control plane without running anything, and prints the session's ceiling and
when the grant expires. klock show (alias passwordless klock) works
offline. It prints file, database, sessionId, role, entId, api,
the current window, windowId and windowEnds. Both commands accept
--json.

Use#

From the CLI:
passwordless query (aliases klock query, pq) and passwordless check
read the database, session, role and ent_id from the file. The flags
--database, --session, --role and --ent-id, and the environment
variables $OUTH_DATABASE, $OUTH_SESSION_ID and $OUTH_API_URL, override
the file.
From the ORM: StoreKey.fromKlock() reads a cloud .klock and signs every
model call as a grant. See StoreKey.
import { 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 secret
StoreKey.fromKlock reads a version 1 file, or a version 2 file of kind
service that carries a secret. It requires database, sessionId,
role and entId in the file, and a secret of 16–64 bytes (it warns under
32). Its queryUrl is addressed by the file's databaseId when there is
one, and by the uuid otherwise. StoreKey.from({ …, databaseId }) does the
same for a key built from fields. If the file has a Kerberos keytab
and no token source, it runs outh spnego --klock <file> --service <spn> for
a ticket on each query (set $OUTH_BIN if outh is not on your PATH).
A local gate: prorm db serve (alias db gate) runs
serveLocalKlockGate in front of the database that a gate klock names. The
default address is 127.0.0.1:7070. Clients present a request token (or
the rotating id, once per window):
import { 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();
The gate's routes are GET /healthz (open) and POST /query with a
{ "sql": "…" } body (at most 256 KiB by default), which returns { rows }.
A missing, wrong, expired or replayed token gets 401, and SQL outside the
gate's scope gets 403. A gate klock whose url has its password in
urlSecretRef needs that variable in the gate's environment, or the .env
beside the klock.
[!WARNING] Keep the gate on loopback
The gate speaks plain HTTP. prorm db serve --host 0.0.0.0 puts 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.

The gate's scope#

A gate klock may carry a scope. db init does not write one; add it to
the gate's klock by hand:
{ "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) and
write (everything else, and any read that contains a writing keyword such
as INSERT, 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 bare
entry (orders) matches orders in any schema; a qualified entry
(public.customers) matches only that. CTE names and aliases are not
tables. A statement whose tables cannot be told (PRAGMA, CREATE INDEX,
a GRANT) is refused when tables is set.
Every statement in a multi-statement query is checked. The SQL is read
under the string and comment rules of Postgres, MySQL and SQLite at once,
and must pass under each, so a statement cannot hide in what one engine
would take for a string or a comment.
LocalKlockGate.fromFile picks the scope up from the file;
new LocalKlockGate(secret, conn, { scope }) takes it directly, and
gate.permits(sql) / checkScope(scope, sql) check a statement without
running it.
readLocalKlock() is the non-passwordless shortcut. It opens the
connection that the local klock names, directly, from the file:
import { 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 });

Revoke#

passwordless revoke (alias passwordless rm) calls
DELETE /provision/databases/:id/passwordless/:sessionId. The row is kept
with revokedAt set, so it stays auditable. The next grant signed with that
file is refused with That session cannot be used. For a Kerberos session,
the principal is also removed from the KDC.
A session from roles grant can be revoked the same way. Deleting the role
disables every session granted from it.
A local klock has no revocation list. Write a new one (for example,
prorm db init --url … --klock ./.klock replaces the file) and restart
db serve, which reads the secret once, at startup.

The whole lifecycle in one terminal#

Issue a read-only session for a support laptop, inspect it, use it, and
revoke it. The last command shows what the holder sees afterwards:
$ 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 used
The windowId is what the example secret derives for 10:00:00 UTC on 26
September 2026. The API also answers with the SQL it ran (statement), which
the CLI prints dimmed above the rows. The revoke leaves the file untouched:
only the session row changed.

Rotation#

There is no secret rotation in v1. The window rotates the id and the
grants every 30 seconds, but a session's secret never changes, and no
command or route replaces it. To rotate:
1.
Issue a new session for the holder (passwordless issue or roles grant)
and deliver the new .klock. Write it beside the old file, not over it,
so the holder keeps working until the switch:
$ prorm roles grant support_ro --db app --label "ada laptop (2026-09)" --klock ~/support/.klock.new
2.
Switch the holder to it, and prove it against the control plane:
$ mv ~/support/.klock.new ~/support/.klock
$ prorm passwordless check --klock ~/support/.klock
3.
Revoke the old session with prorm passwordless revoke <old-session-uuid>.
prorm passwordless list shows which one it is by its label and
lastUsed.
[!IMPORTANT]
If the old file may have leaked, revoke first and accept the gap. The
credential rotation runbook covers the
ordering.
This is one reason to prefer v2 for anything long-lived: a v2 service
session's device key can be rotated
(POST /security/klock/sessions/:id/rotate), and its shared secret dropped
altogether (upgrading a v1 file).

Go and TypeScript compatibility#

What is shared:
The rotating session id. klock.ID (Go),
Migrate.sessionId (src/transfer/local-auth.ts) and prorm klock show
implement the same derivation. The only cross-language test is the pinned
vector above: TestVectorParity in Go and VECTOR in
tests/local-auth.test.ts. If you change one, change the other.
The grant format. Go grant.Sign / grant.Verify, the CLI's
signGrant, StoreKey.sign and the API's verify use the same domain,
canonical key order, sorting and window rule. The shared vectors in
outh/testdata/klock-vectors.json (v1grant) pin the exact body and MAC,
including table names with <, >, & and non-ASCII characters; the
CLI's signGrant and StoreKey.sign are tested against them.
The file. Both sides read the same JSON fields, the same version, the
same mode rule (except the Windows exception in TypeScript), and padded or
unpadded base64.
What is not shared:
The TS-only fields local, dialect, url, urlSecretRef, storage
and scope. Go does not use them, and keeps them on rewrite.
The transfer report and certificate MACs, which exist only in TypeScript.
The gate's request tokens (kl2.…) and its scope: only the TypeScript gate
and LocalKlock implement them. A Go client presents the session id.
Error messages. Go and the API word the same failure differently. For
example, an expired grant is the grant expired about 1m0s ago in Go and
The grant expired about 60 seconds ago from the API.

Troubleshooting#

Each message links to its entry in the klock error reference, which
has the cause, the fix step by step, and where it is raised. That page covers every klock
error, v1 and v2; explainKlockError(err) from ts-prorm-orm/klock finds the entry for
an error or message in code.
MessageReference
<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 undefinedklock_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 yetklock_secret_missing
<path> is not a local klockklock_wrong_kind
<path> is a cloud session klock, not a local one — use StoreKey.fromKlock() for itklock_wrong_kind
this klock has no connection; it is a client credential, not a gate · <path> names no dialectklock_wrong_kind
<path> holds no secretklock_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 8klock_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 32klock_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 base64klock_secret_length
the session secret is not valid base64klock_secret_length
a local klock secret is 32 bytes; this one is 0klock_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 --roleklock_coordinates_missing
A StoreKey needs sessionId — a .klock written by prorm carries itklock_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 startedv1_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 tov1_grant_not_canonical
That session cannot be usedv1_session_unusable
That session is for a different databasev1_wrong_database
The grant is for a different role than the session · … different role id than the sessionv1_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 existsv1_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 kerberosv1_kerberos_refused
present a valid klock token: Authorization: Klock <token> (HTTP 401 from db serve), with a reasongate_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

Known issues#

These are known inconsistencies in v1 that have not been fixed yet.
1.
The API does not check the secret's length. Every writer writes 32
bytes, and the Go and TypeScript readers accept 16–64 bytes and warn under
32. The control plane's verify accepts whatever secret the session row
holds.
2.
Older files address the grant routes by uuid. passwordless issue
and roles grant now record the numeric databaseId beside the uuid, and
passwordless query/check and StoreKey address
/passwordless/{exchange,query} by it. A .klock written before that has
only the uuid, and a holder who is not signed in (or a StoreKey built
without databaseId) sends the uuid. A control plane without the uuid/id
fix answers That session is for a different database. Re-issue the file,
or pass --database <numeric id>, $OUTH_DATABASE, or databaseId.

klock v2#

Version 2 changes what a klock holds. A v1 file holds a shared secret that
the server also holds, so a copy of the file is the credential. A v2 file
holds a device key that never leaves the machine, and a grant the API
signed for that key. Every request is signed by the device key. A copy of the
file without the key is useless, and a captured request cannot be replayed.
v1v2
What the client holdsA 32-byte HMAC secret, which the server also holdsAn Ed25519 signing key and an X25519 sealing key, which only the device holds
What says what it may doThe session row on the serverA grant: a compact JWS the API signed, naming the capabilities, the limits and the device key
What is sentAn HMAC over the grant body for one 30-second windowAuthorization: Klock <grant> plus an RFC 9421 signature over the request, with a single-use nonce
A copied fileIs the credential, until revokedCarries no private key when the keys are in the keychain, so it cannot sign
ReplayA captured v1 grant works for about 60 secondsThe nonce is single-use for unsafe methods and admin requests
Who can verify offlineNobodyAnyone holding the pinned root key
v1 keeps working. Every v1 wire protocol is unchanged: the rotating
session id, the outh/grant/v1 HMAC grant, the transfer report MAC and
Authorization: Klock <id> at the local gate. A v1 file stays v1 until you run
prorm klock upgrade. Nothing upgrades a file
silently.
The code:
WhereWhat
src/klock/format.ts, types.ts, klock-file.schema.jsonThe v2 file: parse, validate, serialise, the integrity tag
src/klock/keys.tsThe pinned roots (KLOCK_ROOTS) and keyset verification
src/klock/jws.tsStrict Ed25519 JWS, grants (signGrant, verifyGrant)
src/klock/httpsig.tsRFC 9421 request signatures and Content-Digest
src/klock/seal.tsklock-seal-v1: secrets sealed to a device
src/klock/canonical.ts, thumbprint.tsCanonical 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.tsThe 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.jsonThe shared vectors Go and TypeScript both test against

The v2 file#

A v2 file has "version": 2 and a kind:
kindWhat it isWhere it livesNeeds device
interactiveA person on the CLI or the local MCP server~/.prorm/klock/<profile>.klockyes
deviceA trusted machine: a long-lived session, opened with step-up~/.prorm/klock/<profile>.klockyes
guestAn anonymous device key with the public set~/.prorm/klock/<profile>.klockyes
serviceA database session. With a secret, it is the v1-compatible form that klock upgrade writesa project .klockyes
localA local klock written by prorm db inita project .klockno; it uses secret only
<profile> is --profile or $PRORM_PROFILE, and default otherwise
(profileKlockPath in src/klock/path.ts). The CLI records the path as
klock in ~/.prorm/config.json.
The fields a v2 file adds to the v1 ones:
FieldMeaning
kindOne of the kinds above.
klockThe klock service's URL, https://klock.prod-orm.io by default.
device.iddev_…, 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.keyStorekeychain or file. priv is in the file only when this is file.
device.certThe device certificate the API signed (a compact JWS).
session{ id: "ks_<uuid>", grant, grantSeq, syncSeq, syncedAt, checkInBy }. The grant is the API-signed JWS.
providersPer-provider state (tls, kerberos, files). Private material is referenced (keyRef, keytabRef), not stored, unless the key store is file.
integrityAn HMAC tag over the rest of the file. Present exactly when the key store is keychain.
The v1 fields (secret, database, databaseId, sessionId, role,
entId, tls, kerberos, local, dialect, url, urlSecretRef,
storage, scope) keep their v1 meaning and shape, so StoreKey and outh
read one shape. Every writer keeps fields it does not know.
A signed-in CLI's klock, with the keys in the keychain. The keys, grant and
tags are shortened:
{
  "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…"
}
The parser (parseKlock) enforces the shape: a v2 file other than local
must have a device; a keychain-backed device must carry an integrity tag
and no priv; a file-backed one must carry both priv keys and no tag;
session.id must be ks_<uuid>; the grant, the seal binding and the
certificate must be compact JWS. A file with a version above 2 is shown
read-only by prorm klock show and refused by everything else.

Where the keys are#

src/klock/agent/devicekey.ts generates both key pairs on the device and
keeps the private halves in the OS keychain:
the service is prorm-klock;
the accounts are sign:<profile>:<id>, seal:<profile>:<id> and
integrity:<id>, where <id> is the device id once enrolled and
pending:<thumbprint> before;
macOS writes go through security -i with the command on stdin, and Linux
uses secret-tool store with the secret on stdin, so a key never appears in
a process list.
Without a keychain (Windows, PRORM_NO_KEYCHAIN=1, or a failed write), the
private halves go into the 0600 file as priv, keyStore becomes file,
and the CLI warns. A file-held key has no integrity tag, because its key
would sit beside it.
The private key never leaves the device. No endpoint accepts one and no
command exports one. To move to a new machine, sign in there: it enrols as a
new device.

The integrity tag and tampering#

With a keychain-backed device, integrity is
b64url(HMAC-SHA256(k, canonical(file without "integrity"))), where k is a
32-byte key kept in the keychain (computeIntegrity / verifyIntegrity in
src/klock/format.ts). It catches an edit to a field the grant does not
cover, such as api.
The agent treats any of these as tampering: a bad or missing tag, a missing
integrity key, a grant that does not verify, and a device key that does not
match the file. It then:
1.
treats the file as having no session, in memory;
2.
records the local finding klock.tamper-detected, which is reported at the
next sync;
3.
lets the caller run one sync, since the server re-issues a grant when the
session is live and the device key is valid;
4.
otherwise asks you to sign in again.
Findings go into a separate 0600 agent-state file beside the klock, so a
tampered file is never re-tagged as good.

Keys, the keyset and root pinning#

Two layers of Ed25519 keys stand behind everything v2 trusts:
The root. An offline key, used only in the keyset ceremony. Its public
half is pinned in KLOCK_ROOTS (src/klock/keys.ts), which the CLI, the MCP
Worker, the console and the klock service import. Go pins the same list in
outh/internal/klockv2/roots.go, and a test holds the two equal.
The keyset. A JWS the root signed (typ: klock-keyset+jws):
{ seq, iat, expires, keys: [{ kid, use, x, nbf, exp }] }. It lives at most
90 days. Each key is either:
use: "grant", kid g-YYYYMM-<4 hex>. This is the API's
KLOCK_GRANT_KEY. It signs grants, device certificates, enrolment
tickets, sync commands and the revocation feed.
use: "klock", kid k-YYYYMM-<4 hex>. This is the klock service's key.
It signs sync answers only.
KLOCK_ROOTS is empty until the production keyset ceremony. With nothing
pinned, every keyset fails verification, so v2 fails closed: the API answers
503 klock_v2_unavailable, and
prorm login falls back to the legacy token. Development and staging pin
their own root with PRORM_KLOCK_ROOTS, a JSON array of {kid, x}. A
standalone production build ignores it. The ceremony is in the
klock deploy runbook.
Where the keyset is published and cached:
The API serves it at GET /.well-known/klock-keys.json
(application/jose, with a Klock-Keyset-Seq header), and the klock
service mirrors it at GET /v2/keys. Before a keyset is published, both
answer 503 no_keyset, as do the MCP
Worker and the klock service's sync and enrolment: it is a temporary state,
so clients retry rather than cache a 404.
The CLI caches it in ~/.prorm/klock/keyset.json, with the highest seq
it has ever seen. A keyset with a lower seq is refused as a rollback.
The CLI refetches when the cache is 30 days old, or when a grant names a
kid the cache does not have.
The API signs only when three things line up: KLOCK_GRANT_KEY is set, the
newest keyset verifies against the pinned roots, and that keyset lists the
key's public half as a current grant 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 answers
503 no_keyset until one is published.
What a keyset carries, decoded
The payload of the root-signed keyset JWS (header
{"alg":"EdDSA","kid":"root-2026","typ":"klock-keyset+jws"}): one grant key
and one klock key, each valid for the keyset's lifetime.
{
  "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,
inside the 90-day limit. A grant's header kid names one of the grant keys.
Rotation. To rotate an operational key, publish a keyset that holds both
the old and the new key, switch signing after 24 hours, and drop the old key
once the longest grant (7 days) has expired. In an emergency, publish a keyset
without the compromised kid: clients pick it up on their next sync, and
verifiers that cache it for an hour pick it up within the hour.

The v2 grant#

A grant is a compact JWS, header {"alg":"EdDSA","kid":"g-…","typ":"klock-grant+jws"}.
Verifiers check the exact bytes they received and never re-serialise the
payload, so grant verification needs no canonical JSON. The payload
(GrantPayload in src/klock/types.ts):
ClaimMeaning
v, iss, aud, jti2, 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, dormantThe resolved tier, the capability ceiling (sorted), and the admin capabilities that need a step-up. See Capabilities.
adminUntilWhen a step-up's admin capabilities lapse.
teamId, teamRole, roles, rolesTruncatedThe current team context, and up to 50 team memberships.
sessionId, kind, bindingks_<uuid>, the session kind, and device.
resourceFor a service session: the database, store or transfer job it is bound to, and its owner.
limitsThe session's limits (see sessions).
net, netRefCompact network rules, at most 2 KB, or a flag saying to fetch them.
iat, nbf, expIssue time, not-before, and when the grant is dead for everything.
checkInByAt or before exp: the last moment an offline verifier may honour gated capabilities.
deviceKeyThumbprint, deviceId, grantSeqThe device the grant is bound to, and its sequence number.
A decoded grant: a developer in a team, on the usage plan
The payload of an interactive grant, as prorm login receives it. The
capabilities and limits are exactly what resolveCapabilities in
src/capabilities.ts gives an account on the usage plan whose session is in
a team where it is a developer. The API also puts the device's public key
in the grant (devicePub), so a verifier can check the request signature
without looking the device up.
{
  "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"
}
Read it as: the grant dies for everything at exp (24 hours, the
grantTtlSec); an offline check honours caps only until checkInBy (one
hour, checkInEverySec); dormant is empty because this account holds no
admin capabilities; and the whole thing is useless without a request signed
by the key whose thumbprint is deviceKeyThumbprint.
verifyGrant (src/klock/jws.ts) checks, in order:
1.
The JWS is strict. alg must be EdDSA and is checked before any key is
looked up. The header may hold only alg, kid and typ. Every part is
canonical base64url and strict JSON. The signature is exactly 64 bytes.
2.
typ is klock-grant+jws, and the kid is a current grant key of a
keyset the roots signed.
3.
The payload has the right shape, and the time is within nbf…exp.
4.
iss and aud, when the caller asks.
5.
The device binding: deviceKeyThumbprint is this device's thumbprint.
A grant is never accepted as a bearer token. Without a signature by the
device key it names, it proves nothing.
Offline rules. When the CLI consults its grant without the network
(src/klock/agent/session.ts):
WhenStatusWhat is honoured offline
now ≥ expexpirednothing: the session is dead
idle longer than limits.idleTimeoutSecidlethe public set only; sync needed
now > checkInBycheck-in-duethe public set only; sync needed
otherwiseactivethe grant's caps; admin capabilities only until adminUntil
Offline decisions are for the CLI's own display and guard. The server
decides again on every request.

Request signing#

Every Klock-authenticated request carries:
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
whenever the request carries them. @query is ? when there is no query.
A verifier (verifyKlockRequest in src/klock/httpsig.ts) requires all of:
the listed components are covered, and there are no unknown parameters;
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;
the Ed25519 signature verifies.
On the API (authenticateKlock), the nonce is then claimed single-use for
120 seconds
in the DeviceGuard Durable Object, for every unsafe method
(POST, PUT, PATCH, DELETE) and for every request made with a live
admin:* capability. After that the API loads the session row. The row must
exist
(v2 fails closed), and must not be revoked, frozen, idle past
limits.idleTimeoutSec or past its maximum duration. Its device thumbprint
must match the grant's. Capabilities are re-resolved from live facts and
intersected with the grant, so a demoted team role takes effect on the next
request, before any new grant is issued.
Clock skew. A request signed more than 60 seconds away from the server's
clock gets 401 clock_skew with a
Klock-Server-Time: <unix seconds> header. The CLI (klockFetch) stores the
offset in memory and retries once, with a fresh nonce.
The order the API checks a request in, and the refusal each check gives
(authenticateKlock in api/security/controllers/klock.ts):
The route then checks its own capability, and answers
403 capability_missing or
401 step_up_required when the
intersected set lacks it.

Signing a request yourself#

signKlockRequest and verifyKlockRequest from ts-prorm-orm/klock are the
two halves the CLI and the API use. Given a device seed and the headers that
must already be on the request (authorization and klock-client), the
signer returns the three headers to add:
import {
  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 seconds
from now, and one of bad-digest, bad-signature, wrong-key, expired,
bad-tag, bad-params, missing-component or malformed otherwise. The API
turns clock-skew into clock_skew and every other code into
signature_invalid.

Sealing#

Secrets the server sends a device (a keytab, a database secret, a file key)
are sealed to the device's X25519 key with klock-seal-v1
(src/klock/seal.ts): an ephemeral X25519 key, HKDF-SHA256 with the info
string klock/seal/v1, a NUL and a context, then AES-256-GCM with the context
as additional data. The context names what is sealed and for what
(kerberos/keytab/<principal>, db/secret/<sessionUuid>,
files/key/<transferId>), so a sealed keytab cannot be opened as a file key.
The klock service relays sealed material and can never open it.

Canonical JSON and the shared vectors#

Where v2 needs bytes both languages agree on (RFC 7638 thumbprints, the
integrity tag, the sync stateDigest, the RPC argument digest, and the v1
grant body), it uses one canonical JSON (src/klock/canonical.ts, Go
outh/internal/canon): keys sorted by UTF-16 code unit, no whitespace,
strings escaped exactly as JSON.stringify escapes them (so <, >, &,
U+2028 and U+2029 are not escaped), and integers only.
outh/testdata/klock-vectors.json pins canonical JSON, v1 grants, the session
id, thumbprints, v2 grants, request signatures and sealed boxes, and both
languages' tests read it.

Sessions#

The API keeps one sessions ledger for every kind. GET /security/sessions
lists them all, and DELETE /security/sessions/:id revokes any of them.
KindWhoBindingOpened byBuilt
interactiveA person on the CLI or the local MCP serverdeviceprorm login, through the consoleyes
deviceA trusted machinedeviceprorm login --device, with a fresh step-upyes
serviceA database host, CI runner or on-device transfer agentdeviceprorm klock upgrade, the owner's …/passwordless/enrol + the host's …/redeem, the console, or prorm transfer create --klock-v2yes; see service sessions
guestNobody: an anonymous device keydeviceprorm login --guestyes; there is no session row
delegatedOne MCP client acting for youbearerprorm mcp clients createyes; see MCP
browser, cli-legacyThe console, and pre-v2 CLI tokensbeareras beforeunchanged
Their default limits (SESSION_DEFAULTS in src/capabilities.ts). Plans may
widen them within the hard limits. A team, and you yourself, may only tighten
them.
Kind (tier)Maximum durationIdle timeoutConcurrentCheck in everyGrant lifetimeStep-up lasts
guest24 h2 h1 per device; 5 new per IP an hour24 h24 h–
interactive (user, team)30 d7 d10 per account, least recently used evicted1 h24 h–
interactive (admin)12 h30 min3 per account, least recently used evicted5 min12 h15 min
devicenone90 d3 (free, starter), 10 (usage), 25 (membership, pro); refused above that24 h7 d–
servicenone180 d100 per database, refused above that24 h7 dnever admin
delegated30 d7 d5 per account, least recently used evicted–– (bearer)never admin
The hard limits (HARD_LIMITS) cap an interactive session at 90 days, an
admin session at 24 hours, a step-up at 60 minutes, a grant at 7 days and an
account at 100 devices.
Eviction. Signing in again on the same device replaces that device's
session. When you open one session too many, a kind whose rule is lru revokes
your least recently seen other session (reason evicted, audited). A kind
whose rule is refuse answers 409
session_limit with the list to choose
from. So a thief who opens a device session cannot push out your real device.
The session routes (all under the API's base path):
RouteAuthDoes
POST /security/klock/sessionsthe console (browser session), or an existing klock sessionOpen 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/refreshthe 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-upthe 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/rotatethe 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, /unfreezeowner (unfreeze needs a fresh step-up)A frozen session answers 423 session_frozen to everything
POST /security/klock/guestdevice-signed, no accountA guest grant: 5 per IP an hour, 1 per device a day
GET /security/klock/revocations?since=<seq>publicThe API-signed revocation feed
Step-up. Admin capabilities in an interactive session are dormant
until you step up with a TOTP code (prorm step-up). They then stay live for
stepUpTtlSec (15 minutes), after which they are dormant again. A route that
needs a dormant capability answers 401
step_up_required, and the CLI offers
the step-up and retries once. Opening a device session and unfreezing a
session also need a fresh step-up. Step-up attempts are rate-limited
(too_many_attempts). See
Capabilities for how dormant capabilities are resolved.
Lockdown and recovery. POST /security/lockdown (with step-up) revokes
every session of the account except service sessions (unless
includeServices), suspends its devices, and refuses every request for 15
minutes. Email recovery (/security/recovery/start, then /complete) gives a
one-hour session that ends a lockdown. Both are in
Access rules and behaviour profiles.

Service sessions#

A service session (kind: "service") stands for exactly one subject: a
passwordless database session, or a local transfer job. One device key holds
it: a database host, a CI runner, or the on-device transfer agent.
What it may do. It holds one capability: passwordless:use for a
database, or transfer:report for a transfer job. It is never admin, even
when an admin opened it: no admin:*, nothing dormant, tier user. The
grant names its subject: principal: { type: "service", id: <thumbprint> }
and resource: { kind: "database" | "transfer-job", id: <uuid> }. On any
account route the grant is refused with
403 service_session_scope; it
works only on its subject's data plane, and for
POST /security/klock/sessions/:id/refresh and /rotate.
Lifetime. Permanent until revoked or rotated. It checks in every 24
hours (checkInBy), and a grant lives 7 days. An optional hard deadline,
rotateBy (limits.rotateBy in the grant), ends the session, and its
passwordless session, when missed. A gate refuses gated operations once
checkInBy has passed (check_in_required).
One list, one revoke. The service row is listed by
GET /security/sessions with kind: "service" and subject: {kind, id}.
Revoking it revokes the passwordless session, and revoking the passwordless
session (DELETE …/passwordless/:sid) revokes the service row (reason
subject-revoked). "Sign out everywhere" (DELETE /security/sessions), a
password reset and a lockdown all spare service sessions, so a database
host or CI runner keeps working while you end your own sessions. Pass
includeServices: true (or ?includeServices=1 on the DELETE) to end
them too; the audit detail records the choice.
v1 is unchanged. An unbound passwordless session works on its HMAC
grant as before and has no sessions row, and v1 transfer jobs keep the
transfer/report/v1 MAC.
How a service session comes to be:
RouteWhoDoes
POST /provision/databases/:id/passwordless/:sid/bindthe 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/enrolthe 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/redeemthe host, no user authRedeems 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 sessionresource: { 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.
A bound session's requests to POST /provision/databases/:id/passwordless/{exchange,query}
are device-signed (RFC 9421 over the method, the path and the body digest,
with a single-use nonce) and carry Authorization: Klock <service grant>.
The body is { sessionId, query } or { sessionId, scope?, table?, op? },
with no credential. A freshly upgraded file that has not synced a grant yet
sends Authorization: Klock device=<thumbprint> and the key in
Klock-Device-Pub; it cannot check in or rotate until it holds a grant.
Refusals include not_bound (the session is
not bound: use v1), wrong_subject,
signature_invalid, nonce_replayed, session_revoked,
device_key_mismatch and grant_expired. GET …/passwordless shows each
session's protocol and whether it is bound.
In code, src/klock/providers/db.ts holds the device side
(openServiceKlock, serviceFetch, passwordlessQueryV2,
passwordlessExchangeV2, and assertServiceCheckIn, the gate rule). It is
Node-only and not part of the ts-prorm-orm/klock export. StoreKey sends
device-signed v2 queries for a klock bound to a device key, and keeps the v1
HMAC grant for an unbound one; see StoreKey.
A host gets its service klock one of two ways: prorm klock upgrade on an
existing v1 file (below), or prorm klock init --service with the owner's enrolment ticket (host setup).
Either way, prorm klock agent keeps it checked in.

v2 transfer reports#

prorm transfer create … --local --klock-v2 creates the job with
klock: "v2": no klockSecret is returned, the agent gets a device key and a
service session over the job, and its klock is written to
~/.prorm/transfer-agent/<job>.klock. prorm transfer agent then signs each
POST /transfer/jobs/:jobId/events whole, with RFC 9421, so every field of the
report is covered, not just the job id, status and window. The API refuses
the v1 MAC for a v2 job, and a device-signed report on a v1 job gets
403 not_bound. Agents without a klock file keep reporting with the v1 MAC.

Devices#

A device is a key the klock service knows. You manage yours on the API; the
routes answer 503 klock_unconfigured when the API has no klock service
binding.
RouteDoes
GET /security/devicesYour devices, the current one marked. ?all=1 includes revoked ones.
GET /security/devices/:idThe device, its sessions, and its last 50 audit lines.
PATCH /security/devices/:idRename: { label }, 1–120 characters.
DELETE /security/devices/:idRevoke. Its sessions end first, the device and its credentials go on the revocation feed, and it is nudged.
POST /security/devices/:id/suspend, /reinstateA 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/wipeEnds 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-keyQueues a rotate-key command for the device's next sync.
POST /security/devices/pairings/approveApprove a pairing code (below).
Reading needs device:read and changing needs device:manage, in every
enforcement mode. Someone else's device answers 404 device_not_found, the
same as one that does not exist.
A lost laptop, three ways. Find the device and wipe it: its sessions end
at once, it is suspended, and a signed wipe command waits for its next sync.
Once you no longer expect it to come online, revoke it for good (a revoked
device cannot sync, so it would never receive the wipe):
$ 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.
Later, once it has synced the wipe or you have given up on it:
$ prorm devices revoke dev_01J8W2H3J4K5L6M7N8P9Q0R1S2 --reason "laptop stolen"
Revoke dev_01J8W2H3J4K5L6M7N8P9Q0R1S2? Every session on it ends now. [y/N] y
✓ dev_01J8W2H3J4K5L6M7N8P9Q0R1S2: revoked.
Each request is signed like any other klock request (see
request signing); the DELETE and POST claim their
nonce.
Open Security → Devices, select the device, and choose Wipe; later,
Revoke. The same page has Rename and Suspend (or Reinstate).
Use suspend instead when you expect the device back: its sessions are
frozen rather than ended, and reinstate restores them.
Team administrators with device:manage-members use
GET /teams/:teamId/devices and DELETE /teams/:teamId/devices/:deviceId,
and with session:manage-members use GET /teams/:teamId/sessions and
DELETE /teams/:teamId/sessions/:sid. Acting on a member's device or session
needs a reason (reason_required), is
audited on the member's trail with the actor, and emails the member. Only the
team owner may act on the owner's devices and sessions.
prorm staff with the support scope use GET /admin/users/:id/devices,
DELETE /admin/users/:id/devices/:deviceId,
POST /admin/users/:id/devices/:deviceId/wipe and
DELETE /admin/users/:id/sessions/:sid. Each change needs a fresh step-up
and a reason of 10–500 characters, is audited at level alert, and emails
the account holder. GET /admin/klock/devices gives the fleet counts.

Signing in (login v2)#

prorm login opens an interactive session bound to this machine's key. The
CLI guide has the full walkthrough and the fallbacks.
The confirm code is what stops a crafted sign-in link from binding someone
else's key to your account: the API recomputes it from state and the device
key and refuses a mismatch with
confirm_code_mismatch. A parked
grant is harmless to intercept, because it names this device's key. Once a CLI
has signed in with v2, it refuses a silent fall back to a legacy token
(klock_downgrade); prorm login --legacy does it on purpose.
A whole sign-in on a Mac, in the context of the team acme, then a look at
what it produced. The confirm code is printed first:
$ 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 run prorm login just now, someone sent you a crafted link
for their own device key. Close the tab; approving would sign their
machine in as you.
The times print in your locale. whoami asks the API (--offline answers
from the grant alone, --caps and --limits show the detail); klock status
never goes to the network. An account with admin capabilities sees one more
line after signing in, saying they are dormant until prorm step-up, and
makes them live for 15 minutes with an authenticator code:
$ prorm step-up --totp 123456
✓ Stepped up; admin capabilities are live until 9/26/2026, 10:17:00 AM.

Sync#

A device keeps its session fresh by syncing with the klock service:
POST /v2/sync, device-signed. The CLI syncs when prorm klock sync runs,
and at start-up when syncedAt + nextSyncAfter has passed or checkInBy is
less than 10 minutes away.
$ 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
}
When the device or the session has been revoked, klock sync says so, tells
you to run prorm login, and exits with the authentication status. A
suspended device syncs, but gets a warning and no grant.
The rules:
The server wins for device status, the session and its grant,
revocations, policy and commands. The device's body can change only what the
device reports (agent version, platform, installed credential serials),
never its status, keys, kind or owner. Reports are appended and never
rewritten.
Replay and rollback. The answer echoes the request's nonce, so an old
answer cannot answer a new request. syncSeq only rises. A device that
claims a sequence the server never issued (ahead), or one below what it
already acknowledged (rollback, for example a restored or cloned klock
file), is refused. The CLI refuses an answer with a lower syncSeq and
records klock.sync-rollback.
Commands (logout, wipe, rotate-key, resync) are applied only
when their own API signature (typ: klock-command+jws) verifies. The klock
service cannot forge one.
A suspended device gets its status and commands, and no grant.
Rate limit. 30 syncs a minute per device. nextSyncAfter is 15 minutes
for an active device and an hour for a suspended one.
stateDigest is the canonical SHA-256 of what the device holds. When it
differs from the server's, the answer carries full state instead of a
delta.

Policy at sync#

The klock service applies your access rules and behaviour
profiles
itself. It asks the API for the
rules that apply to the device (ApiInternal.compiledRules: the owner's
account rules, the rules of every team they are an active member of, and the
device's own rules) and evaluates them with the same engine the API uses.
A denied device gets no grant. It still syncs, and gets its status,
revocations and commands, but the answer carries
grantError: "access_denied" instead of a grant, and its credentials
carry only remove updates. The sync log records denied, the event chain
gets device.access-denied (mirrored to the audit log as
klock.device.access-denied), and the API records an access.denied audit
row and finding (at most one per device an hour). Once the rule is removed
or expires, the next sync gets a grant again.
Fail closed. When klock cannot read the rules, the sync answers
grantError: "policy_unavailable" and no grant; the device keeps its current
grant until it expires. Enrolment and credential issue answer
503 policy_unavailable.
Enrolment and credential issue are checked too. POST /v2/enrol checks
the ticket's user's rules before anything is written; a deny is
403 access_denied, no device is made and
the ticket is not spent. /v2/credentials/* applies the same check to the
capabilities the credential stands for.
What the rules see: the client IP, Cloudflare's country and ASN, the
device id, the time, and the client selector (cli for a person's device,
agent for a service device). Rules with appliesTo do not decide a sync
or an enrolment, since neither uses a capability of its own; the API applies
them to each request the grant makes.
Report mode never refuses. A would-deny (including a new rule's first 24
hours) is reported as access.would-deny.
Behaviour signals only klock sees: 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.
Findings go to the API (ApiInternal.recordFindings) before the grant
is minted. The API re-derives each finding's severity and actions from the
profile, so in enforce mode a freeze or revoke means that same sync gets
grantError: "session_frozen" or "session_revoked".
Lag. The API caches rule reads for ACCESS_RULES_CACHE_MS (30 seconds by
default), and a device learns of a change at its next sync (up to 15
minutes, or at once after a nudge).
The answer's policy is null for a suspended device, when the device did not
ask for it, or when the rules could not be read. Otherwise:
"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 runs
evaluateLocal once per entry of policy.local.profiles against the
capabilities it holds. Its findings (hours.outside,
cap.outside-profile, …) go into the next sync's report.findings, and it may
log a step-up advice. It never blocks anything: the server decides.

Credentials at sync#

The answer's credentials list carries updates from every provider. The
device's report can never add anything; it only tells klock what the device
holds.
ProviderThe device reportsThe answer carries
dbproviders.db: its service bindingsinstall 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
tlsproviders.tls = { serial, notAfter, renew?: { csr, spki, csrAttestation } }install, renew (when a CSR was sent), remove for a revoked certificate, or error
kerberosproviders.kerberos = { principal, kvno, renew?: {} }renew (a rotated keytab) when asked past renewAfter, remove for a revoked principal#kvno
See credentials for the providers.
The push channel. A long-running agent holds GET /v2/channel, a
WebSocket opened with a device-signed upgrade. The service sends nudges only:
{type: "sync-now" | "revoked" | "transfer-offered"}. A nudge carries no
authority. revoked does not end the session by itself; it triggers a sync,
and the signed answer decides. A forged nudge costs one extra sync. Without a
WebSocket, the agent polls at nextSyncAfter.

The klock service and its trust boundary#

The klock service (prorm-klock, at klock.prod-orm.io) is a Cloudflare
Worker with its own D1 database (prorm-klock), a Durable Object per device
(DeviceChannel) and an R2 bucket for the file-transfer relay
(prorm-klock-relay, bound as RELAY), which only ever holds ciphertext. Its
code is api/klock/** and its configuration api/wrangler.klock.jsonc.
It is the authority for devices, the credential ledger, sync, pairing and
file transfer
. The API stays the authority for users, teams, capabilities,
sessions and grants, and holds the only key that can sign a grant, a device
certificate, an enrolment ticket, a command or the revocation feed. Klock
verifies those, and can mint none of them.
How the boundary holds:
No public route between them. The API reaches klock only through the
KlockInternal RPC entrypoint of a service binding, and klock reaches the
API only through ApiInternal. Neither has an HTTP route. Each call also
carries a JWS by the caller's service key (svc:api or svc:klock,
typ: klock-rpc+jws) over the method, a digest of the arguments, the time
and a single-use nonce. The callee checks all of these.
What klock checks before it trusts the API. It fetches the keyset over
RPC, verifies it against the pinned roots, refuses a lower seq, and keeps
it for an hour. It refuses to answer syncs unless the keyset's klock key
is the one it signs with.
Enrolment. A device enrols with an API-signed enrolment ticket (valid 10
minutes) and a proof of possession of its key. Klock checks the ticket
(signature, issuer, audience, validity, kind, thumbprint), the proof and the
seal binding, then redeems the ticket's nonce in the same D1 batch that
writes the device, so a ticket works once even under a race. The device
certificate comes from ApiInternal.certifyDevice and the first grant from
ApiInternal.mintGrant, and klock verifies both before it stores or passes
them on.
The event log is hash-chained and append-only.
What a compromised klock service could and could not do: it cannot mint
grants, device certificates or revocations; it cannot read sealed material;
and it cannot stop a revocation, because the API revokes sessions itself. It
can delay syncs and credential renewal.
The public routes:
RouteState
POST /v2/enrollive
POST /v2/synclive
GET /v2/channellive: 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/:idlive: pairing
GET /v2/credentials, POST /v2/credentials/:provider, POST /v2/credentials/:id/renew, DELETE /v2/credentials/:idlive: credentials (tls, kerberos)
/v2/transfers/…live: file transfer; 503 klock_unconfigured while the RELAY bucket is not bound
Any other path is 404, and a known path with another method is 405.
Maintenance. The Worker's cron (triggers.crons: ["17 * * * *"], hourly)
runs one maintenance pass:
it prunes klock_sync_log rows older than 30 days, spent RPC nonces
past the 5-minute replay window, and redeemed enrolment-ticket nonces a day
after the ticket expired;
it sweeps transfers: it expires those past their 24 hours, cancels those
whose sender or recipient device was revoked or wiped, deletes ended
transfers' R2 objects, and prunes spent transfer nonces.
Deletes run in batches (5,000 rows per statement, 20 statements per table per
pass), so what one pass leaves, the next takes. The two steps are
independent: one failing does not stop the other. The event chain, the device
registry and the credential ledger are never pruned. Each run logs a line
such as klock: pruned N sync log rows, ….
No probing for enrolled keys. A signed request from a key that is not
enrolled gets exactly the same answer as a bad signature from one that is:
401 bad_signature, the signature does not verify against an enrolled device key. For an unknown key the service still checks the signature, against a
stand-in key, so both cases take the same work. unknown_device appears only
in the service's own logs. Checks that need no key (clock_skew,
signature_expired, bad_digest, signature_malformed) keep their own codes.
Device management (/security/devices…) lives on the API, not on klock. For
running it, see the klock deploy runbook.

Pairing#

Pairing signs a new device in with the approval of one that is already
signed in, without a browser on the new device.
1.
The new device runs prorm login --pair (or prorm devices pair).
It makes its keys and calls POST /v2/pair/start, signed by the key being
paired and carrying its sealing key and binding. It gets a code like
KXQ7-M2PD (8 base32 characters), a prorm://pair?… QR payload, and a
console link <console>/#/pair?p=…&c=… for a phone camera. The pairing
lasts 10 minutes. Starts are limited to 5 an hour per address.
2.
A signed-in device approves the code: prorm devices approve <code> [--kind device|interactive] (the same command as devices pair <code>),
or the console's pair page. This calls
POST /security/devices/pairings/approve. Approving a device kind needs
a fresh step-up, checked before the code is spent, and the kind must be the
one the new device asked for (409 pairing_kind_mismatch). Approvals are
limited to 10 an hour per approving user.
3.
The new device collects its session by polling GET /v2/pair/:id,
signed by the same key: 202 pending until approved, then 200 with the
grant, session id, device id, device certificate and keyset (the grant is
minted then and never stored), or 200 expired.
The same pairing from both keyboards. The new device (a Linux server with no
browser) starts it and waits; your laptop approves it:
$ 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 fresh
step-up. To pair an ordinary interactive session instead, start with
prorm devices pair --kind interactive on the new device and approve with
prorm devices approve <code> --kind interactive.
A code works once (pairing_used); an
unknown code is pairing_not_found and
an expired one pairing_expired. A poll
signed by another key gets the same 404 as an unknown pairing, so only the
key that started a pairing learns anything about it. The service stores only a
hash of the code, and the new session obeys the same limits and eviction as a
sign-in.
[!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.

File transfer#

prorm klock cp sends files from one device to another, end to end
encrypted. The relay (the klock service and its R2 bucket) only ever holds
ciphertext, and a transfer lives 24 hours.
$ 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"
On the receiving device:
$ 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….
CommandDoes
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.
Before anything is written, klock accept checks:
1.
the sender's device certificate, from the pinned root to the device key;
2.
the sender's signature over the manifest (names, sizes and SHA-256s);
3.
every file's SHA-256, as it is decrypted.
Files are written only into a sandbox, ~/Downloads/prorm-klock/<id>/ by
default (or --into). Absolute paths, .., symlinks and device files are
refused. Nothing is overwritten without --force, and nothing is made
executable without --keep-mode.
The receipt is a JWS (typ: klock-transfer-receipt+jws) signed by the klock
service. It names the transfer's final status (completed, refused,
cancelled or expired), the bytes and chunks, and digests of the manifest
and of the ciphertext. klock receipt needs files:send or files:receive.

Who may send#

Capabilities come from the grant; plans and sessions are checked by the API
(filesPolicy) on every offer.
ToThe sender needsThe recipient needs
your own devicefiles:sendfiles:receive
a teammate's devicefiles:team, in a team you are both active in (roles owner, admin, operator); the team's plan paysfiles:receive
support:files:send and --reasonthe staff drop device's session holds admin:support or admin:dev
any device, as staffadmin:support or admin:dev (live only after step-up) and a reason; audited at level alertonly acceptance
The free and starter plans have no file transfer: files:* is removed from
their grants.
A suspended, revoked or wiped device can neither send nor receive
(device_suspended,
device_revoked). A transfer whose
sender is revoked before delivery is cancelled
(transfer_sender_revoked). A
revoked session is refused at offer and at accept, even with a grant that
has not expired yet.
A wrong or unknown recipient always gets
transfer_recipient_refused,
so device ids cannot be probed. Only the two ends see a transfer; everyone
else, and the recipient until the upload is finished, gets
transfer_not_found.

Limits#

LimitValueOver it
Per transferthe plan's perTransferBytes: 2 GB on usage, 10 GB on membership and pro; the relay's hard cap is 10 GB413 transfer_too_large
Per daythe plan's perDayBytes, over your offers in the last 24 hours: 10 GB on usage, 100 GB on membership and pro429 transfer_quota_exceeded, with Retry-After
Waiting for one device50 live transferstransfer_quota_exceeded
In flight from one device20transfer_quota_exceeded
Manifest10,000 files; the offer body at most 1 MiB; the same name rules as the clienttransfer_manifest_invalid
Lifetime24 hourstransfer_expired
Chunks are 4 MiB of plaintext, sealed with AES-256-GCM, and each sealed chunk
must be exactly its one allowed length.

What the relay can and cannot see#

Contents: no. The file key is sealed (klock-seal-v1, context
files/key/<id>) to the recipient's X25519 key. The sender checks that key
against the API root, the device certificate and the seal binding, so a
compromised klock service cannot substitute its own key.
The manifest: yes. File names, sizes and SHA-256s are visible to the
relay. Only the contents are end to end encrypted.
Tampering is caught. The relay records each chunk's SHA-256 on arrival
and re-checks it before serving; an object altered in the bucket gets
transfer_chunk_corrupt and
raises an alert event. A stored chunk cannot be replaced
(transfer_chunk_exists), each
unsafe request's nonce is single use
(nonce_reused), and a grant past its
checkInBy is refused
(check_in_required; run
prorm klock sync).
Every step is on the record. transfer.offered, .sent, .accepted,
.completed, .refused, .cancelled, .expired, .denied,
.chunk-refused, .tamper-refused and .chunk-corrupt are events on
klock's hash chain, mirrored to the API audit log.

The wire#

Every route is on klock.prod-orm.io, RFC 9421 device-signed, with
Authorization: Klock <grant>. The shared protocol is in
ts-prorm-orm/klock (checkManifest, encryptChunk, decryptChunk,
sealedChunkBytes, verifyTransferReceipt, MANIFEST_TYP,
TRANSFER_RECEIPT_TYP).
RouteWhoBody and answer
POST /v2/transferssender{ 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/:nsender{ ct }
POST /v2/transfers/:id/completesender{ manifestSig, wrappedKey, chunks } → { id, status: "ready" }
POST /v2/transfers/:id/completerecipient{ received: true } → { id, status: "completed", receipt }
GET /v2/transfers[?box=sent]either{ transfers: [...] }
GET /v2/transfers/:ideither endstatus, times, receipt (and the manifest, for the recipient)
POST /v2/transfers/:id/acceptrecipient→ { id, from: { deviceId, signPub, deviceCert, label, user }, manifest, manifestSig, wrappedKey, chunks, … }
GET /v2/transfers/:id/chunks/:nrecipient, after accept→ { ct }
DELETE /v2/transfers/:ideither end→ { id, status: "refused" | "cancelled", receipt }
[!NOTE] Until the relay is bound
Every /v2/transfers route answers 503 klock_unconfigured until the
operator binds the RELAY bucket, and the CLI says file transfer is coming
soon (nothing is sent or written). support: answers the same until
SUPPORT_DROP_DEVICE_ID is set. See the
klock deploy runbook.
Caveats: an interrupted upload is not resumed (send it again); the inbox is
per device, and support: goes to one configured staff device rather than a
pool; access rules and behaviour profiles are not evaluated on transfers yet.

Credentials: TLS and Kerberos#

The klock service keeps a credential ledger for each device and hands out
two kinds of credential through /v2/credentials, both issued so that no
private key or keytab is ever made or kept in the clear on a server.
RouteDoes
GET /v2/credentialsThis device's credentials: id, provider, status, serial or principal, validity, fingerprint
POST /v2/credentials/:providerIssue. tls takes { csr, spki, csrAttestation }; kerberos takes {} (any field is a 400). Answers 201 { credential, delivery }.
POST /v2/credentials/:id/renewRenew (TLS: a new CSR) or rotate (Kerberos: a new kvno)
DELETE /v2/credentials/:idRevoke, with an optional { reason }
The routes are device-signed and open to active devices only; a suspended one
gets device_suspended. Issue is checked
against your access rules (access_denied,
policy_unavailable). A plan without
the provider gets provider_unavailable,
a session without the capability
capability_required, and a missing
authority authority_unavailable.

TLS client certificates#

A short-lived TLS client certificate for each device, for a key the
device makes itself and never sends anywhere.
The key is ECDSA P-256, made on the device with WebCrypto, and kept in
the OS keychain (service prorm-klock, account tls:<deviceId>). A device
whose key store is file keeps it in the 0600 klock, under
providers.tls.key.
The request is a PKCS#10 CSR with the subject CN=device/<deviceId>,
exactly one SAN, URI:urn:prorm:device:<deviceId>, and an attestation: the
device's Ed25519 signature over
klock/csr/v1\n<b64url(sha256(SubjectPublicKeyInfo))>.
Lifetime. 12 hours for an interactive or device session, 24 hours for a
service session; the authority caps any leaf at 48 hours. Renewal falls due
at two thirds of the lifetime.
Who. The starter, usage, membership and pro plans (in a team context,
the team's plan), and a session holding klock:sync (a person's device) or
passwordless:use (a database host's service session).
Binding. The API records the certificate's fingerprint on the klock
session, and, for a host bound to a passwordless session with auth: tls,
on that session too. The API re-verifies the attestation against the
session's own device key, so a certificate for a key the device never
vouched for cannot be bound.
Revocation. A revoked certificate goes onto the API-signed revocation
feed as { kind: "cert", ref: <sha256 fingerprint> } and its binding is
dropped; the device learns at its next sync, as a remove. There is no
CRL
, by design: the controls are the short lifetime, the fingerprint
binding and the feed.
The commands. --file points at another klock, for example a database host's
./.klock:
CommandDoes
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.
A running klock agent renews at sync, sending a new CSR
in providers.tls.renew, so a host rarely needs the command. A database host
presents the certificate through StoreKey for --auth tls.
The certificate is signed by the Kerberos authority (api/kerberos) at
POST [/{team}]/v1/csr, with the bearer AUTHORITY_TOKEN or the optional
KLOCK_AUTHORITY_TOKEN, which is accepted on that route only. It refuses a
bad CSR with 422 (csr_invalid,
csr_key_type, csr_subject_mismatch) and returns no key. The older
POST /v1/certificates, which made the client key on the server, is
deprecated and kept only for v1 passwordless --auth tls.

Kerberos principals#

A person's device (an interactive or device session, not a service or
guest one) can hold one Kerberos principal, session/<uuid>@<REALM>, in its
team's realm (in a team context) or the platform realm, PROD-ORM.IO.
The KDC makes the key, and the API seals the exported keytab to the
device's X25519 key (klock-seal-v1, context
kerberos/keytab/<principal>, the full principal with its realm). The klock
service passes the sealed box on in the route answer or the sync response
and keeps no copy; its ledger holds only the principal and kvno. The keytab
is never in the clear outside the authority → API hop and the device.
The keytab is an MIT keytab (format 0x0502, as kadmin ktadd writes
it) with one principal at one kvno; anything else is
keytab_invalid.
At rest it stays sealed: in the keychain (service prorm-klock, account
keytab:<deviceId>, with providers.kerberos.keytabRef = "keychain:prorm-klock/keytab:<deviceId>"), or, for a keyStore: "file"
device, in providers.kerberos.keytab with keytabRef: "file".
Rotation. renewAfter is 30 days after issue. Rotating sets a new random
key on the same principal, so the kvno goes up by one. A reported
principal#kvno that has been revoked is answered with remove at sync.
Revocation deletes the principal from the KDC, so no ticket can be had
for any of its keys, and adds a feed entry. The name is never reused.
Who. kerberos:manage and klock:sync in the session's ceiling, a paid
plan, and, in a team context, a team role that may manage Kerberos (owner,
admin or security). The device's sealing key must carry a valid binding by
its device key (seal_binding_invalid
otherwise), so a compromised klock cannot redirect the keytab. A team with
no realm gets
409 kerberos_realm_missing;
create it with POST /teams/:teamId/kerberos.
CommandDoes
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 seal
key, and checks the keytab is the file's principal at the file's kvno before
using it. A keytab left over from before a rotation fails with
the delivered keytab: keytab: is kvno N, not M; run prorm klock sync.
[!NOTE]
A ticket from a device principal verifies at the authority as
session: <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 with authority_unavailable; use a real
KDC to try it.

Host setup: keygen, init and the agent#

A database host gets a service klock with no secret in it, in three commands.

Make the device key#

$ 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
holding only the pending device, or shows the existing key (--json gives
action: created | rotated | existing and the sealBinding). --rotate
replaces the key only before enrolment; an enrolled key is refused (exit
2), because it is rotated with the server's cross-signature
(prorm klock rotate). A v1 file is refused with a pointer to
prorm klock upgrade. The default file is ./.klock, or $OUTH_KLOCK.

Redeem the owner's enrolment ticket#

The database owner issues a ticket (POST …/passwordless/enrol, the console,
or the MCP tool 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]
calls POST /provision/databases/:id/passwordless/redeem with
{ticket, signPub, sealPub, sealBinding, hostname, platform}. No account
token is sent
: the ticket is the owner's approval. The ticket may also come
from $PRORM_ENROL_TICKET, and --database defaults to the one the ticket
names. It writes a v2 kind: "service" klock with the enrolled device, the
service session (ks_…) and its grant, and no secret: the session is
v2 from birth. Without a ticket it writes the pending key only; run it again
with --ticket later and the same key (or one from klock keygen) is reused.
An existing bound klock is refused without --force (exit 2), and a refused
ticket (ticket_invalid,
ticket_expired, ticket_used) leaves no bound file.

Keep it checked in#

$ prorm klock agent
The agent syncs, checks in and renews for as long as it
runs. Run it under your service manager.

The agent daemon#

prorm klock agent [file] [--once] [--no-channel] [--json] is the
long-running process that keeps a klock current. The file is the argument,
else $OUTH_KLOCK, else this profile's session klock, else ./.klock; it must
be a v2 file with an enrolled device, or the agent exits 3.
Schedule. It syncs on the server's nextSyncAfter, clamped to between
30 seconds and an hour (15 minutes by default), and always wakes before
checkInBy minus 10 minutes. A push-channel nudge asks for a sync at once;
the signed answer decides, and unknown messages are ignored. Timer ticks and
nudges share one queue, so they never overlap. A failed sync backs off 1, 2,
4 … minutes, up to an hour.
Check-in. When the grant's checkInBy is within 10 minutes, it calls
POST <api>/security/klock/sessions/:id/refresh, device-signed with the
grant. The new grant is kept only if it verifies against the pinned keys;
otherwise the file is restored and the failure logged. session_revoked,
device_revoked or session_unknown stops the agent.
Service klocks report providers.db (subject, service session,
protocol) at each sync.
Local policy. It evaluates policy.local from the sync answer (see
policy at sync). This is advisory: findings go with the
next sync, and a step-up advice is logged. Nothing is blocked.
Renewals. It applies TLS and Kerberos deliveries from the sync answer and
calls each provider's renew hooks.
Signed commands. logout, wipe and a revocation stop the agent (exit
3); a wipe removes the klock material. rotate-key rotates the key when this
is the CLI's own session klock, and otherwise logs "run prorm klock rotate". A command that does not verify is ignored and logged.
Logs and signals. One line per event on stderr
([klock agent] <iso time> …). SIGTERM or SIGINT stop it cleanly with exit
0: the tick in flight finishes and the channel closes.
--once runs one round (sync, check-in, renewals) and exits: 0 if all of
it succeeded, 1 if something failed, 3 if the session or device is gone.
With --json it prints the round's report. It suits cron, systemd timers
and CI. --no-channel syncs on schedule only.
A systemd unit for a database host:
It is safe to stop with SIGTERM at any time.

Providers#

A provider is one kind of credential the klock service manages for a device.
It is code-defined in both repositories (src/klock/providers,
api/klock/providers).
ProviderStateWhat it does
sessionbuiltGrant refresh, revocations and commands, through sync
dbbuiltService sessions: device-signed passwordless queries and transfer-agent reports, with install and remove updates at sync; the v1 HMAC stays accepted beside it
tlsbuiltShort-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
kerberosbuiltA principal per device, the keytab sealed to the device's X25519 key; klock never stores it; rotated by kvno
filesbuiltEnd-to-end encrypted file transfer; live once the operator binds the relay bucket
sshlaterShort-lived OpenSSH user certificates for bastions
webauthnlaterPasskeys for step-up
oidc, signing, secrets, attestlaterWorkload identity for CI, code-signing certificates, sealed secret delivery, and hardware-backed keys with attestation

Upgrading a v1 file#

prorm klock upgrade [file] [--drop-secret] [--no-bind] rewrites a v1
.klock as v2, in place, keeping every field:
1.
It makes a device key, in the keychain where there is one.
2.
It asks the API to bind the passwordless session to that key,
POST /provision/databases/:id/passwordless/:sid/bind, which the owner's
session must be allowed to call (access:write). --no-bind only
rewrites the file.
3.
It writes version: 2, kind: service, keeping secret, so outh,
older clients and unbound readers still work (the binding's protocol is
v1+v2).
4.
It stores the bind's answer: session holds the service session id
(ks_…), its grant, grantSeq and checkInBy, so the file can check in
(klock agent) and rotate. api is set when the file
has none, and the keyset is cached beside the file, so klock status
verifies the grant offline. --json gains sessionId and protocol
(v1+v2 or v2).
--drop-secret makes the session v2-only, server first: it binds with
protocol: "v2", so the server refuses the v1 secret from then on
(device_signature_required),
and only then removes secret from the file. On a file already upgraded it
binds again with the same key; the server answers 200 rebound and tightens
to v2. If the server cannot take the session as v2 (the API has no bind route,
or you are not signed in as the owner), it refuses (exit 2) and keeps the
secret
. It cannot be combined with --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.
A v2 service file that is bound but has no grant (upgraded by an older CLI)
gets its grant the same way when prorm klock upgrade runs again. Until the
file holds a grant, it signs as Klock device=<thumbprint>; see
service sessions. A local klock (db init) is already
version 2, kind: local, and needs no upgrade.
prorm audit klock finds v1 files that still hold a secret where v2 is
available (KLOCK-003); see the security toolkit.

What's next#

Built and documented above: the v2 file, device keys, the keyset, grants,
request signing, interactive, device, guest, delegated and service sessions,
step-up, sync with access rules and behaviour profiles, device management,
pairing, lockdown and recovery, v2 transfer reports, file transfer, TLS
certificates, Kerberos principals, host setup (klock keygen,
klock init --service) and the klock agent daemon, and the klock service
with its maintenance cron. Access rules and behaviour profiles are in
their own guide.
Known limits of this release:
A transfer interrupted mid-upload is not resumed; send it again.
support: transfers go to one configured staff device, not a pool, and
access rules are not yet evaluated on transfers.
The API does not map device Kerberos principals to klock sessions for
Negotiate authentication yet.
alert emails are not sent for findings klock raises at sync; they are
recorded and escalated.
The agent logs, rather than runs, a rotate-key command for a klock other
than the CLI's own session klock.
CA keys stay on the authority's volume; KMS or HSM custody is a later step.
Later:
OAuth 2.1 for MCP clients, hosted on the klock service, replacing
delegated tokens for third-party clients.
SSH certificates, passkey step-up, JIT elevation, and hardware-backed keys.

Related reading#

klock error reference: every klock error code and message, with fixes
Capabilities: tiers, capability ids, team roles, admin scopes and enforcement
Access rules and behaviour profiles
klock deploy runbook: the root key ceremony, keys, the klock service, rollout
StoreKey: a cloud .klock as the ORM's storage
CLI: API keys, roles and passwordless grants
Local databases: prorm db up, and db init's local klock
Migrate: the transfer klock
Credential rotation
The security toolkit: SEC-003, committed .klock files, and prorm audit klock with the KLOCK-00x checks
Modified at 2026-10-04 00:40:16
Previous
klock-errors
Next
Local databases (`prorm db up`)
Built with