pocket
Security

The vault and locking

Two-tier encryption, the header as authenticated data, and why a corrupt vault must never be reported as a wrong password.

Two tiers

password ──scrypt──► KEK ──AES-GCM──► DEK ──AES-GCM──► payload

The password derives a key-encryption key. That key wraps a random 32-byte data key. The data key encrypts the payload.

Two tiers rather than one, because a password change then re-wraps 32 bytes instead of re-encrypting an unbounded store. The payload holds the confidential opening store, which grows with every inbound event.

ParameterValue
Key derivationscrypt, N=131072, r=8, p=1, dkLen=32
Minimum acceptedN=2¹⁵
EncryptionAES-256-GCM
Salt16 bytes, fresh per vault
IV12 bytes, fresh per write
Wrapped data keyexactly 48 bytes

The scrypt parameters are OWASP's recommended ladder, measured at about 250 ms in V8. Deliberately slow, so guessing passwords in bulk is expensive. scrypt rather than PBKDF2 because scrypt is memory-hard and SHA-256 is the most ASIC-accelerated function in existence.

Parameters come from the vault's own header rather than the module constant, so raising the default for new vaults does not lock anyone out of an existing one. The floor is belt and braces against ever honouring weak parameters.

Passwords are normalised to NFKC before hashing, so a password typed identically on two machines derives the same key.

The header is authenticated data

The header sits in the clear, because it is needed in order to know how to decrypt. So it is passed as AES-GCM additional authenticated data on both encryption and decryption.

That turns a schema downgrade from a convention somebody has to remember to enforce into an authentication-tag mismatch.

The serialisation is canonical by construction: fixed key order, no whitespace, and each field JSON-quoted rather than interpolated. Base64 and a fixed enum cannot contain a quote today, but a header is untrusted input, and the authenticated data has to be unambiguous by construction rather than by luck. Two distinct headers must never serialise to the same bytes.

Corrupt is not the same as wrong password

This distinction is load-bearing, because the unlock screen offers exactly two things: try again, and erase.

Telling somebody holding the correct password that it is wrong sends them to the flow that destroys every confidential opening. That is a social-engineering step away from data loss.

So structural damage is checked before the tag is ever tested, and reported as damage:

CheckedReported as
The schema version is a sane integer, and not newer than this build understandsa damaged vault, or a version this build refuses to read
The salt, IV and ciphertext are valid base64a damaged vault
The IV is exactly 12 bytesa damaged vault
The wrapped key is exactly 48 bytesa damaged vault
The key-derivation parameters are integersa damaged vault
The derived key length is 32a damaged vault

The exact-length checks matter more than a lower bound. Both writers produce exactly 48 bytes, so any other length is damage rather than a shorter key. A range check would pass every truncation from 33 to 47 bytes through to the tag test, where it fails and reads as a wrong password.

The base64 checks matter for the same reason. Without them an undecodable salt escapes as a raw decoder error, which carries no name on the error allowlist, so a vault that will never open again is reported as a connection problem.

A tag failure past those checks is either a wrong password or a tampered header, and Pocket deliberately does not distinguish them. Telling an attacker which one failed is a free oracle.

The session

Unlocking derives the data key, opens the vault, derives the Stellar keypair, and installs a session in worker memory:

HeldWhere
The data keyworker memory, and mirrored to chrome.storage.session
The seedworker memory only, never written anywhere
Your addressworker memory, and separately on disk in the clear
The lock deadlineboth

Your address is stored in the clear deliberately. It is public the moment the account is funded, so storing it reveals nothing, and it is what allows a forgotten-password recovery to check that the phrase you typed belongs to the wallet on this device.

The mirror carries the data key and a deadline and nothing else. A restarting worker re-derives the seed from the vault.

Locking

TriggerEffect
Lock now, or the header menufull lock
Idle timeoutfull lock
Browser closethe RAM-backed mirror is gone, so the next start is locked
Erasefull lock, plus removal
Worker evictionkeys drop from memory; the mirror survives, so a restart inside the window comes back unlocked

A full lock does four things, in this order:

Drop the keys from memory, synchronously

The buffers are zeroed in place first. That does not defeat a memory dump taken mid-session; it shortens the window in which a key sits in a reachable heap object after the lock.

Synchronously and first, so nothing can read a half-locked wallet during the part of the cleanup that suspends.

Drop everything else held in memory

The readiness flags, the decrypted history, the built-but-unconfirmed envelopes, and every parked website approval.

The envelopes matter: a staged private operation holds its post-state openings there as plain decimal strings, value and blinding both, alongside the unsigned transaction.

Parked approvals are resolved as refused rather than dropped, because the map holds each site's callback and clearing it alone would leave the page hanging until its own timeout. A lock is an answer, and the answer is no.

Purge the session mirror

Without this the wallet would come straight back unlocked on the next worker start.

Drop every website grant

A grant says a site may see your address and may ask you to sign. Neither is true of a locked wallet.

This is awaited rather than fired and forgotten, so a lock that has returned has finished locking.

The idle lock

An alarm rather than a timer, because a timer dies with the worker and a wallet relying on one would silently stay unlocked across a restart.

Two enforcers agree on one deadline: the alarm fires while a worker is alive, and the mirror refuses and purges an expired record on every worker start regardless of what the alarm did.

The window is yours to set, between 1 minute and 8 hours, defaulting to 15 minutes. "Never" is deliberately not offered.

The lock will not fire mid-operation. Between a submission and the write that records its consequence, the money has already moved and finishing the job needs the very keys the lock destroys. So the alarm re-arms for a minute and looks again. This postpones the lock by at most one operation, which the platform caps at five minutes for a single request anyway.

Showing the phrase again

Settings → Recovery phrase re-derives the data key from the password, not from the live session.

So an already-unlocked wallet still has to prove the password to see its own words, which is the same gate erasing stands behind. A locked wallet refuses the request outright, so an idle-locked screen left open cannot be talked into showing the phrase.

The words are fetched only after the password clears, live in one component, and are dropped the instant the sheet closes. Nothing auto-copies.

On this page