# Keys, proofs and signing

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:

- **Glyphs.** The first 80 bits (10 bytes) of the key's RFC 7638 thumbprint in Crockford base32 (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`: no I, L, O or U), as four groups of four, upper case: `5NZV DP67 PXT0 DB6E`. When a human types a group, `I` and `L` read as `1` and `O` as `0`; a `U` is refused.
- **Words.** Bytes 10 to 13 pick four words from a fixed list of 256 common nouns, and bytes 14 and 15 pick a colour name for each from 16.
- **The hidden group.** Which group the enrol page hides is the first byte of a SHA-256 over the enrolment code, modulo 4, so it moves when the link is re-issued. The page's data holds only the hidden index and a hash the browser compares with what is typed, to enable Approve. The server does its own comparison when the owner accepts or an insider admits: every attempt is counted in one conditional write before anything is compared, a wrong group is refused, and the fifth wrong group rejects the enrolment. Without a valid passkey, a right and a wrong group get the same answer. The page's hash is a 20-bit preimage, so whoever holds the page can recover the group offline; it is a forcing function for the honest owner to look at the terminal, and the server's five-attempt cap is what stops guessing through the page.

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:

| Claim | Meaning |
| --- | --- |
| `ten` | Workspace id (`tn_...`) |
| `dev` | Device id |
| `sid` | Session label (optional, audit only) |
| `gnt` | Grant the request acts under (optional, unused) |
| `htm` | HTTP method |
| `htu` | Full request URL without fragment |
| `bh` | base64url SHA-256 of the exact body bytes |
| `iat` | Issued at, seconds |
| `jti` | Unique 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:

| Action | Signs | Used for |
| --- | --- | --- |
| `intake.submit` | intake id, digest of slug, binding, public fields, sealed boxes, tags and home | Storing a new credential or rotating one |
| `connect.start` | connect id, provider, slug, home, tags, expected account, scopes, the client and reconnect subject | Starting an OAuth connection, before the provider is visited |
| `enrol.accept` | workspace, enrolment, the key's full RFC 7638 thumbprint, the accepting account and membership, the complete scope with `privateCredentials`, the start time, place and client | A member accepting their own machine |
| `enrol.approve` | enrolment, its digest, the owner, the admitting role | An owner or admin admitting a team's machine, or an insider admitting a guest's; never satisfies acceptance |
| `device.rescope` | device, thumbprint, the scope the page showed, the whole new scope | Narrowing a machine (its owner, an owner or an admin), or widening a team's (owner or admin) |
| `device.rescope_accept` | device, thumbprint, the owning account, before and after, whether admission is also needed | A member accepting a proposed widening of their own machine |
| `device.revoke` | device | Revoking a machine |
| `approval.decide` | approval id, the stored digest, approve or deny, the chosen uses and duration | Deciding an approval |
| `policy.decide` | proposal id, digest, approve or deny, the effect digest | Deciding a policy proposal |
| `item.update` | item, 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.rehome` | item, the home before and after, the dependency revision | Moving a credential between homes |
| `item.delete` | item | Deleting a credential |
| `override.set` | project, environment, variable, the source by stable id or null | Choosing or clearing a personal override |
| `project.create`, `project.share`, `project.configure`, `project.delete` | slug, remote and environments; project, subject, the share terms before and after; project and configuration digest; project | Projects and their shares |
| `member.invite`, `member.confirm`, `member.role`, `member.suspend`, `member.reinstate`, `member.remove`, `member.leave` | the complete invite terms; member, terms digest and identity-rule revision; member, role before and after; member | People |
| `team.update`, `team.members` | team and the change (create, rename, delete; add, remove) | Teams |
| `workspace.create` (account scope), `workspace.update`, `workspace.identity_rule`, `workspace.delete`, `workspace.delete_cancel`, `workspace.unfreeze` | slug and name; the whole rule or null; the name; nothing else | The 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 identity | The 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](https://docs.mehen.dev/concepts/audit.md#reading-it) 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](https://docs.mehen.dev/reference/http.md#the-release-channel)).

- **Signed manifests.** Every release manifest is signed with an ES256 release key, and the public half of each trusted release key is compiled into the `mehen` binary. An installed `mehen` installs only a manifest signed by a compiled key, and only bytes whose SHA-256, compressed and uncompressed, that manifest names. A release it cannot verify is refused, never installed; a build with no compiled release key updates nothing.
- **Anti-rollback.** An automatic update must be newer than the version running, at least the highest version this installation has seen, and above any hold a rollback set. `mehen update --force` and `--rollback` are explicit, local, and still signature-checked.
- **One rename.** The updater runs as the user, never with sudo, reads no workspace slot and no key, and replaces the binary with one journalled rename next to it, under a lock, so a kill at any step leaves the old or the new binary and a state file beside it that says which.
- **In the background.** After a command, `mehen` checks for a release about every six hours. `MEHEN_NO_AUTO_UPDATE=1`, or `autoUpdate: false` in `settings.json` under `MEHEN_HOME`, turns it off; CI and source runs never update themselves. A running `mehen mcp` keeps the version it started with and asks for a restart in a tool result once a newer signed binary is in place. Switching automatic updates off stops automatic installation, not this notice: a human may still run `mehen update`, and the agent is told.
- **The release key.** The release mechanism keeps the signing key in a hardware-backed key store on the release machine, and each compiled entry says whether every signature needs the operator's presence. That describes the mechanism, not proof that a production key is provisioned today: a build with no compiled release key updates nothing. A stolen key is retired by shipping a release without it and raising the vault's minimum version.

**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.
