What your vault password becomes
Type it and the key that unwraps your vault is rebuilt from what you typed, by a calculation deliberately made to cost sixty-four megabytes of memory. Here is what that buys, what it does not, and who polices the numbers.
What the vault is, and what its key opens
A password you can remember is a bad key.
Start with what is being opened. Your vault is the locked drawer of the system, and it holds two different kinds of thing. The first is key records: small wrapped copies of other keys, kept so they can be recovered later. The second is content you put there on purpose โ website passwords, card details, backup codes for two-factor logins, keys for outside services, and freeform private notes. Those five kinds are the ones it accepts, and the project's own developer documentation calls that half of it an on-device password manager.
Your content in the vault is encrypted under a data encryption key โ the DEK โ and that key is not left lying around: it is stored as a wrapped copy, encrypted under a second key whose job is to encrypt keys rather than content, a key encryption key or KEK. That split is the DEK/KEK pattern, and the sections that follow build on it. One thing it buys is that granting someone access does not mean re-encrypting the content; what changes is that small wrapped copy of the key. We have written about that split before, and about how each memory carries its own lock.
What the vault's data key does not reach is the local database where your notes and messages live. That store is opened with a different key, made by a different calculation from a passphrase the system is given when it starts. It may well be the same password you type to unlock your vault โ your vault password is one of the places the system looks for it โ but what it becomes there is a different key, from a different calculation, for a different store.
The vault holds a key, and it holds content, and it is not the only place a password of yours turns into a key.
There are three ways to hand the vault a credential, in order of precedence: a key released by a biometric unlock, a password you type, or the identity key that your recovery phrase reconstructs. Supply none of them and the vault refuses and names all three; ask it to open a vault whose stored record is missing and it refuses outright. Biometric takes precedence, and on that path there is no typed password to stretch โ the key arrives already released. It is not a full replacement, though. The biometric path needs the key material behind your recovery phrase to have been sealed away for it already, and a vault that has not yet been opened with its password โ or that predates the feature โ has not had that done yet. Such a vault still opens; what it cannot do is start up the part that signs on your behalf, so the app asks for the password once to finish that job.
This piece is about the middle credential, because it is the one where what you supply is something a human invented.
Which is a genuine problem. A key has to be a run of essentially unguessable material โ here, thirty-two bytes of it. A password you can remember is nothing like that: it is short, drawn from a small alphabet, and โ the part that actually matters โ reused. You cannot take the letters someone typed and call them a key.
Here is the ceremony for opening a locked vault, before we take it apart.
flowchart TD A["You type your vault password
to open a locked vault"] --> T{"Still waiting out
a previous failed attempt?"} T -- "yes" --> R["Refused, before this derivation runs"] T -- "no" --> B["A version label is glued to the front"] B --> C["Argon2id, with this vault's salt:
sixty-four megabytes of memory,
three passes over it, four lanes"] C --> D["A thirty-two-byte key"] D --> E{"Does it open the stored,
wrapped copy of the data key?"} E -- "no" --> F["The attempt is counted, and the next one
is made to wait โ one second, then two,
then four, up to a minute"] F --> H E -- "yes" --> G["The data key is recovered"] G --> G2{"Does the part that signs on
your behalf still need arming?"} G2 -- "yes" --> G3["A second run, at whatever cost that
record names, unwraps the recovery
phrase from its own record"] G2 -- "no" --> H G3 --> H["The derived key material and the working
buffers are overwritten with zeroes"]
Stretching, and why it has to hurt
The standard answer to that gap between what a password is and what a key has to be is a key derivation function: a calculation that takes a low-quality secret and produces a proper key from it. The interesting property is that a good one is deliberately expensive. If turning a password into a key costs a fraction of a second, you will barely notice; a machine grinding through millions of common passwords notices enormously. The cost is not a side effect of the design. It is the design.
For the vault password we use Argon2id. Older derivation functions were expensive in time above all, and time is the resource an attacker buys most cheaply โ a graphics card runs thousands of guesses side by side. The whole Argon2 family is instead memory-hard: each attempt must physically fill a large block of RAM and walk over it, so running a thousand guesses at once means paying for a thousand blocks of memory rather than just more clock cycles. That does not make parallel attack impossible; it makes it cost silicon area as well as time, which is the axis purpose-built cracking hardware is actually optimised along.
The id on the end is not what makes it memory-hard โ it names which variant of the family this is. Argon2 offers two ways of deciding which memory to read next: one that ignores the secret, which is safe against an attacker who can watch the machine's memory access pattern but weaker against one who can trade extra computation for less memory, and one that depends on the secret, which is the other way round. Argon2id runs the first for part of the work and the second for the rest, so it holds up reasonably against both instead of excellently against one. Section 4 of RFC 9106, the standard that specifies Argon2, tells an implementer who does not know the difference between the variants, or who treats side-channel attacks as a viable threat, to choose exactly that hybrid.
What this buys is a multiplier, and it is worth being blunt that a multiplier is all it is. Stretching raises the price of each guess; it does not reduce how many guesses a bad password takes. It is what makes a decent password expensive to attack, and it cannot rescue a poor one.
Three numbers set that cost. Where a vault password is first turned into a key, they are written into the code as literals โ though not everywhere in the vault, and the places that read them from somewhere else are what this piece comes back to at the end:
- Sixty-four megabytes of memory per attempt.
- Three passes over that memory.
- Four lanes โ the number of strips the memory is divided into inside a single attempt.
That cost is paid when a locked vault is opened with a password, and again for each wrong guess that gets far enough to be checked. It can be paid more than once per unlock: opening the vault and arming the part that signs on your behalf are two separate unwrappings of two separate records. The second is skipped when there is nothing to arm, or when that part is already armed or being armed, among other reasons.
Those particular three numbers are not invented here: they are the second of the two configurations recommended in section 4 of RFC 9106 โ the one intended for systems that cannot spare two gigabytes of RAM per login. How long they take in practice depends on the machine, and this piece does not put a number on it; no timing was taken for it. On this path the derived key is thirty-two bytes.
Two more inputs go in beside the password. A salt: a random value generated per vault and stored next to the wrapped key. It is not trying to be secret โ its job is to make sure two people who chose the same password get different keys, so that a table of pre-computed keys is worth one vault instead of all of them. And on this path, a short version label glued to the front of the password bytes: a fixed string that names and numbers this derivation scheme, so the key comes from your password under this scheme rather than from the password alone. Give a later scheme a different label and the same password produces a different key under it.
Three smaller disciplines finish the picture.
Attempts are counted, and a failed one makes the next attempt wait โ one second, then two, then four, doubling up to a minute. That count is kept per device in device-local storage rather than only in memory, so a restart normally carries it over instead of handing back a fresh budget. The unlock path this piece describes runs that check before the derivation described here.
The working buffers are wiped. Whether or not the wrapped key opens, the derived key, the combined input bytes and the salt are all overwritten with zeroes rather than left for whatever reads that memory next. That covers the raw byte buffers, which is worth being precise about: the password itself arrives as an ordinary immutable string in this runtime, and a string cannot be overwritten in place at all.
And when you set that password it is refused outright above a few thousand characters โ for the unglamorous reason that someone who can hand you an unbounded string in front of a sixty-four-megabyte calculation can hurt you without ever guessing anything.
When the cost is data
Several callers do not hardcode the three numbers. Some read them back out of the record they are opening; others take them straight off the wire from whoever is asking.
The clearest case sits inside the ordinary password unlock above. That second unwrapping reads its memory, iteration and lane counts out of the stored record holding your recovery phrase, where they were written when the record was made โ so that a device which never chose those numbers can still re-derive the same key from the same password. On that path the cost of the calculation arrives as data.
That is a different shape of problem from anything above, and careful calling code is the wrong place to solve it, because a stored record is exactly the thing a careful caller cannot vouch for. So the answer to that problem lives below the callers, in the compiled cryptography core that the desktop service and the phone runtime both derive through: a memory cost below one megabyte or above a ceiling that ships at two hundred and fifty-six megabytes is refused outright, as are absurd iteration and lane counts. The core's own note is blunt about what it is bounding โ a caller who gets to choose the memory cost can exhaust the machine's memory in a single call. The browser build carries its own copy of those bounds โ the same values today โ rather than calling that core, which is its own small lesson about how far down a limit has to sit before it is really underneath everything.
How that refusal is reported decides whether the guard is usable, so the core's memory-cost refusals carry fixed tags rather than free-form prose, and the tag for a cost above the ceiling is declared part of the core's public interface. The layer above turns those refusals into a distinct error type that callers can branch on โ from a sentinel return code on desktop, and on the phone runtime by matching those tags. That declaration sits beside the constant that carries the tag, so anyone changing that string sees the promise attached to it.
Choosing a deliberately expensive calculation is the easy half of the decision. The hard half is that the expense is real and lands somewhere, and every layer that touches it inherits a share of the choice: the code that picks the numbers, the store that remembers them for later, and the core that has to honour whatever it is handed. Get the first one right and you have a good password check. Forget the other two and you have built a machine that will spend whatever it is told to, on behalf of whoever gets to do the telling.
Written by AI agents from real project logs; owned and edited by Mujo.
Written by AI agents from real project logs; owned and edited by Mujo.