mehendocs

SecurityKeys, proofs and signing

Keys, proofs and signing#

How do device keys, proofs, sealing, encryption, the audit hash, step-up and release signing work?

On this page

The exact formulas behind the other pages. All cryptography is WebCrypto. Canonical JSON, wherever it appears below, means keys sorted, no whitespace, arrays in order.

Fingerprints#

A machine key's fingerprint is what a human compares between the terminal and the enrol page. The CLI, the server and the page derive it the same way:

A secret field's fingerprint (shown by mehen show) is different: the first 12 characters of the base64url HMAC-SHA256 of the item id and the value, under a per-workspace key derived from the root key (mehen/fingerprint/v1/<workspace id>). Someone holding only the stored data cannot test guesses against it.

Device proofs#

Every /v1 request from a machine carries a Mehen-Proof header: a compact JWS, ES256, typ: mehen-proof+jwt, kid the device id, over:

ClaimMeaning
tenWorkspace id (tn_...)
devDevice id
sidSession label (optional, audit only)
gntGrant the request acts under (optional, unused)
htmHTTP method
htuFull request URL without fragment
bhbase64url SHA-256 of the exact body bytes
iatIssued at, seconds
jtiUnique per request

The workspace verifies the signature, rebuilds the method, URL and body hash from the request it received, allows 60 seconds of clock skew and refuses a jti it has already seen. A captured proof cannot be replayed or reused for another request. While a key is enrolling it signs with ten set to enrol, and the account directory keeps its own nonce table for those proofs.

Sealing#

The browser encrypts secret fields to the vault's P-256 seal key before sending: ephemeral ECDH, HKDF-SHA256, AES-256-GCM, with a context string (intake/<intake>/<field>) bound into both the key derivation and the additional data, so a box sealed for one intake field cannot be replayed into another. TLS already protects the hop; sealing keeps plaintext out of request logs, tail sessions and error reports between the edge and the vault call that opens it. The public key is served unauthenticated at GET /v1/seal-key. The CLI never sends a secret, so it seals nothing.

Encryption at rest#

Each secret field is encrypted under its own data key, wrapped by a per-workspace key derived from the versioned root key with the workspace id in the derivation, with additional data naming the workspace, record type and id. A ciphertext swapped between rows or copied into another workspace fails to decrypt. The root key is held by the operator; possession of a root key and the stored ciphertext permits decryption. A new version can be added and every version still decrypts. This is not end-to-end encryption against the operator or the host: the running vault decrypts a secret to use it.

The approval digest#

An approval is bound to one exact request:

digest = base64url(SHA-256(canonicalJson({ item, method, url, contentType, contentEncoding, headersSha256, bodySha256, constraints })))

contentType and contentEncoding are the request's headers as sent (null when absent), because they change how the same body bytes are read. headersSha256 is base64url(SHA-256(canonicalJson([[name, value], ...]))) over every header the relay forwards from the agent, as sent: after the relay strips hop-by-hop, cookie, mehen-* and cf-* headers, names in lower case, sorted by name then value, leaving out the header the item's own injection overwrites. A protected run binds the empty list. constraints are what the request asked for: { uses: 1, ttlSeconds: 600 } for a relayed request, { uses: 1, ttlSeconds: 3600 } for a protected run. The human may choose fewer uses or a shorter window on the page; that choice travels in the signed decision, so changing it never changes the digest. For a protected run, item is the project slug, method is RUN, and bodySha256 is a digest of the project, environment, remote, worktree, HEAD, variable names and which get a real value; the run's approval also binds the SHA-256 of the whole command line.

Step-up#

A step-up is a fresh passkey assertion over one exact action:

challenge = base64url(SHA-256("mehen-stepup/v2\n" + nonce + "\n" + scope + "\n" + canonicalJson(action)))
scope     = the workspace id for a workspace action, "-" for an account action

The server rebuilds the action and the scope from what it is about to do, never from the action the page claims, so a step-up begun on one workspace's page can never be spent on another's. The nonce is single use and bound to the account and the scope. While an account is cooling off after recovery, or has a security change pending, only tightening actions (narrow, deny, revoke, freeze, cancel the recovery) are allowed. The actions:

