Sari la conținut
TapSign

Specificație

Formatul seifului, versiunea 1

Tot ce face seiful criptat, descris în detaliu: cheile, algoritmii, felul în care este sigilată cheia principală, cum arată cuprinsul criptat, ce vede totuși serverul și limitele de protecție ale arhitecturii. Este fișierul după care sunt scrise aplicațiile, clientul din browser și testele lor.

Specificația de mai jos este în engleză în ambele versiuni ale site-ului. Este un contract la nivel de octet între trei implementări, iar existența a două formulări ale aceleiași reguli ar crea tocmai neînțelegerea pe care documentul urmărește să o prevină. Conținutul nu este secret; tocmai publicarea lui este ideea, fiindcă afirmația că fișierele utilizatorilor nu pot fi citite de noi se verifică doar dacă formatul este cunoscut.

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's AES.GCM.SealedBox.combined and by WebCrypto's encrypt with 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.

Pagina aceasta este generată din docs/vault-format.md, din depozitul de cod TapSign, deci nu poate diverge de versiunea după care sunt scrise aplicațiile.

Cum protejează TapSign confidențialitatea documentelor