Specification
Vault format, version 1
Everything the encrypted vault does, documented in detail: the keys, the algorithms, how the master key is wrapped, what the encrypted table of contents looks like, what the server can still see, and what the design does not protect against. It is the file the apps, the browser client and their tests are written against.
The specification below is in English in both languages of this site. It is a byte level contract between three implementations, and two wordings of the same rule is exactly the disagreement it exists to prevent. Nothing in it is secret; publishing it is the point, because a claim that your files are unreadable to us can only be checked against a format that is known.
Three implementations have to agree byte for byte: the iOS and macOS app, the browser client, and the tests on both sides. This is the contract. Anything not written here is not part of it.
Where a value is fixed rather than derived, it is written out in full below. Anything an implementation has to guess is a bug in this document.
Nothing in this document is secret. Publishing it is the point: a claim that files are unreadable to us is only checkable if the format is known.
Keys
- Master key: 32 random bytes, generated once on the device that enables the vault. It never leaves a device in the clear and never reaches the server.
- File key: 32 random bytes per file, generated at upload. Stored inside the manifest, which is itself encrypted with the master key.
Primitives
- Content and manifest: AES-256-GCM, 12-byte random nonce, 16-byte tag.
- Encoded as
nonce || ciphertext || tag, exactly as returned by CryptoKit'sAES.GCM.SealedBox.combinedand by WebCrypto'sencryptwith the nonce prepended. Base64 over the wire. - Recovery code to key: PBKDF2-HMAC-SHA256, 600 000 iterations, 16-byte random salt, 32-byte output. Chosen over Argon2 because both CryptoKit (through CommonCrypto) and WebCrypto have it without a dependency, and the recovery code carries 120 bits of entropy of its own.
- Passkey PRF output to key: HKDF-SHA256, salt empty, info the ASCII string
tapsign-vault-v1, 32-byte output.
Wrapping the master key
Each way into the vault stores the same master key sealed under a different key,
as one row in vault_key_wraps:
| method | how the wrapping key is derived | params |
|---|---|---|
recovery |
PBKDF2 over the recovery code | {"kdf":"pbkdf2-sha256","iterations":600000,"salt":"<base64>"} |
passkey |
HKDF over the WebAuthn PRF output | {"kdf":"hkdf-sha256","info":"tapsign-vault-v1"} |
device |
key held in the device keychain | {"kdf":"none"} |
wrapped_key is nonce || ciphertext || tag of the 32-byte master key.
Opening the vault with a passkey
The passkey never holds the master key. It holds a secret the WebAuthn PRF extension will evaluate at a caller-chosen point, and that point is fixed here so that the same passkey produces the same 32 bytes on every device and in every client, forever.
The evaluation input is SHA-256("tapsign-vault-v1"), 32 bytes:
8a4a410d3f84a4db36fa490c0e0851b5febd16f962d8beb86b9474577c824a1b
or ikpBDT+EpNs2+kkMDghRtf69Fvli2L64a5R0V3yCShs= in base64. It is passed as
first and second is never used. Hashing the label rather than padding it
was chosen for one reason: 32 bytes is what the extension wants, the label is 16
characters, and a hash is one line in every language while a padding rule is a
sentence somebody will read differently.
The ceremony asks for user verification and names the account's existing
credentials in allowCredentials. It is not an authentication: the caller
already has a session, the signature is discarded, and the challenge is random
bytes the client generated for itself. Verifying it would prove nothing that is
not already proven, and the PRF output cannot be forged by whoever chose the
challenge.
The PRF output goes through HKDF as described above to become the AES key that
unwraps the passkey row. If the authenticator returns no PRF result, there is
no key and the recovery code is the only way in.
Recovery code
24 characters from the alphabet 23456789ABCDEFGHJKLMNPQRSTUVWXYZ, shown in six
groups of four. No 0, O, 1 or I: it gets read out loud and written down.
That is 120 bits. Case-insensitive on entry; spaces and dashes ignored.
PBKDF2 takes a byte string, so what it is given has to be exact. Normalise by
upper-casing and dropping every character outside the alphabet, then feed the
remaining 24 characters as ASCII with no separators. abcd-efgh 2345... and
ABCDEFGH2345... are therefore the same code, and a code that does not
normalise to exactly 24 characters of the alphabet is rejected before any
derivation is attempted.
Manifest
One JSON document per account, encrypted with the master key. Structure:
{
"version": 1,
"nodes": [
{
"id": "uuid",
"parent": "uuid or null for the root",
"kind": "folder" | "file",
"name": "Contract 2026.pdf",
"createdAt": "2026-08-31T15:00:00.000Z",
"favorite": false,
"object": "uuid of the vault object, files only",
"key": "base64 of the 32-byte file key, files only",
"bytes": 128394,
"ext": "pdf"
}
]
}
Dates are ISO 8601 with milliseconds, which is what JavaScript's
toISOString writes. Readers should accept the form without them too, since
that is what a stricter encoder produces.
ext lives here rather than on the server, and is what the monthly usage
snapshot counts locally before sending totals.
bytes is the size of the plaintext, not of the stored blob. The two differ by
the 28 bytes of nonce and tag, and the number a person is shown has to be the
size of their document rather than the size of our envelope. The server's own
bytes column counts the blob, which is why the two totals do not match and
neither is wrong.
favorite keeps the American spelling because it is a wire field and renaming
it would break a released app. The interface says favourites.
A node with no parent is at the root. id is a UUID chosen by whichever
client created the node, so two clients that add a folder at the same moment
produce two folders rather than a collision. Names are not unique and are not
required to be: this is a table of contents, not a filesystem.
What may be put in it
Enforced by each client before it encrypts anything, because the server sees only bytes and cannot enforce it at all:
- At most 50 MB per file, matching
VaultController::MAX_OBJECT_BYTES. - One of these extensions:
pdf,doc,docx,xls,xlsx,ppt,pptx,txt,rtf,odt,ods,csv,xml,json,jpg,jpeg,png,heic,heif,gif,webp,tiff,zip.
This is a document vault, and the list is what a person signs, scans or files alongside something they sign. It is a client-side courtesy rather than a security boundary: anyone can rename a file, and the server could not tell.
What the server sees
Object count, the size of each encrypted blob, timestamps, and how many ways in an account has. That is the complete list. There is no column anywhere that could hold a file name, and a test enforces it.
The browser client
It reaches the same endpoints as the apps, through /account/vault/* rather
than /api/v1/vault/*. The two mount the same controller: the API group carries
a bearer token and no session, and a page in a browser has a session and no
token, so the paths differ and nothing else does.
The master key exists in the browser as one variable in one module, for as long
as the tab is open. It is not in localStorage, not in sessionStorage, not on
window, and not in any request body. Leaving the page loses it, which is the
intended behaviour and not an inconvenience to be designed around.
Filenames are decrypted for display only. Every request the page makes carries ciphertext, an object id or a manifest version, and never a name.
What this does not protect against
Stated plainly because a security claim without limits is marketing:
- Anyone who has both the recovery code and the account can read everything.
- A device that is unlocked and signed in can read everything, which is the point of it.
- Sizes and upload times are visible to us and to anyone who can see the database. Padding would hide sizes and is not implemented.
- On the web, the code that decrypts is served by us on each visit. A compromised server could serve code that steals the key. The apps do not have this weakness, because the code is signed and shipped by Apple.
This page is rendered from docs/vault-format.md in the TapSign repository, so it cannot drift from the version the apps are written against.