pod-signature
| Subpath | @noy-db/hub/pod |
| Factory | none — direct named imports |
| Cluster | F — Snapshot & Portability |
| Since | 0.6.0 |
The .noydb pod format wraps vault.dump() for safe cloud-storage drops: a 10-byte magic prefix, a JSON header, and a compressed body. writePod signs that header by default whenever the source vault has a persisted document signer, and verifyPodHeader(bytes, trustedKeys) authenticates it using nothing but WebCrypto — no store, no enclave, no vault, no decompression.
What gets signed
The header — formatVersion, handle, bodyBytes, bodySha256, plus whichever of publicEnvelope / autoUnlock / bundleKind / transferSeal / engineRange / unlockMethods / hasApp / species / pointerMode are present — is signed as canonical JSON over Ed25519. The Redirect record and manifest writes sign through the same convention, so a verifier learns one scheme rather than three.
Signing bumps the header to formatVersion: 2 and attaches a three-part tuple:
| Field | |
|---|---|
sigAlg | currently always 'ed25519' |
sig | base64url Ed25519 signature over the header, including keyId and sigAlg, excluding sig itself |
keyId | a 16-hex-character fingerprint of the signing public key |
sigAlg rides inside the signed bytes, not alongside them. Stripping or swapping the algorithm tag breaks verification rather than silently downgrading to an unauthenticated check.
Trust model
The signing key lives on the vault as a DocSigner in the encrypted _attestations/_signer record. That record holds the private key and is deliberately excluded from vault.dump() — it never rides in the pod body.
The header carries only keyId, never the public key. Three consequences:
keyIdis readable pre-unlock, straight off the plaintext header, so a caller pinning a known vault can recognise "this is signed by the key I expect" before decrypting anything.- The verifier must supply the public key out of band.
verifyPodHeadertakes akeyId → publicKeyB64map the caller already trusts — pinned from a prior interaction, distributed by the attestation issuer, or configured by an operator. - There is no in-pod channel for learning a new public key. A first-contact verifier cannot bootstrap trust from a pod alone. Publishing the public key in the pod body to enable that is a known follow-up, not built.
verifyPodHeader
import { verifyPodHeader } from '@noy-db/hub/pod'
const result = await verifyPodHeader(bytes, { [keyId]: publicKeyB64 })
It reads only the prefix and header region. Four outcomes:
status | Meaning |
|---|---|
verified | Signed, keyId is trusted, signature checks out. |
unsigned | No signature tuple — a legacy v1 pod, or written with { sign: false }. Not an error, and never conflated with verified. |
untrusted | Signed, but keyId is not in trustedKeys. The signature was not checked. |
tampered | Signed by a trusted key, but the signature fails — the header was altered, or the wrong public key is mapped to that keyId. |
Authenticity is not integrity — run both
This is the part that bites.
verifyPodHeader authenticates the header, and the header contains the claimed bodySha256. A verified result proves the signer vouched for that hash. It does not prove the body matches it.
To trust pod contents you must compose two checks and treat either failure as untrusted:
verifyPodHeader(bytes, trustedKeys)returnsverified— the header, includingbodySha256, is signed by a key you trust; andreadPod(bytes)succeeds — the body hashes to that signedbodySha256, or it throwsPodIntegrityError.
Running only step 1 is a footgun: a verified header paired with a swapped body is caught only by step 2. verifyPodHeader never folds in the body check by design — it has to stay body-free so a static page can authenticate a header without pulling megabytes. The composition is the caller's responsibility.
Legacy and scope
A header with no sig is always reported unsigned, never silently verified. The tuple is all-or-nothing: a partial tuple is a validation error, not a degraded-trust state. A v1 reader meeting a v2 signed pod fails with a clean format-version error rather than misreading the new fields.
Current limits, stated plainly:
- Partitions are unsigned. Signing does not extend to the partition-transfer format.
- Verification is additive.
readPoddoes not fold signature status into its return value; callers that want authenticity callverifyPodHeaderthemselves. - No revocation. Attestation has a revocation list; connecting it to pod verification is not wired up.
See also
- pod-redirect — the signed "this moved" pointer, using the same signing convention
- manifest — the pod-wide schema manifest
- attestation — where the document signer comes from