Whitepaper · version 1.0 · September 2026

From a password to a permanent hash

The cryptography of crycrypt, specified as it is implemented: how keys are derived and wrapped, how files are encrypted, how the vault lock works, what the server can and cannot see, and how a file is kept for good on Arweave. Every construction names the module that implements it; every figure is computed from the parameters in the code.

1Goals and threat model

crycrypt stores arbitrary files for a user, and is built around five properties.

  • Confidentiality against the crycrypt server, the storage provider and anyone holding a copy of the database: contents, names, types and folder structure are encrypted before they leave the browser.
  • Integrity: every ciphertext is authenticated and bound to its context — item identifier, chunk index, purpose — so nothing can be swapped, reordered or truncated unnoticed.
  • No self-opening: the server holds key shares, never enough to derive a vault key.
  • Indistinguishable failure: an unlock attempt reveals whether it succeeded, never which factor was wrong.
  • Stated limits: availability, code delivery and freeze deadlines depend on the operator, and the document says so where it matters (§14).
AdversaryCanCannot
Database thiefread every table offlinederive a vault key: it needs the lock secrets (≥ 180 bits) behind Argon2id
Curious serverlog requests, sizes and timingsdecrypt names, contents or keys
Storage providerread, drop or alter blobsread them; any alteration fails the GCM tag
Network attackersee TLS metadataanything else (HTTPS assumed)
Active malicious serverserve modified JavaScript— this is the limit of cryptography in a web page (§14)
Your browsertrusted while it runs genuine codeVK, MK, FK: memory onlyArgon2id · AES-GCMencrypts before uploadsigns Arweave itemsdecrypts on opencrycrypt serverhonest but curiousscrypt(authHash)HMAC(lock verifier)key shares S_srv, S_vaultenvelopes, encMetaemail, TOTP secretGofileuntrusted storage<uuid>.bin — ciphertextArweave · Turbopublic and permanentciphertext, or a public file12345
  1. authHash, the lock verifier, envelopes, encrypted metadata and ciphertext streams — never a key, never plaintext.
  2. Key shares, released only once the required factors are proven.
  3. Ciphertext relayed to Gofile with the operator's token; Gofile never talks to the browser.
  4. Signed Arweave items, relayed to Turbo through a proxy and kept nowhere: ciphertext, or the public file in the clear.
  5. Permanent copies read back from public gateways, straight to the browser.
Figure 1 — Trust boundaries. Keys exist in one place only: the browser's memory while the vault is open. Everything else holds ciphertext, envelopes or verifiers.

2Notation and primitives

a∥ba \cat b is concatenation, ∅\varnothing the empty string, Enc⁡K(m; a)\Enc_K(m;\,a) AES-256-GCM of mm under KK with additional authenticated data aa and, unless stated otherwise, a fresh random 96-bit IV. SS is the server's HMAC secret (SERVER_SECRET). Every sub-key is produced by HKDF with a 32-byte output, which is a single HMAC block:

PRK=HMAC⁡SHA-256⁡(salt, IKM),HKDF⁡(IKM,salt,info)=HMAC⁡SHA-256⁡(PRK, info∥0x01)\begin{aligned} \mathrm{PRK} &= \HMAC_{\SHA{256}}(\text{salt},\ \text{IKM}), \\ \HKDF(\text{IKM}, \text{salt}, \text{info}) &= \HMAC_{\SHA{256}}\big(\mathrm{PRK},\ \text{info} \cat \texttt{0x01}\big) \end{aligned}
(1)
PrimitiveUsed forParametersImplementation
Argon2id (RFC 9106)password, lock and recovery materialm = 64 MiB, t = 3, p = 1hash-wasm
HKDF-SHA-256 (RFC 5869)every sub-key, one label each32-byte outputWebCrypto
AES-256-GCM (SP 800-38D)envelopes, metadata, file chunks96-bit IV, 128-bit tagWebCrypto
HMAC-SHA-256server-side verifiers and codeskey = Snode:crypto
scrypt (RFC 7914)stored password fingerprint, PINN = 2^14, r = 8, p = 1node:crypto
Ed25519 (RFC 8032)freeze signatures—@noble/curves
TOTP (RFC 6238)second factorHMAC-SHA-1, 30 s, 6 digitsnode:crypto
RSA-PSS 4096 (RFC 8017)Arweave data itemsSHA-256, 32-byte saltWebCrypto
SHA-384Arweave deep hash—WebCrypto

Random values come from crypto.getRandomValues in the browser and node:crypto on the server. The primitives live in lib/crypto/, and the same code runs in both places.

3Accounts, sessions and registration

3.1 The password never leaves the browser

Kpw=Argon2id⁡(password, spw),authHash=HKDF⁡(Kpw, ∅, vcrypt/v1/auth)K_{pw} = \Argon(\text{password},\ s_{pw}), \qquad \text{authHash} = \HKDF(K_{pw},\ \varnothing,\ \str{vcrypt/v1/auth})
(2)

