# Private Capture format

This document describes the open format. If Permavault disappears, use it with your Recovery Kit, the locked files and their locations from receipt emails or an exported Vault Index to open your files using standard open-source tools.

Format version: **`age-v1`**. Every vault and every private capture records the version it was made with, so a future version can be added without breaking anything already stored.

## What the pieces are

| Name in the product | What it actually is |
| --- | --- |
| Recovery Phrase | 12 words from the BIP-39 English wordlist, encoding 128 bits of randomness |
| Private Vault | an X25519 key pair in the [age](https://age-encryption.org) format |
| recipient (the part Permavault stores) | the public half, an `age1...` string |
| identity (the part only you have) | the private half, an `AGE-SECRET-KEY-1...` string |
| a locked capture | a standard age file, encrypted to your recipient |

Permavault stores the recipient and wrapped key material, never a key that can open a capture on its own. The unwrapped identity exists in your browser, derived from the Recovery Phrase or unwrapped by one of your unlockers.

## Standalone browser opening tool

The app build produces `private-decryptor.html`, a single HTML file with all its code and its opening worker inline. Download it from `/private-decryptor.html` and save it with your Recovery Kit. Open the local file in a current browser, choose a local age file, and enter the 12-word Recovery Phrase from your kit, or an age identity instead: the key file a trusted person saved, or the identity line on page 3 of the kit. From release 1.0.4 that option is called "Trusted person key" and reads the key file directly, comment lines included; earlier releases take the identity line pasted on its own. It works with networking disabled and requires no account or Permavault server. Keep your receipt emails or Vault Index to locate the locked files; the kit alone is not an index.

From release 1.0.4 the words show as typed, with an option to hide them where the browser can mask the field. Each word is checked against the BIP-39 English list as it is typed, and a word that is not in the list is named by its place, with the likely word when only one fits. Every chosen file's age header is checked before the key is used. The words or key stay on the page until a file other than a manifest opens, and are then cleared; nothing is stored or logged.

The optional gateway button makes an explicit request to `https://arweave.net/raw/<transaction-id>?attempt=<fresh-value>` for each locked-file transaction entered; release 1.0.4 takes up to ten, so a locked file and its sealed manifest can be fetched together. The fresh value avoids a stale cached response when retrying a file ID. It sends neither the Recovery Phrase nor the readable bytes. The gateway sees your IP address and the transaction IDs. Given a private capture's public anchor record instead, such as a receipt's Proof record ID, release 1.0.4 lists the locked files that record names and fetches them only when the button is pressed again. Downloading separately from any gateway and choosing the local files avoids depending on that one gateway. No other network request is made by the tool.

From release 1.0.4, a file opened together with its sealed manifest is saved under the original name the manifest records, matched by SHA-256 so a manifest can only name the bytes it describes. A recorded name without an extension gains the one its recorded type implies, and every name is made safe to save on common systems. A file opened without its manifest gets a neutral name, `private-capture-` and the first eight characters of its SHA-256, with an extension only when its first bytes are unmistakable (PDF, PNG, JPEG, GIF or WebP). The sealed manifest itself is saved as `sealed-manifest.json`, and a private web page capture's locked index as `capture-index.json`; the index also names the capture's supporting files. Earlier releases save every opened file as `capture.original`; open the sealed manifest separately with the same tool to read the original name, type and recorded hash. This tool opens files; it does not verify a capture signature, timestamp, or the relationship between a plaintext and a public anchor. Preserve both manifests and the locked originals, and use the evidence/disclosure package verifier for proof checks.

Release 1.0.5 also strips control and direction characters from displayed names, saves a name only with a supported extension or an opaque `.bin` ending, uses friendly names for the capture index's own known roles and treats other roles as ordinary filename data, and computes word suggestions only for the three unknown words displayed. The original decrypted file and record bytes are unchanged; adjusted display/download names do not rewrite the sealed manifest.

Release 1.0.6 describes a saved name as adjusted only when saving needed a different name from the one recorded, and no longer stops working when far more text than a Recovery Phrase is pasted: more than 20,000 characters is not checked word by word.

Current tool release: **1.0.6**, available at [the permanent release address](https://arweave.net/BLGF1Sbsek8ZRPdVV3UXXh79va5QGQcyQKZwmOqpF24). Transaction ID: `BLGF1Sbsek8ZRPdVV3UXXh79va5QGQcyQKZwmOqpF24`. Verified gateway bytes on 2026-10-14: 188,636 bytes, SHA-256 `0853b6f8a8ac001be893629e9f95ae3857994f5c5bc098e9b2b0c8e39a401186`. New Recovery Kits print this transaction ID. Older kits remain valid: use the same Recovery Phrase with this newer tool, and keep a saved copy with your kit. Release 1.0.4 shows the words as typed, names a word that is not in the word list, checks each file before the key is used and keeps the words until a file opens, saves opened files under the original name in their sealed manifest, and reads a trusted person's key file. Maintainer: Alex Bendella; review at least annually and after crypto dependency changes. Next review due September 23, 2027. These maintenance dates do not guarantee future browser or cryptographic security.

Earlier releases remain permanent: [1.0.5](https://arweave.net/Kdod5Xghtcd5GEmnLIOYe8oZDTPb224l-cNWd_WZwks) (188,238 bytes, SHA-256 `f6de967c21e5d8f7faafce7335c3f000f4f91dede461224921b10d2df579b0b3`) hardened displayed names and saved-file endings. [1.0.4](https://arweave.net/Ys0rFrItqFZcfO8Q1kMyByvvq_oOs8AjfLBG3BXLxlI) (187,826 bytes, SHA-256 `14351bf67ff09788ea6b6381e65d1b340b75149cb04b1ad9190c335f3a500b4c`) added visible word checks, original-name recovery and trusted-person key files. [1.0.1](https://arweave.net/ImEgIu36ZJidTKOEL7HeGtBsV9DGT54BQJg6vG-NnGM) (165,666 bytes, SHA-256 `20e2cc500af5744f8ede0ba3b296f4331739e8452169fa849ff101772c115dc0`) opens saved locked files but its fetch-by-ID button is broken by gateway redirects. [1.0.2](https://arweave.net/D6LfKsYsJfKIubXSp4xF2_mCYfrOUqqsrORegL49bkc) (165,670 bytes, SHA-256 `14b86a549e14476d546674d0da922a6310fe100d7a67cd6a5da017febec42a11`) changed the fetch to the raw gateway endpoint, but an early request to its ordinary raw URL received corrupt cached bytes while the upload was indexing. [1.0.3](https://arweave.net/50WfYKx5nytRC9_KClu0dPsI6oPBDG6D8iaECEdtp18) (165,778 bytes, SHA-256 `36579f286f8780d9b4cf9d33a1bfeccf7a8b670a033fd9af17536311072bb650`) adds a fresh request value to avoid that cached response, and saves opened files as `capture.original`. Keep local copies and compare hashes if using a gateway for recovery.

The frontend build and CI test suite rebuild the single-file artifact and execute its real bundled page and worker with networking disabled, against captures sealed by the app's own seal worker and sealed-manifest code: both kit inputs, a trusted person key file, a misspelled word, a file that is not locked, a file from another vault, corrupted bytes, and saving under the recorded name. Browser compatibility is a separate release check, `node scripts/check-decryptor-browsers.mjs` in the frontend package, which drives the built file in Chromium, Firefox and WebKit with the network blocked; a Node harness is not evidence of Safari or Firefox support.

Release 1.0.1 keeps its opening worker available until it finishes, fixing a worker-loading timing issue in 1.0.0. Chromium 153, Firefox 151 and WebKit 26.5 recovered byte-identical fixtures with both kit inputs, rejected damaged files, and made no HTTP requests. Chromium and Firefox ran with offline mode enabled; WebKit used blocked network requests because its test runner's offline mode also breaks local file access. Release 1.0.4 was checked the same way in Chromium 149, Firefox 148 and WebKit 26.5: a mistyped word was named, a file that is not locked and a file from another vault were refused without losing the words, and the right file and its sealed manifest opened and downloaded under the recorded name with identical bytes, with the words and with a trusted person key file, making no HTTP requests. Release 1.0.5 passed those same three engines with HTTP blocked, including crafted filename/index records, safe downloaded names, byte-identical file and record downloads, and long word-list input. Release 1.0.6 passed the same checks in Chromium 149, Firefox 148 and WebKit 26.5, plus a paste of about 130,000 words and the adjusted-name wording. This is engine testing, not a test on an actual Safari device. Keep a local copy of the tool and your locked files so recovery does not require a gateway connection. Older release addresses remain unchanged; existing kits can use this newer tool without changing their Recovery Phrase.

## Step 1: Recovery Phrase to seed

The 12 words are a standard BIP-39 mnemonic. Convert them to a 64-byte seed exactly as BIP-39 specifies:

```
seed = PBKDF2-HMAC-SHA512(
  password   = NFKD(phrase),          # the 12 words, single spaces, lowercase
  salt       = NFKD("mnemonic"),      # no BIP-39 passphrase is used
  iterations = 2048,
  length     = 64 bytes
)
```

Any BIP-39 implementation with an empty passphrase produces this. We do not use a BIP-39 passphrase, and we never derive anything from user-chosen input.

## Step 2: seed to age identity

```
key = HKDF-SHA256(
  ikm    = seed,                                  # the 64 bytes from step 1
  salt   = utf8("permavault-private-capture-v1"),
  info   = utf8("age-x25519-identity"),
  length = 32 bytes
)
```

Those 32 bytes are the X25519 private key. Encode them as an age identity: Bech32 (BIP-173, **not** Bech32m) with the human-readable part `AGE-SECRET-KEY-`, then uppercase the whole string.

```
identity = uppercase(bech32_encode("AGE-SECRET-KEY-", key))
```

The matching recipient is the standard age public key: X25519 base point multiplication, Bech32-encoded with the human-readable part `age`.

```
recipient = bech32_encode("age", X25519_base_mult(key))
```

## Step 3: opening a file

A locked capture is an ordinary age file. With the identity in a file named `key.txt`:

```bash
age --decrypt --identity key.txt capture.age > capture.original
```

Nothing about this step is Permavault-specific. `age` is an independently maintained, widely reviewed open-source tool.

## Worked example

These vectors are pinned by the test suite (`packages/web/frontend/src/lib/vaultCrypto.test.ts`). Use them to check your own implementation.

```
phrase:     abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
identity:   AGE-SECRET-KEY-1PHH0CT85MVCW0HYERJNN477UXFNTHUGLC90PQUQWXLJSQCD7M9CQ59CFHV
recipient:  age1kqxvva3g29cw4j778kwhc7wvzfprhrr7zdnwsmrncku09a79zasq3dp0q6
vault code: 3B1B-E111-9F18
```

(That phrase is the well-known all-zeros BIP-39 test vector. Never use it for a real vault.)

## Vault code

Every page of a Recovery Kit, the vault section of Settings, and the confirmation before a private capture show a short **vault code**, such as `3B1B-E111-9F18`. It tells one vault, and so one kit, from another. It is computed from the vault's recipient alone:

```
vault_code = first 12 characters of uppercase(hex(SHA-256(recipient))), in groups of four joined by "-"
```

Hash the recipient's text exactly as printed, with no line break after it. On a Mac or Linux:

```bash
printf '%s' 'age1kqxvva3g29cw4j778kwhc7wvzfprhrr7zdnwsmrncku09a79zasq3dp0q6' | shasum -a 256
```

prints a hash beginning `3b1be1119f18`, so that vault's code is `3B1B-E111-9F18`. `echo` adds a line break and gives a different code (`08B8-AE6A-9CC6`), as does hashing a saved text file that ends with one.

The vault code cannot open anything, and it is not secret. The recipient it comes from is in every private capture's public anchor record (see "What is public, and what is not"), so anyone who has a vault code can find that vault's anchor records. That is why a kit's file name carries the date and time it was made, such as `Permavault-Recovery-Kit-2026-09-27-05-12-33.pdf`, and not its vault code. Older kits, made before the vault code was added, do not print one; the recipient on their page 3 gives it by the steps above.

## Unlockers

Deriving the identity from the Recovery Phrase is the permanent fallback and always works. For day-to-day use, the vault holds a *vault secret* (the same 32 bytes as the identity key) wrapped separately to each **unlocker**, so you can open your vault with a passkey instead of typing 12 words.

An unlocker row stores a `wrapped_secret`: the vault secret encrypted as an age file.

| Unlocker kind | How the secret is wrapped |
| --- | --- |
| `recovery_phrase` | not stored at all; the identity is re-derived from the words by steps 1 and 2 |
| `passkey` | age file encrypted under a passphrase derived from the WebAuthn PRF output for that credential (see below) |
| `trusted_person` | age file encrypted to *that person's own* recipient, so only they can open it |

Wrapping always produces a standard age file. Nothing here is a Permavault-specific container format, so a wrapped secret can be opened with the reference `age` tool like any other file.

### The passkey passphrase

A passkey unlocker is a standard age **scrypt** (passphrase) file, not a plugin stanza, so stock `age` can open it. Two values are stored in plaintext next to the blob, because neither is secret: the WebAuthn credential id, and a random 32-byte `prf_salt`.

The passphrase is the PRF output, encoded. Ask the authenticator to evaluate its PRF over:

```
prfInput = utf8("permavault.xyz/vault-unlock/v1") || prf_salt
```

taking the `first` output only (32 bytes). The passphrase is that output as unpadded base64url:

```
passphrase = base64url(prf_first).rstrip("=")     # "+" -> "-", "/" -> "_"
```

The file is then an ordinary `age --passphrase` file at scrypt work factor 12. The work factor is low on purpose: the passphrase is 256 bits of authenticator-supplied randomness, so there is nothing to guess and a higher factor would only cost a second of every unlock on a phone.

The prefix in `prfInput` is a fixed label, not a hostname, and it does not change if our domains do. It matters because if a passkey is ever used for anything else here, it must evaluate its PRF over a different input, so one purpose can never reproduce another's key.

A passkey is bound to the site that created it, `app.permavault.xyz`, and cannot be used anywhere else. That scope is deliberately the app's own host rather than the wider `permavault.xyz`: a credential offered to every sibling subdomain would also be offered to the origin that replays captured web pages and runs their scripts, and that origin exists to be isolated from your account.

**A passkey is not a vendor-independent unlocker.** If Permavault is gone, the Recovery Phrase is the way in. That is why the kit is mandatory and a passkey is not.

Permavault stores the wrapped blobs and never the unwrapped secret. Removing a `trusted_person` unlocker deletes their stored wrapped blob. It cannot revoke a secret or readable file they already copied.

## What is public, and what is not

On the Arweave network, forever and for anyone:

- the ciphertext of your capture, and its size
- the time it was uploaded
- the anchor record, and with it the blinded manifest hash, the vault recipient, and which ciphertext transactions belong together
- on every data item: that Permavault made it, that it is in the private lane, its size, and when it was uploaded. Tags carry `Content-Type: application/octet-stream` and nothing else identifying: no filename, title, source, original type, or role
- the wallet that paid, which is Permavault's own and is shared across all customers

For private file uploads, readable contents, filename, original source, plaintext hash and blinding nonce are not published and are not stored by Permavault in readable form. The file's declared original type and size are processed during upload for eligibility checks and stored inside encrypted metadata. The sealed manifest holds the file description and nonce; the unwrapped identity comes from your Recovery Kit or an unlocker, not from the manifest. Permavault holds wrapped key blobs but cannot open a capture on its own.

## The public anchor record

A locked capture's ciphertext proves nothing about readable contents on its own. Alongside it, the service attempts to upload a small unencrypted JSON record, the **anchor record**, which enables checks without revealing the contents. Publication and timestamps can be pending or fail; inspect their actual status rather than assuming that a stored file has complete proof.

```json
{
  "schemaVersion": "1.0",
  "lane": "private",
  "generator": { "name": "permavault", "version": "0.7.0" },
  "formatVersion": "age-v1",
  "blindedManifestHash": "<64 hex chars>",
  "vaultRecipient": "age1...",
  "ciphertextTxIds": ["<sorted transaction ids>"],
  "anchoredAtUtc": "2026-09-06T12:00:00Z",
  "signature": { "algorithm": "ed25519", "keyId": "...", "valueB64": "..." }
}
```

Canonical form is the same as the capture manifest: keys sorted recursively, two-space indent, trailing newline. The signature is a detached Ed25519 signature over those exact bytes **with the `signature` field removed**, using the same published Permavault signing key as the manifest.

### The sealed manifest, and the blinded hash

A private capture has **two** manifests, and the distinction is the whole of how it can be both private and provable.

**The capture manifest** (`manifest.json`) is written by Permavault, published unencrypted, and describes only what Permavault actually saw: the ciphertext it received, its SHA-256, its size, and when. It is an honest record of receipt and it deliberately says nothing about your file, because Permavault cannot read it. It stays at schema **1.0** and carries one extra field for this lane, `privateAnchor`.

**The sealed manifest** (`sealed-manifest.age`) is written in your browser, encrypted to your vault, and uploaded beside the ciphertext so it survives Permavault ceasing to exist. It is schema **`private-1.0`** and it is the document that names things:

```json
{
  "schemaVersion": "private-1.0",
  "algorithm": "sha256",
  "generator": { "name": "permavault", "version": "0.7.0" },
  "capture": {
    "captureId": "<uuid>",
    "capturedAtUtc": "2026-09-08T10:00:00Z",
    "clockSource": "capture device browser clock"
  },
  "privacyNonce": "<64 hex chars>",
  "files": [
    { "name": "<your file name>", "role": "primary", "sha256": "<64 hex chars>", "sizeBytes": 482910 }
  ],
  "mimeType": "application/pdf"
}
```

Canonical form is the same as the capture manifest: keys sorted recursively, two-space indent, one trailing newline, UTF-8.

The `privacyNonce` is 32 random bytes, hex, generated in your browser. What is anchored publicly is:

```
privateAnchor = SHA-256( nonce_bytes || canonical_sealed_manifest_bytes )
```

The nonce goes first, as raw bytes rather than its hex form. That is the whole mechanism: someone holding a candidate document cannot confirm it was captured without also holding 32 bytes they have no way to guess. The anchor commits to your file while saying nothing about it.

Blinding only works because the sealed manifest is **encrypted**. A nonce inside a document anyone can download blinds nothing, so the two halves are not separable: if a future version publishes this manifest, the blinding stops meaning anything the same day.

That `privateAnchor` value is what Permavault signs into the capture manifest and what both timestamp services stamp. So the public, signed, timestamped record commits to your plaintext, while every party who handled it, Permavault included, learned nothing about what it was.

### Proving what was captured

Disclose the sealed manifest. It carries its own nonce, so anyone can rebuild the preimage and check it themselves:

```
# 1. the readable file is the one named in the sealed record
shasum -a 256 disclosed/<your file>
grep -o '"sha256": "[a-f0-9]*"' sealed-manifest.json

# 2. the sealed record is the one that was published at capture time
#    anchor-preimage.bin is the 32 nonce bytes followed by the sealed
#    manifest's exact bytes
shasum -a 256 anchor-preimage.bin
# compare with "privateAnchor" in manifest.json, which Permavault signed

# 3. that value was fixed at a point in time
openssl ts -verify -digest <privateAnchor> -in manifest.tsr -token_in -CAfile tsa-chain.pem
ots verify -f anchor-preimage.bin manifest.ots
```

Note the input to `ots verify`: OpenTimestamps hashes a **file**, so it needs the preimage rather than the digest. A disclosure package ships `anchor-preimage.bin` for exactly that reason; a package that does not contain it cannot run that one check, and the shipped `verify.sh` says so rather than reporting a failure.

The packaged `verify.sh` and `verify.ps1` use `verify-disclosure.cjs` (Node.js 18 or later, built-in modules only) to check the actual disclosed file hash and size, canonical sealed record, nonce-prefixed preimage, signed `privateAnchor`, and detached signature. Original ciphertext artifacts remain intact. No Permavault account is required. This checks the committed plaintext description; it does not decrypt the ciphertext to establish that relationship independently.

Offline mode skips Arweave and OpenTimestamps network checks. Exit 1 indicates failed integrity; exit 2 indicates incomplete checks, including missing proof inputs or tools and pending timestamps. A clean result without timestamp proof reports local integrity only. TSA verification still requires independent trust in the certificate chain and an appropriate historical validation policy. A timestamp dates the committed value, not when a detached signature was created. The stored private format and canonical bytes are unchanged.

Until you disclose, the anchor does not reveal the readable file or its plaintext hash. It does reveal the public metadata listed above, including linked transactions and the vault recipient.

## Crypto-agility

Every vault and capture records `format_version`. `age-v1` means: BIP-39, HKDF-SHA256 as above, age v1 files, X25519 with ChaCha20-Poly1305. If a future version changes any of that, it gets a new version string and this document gains a section; anything already stored keeps working under the version it was written with, because the version travels with the data.

Because the ciphertext is permanent and public, anyone may keep a copy and attempt to break it later. We do not warrant any encryption against future cryptanalysis. See the terms of service.