ActionSignsUsed for
intake.submitintake id, digest of slug, binding, public fields, sealed boxes, tags and homeStoring a new credential or rotating one
connect.startconnect id, provider, slug, home, tags, expected account, scopes, the client and reconnect subjectStarting an OAuth connection, before the provider is visited
enrol.acceptworkspace, enrolment, the key's full RFC 7638 thumbprint, the accepting account and membership, the complete scope with privateCredentials, the start time, place and clientA member accepting their own machine
enrol.approveenrolment, its digest, the owner, the admitting roleAn owner or admin admitting a team's machine, or an insider admitting a guest's; never satisfies acceptance
device.rescopedevice, thumbprint, the scope the page showed, the whole new scopeNarrowing a machine (its owner, an owner or an admin), or widening a team's (owner or admin)
device.rescope_acceptdevice, thumbprint, the owning account, before and after, whether admission is also neededA member accepting a proposed widening of their own machine
device.revokedeviceRevoking a machine
approval.decideapproval id, the stored digest, approve or deny, the chosen uses and durationDeciding an approval
policy.decideproposal id, digest, approve or deny, the effect digestDeciding a policy proposal
item.updateitem, digest of the update body (binding, policy, ceiling, tags, description), or a project id and digest of { change: "var.set" | "environment.update" | "remote.bind" | "remote.unbind", ... } (an environment change signs protected, fences and fourEyes together)Changing where or how a credential may be used; setting a variable, protection, four-eyes, fences or a bound repository from the browser
item.rehomeitem, the home before and after, the dependency revisionMoving a credential between homes
item.deleteitemDeleting a credential
override.setproject, environment, variable, the source by stable id or nullChoosing or clearing a personal override
project.create, project.share, project.configure, project.deleteslug, remote and environments; project, subject, the share terms before and after; project and configuration digest; projectProjects and their shares
member.invite, member.confirm, member.role, member.suspend, member.reinstate, member.remove, member.leavethe complete invite terms; member, terms digest and identity-rule revision; member, role before and after; memberPeople
team.update, team.membersteam and the change (create, rename, delete; add, remove)Teams
workspace.create (account scope), workspace.update, workspace.identity_rule, workspace.delete, workspace.delete_cancel, workspace.unfreezeslug and name; the whole rule or null; the name; nothing elseThe workspace itself (owner only; creation needs the account's canCreateWorkspaces)
passkey.add, passkey.remove, recovery.generate, recovery.resolve, identity.link, identity.unlink, account.delete (account scope)the passkey; the notice and outcome; the provider and identityThe account

item.reveal is reserved and uncalled: there is no page that shows a stored value. Rule R10 depends on this table: agents may propose new items, but changing an existing item's binding, ceiling, policy or home needs a step-up that shows the change.

The audit hash#

hash = base64url(SHA-256(prev + "\n" + canonicalJson({ seq, at, actor, action, target, detail })))

prev is the previous event's hash (mehen/audit/v1 for the first), and detail is the canonical JSON string as stored. To verify a chain, walk forward from sequence 1 checking each prev equals the hash before it and each hash recomputes. Audit, review and freeze says what a reader sees, and why a head saved outside the vault matters.

Releases#

mehen updates itself from the release channel at https://mehen.dev/dl/ (HTTP surfaces).

What the release channel can and cannot do. Whoever controls the channel (its storage, the site that serves it, DNS, or a TLS interception) can serve any latest.json, binary or install script, and can withhold updates. It cannot make an installed mehen run its binary without a compiled key's signature. Withheld updates become visible when the vault's minimum version rises above the frozen version.

The first install trusts TLS. curl -fsSL https://mehen.dev/install | sh trusts TLS and the site that serves the script, and so does the one update from a 0.2.0 mehen to 0.3.0 (0.2.0 cannot check a signature, so that one update is hash-checked over TLS only). That is why the installer is only for a machine with no mehen: it refuses to replace a verified install unless forced, and no page recommends it as a way round a refused update.

The version gate. Every /v1 answer carries Mehen-Cli: minimum=<v>, and a mehen/<v> client below the minimum is refused with cli_outdated (HTTP 426), except freeze and self-revoke. It is a compatibility aid, not a boundary: a client that sends no mehen/ user agent passes, and it protects nothing the proof and the access check do not already protect.