The server stores scrypt⁡(authHash, s′, N=214, r=8, p=1)\scrypt(\text{authHash},\ s',\ N = 2^{14},\ r = 8,\ p = 1). A leaked table gives neither the password nor a value that can be replayed, and HKDF being one-way, authHash does not lead back to KpwK_{pw}. For an unknown address the pre-login step returns a deterministic decoy salt, HMAC⁡(S, fake-salt:∥email)\HMAC(S,\ \str{fake-salt:} \cat \text{email}) truncated to 16 bytes, so that the salt endpoint does not tell which addresses exist.

The interface accepts only a strong password, at sign-up as on a reset: 12 characters or more, upper- and lower-case letters, a digit and a symbol, and no common pattern — a well-known password at the start, a keyboard or number run, one character four times in a row. The rule lives in the browser alone: the server only ever receives authHash, so it has nothing to check it against. The password opens the session, never the vault (§3.3).

3.2 Second factors

code(T)=Trunc⁡(HMAC⁡SHA-1⁡(k, ⌊T/30⌋)) mod 106\text{code}(T) = \operatorname{Trunc}\big(\HMAC_{\SHA{1}}(k,\ \lfloor T/30 \rfloor)\big) \bmod 10^{6}
(3)

A TOTP code is accepted for the counters c0−1, c0, c0+1c_0 - 1,\ c_0,\ c_0 + 1, and only if c>clastc > c_{\text{last}}: a code already used cannot be replayed, even within its 30 seconds. A blind guess therefore succeeds with probability at most 3⋅10−63 \cdot 10^{-6}. Email codes are six CSPRNG digits, stored as

HMAC⁡(S, email-code| uid | purpose | code)\HMAC\big(S,\ \str{email-code|}\,uid\,\texttt{|}\,\text{purpose}\,\texttt{|}\,\text{code}\big)

valid ten minutes and for five attempts: a guess succeeds with probability at most 5⋅10−65 \cdot 10^{-6} per code.

3.3 Registration ends with the lock

Nothing is written to the accounts table until the lock of §5 exists. The first step only creates a pending sign-up — salt, scrypt of authHash, TOTP secret and the HMAC of an email code — bound to a random 256-bit token kept in an HttpOnly cookie restricted to /api/auth/register; the database keeps SHA-256⁡(token)\SHA{256}(\text{token}), not the token. The second step checks the authenticator code and the email code, and only then draws the salts and shares of the lock. The third step is the lock itself: the browser draws VKVK, the seed, the three keys and the recovery key, the user chooses whether an email code is asked at each opening, the browser seals VKVK under both paths, and the server writes the account, its vault, its lock, its recovery envelope and its free subscription in one SQLite transaction (lib/server/registration.ts). No phone number is asked, at sign-up or later.

A sign-up abandoned at any point expires on its own — after an hour, or two once the codes are proven — and leaves the address free. Three properties follow: there are no ghost accounts; nobody can reserve someone else's address under their own password, since that now requires reading its mailbox; and a vault never exists without its lock — VKVK is never wrapped under the password, which only ever opens the session. The server enforces the last point too: every vault route refuses a vault that has no lock.

CredentialsArgon2id · /registerPending sign-upcookie · no account rowCodes provenTOTP + email · /verifyAccount + vault + lockone transaction · /finishvault lock sealedabandonedPurgedthe address stays freeafter 1 h, or 2 h oncethe codes are proven
Figure 2 — Registration states. Until the lock is created, a sign-up is only a pending row keyed by the hash of a cookie token. Accounts written by an earlier flow before any proof are erased at start-up; they held no key and no file.

3.4 Sessions and devices

A session token is 256 random bits; the database stores its SHA-256. A session is either at the password stage (15 minutes, only the second factor may follow) or full (7 days, 30 with “keep me signed in”). Cookies are HttpOnly, SameSite=Lax and Secure in production, and every mutation checks the request origin. A trusted device carries a 30-day cookie

uid . t . HMAC⁡(S, device| uid | t)uid \,\texttt{.}\, t \,\texttt{.}\, \HMAC\big(S,\ \str{device|}\,uid\,\texttt{|}\,t\big)

and on any other device the email code is required on top of the authenticator code.

3.5 Repeated failures and a new password

Wrong passwords never block sign-in: the right password still passes, and the authenticator code stays the only judge after it. Failures more than a day apart do not add up, and attempts are rate-limited per address and per IP. The counter is keyed by the address typed, whether an account exists or not, so it says nothing about an address. The third failure — or “Forgot your password?” — emails the account a reset link carrying a random token t←{0,1}256t \leftarrow \{0,1\}^{256}, stored only as SHA-256⁡(t)\SHA{256}(t), valid 24 hours, single-use, at most one every two minutes. The mail leaves after the response, so its latency does not reveal the account either.

The token travels in the fragment, /reset-password#token=t: a browser never sends a fragment to a server — no request line, no access log, no Referer — and the page clears it from the address bar once read. Choosing the new password takes the link and an authenticator code, so a mailbox alone is not enough. The browser derives a fresh spws_{pw} and authHash as in eq. (2); the server replaces the stored fingerprint and closes every session. Nothing else moves: VKVK is never wrapped under the password. The one exception is an account created before the lock, whose VKVK is still wrapped under its password (eq. 5): it never receives a token, only a pointer to the recovery-key path, which re-wraps VKVK under the new password.

4Key hierarchy

At registration the browser draws VK←{0,1}256VK \leftarrow \{0,1\}^{256}. Everything else is derived from it or wrapped under it (lib/crypto/keys.ts):

MK=HKDF⁡(VK, ∅, vcrypt/v1/meta)sksign=HKDF⁡(VK, ∅, vcrypt/v1/sign),pksign=Ed25519.pub⁡(sksign)taglocal=HKDF⁡(VK, ∅, vcrypt/v1/local-tag)[0,16)Wi=Enc⁡VK(FKi; vcrypt/item/∥idi),FKi←{0,1}256 per file and per edit\begin{aligned} MK &= \HKDF(VK,\ \varnothing,\ \str{vcrypt/v1/meta}) \\ sk_{sign} &= \HKDF(VK,\ \varnothing,\ \str{vcrypt/v1/sign}), \qquad pk_{sign} = \operatorname{Ed25519.pub}(sk_{sign}) \\ tag_{local} &= \HKDF(VK,\ \varnothing,\ \str{vcrypt/v1/local-tag})_{[0,16)} \\ W_i &= \Enc_{VK}(FK_i;\ \str{vcrypt/item/} \cat id_i), \qquad FK_i \leftarrow \{0,1\}^{256}\ \text{per file and per edit} \end{aligned}
(4)

A vault created today is sealed by the lock of §5 from its first second. Accounts created before the lock became part of sign-up had VKVK wrapped under the password and a server share SsrvS_{srv}, released after the second factor:

Kunlock=HKDF⁡(Kpw, Ssrv, vcrypt/v1/unlock),Wpw=Enc⁡Kunlock(VK; vcrypt/vault-key)\begin{aligned} K_{unlock} &= \HKDF(K_{pw},\ S_{srv},\ \str{vcrypt/v1/unlock}), \\ W_{pw} &= \Enc_{K_{unlock}}(VK;\ \str{vcrypt/vault-key}) \end{aligned}
(5)

For those accounts, an optional 160-bit recovery key of the same generation could wrap VKVK as well (HKDF⁡\HKDF labels vcrypt/v1/recovery*). Such a vault cannot be used until its lock is set, and both envelopes are destroyed the moment it is: from then on the password opens the session, not the vault.

passwordmemorisedArgon2id(s_pw)Kpw​→ authHashHKDF · S_srvKunlock​opensWpw​older accounts onlyk1 ‖ k2 ‖ k3 ‖ answerpaper + memoryArgon2id(s_lock)Kmat​→ VHKDF · S_vaultKlock​opensWlock​stored by the serverR ‖ seed ‖ answerpaper + memoryArgon2id(s_rec)Krmat​→ V_recHKDF · S_recKrec​opensWrec​stored by the serverVK — vault key256 random bits · memory onlyMKHKDF 'meta'Ed25519 seedHKDF 'sign'local tagHKDF 'local-tag'FKi​ per fileEnc_VK(FK_i)
Figure 3 — Three paths to one vault key. Each path goes through Argon2id, then HKDF with a share held by the server, then an AES-GCM envelope stored by the server. The seed feeds the recovery path only. Dashed boxes are stored server-side; none of them opens without the user's secrets.

Domain separation. Every HKDF call has its own label and every envelope its own AAD (appendix B). A value made for one purpose is never accepted for another: the recovery envelope fails the GCM tag if presented to the lock path, and the metadata of item aa fails if moved onto item bb.

5The vault lock

5.1 Construction

Opening a locked vault takes three 12-character keys in order, the answer to a secret question and a code from the authenticator app — plus a code by email, asked by default: up to six things at once. The keys and the answer form the lock material (lib/crypto/vault-lock.ts); the 20-character seed is not part of it and serves the recovery path only (§5.7):

m=v3| k1 | k2 | k3 | ν(answer)m = \texttt{v3|}\,k_1\,\texttt{|}\,k_2\,\texttt{|}\,k_3\,\texttt{|}\,\nu(\text{answer})
(6)
Kmat=Argon2id⁡(m, slock),V=HKDF⁡(Kmat, ∅, vcrypt/v3/vault-verifier)K_{mat} = \Argon(m,\ s_{lock}), \qquad V = \HKDF(K_{mat},\ \varnothing,\ \str{vcrypt/v3/vault-verifier})
(7)
Klock=HKDF⁡(Kmat, Svault, vcrypt/v3/vault-lock),Wlock=Enc⁡Klock(VK; vcrypt/v3/vault-key)\begin{aligned} K_{lock} &= \HKDF(K_{mat},\ S_{vault},\ \str{vcrypt/v3/vault-lock}), \\ W_{lock} &= \Enc_{K_{lock}}(VK;\ \str{vcrypt/v3/vault-key}) \end{aligned}
(8)

The server stores slocks_{lock}, hV=HMAC⁡(S, vault-verifier|∥V)h_V = \HMAC(S,\ \str{vault-verifier|} \cat V), SvaultS_{vault}, WlockW_{lock}, the question label, the lock version and whether the email code is required. The normalisation ν\nu decomposes accents, lowercases and drops every character outside [a-z0-9][a\text{-}z0\text{-}9], so that “L'OM”, “l om” and “LOM” open the same vault; tokens are uppercased, I and L read as 1, O as 0, separators dropped. The separator | belongs to neither alphabet and the token lengths are fixed, so (k1,k2,k3,answer)↦m(k_1, k_2, k_3, \text{answer}) \mapsto m is injective: two different sets of factors never yield the same material. The codes are not in mm: they gate the release of SvaultS_{vault} (§5.4) and enter no derivation.

5.2 Entropy

The alphabet is 0123456789ABCDEFGHJKMNPQRSTVWXYZ (no I, L, O, U): 32 symbols, 5 bits each. A symbol is Σ[b mod 32]\Sigma[b \bmod 32] for a CSPRNG byte bb; since 256=8⋅32256 = 8 \cdot 32, every symbol has exactly eight preimages and the draw is exactly uniform — there is no modulo bias to correct.

H(ki)=12log⁡232=60,H(k1,k2,k3)=3⋅60=180 bits,H(seed)=20log⁡232=100\begin{aligned} H(k_i) &= 12 \log_2 32 = 60, \\ H(k_1, k_2, k_3) &= 3 \cdot 60 = 180 \text{ bits}, \\ H(\text{seed}) &= 20 \log_2 32 = 100 \end{aligned}
(9)

The answer adds its own, unknown entropy on top of the 180 bits. The order of the keys is enforced by the arithmetic rather than by a comparison: swapping two keys changes mm, hence KmatK_{mat} and KlockK_{lock}, and the GCM tag of WlockW_{lock} fails. Taking the seed out of the opening costs 100 bits that were never the weak point — 180 bits behind Argon2id stay out of reach (§5.3) — and leaves the seed to guard the recovery envelope, the one path that asks for no code.

5.3 One verifier, never one per factor

With a verifier per key, a database thief could attack each key separately and the costs would add up instead of multiplying:

3⋅260⏟one verifier per key≈261.6versus(260)3⏟a single verifier=2180\underbrace{3 \cdot 2^{60}}_{\text{one verifier per key}} \approx 2^{61.6} \qquad \text{versus} \qquad \underbrace{\left(2^{60}\right)^{3}}_{\text{a single verifier}} = 2^{180}
(10)

At a deliberately generous 10910^{9} Argon2id evaluations per second — 64 MiB each, a data-centre budget — 2602^{60} guesses take 260/109≈1.15×1092^{60}/10^{9} \approx 1.15 \times 10^{9} s, about 36 years, so a key checked on its own would eventually fall; 21802^{180} guesses take about 5×10375 \times 10^{37} years, before counting the answer. A single verifier is also what makes the generic error structural: the server sees one value and cannot know which factor made it wrong.

5.4 Unlock protocol

Browsercrycrypt serverGET /unlock/challenge1s_lock, Argon2id parameters, question, version, codes2POST /unlock/codes3email if on, 5 minm = v3|k1|k2|k3|norm(answer)K_mat = Argon2id(m, s_lock)V = HKDF(K_mat, ∅, 'vault-verifier')POST /unlock { V, TOTP, email? }4every required check, all evaluatedok = V ∧ TOTP ∧ [email]fail → one generic error + lockoutS_vault, W_lock (only if ok)5K_lock = HKDF(K_mat, S_vault, 'vault-lock')VK = Dec(K_lock, W_lock; 'v3/vault-key')GCM tag fails ⇒ same generic error
Figure 4 — Opening a locked vault. The secrets never cross the network; only V does. The server releases S_vault only when every required check passes, and the browser alone can turn it into a key.
ok=[HMAC⁡(S, vault-verifier|∥V)=hV]∧TOTP∧(¬rmail∨email)∧(¬rsms∨SMS)\begin{aligned} \text{ok} = {} &\big[\HMAC(S,\ \str{vault-verifier|} \cat V) = h_V\big] \wedge \text{TOTP} \\ &\wedge \big(\neg r_{mail} \vee \text{email}\big) \wedge \big(\neg r_{sms} \vee \text{SMS}\big) \end{aligned}
(11)

Here rmailr_{mail} and rsmsr_{sms} are the account's settings (§5.5): the email code is required by default; codes by SMS are not offered at the moment, so rsmsr_{sms} is always false; and a required code that is missing counts as wrong. The predicates are all evaluated before being combined, so the response time does not depend on which one failed; the message is the same in every case; and the codes are consumed only on complete success — invalidating them after a partial failure would reveal that they were right. Each attempt is logged server-side with the per-factor detail, for the account holder; no email is sent, whether the opening succeeds or fails.

5.5 Choosing the codes

The authenticator code is always required. The email code is on by default and can be switched off — a shared or lost mailbox (app/api/vault/factors). Codes by SMS are not offered at the moment, and no phone number is stored. No email announces a change: the only alerts are a recovery (§5.7) and a reset link after repeated failures (§3.5).

A change moves the gate in front of SvaultS_{vault}, so it takes more than a signed-in session. The session's “vault open” state proves nothing: it only records a wrapping key sent by the browser (§9). The server requires together a single-use nonce it issued less than five minutes earlier, a fresh authenticator code, and a signature by the key derived from VKVK (eq. 4):

σ=Ed25519.Sign⁡(sksign, vcrypt/factors/v1| uid | nonce | action | detail)\sigma = \operatorname{Ed25519.Sign}\big(sk_{sign},\ \str{vcrypt/factors/v1|}\,uid\,\texttt{|}\,\text{nonce}\,\texttt{|}\,\text{action}\,\texttt{|}\,\text{detail}\big)

checked against pksignpk_{sign}. Only a page where the vault is really open holds sksignsk_{sign}; the nonce makes a signature useless a second time, and the action and its detail — on, off, the number as typed — make it useless for any other change. Switching a code off never touches the lock material: it removes a gate in front of SvaultS_{vault}, not a single bit from mm.

5.6 Rate limiting

d(n)={0n≤3min⁡ ⁣(30⋅2 n−4, 86 400) sn>3d(n) = \begin{cases} 0 & n \le 3 \\ \min\!\left(30 \cdot 2^{\,n-4},\ 86\,400\right) \text{ s} & n > 3 \end{cases}
(12)

After the nn-th consecutive failure the next attempt waits d(n)d(n): three free attempts, then 30 s, 1 min, 2 min… up to 24 h. The counter is persisted (lockouts table, it survives a restart), a success clears it, and a counter left idle for 24 hours is forgotten. Rate limiting is defence in depth here: against 180 bits behind Argon2id, it is not what stops an attacker.

5.7 Recovery path

mrec=v3r| R | seed | ν(answer),Krmat=Argon2id⁡(mrec, srec)m_{rec} = \texttt{v3r|}\,R\,\texttt{|}\,\text{seed}\,\texttt{|}\,\nu(\text{answer}), \qquad K_{rmat} = \Argon(m_{rec},\ s_{rec})
(13)
Krec=HKDF⁡(Krmat, Srec, vcrypt/v3/recovery-lock),Wrec=Enc⁡Krec(VK; vcrypt/v3/recovery-key)\begin{aligned} K_{rec} &= \HKDF(K_{rmat},\ S_{rec},\ \str{vcrypt/v3/recovery-lock}), \\ W_{rec} &= \Enc_{K_{rec}}(VK;\ \str{vcrypt/v3/recovery-key}) \end{aligned}
(14)

The recovery key RR has 30 symbols, 150 bits; with the seed the path weighs 150+100=250150 + 100 = 250 bits before the answer. It asks for no key and no code — it exists for when a key, the phone, the mailbox or the authenticator app is lost. The answer is asked on both paths, so that the written secrets alone — keys, seed and recovery key found together — open nothing; the price is that a forgotten answer loses the vault (§14).

A recovery replaces one element only, chosen by the user: the three keys (a new lock envelope, eq. 6–8, under the same answer), the email address (proven by a code sent to the new address), the authenticator app (a new TOTP secret, proven by a code). The seed and the answer never change, nor do the question and the email-code setting. Only RR is renewed, every time, because it works once:

mrec′=v3r| R′ | seed | ν(answer),srec′, Srec′ freshm'_{rec} = \texttt{v3r|}\,R'\,\texttt{|}\,\text{seed}\,\texttt{|}\,\nu(\text{answer}), \qquad s'_{rec},\ S'_{rec}\ \text{fresh}

and the old key stops working the moment the new envelope is written. Everything is checked before the first write and written in one transaction; the other sessions are revoked, and an alert goes to the account's address as it was before the recovery — the old mailbox, when it is the address that changed.

5.8 Locks sealed before version 3

Earlier vaults put the seed in the opening material and the keys in the recovery material, under vcrypt/v2/* labels:

m(2)=v2| seed | k1 | k2 | k3 | ν(answer)m^{(2)} = \texttt{v2|}\,\text{seed}\,\texttt{|}\,k_1\,\texttt{|}\,k_2\,\texttt{|}\,k_3\,\texttt{|}\,\nu(\text{answer})
mrec(2)=v2r| R | seed | k1 | k2 | k3m^{(2)}_{rec} = \texttt{v2r|}\,R\,\texttt{|}\,\text{seed}\,\texttt{|}\,k_1\,\texttt{|}\,k_2\,\texttt{|}\,k_3

The server records a version with each lock and each recovery envelope, and the challenges tell the browser which rule to apply; prefixes and labels differ, so material made under one version never opens the other. Such a vault keeps its rule — 280 bits to open, 430 to recover — until its first recovery, which seals it again under version 3. The answer, absent from a version-2 recovery, is asked as well and checked in the browser, by opening the version-2 lock envelope with the seed and keys just proven — the recovery session receives that envelope and its share, holding VKVK already. The lock is then sealed under version 3 with the same keys, unless they are the element being replaced. One side effect: for an unknown address the recovery challenge answers with the current version, so while version-2 envelopes exist, a “2” tells that an account exists.

6File encryption: VCR1 and .crycrypt

6.1 The VCR1 format

Each file, and each edit of a file, gets a fresh FK←{0,1}256FK \leftarrow \{0,1\}^{256}. A plaintext of length LL is cut into n=max⁡(1,⌈L/220⌉)n = \max(1, \lceil L / 2^{20} \rceil) chunks of 1 MiB and streamed (lib/crypto/file-format.ts):

VCR14v1chunk4size8nonce base8AES-GCM(chunk 0)≤ 1 MiBtag16…AES-GCM(last)≤ 1 MiBtag16header, 25 bytes — part of every chunk's AADchunk i: IV = base ‖ u32(i)AAD = header ‖ u32(i) ‖ lastbytes of the file stored on Gofile (or Arweave, in encrypted mode)
Figure 5 — Layout of a VCR1 file. The 25-byte header carries the magic, the version, the chunk size, the plaintext size and an 8-byte random nonce base. Integers are big-endian.
IVi=b∥u32⁡(i),AADi=header∥u32⁡(i)∥[ i=n−1 ],Ci=Enc⁡FK(Pi; AADi)\text{IV}_i = b \cat \operatorname{u32}(i), \qquad \text{AAD}_i = \text{header} \cat \operatorname{u32}(i) \cat [\,i = n - 1\,], \qquad C_i = \Enc_{FK}(P_i;\ \text{AAD}_i)
(15)
∣C∣=25+L+16 n|C| = 25 + L + 16\,n
(16)
  • Reordering: a chunk decrypted at another position fails, its index being in both the IV and the AAD.
  • Truncation: the header fixes LL, hence nn; the last chunk carries the final flag, so a missing chunk, or an earlier chunk passed off as the last, fails.
  • Extension: any byte after chunk n−1n - 1 is rejected.
  • Header substitution: the header is in every AAD.

Because FKFK is fresh for every file version and the IVs differ for i<232i < 2^{32}, no (key, IV) pair is ever used twice; a file can reach 232×1 MiB=42^{32} \times 1\ \text{MiB} = 4 PiB. Small envelopes — wrapped keys, metadata — use random 96-bit IVs under long-lived keys (VKVK, MKMK). After qq envelopes under one key, the probability of an IV collision is bounded by the birthday bound:

Pr⁡[IV collision]≤q2297⇒q=220 envelopes: ≤2−57\Pr[\text{IV collision}] \le \frac{q^2}{2^{97}} \qquad \Rightarrow \qquad q = 2^{20} \text{ envelopes: } \le 2^{-57}
(17)

6.2 Exporting a file: the .crycrypt container

Any file can leave the vault still encrypted, as a .crycrypt file on the user's disk (lib/crypto/vcrypt-file.ts). The browser decrypts it in memory, as for a download, and seals it again at once under a fresh key; that key and the metadata are sealed under a key derived from VKVK and used for nothing else:

EK=HKDF⁡(VK, ∅, vcrypt/v1/export),FK′←{0,1}256,e←{0,1}128EK = \HKDF(VK,\ \varnothing,\ \str{vcrypt/v1/export}), \qquad FK' \leftarrow \{0,1\}^{256}, \qquad e \leftarrow \{0,1\}^{128}
Kx=Enc⁡EK(FK′; vcrypt/export/key|∥e),Mx=Enc⁡EK(JSON⁡(name,type,size,dates); vcrypt/export/meta|∥e)\begin{aligned} K_x &= \Enc_{EK}\big(FK';\ \str{vcrypt/export/key|} \cat e\big), \\ M_x &= \Enc_{EK}\big(\operatorname{JSON}(\text{name}, \text{type}, \text{size}, \text{dates});\ \str{vcrypt/export/meta|} \cat e\big) \end{aligned}
VCRYPT6v101|h|4h = JSON(e, Kx, Mx)|h| bytesVCR1 under FK′ (Figure 5)25 + L + 16nKx = Enc_EK(FK′; key|e)Mx = Enc_EK(name, type, dates; meta|e)fresh FK′ at every exportevery chunk authenticatedEK = HKDF(VK, ∅, 'vcrypt/v1/export') — no vault identifier in the clearan exported file, as saved on the user's disk
Figure 6 — Layout of a .crycrypt file. A 12-byte prefix, a JSON header carrying the export identifier e and the two envelopes, then the content as VCR1 under FK′. Nothing in the clear names the vault, the file or its type.
∣X∣=12+∣h∣+25+L+16 n|X| = 12 + |h| + 25 + L + 16\,n
  • Only its vault opens it. Without VKVK, neither FK′FK' nor the name can be recovered. A file exported by another vault fails the GCM tag of KxK_x and is refused — never stored as it is.
  • Unlinkable. No vault identifier is written in the clear; ee is random and FK′FK' fresh, so two exports of the same file share nothing but the magic and the structure.
  • Integrity. KxK_x and MxM_x are bound to ee by their AAD, every VCR1 chunk is authenticated (§6.1), and the body must measure exactly 25+L+16n25 + L + 16n for the LL sealed in MxM_x.
  • Back as it was. Dropped into the vault, a .crycrypt is decrypted in full — every tag checked — then uploaded like any file, under a new FKFK and new metadata: name, type and dates come back; the item is new. “Decrypt a .crycrypt file” performs the same reading and hands the original file to the browser, without storing anything.

Export and decryption are file exits: the confirmation code of §10 is asked. What a disk holding a .crycrypt reveals: its size, within a few hundred bytes of the original, and the name it was saved under — the original base name, with .crycrypt in place of the real extension. An export stays readable for as long as its vault exists, even after the file is deleted from it.

7Metadata, names and the local cache

encMetai=Enc⁡MK(JSON⁡(name,type,size,parent,dates,…); vcrypt/item/∥idi)\text{encMeta}_i = \Enc_{MK}\big(\operatorname{JSON}(\text{name}, \text{type}, \text{size}, \text{parent}, \text{dates}, \ldots);\ \str{vcrypt/item/} \cat id_i\big)
(18)

The server sees no name, no type and no folder structure. The item identifier being the AAD, it cannot move one item's metadata onto another. In the explorer every file shows as name.vcrypt — a file type already says something to whoever looks over a shoulder — and its real name comes back for five minutes after a decryption only. The IndexedDB cache holds encMeta, wrapped keys, sizes and versions under taglocaltag_{local}: no email, no user identifier, no name in the clear, and it is wiped at sign-out.

Moving items — one, or a whole selection dragged onto a folder — rewrites their encMeta with the new parent, item by item; for the server a move looks exactly like a rename. Since the server ignores the tree, the browser is the one that refuses to put a folder inside itself or inside one of its descendants.

8Time freeze

Kfrz=HKDF⁡(VK, P, vcrypt/v1/freeze),W′=Enc⁡Kfrz(FK; vcrypt/item/∥id),P←{0,1}256\begin{aligned} K_{frz} &= \HKDF(VK,\ P,\ \str{vcrypt/v1/freeze}), \\ W' &= \Enc_{K_{frz}}(FK;\ \str{vcrypt/item/} \cat id), \\ P &\leftarrow \{0,1\}^{256} \end{aligned}
(19)
σ=Ed25519.Sign⁡(sksign, vcrypt/freeze/v1| id | t | SHA-256⁡(W′))\sigma = \operatorname{Ed25519.Sign}\big(sk_{sign},\ \str{vcrypt/freeze/v1|}\,id\,\texttt{|}\,t\,\texttt{|}\,\SHA{256}(W')\big)
(20)
server refuses P, the content and deletion — even with all factorstimet₀ — freezet — deadlinebrowser, at t₀:P ← 256 random bits · K_frz = HKDF(VK, P, 'freeze')W′ = Enc(K_frz, FK) · σ = Ed25519(sk, id | t | SHA-256(W′))P goes to the server and is forgotten locallyafter t: server releases Pbrowser checks σ, then re-wraps FK under VK
Figure 7 — A file frozen until t. The browser keeps nothing that can open the file before t. The server keeps P but cannot read the file, and cannot move the deadline without breaking σ.

The server verifies σ\sigma against pksignpk_{sign}, registered at sign-up, keeps PP, and refuses PP, the content and deletion until tt — even to a fully authenticated session, even through recovery. After tt it returns PP; the browser recomputes the digest, checks σ\sigma, and wraps FKFK back under VKVK. PP is erased only after that, so an interruption never loses data. The freeze relies on the operator for timing: a compromised server could release PP early — never read the file.

9The open window

After an unlock the browser draws a random KwrapK_{wrap}, keeps Enc⁡Kwrap(VK)\Enc_{K_{wrap}}(VK) in sessionStorage and hands KwrapK_{wrap} to the server, which keeps it in the session for twenty minutes — an hour with Premium. That is the vault's open window, shown as a countdown: a reload within it fetches KwrapK_{wrap} back; at its end the interface closes the vault and the server stops returning KwrapK_{wrap}, so the lock is asked again; signing out destroys it sooner. Neither half opens anything alone. What is conceded: during that window a script injected into the page could join the two halves — the same script could already read VKVK from memory while the vault is open.

Reopening with one character of each key. After a complete opening the browser also keeps the three keys, split the same way:

Krem←{0,1}256,B=Enc⁡Krem((k1,k2,k3); vcrypt/v1/remembered-keys)K_{rem} \leftarrow \{0,1\}^{256}, \qquad B = \Enc_{K_{rem}}\big((k_1, k_2, k_3);\ \str{vcrypt/v1/remembered-keys}\big)

BB stays in localStorage, KremK_{rem} in the server session until that session ends. When the open window runs out on its own, both stay, and the next opening asks for one character of each key, at positions drawn afresh for every attempt, with the answer and every code as usual. The browser joins BB and KremK_{rem}, compares the characters itself and never says which one is wrong:

pi←{1,…,12},k1′={k1if ci=ki[pi] for i=1,2,3σ(k1)otherwisep_i \leftarrow \{1, \dots, 12\}, \qquad k'_1 = \begin{cases} k_1 & \text{if } c_i = k_i[p_i] \text{ for } i = 1, 2, 3 \\ \sigma(k_1) & \text{otherwise} \end{cases}

where σ\sigma moves the first symbol of k1k_1 one place along Σ\Sigma. The material of (6) is then built from (k1′,k2,k3)(k'_1, k_2, k_3): a wrong character does not stop the attempt in the browser, it makes it fail at the single verifier of §5.3 — same message, counted by the lockout of §5.6. Locking the vault by hand, signing out, signing in again (a new session holds no KremK_{rem}) or choosing “Forget my keys” erases both halves, and the three keys are asked in full again. A version-2 lock, which still asks for the seed to open, keeps the seed with the keys.

10The confirmation code

Four digits, stored as scrypt, asked by the interface before any file leaves the vault — open, download, edit, export as .crycrypt, decrypt a .crycrypt, make permanent. It enters no derivation and protects no key: it guards against a session left open on a desk. Wrong codes follow the schedule d(n)d(n) of §5.6. It is checked by the interface, not by the endpoints that serve ciphertext: a speed bump for an unattended session, not an access control.

11Storage, deletion and payment

Gofile receives files named <uuid>.bin in one private folder per vault, named v-<random>, through upload.gofile.io (nearest region) with the operator's token, which never reaches a browser. It sees sizes and times; never a name, a type, a structure or a key.

What the database links. The server keeps, in the clear, the Gofile folder of each vault and the Gofile identifier of each blob: it needs them to relay downloads and deletions and to count quotas. Whoever holds the database can therefore list the blobs of an account, or find the account behind a blob — without being able to read any of them.

Deleting. Deleting a file, or a folder with everything inside it, deletes the blobs from Gofile too. The browser sends every identifier — the server ignores the tree — and the server, in one transaction, removes the items and writes their blob references to a deletion queue (storage_deletions); it then deletes them from Gofile. A reference Gofile does not confirm stays in the queue and is retried every hour by the server's maintenance task (lib/server/maintenance.ts); a blob already gone counts as deleted. Editing a file queues its previous blob the same way. A deleted file cannot linger at the host because a request failed.

Paying for Premium. Premium is paid in cryptocurrency — BTC, ETH, USDT, USDC, SOL or XMR — through Plisio, one period at a time: a month or a year, never renewed on its own. The server sets the price and creates the invoice in euros, payable in the chosen coin and network; the billing page shows the address, the exact amount and a QR code, and the customer pays from any wallet without leaving the site. Plisio receives a random order identifier, the price and the coin — never an email address, nothing from the vault. Its webhook is signed with hmac⁡sha1(kapi,body)\operatorname{hmac}_{\mathrm{sha1}}(k_{api}, \text{body}), the provider's scheme, which nothing here relies on alone: whatever prompts a check — the page every ten seconds, Plisio's webhook, the hourly maintenance — the state of the payment is read back from Plisio's API by the invoice reference we stored ourselves, and only a completed invoice for the same order, price, coin and network adds the period, once. A forged webhook can at most trigger that check. Without a Plisio key there is no payment at all: Premium is given directly, with no end date.

End of Premium. When a paid period ends without a new payment the account falls back to the free plan at that moment, and the end of the period is recorded as tendt_{end}. One month later — tpurge=tend+30 dayst_{purge} = t_{end} + 30\ \text{days} — the maintenance task deletes every item of the vault and queues all its blobs: files, folders, freezes and the links to permanent copies. A payment before tpurget_{purge} cancels it. The vault and the billing page warn a week before tendt_{end} and show tpurget_{purge} after it; no email is sent (§5.5). Permanent copies stay on Arweave, where nobody can delete them: a public one stays readable by anyone holding its link, an encrypted one becomes unreadable, its key FKFK having gone with the item — unless it was exported first (§6.2).

12Permanent storage on Arweave

12.1 Two modes

Premium and Business accounts can place a file of up to 100 KB on Arweave through the free tier of Turbo (ArDrive / ar.io). Encrypted: Arweave receives VCR1 ciphertext under a fresh FKFK, wrapped under VKVK like any other file key. Public: Arweave receives the file itself — an HTML page included — readable by anyone who has its identifier, for as long as the network exists. The public mode requires a separate confirmation, in red: it is the only action in the product that no key, no support and no deletion can undo.

The storage is paid once, into the network's endowment: a reserve held in its own token, AR, that pays the independent nodes keeping copies, year after year, in many countries — out of reach of any single operator, host or jurisdiction. Arweave is built to keep data online for fifty years and more; Premium includes it at no extra cost, through Turbo's free tier.

12.2 The data item

The browser builds an ANS-104 data item and signs it itself (lib/crypto/ans104.ts); the server only relays the signed item to Turbo (§12.5).

type2signature512owner (n)512target1anchor1 + 32#tags8tag len8tagsAvrodata≤ 100 KBlittle-endian integers · 1,076 bytes of fixed overhead before tags and datasignature = RSA-PSS-SHA256(sk, deepHash(["dataitem", "1", "1", owner, target, anchor, tags, data]))id = base64url(SHA-256(signature)) — known before upload, 43 charactersencrypted mode: data = VCR1 ciphertext, tag Content-Type: application/octet-streampublic mode: data = the file itself, tag Content-Type = its real type (text/html, image/webp…)
Figure 8 — An ANS-104 data item as crycrypt builds it. The encoding is checked against @dha-team/arbundles, the library Turbo validates uploads with: same identifiers, same tag bytes, signatures accepted in both directions.
DH⁡(x)=SHA-384⁡(SHA-384⁡(blob∥∣x∣10)∥SHA-384⁡(x))\operatorname{DH}(x) = \SHA{384}\big(\SHA{384}(\str{blob} \cat |x|_{10}) \cat \SHA{384}(x)\big)
(21)
DH⁡([x1,…,xk])=ak,a0=SHA-384⁡(list∥k10),aj=SHA-384⁡(aj−1∥DH⁡(xj))\operatorname{DH}([x_1, \ldots, x_k]) = a_k, \qquad a_0 = \SHA{384}(\str{list} \cat k_{10}), \qquad a_j = \SHA{384}\big(a_{j-1} \cat \operatorname{DH}(x_j)\big)
(22)
h=DH⁡([dataitem,1,1,n,∅,anchor,tags,data]),σ=RSA-PSS⁡SHA-256⁡, s=32(sk, h),id=b64url⁡(SHA-256⁡(σ))\begin{aligned} h &= \operatorname{DH}\big([\str{dataitem}, \str{1}, \str{1}, n, \varnothing, \text{anchor}, \text{tags}, \text{data}]\big), \\ \sigma &= \operatorname{RSA\text{-}PSS}_{\SHA{256},\ s = 32}(sk,\ h), \\ id &= \operatorname{b64url}\big(\SHA{256}(\sigma)\big) \end{aligned}
(23)

Here ∣x∣10|x|_{10} is the length written in decimal, nn the raw 512-byte RSA modulus, and the anchor 32 random bytes. Tags are an Avro array of zigzag varints and strings: Content-Type: text/html encodes to 02 18 "Content-Type" 12 "text/html" 00. The key pair is ephemeral — one per upload, private half non-extractable: an Arweave address is public and permanent, and reusing one key would link all of a user's uploads forever. The free tier is counted per IP address, so a fresh key costs nothing.

12.3 Size budget

∣item∣=2+512+512+1+33+16⏟1 076+∣tags∣+∣data∣ ≤ B=107 520|\text{item}| = \underbrace{2 + 512 + 512 + 1 + 33 + 16}_{1\,076} + |\text{tags}| + |\text{data}| \ \le\ B = 107\,520
(24)

BB is read from Turbo at run time rather than hard-coded. An encrypted 100,000-byte file weighs 100 000+41100\,000 + 41 bytes once in VCR1, and its Content-Type tag 40 bytes: the item is 1 076+40+100 041=101 1571\,076 + 40 + 100\,041 = 101\,157 bytes. The 100 KB rule therefore always fits, with 6 KB to spare.

12.4 Compressing images

An image above the limit is re-encoded in the browser — WebP when the browser can write it, JPEG otherwise — and its embedded metadata (GPS position, device, date) disappears with the re-encoding. The search starts from a pixel budget, walks down a quality ladder, then shrinks:

s0=min⁡ ⁣(1, 8B′β WH),  β=1.6,sk+1=sk⋅clamp⁡ ⁣(0.94B′∣xk∣, 0.5, 0.85)s_0 = \min\!\Big(1,\ \sqrt{\tfrac{8B'}{\beta\, W H}}\Big),\ \ \beta = 1.6, \qquad s_{k+1} = s_k \cdot \operatorname{clamp}\!\Big(0.94\sqrt{\tfrac{B'}{|x_k|}},\ 0.5,\ 0.85\Big)
(25)

B′=100 000B' = 100\,000 is the byte budget, W×HW \times H the original size, and ∣xk∣|x_k| the smallest encoding obtained at scale sks_k over the qualities 0.86, 0.74, 0.62, 0.50 and 0.40. Encoded size grows roughly with the pixel count, so each round divides it by at least 1/0.852≈1.381/0.85^2 \approx 1.38; the search stops after 12 rounds or below 32 pixels. Measured while testing: a 3.48 MB PNG of 2400 × 1600 pixels became a 92.5 KB WebP of 866 × 577. Any other file above 100 KB is refused.

12.5 The link stays encrypted

The transaction identifier, the mode and a SHA-256 fingerprint of the published bytes are stored only inside encMeta (§7). The server stores an opaque item: encrypted metadata, a wrapped key in encrypted mode, no storage reference, zero bytes counted against the quota. The signed item does pass through it: a relay forwards it to Turbo through the operator's proxy — Turbo sees the proxy's address, never the user's — and keeps nothing, neither the bytes nor the identifier, not even in a log. An encrypted copy crosses it as ciphertext, a public one in the clear. The Premium check comes before anything irreversible leaves: the link is recorded first, the upload follows, and the link is withdrawn if the upload fails. The interface shows the identifier and the gateway link of every permanent copy, to copy one by one or all at once — what remains of a copy if its link in the vault ever disappears (§11).

12.6 Deleting

Deleting the item deletes the link. In encrypted mode it also deletes the only copy of FKFK: the ciphertext stays on Arweave forever, but without FKFK it is computationally indistinguishable from random bytes — the copy is crypto-shredded. In public mode nothing can be withdrawn: the file stays readable at its identifier.

12.7 Reading back

The browser fetches /raw/{id} from turbo-gateway.com, which serves an upload immediately, then from arweave.net, which serves it once indexed (40 to 60 minutes), and compares its SHA-256 with the fingerprint in encMeta. In encrypted mode GCM would detect tampering anyway; in public mode the fingerprint is the only defence against a gateway serving something else.

12.8 Limits

  • Turbo sees the proxy's address, not the user's. The server sees every item it relays: a compromised server could note which copy belongs to which account.
  • A gateway may refuse to serve some content; the data stays on the network, and other gateways can serve it.
  • The free tier allows about 10 MiB per IP address for life, and 107,520 bytes per item.
  • Someone holding both the crycrypt database and Turbo's logs could correlate an item's creation time with an upload.
  • One file, one identifier: a public HTML page must be self-contained (inline styles and images).
  • The fifty-year horizon is the network's, not ours: it rests on Arweave's economics — the value of its endowment and the falling cost of storage — which no party to crycrypt controls.

13What each party sees

DataBrowser, vault opencrycrypt serverGofileArweave · Turbo
Passwordderives, then forgetsno — scrypt of a derivativenono
Seed, keys, answer, recovery keywhile typedno — one verifier per pathnono
Vault key VKmemory onlyno — envelopesnono
Names, types, foldersyesno — encMeta; a public copy's type, in transitnotype tag, public mode
File contentswhen openedno — a permanent copy only passes throughciphertextciphertext, or the public file
Email addressyesyesnono
Which codes open the vaultyesyesnono
Password reset tokenin the fragment, then clearedno — SHA-256 onlynono
An exported .crycryptwhile sealing or opening itnever sees itnono
A deleted filegoneblob reference until Gofile confirmsuntil the deletion—
TOTP secretonce, at set-upyesnono
Which blob belongs to whomyesyes, in the clearone folder per vault—
Which permanent copy belongs to whomyesno — inside encMeta; seen only in transit—the proxy's IP, not the user's
Sizes and timesyesyesyesyes
A Premium paymentwhile payingaccount ↔ address, amount, transactionnono

Plisio, which processes payments, sees for each one a random order identifier, the price, the coin and the blockchain transaction — never an email address, nothing from the vault. On public chains the paying address and the amount are visible to anyone; Monero hides them.

14Limits and assumptions

  • The code is served by the server. A malicious server could send a modified client that leaks keys. This is the standing limit of encryption in a web page; HTTPS, audits of the deployment and a content-security policy reduce it. Every script carries a Subresource Integrity hash, and each release publishes an Ed25519-signed manifest of those hashes on /integrity: anyone can compare what their browser received with what was signed. This detects a swapped script after the fact; it cannot stop a server that serves a backdoored page to one person only, at one moment.
  • The TOTP secret is stored in the clear server-side — verifying a code requires it. It opens nothing without the three keys and the answer.
  • A forgotten answer loses the vault. It is required to open and to recover (§5.7); no third path exists, by design — the operator could not offer one without being able to open vaults itself.
  • Remembered keys belong to the signed-in browser. Whoever holds that browser with its session can join BB and KremK_{rem} and read the three keys (§9); the characters asked at reopening come from what the browser already holds and add nothing against that person. The answer, the authenticator code and the email code are still required, and the lockout still counts. On a shared computer, lock the vault by hand: that forgets the keys.
  • A payment links an account to a transaction. For every payment the server keeps the account, the paying address and the transaction (§11); on public chains the last two are visible to all. Monero hides them on-chain, not in our database.
  • Nothing renews on its own. A period not paid in time ends Premium and starts the month of grace; the vault warns a week before, no email does.
  • The password rule is the interface's. The server never sees the password (§3.1); a modified client could register a weak one. It would only weaken that account's sign-in, never its vault.
  • No alert for a failed opening. Email carries codes, the recovery alert and the reset link, nothing else; refused openings only appear in the account's activity journal. Its holder can erase what is older than 48 hours — never the last 48 hours, so a recent sign-in cannot be wiped by whoever made it — and the erasure is itself recorded.
  • The codes are a gate, not a key. The email code can be switched off (§5.5); doing so removes a check before SvaultS_{vault} is released, not entropy from the lock material.
  • Freeze deadlines rely on the operator for timing (§8).
  • Inline styles are allowed by the content-security policy (style-src 'unsafe-inline'): the interface library sets positions and animations through the style attribute. Scripts stay strict — each one needs the per-request nonce — so an injected style cannot run code.
  • The request limiter is in memory: several server instances need a shared store. The vault lockout of §5.6 is persisted and not affected.
  • Blob identifiers are linkable in the database (§11); permanent copies are not (§12.5).
  • Registration tells whether an address already has an account, by refusing it at the first step — and so does a version-2 recovery challenge, until those vaults are recovered (§5.8).
  • The answer is normalised permissively: it enters both paths, and a strict normalisation would lose vaults over an apostrophe.
  • The end of Premium deletes everything, for good. One month after the plan ends, every stored file goes, and no email warns about it: the vault and the billing page show the date. Exported .crycrypt files survive, since they live on the user's disk.
  • A .crycrypt is as strong as its vault. Whoever can open the vault can open every file ever exported from it, including files since deleted; its name on disk keeps the original base name.
  • Maintenance runs inside the server process. With several instances, run npm run maintenance from a scheduler instead.
  • Permanent means permanent. A public copy cannot be withdrawn by anyone, the operator included.

AParameters

ParameterValue
Argon2idm = 65,536 KiB · t = 3 · p = 1 · 32 bytes
scrypt (authHash, PIN)N = 16,384 · r = 8 · p = 1 · 32 bytes
Seed · key · recovery key20 · 12 · 30 symbols, 5 bits each
VCR11 MiB chunks · 25-byte header · 16-byte tag
Session15 min (password stage) · 7 days · 30 days (remember)
Pending sign-up1 hour · 2 hours once both codes are proven
Email code (sign-in)6 digits · 10 min · 5 attempts
Repeated failures3 wrong passwords in a row → reset link emailed · never blocks sign-in · failures forgotten after 24 h
Password reset link256-bit token in the fragment · SHA-256 stored · 24 h · single use · 1 per 2 min
Codes to openauthenticator always · email on by default
Vault codes (email)6 digits · 5 min · 5 attempts
Codes settingssingle-use nonce · 5 min · signature + authenticator code
Premium paymentPlisio · BTC, ETH, USDT, USDC, SOL, XMR · invoice valid 1 h · one month or one year per payment, no renewal
Payment checkspage every 10 s · webhook · maintenance for 2 days · state always read back from the API
End of Premiumfree at the end of the paid period · files kept 30 days, then every item deleted and every blob queued
Maintenanceevery hour · queued blob deletions retried up to 50 times
.crycrypt12-byte prefix · header ≤ 64 KiB · fresh FK′ per export
Recoveryreplaces one of: keys · email · authenticator — new recovery key, same seed
Vault open window20 min (Free) · 1 hour (Premium)
Remembered keysone character per key · fresh positions each attempt · until sign-out, manual lock or “Forget my keys”
Password≥ 12 characters · upper and lower case · digit · symbol · no common pattern (browser)
Lockout3 free attempts · 30 s doubling · 24 h cap
Real names shown after decryption5 min
Freeze deadline60 s to 10 × 366 days
Permanent copyfile ≤ 100,000 bytes · item ≤ 107,520 bytes
Quotas3 GB (Free) · 1 TB (Premium)

BDomain-separation labels

KindLabels
HKDF infovcrypt/v1/auth · vcrypt/v1/unlock · vcrypt/v1/meta · vcrypt/v1/sign · vcrypt/v1/local-tag · vcrypt/v1/recovery · vcrypt/v1/recovery-verifier · vcrypt/v1/recovery-unlock · vcrypt/v1/freeze · vcrypt/v1/export · vcrypt/v3/vault-verifier · vcrypt/v3/vault-lock · vcrypt/v3/recovery-verifier · vcrypt/v3/recovery-lock · vcrypt/v2/vault-verifier · vcrypt/v2/vault-lock · vcrypt/v2/recovery-verifier · vcrypt/v2/recovery-lock · vcrypt/v2/fingerprint
GCM AADvcrypt/vault-key · vcrypt/v3/vault-key · vcrypt/v3/recovery-key · vcrypt/v2/vault-key · vcrypt/v2/recovery-key · vcrypt/item/{id} · vcrypt/export/key|{e} · vcrypt/export/meta|{e} · vcrypt/v2/open-session · vcrypt/v1/remembered-keys · VCR1 header ‖ u32(i) ‖ last
Lock material prefixesv3 (open) · v3r (recover) · v2 · v2r (earlier locks)
File magicsVCR1 (stored content) · VCRYPT, version 1 (exported file)
Server HMACvault-verifier · recovery-verifier · email-code · vault-email-code · vault-sms-code · register-code · device · fake-salt
Signed messagesvcrypt/freeze/v1|{id}|{deadline}|{SHA-256 of the envelope} · vcrypt/factors/v1|{uid}|{nonce}|{action}|{detail}
Arweave deep hashdataitem · 1 · 1 (signature type: RSA-PSS 4096)

The detailed security model, in French, is kept with the source code in docs/SECURITY.md.