Class Vault
A password-protected store an application opens once and then uses, without choosing a cipher, a nonce, a KDF or a wrapping scheme.
What it is
One random 32 byte data key protects everything the vault holds. The data key itself is
never stored: what is stored are wraps of it -- a copy sealed under a key derived from the
user's password, optionally a copy sealed under a recovery code, and, if the user asked to be
remembered, a copy sealed by this device's key store or by a non-extractable browser
CryptoKey. Unlocking means unwrapping one of those copies. Changing the password rewrites
one wrap and touches no data; forgetting a device deletes one wrap and touches no data.
That separation is what makes cross-device synchronization work without sending anything usable to a server. The record describing a vault -- the wraps included -- is ciphertext, and the records the vault seals are ciphertext, so a sync server stores both and can read neither.
Vault vault = Vault.named("notes");
if (!vault.isEnrolled()) {
vault.enroll(password, new VaultOptions().policy(UnlockPolicy.REMEMBER_DEVICE)).get();
} else {
try {
// Not isDone(): that answers whether the operation has FINISHED, and it is true
// for the ordinary KEY_MISSING this returns on a device that was never remembered.
vault.unlockRemembered().get();
} catch (RuntimeException notRemembered) {
vault.unlockWithPassword(password).get();
}
}
vault.putSecret("api.token", token).get();
What it protects against, and what it does not
| Against | Protected |
|---|---|
| A stolen ciphertext file, a stolen database, a backup | Yes. Everything at rest is AEAD ciphertext under a key that is not stored beside it |
| A copied browser profile or device image, vault locked | Yes for UnlockPolicy.SESSION_ONLY. No for UnlockPolicy.REMEMBER_DEVICE in a browser: a full profile copy carries the IndexedDB the wrapping key lives in, and the key works in the copy |
| A copied browser profile, vault unlocked | No. The data key is in memory |
| Malicious script in the application's own origin, vault unlocked | No. It calls the same decrypt the application calls. Nothing in this package prevents that, and non-extractable keys and Web Workers do not either |
| Malicious script, vault locked | It cannot read the data, and it can wait, log keystrokes and read the password when the user next types it |
| A browser extension, or malware on the device | No |
| A compromised server serving new application code | No. New code runs in the origin and inherits everything the application has |
| Data already copied elsewhere | No. forgetDevice() and destroyLocalData() remove local access; they cannot reach a copy |
The row that gets misread is the second. "The database file alone is useless" and "a copy of the whole profile is useless" are different claims, and only the first is true of a browser with a remembered device. Say so to users rather than implying the stronger one.
Threads and the EDT
Every operation returns an AsyncResource. The expensive ones -- anything that derives a key
from a password, which is six hundred thousand iterations by default -- run on a background
thread, so calling get() on the result from the EDT parks the EDT through
CN.invokeAndBlock(Runnable) rather than freezing it. Calls that only touch memory
complete before they return and get() on them is free.
Applications should serialize state-changing operations on a vault instance. Locking can
cancel work already running on a worker; generation checks share a monitor with key changes
so the cancellation is visible across threads. Ports where an application runs in more than
one process converge device-key creation (see DeviceProtection.ensureKey(String)), and the metadata
counter detects another writer's changes.
-
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final intVault exists and is locked.static final intVault is not set up on this device and has nothing stored.static final intThe store could not be read, so nothing is known.static final intVault exists and is unlocked. -
Method Summary
Modifier and TypeMethodDescriptionWhat this device can actually provide, per unlock policy.changePassword(char[] oldPassword, char[] newPassword) Changes the password by rewrapping the data key.configure(VaultOptions newOptions) Applies options -- required protections, KDF profile, unlock policy, auto lock.AsyncResource<char[]> Creates a recovery code and wraps the data key under it.AsyncResource<byte[]> databaseKey(String alias) Raw key bytes for an encrypted database, which is the one deliberate exposure in this package.AsyncResource<byte[]> databaseKey(String alias, int version) Derives a database key from a specific current or retired data-key version.What protects the keydatabaseKey(String)produces, reported honestly.Deletes everything this vault stores on this device: the record, the secrets, the device wrap and the device key.enroll(char[] password, VaultOptions opts) Sets up the vault for the first time.byte[]The vault record, as bytes to hand to a sync server or copy to a new device.Forgets this device: the local wrap is deleted and so is the device key behind it.intThe current data-key version.getName()The name this vault was opened with.The policy this device is operating under.AsyncResource<char[]> Reads a secret back.importSyncState(byte[] state, char[] password) Enrolls this device from another device'sexportSyncState().booleanWhether this vault has been set up on this device.booleanWhether the data key is currently available.voidlock()Locks the vault: the data key is overwritten and dropped, and every handle this vault issued stops working.static VaultReturns the vault with this name, creating the object but not the vault.AsyncResource<byte[]> Opens what
seal(String, byte[])produced, including envelopes written before a.invalid reference
#rotateDataKey()operationalKey(String purpose) An opaque key for a named purpose, derived from the data key.booleanWhether the password wrap was written under a weaker KDF profile thanKdfProfile.current().What currently protects this vault on this device, as observed.Stores a secret string under a name.Remembers this device, so the vault reopens without a password.removeSecret(String secretName) Removes a secret.rotateDataKey(char[] password) Generates a new data key and retires the current one.AsyncResource<byte[]> Seals application data into a portable envelope, bound to a record id.setPolicy(UnlockPolicy policy) Changes the unlock policy, doing the work the new policy implies.intstate()Unlocks from this device's remembered key, without a password.unlockWithPassword(char[] password) Unlocks with the user's password.unlockWithRecoveryCode(char[] code) Unlocks with a recovery code fromcreateRecoveryCode().
-
Field Details
-
NOT_ENROLLED
public static final int NOT_ENROLLEDVault is not set up on this device and has nothing stored.- See Also:
-
LOCKED
public static final int LOCKEDVault exists and is locked.- See Also:
-
UNLOCKED
public static final int UNLOCKEDVault exists and is unlocked.- See Also:
-
STATE_UNKNOWN
public static final int STATE_UNKNOWNThe store could not be read, so nothing is known. Callers must not enroll over this.- See Also:
-
-
Method Details
-
named
Returns the vault with this name, creating the object but not the vault.
The name separates one vault from another within an application; it is part of the binding every envelope carries, so two vaults cannot open each other's records even with the same password.
Parameters
name: a short stable name, e.g."notes". Must not be null or empty
-
getName
The name this vault was opened with. -
configure
Applies options -- required protections, KDF profile, unlock policy, auto lock.
Call before
enroll(char[], VaultOptions). Changing the policy afterwards issetPolicy(UnlockPolicy), which does the work of removing wraps the new policy forbids. -
capabilities
What this device can actually provide, per unlock policy.
Ask before offering the user a choice: a "remember this device" switch on a platform that cannot remember is worse than no switch.
-
protection
What currently protects this vault on this device, as observed.
This describes the state that exists, not the state that could: a vault enrolled
UnlockPolicy.SESSION_ONLYreports no device key because there is none, on the same platform wherecapabilities()says one is available. -
isEnrolled
public boolean isEnrolled()Whether this vault has been set up on this device. -
isUnlocked
public boolean isUnlocked()Whether the data key is currently available. -
state
public int state() -
passwordNeedsRewrap
public boolean passwordNeedsRewrap()Whether the password wrap was written under a weaker KDF profile than
KdfProfile.current().True only after a successful
unlockWithPassword(char[]). An application that sees this and still holds the password should callchangePassword(char[], char[])with the same password on both sides, which rewraps under today's profile. -
enroll
Sets up the vault for the first time.
What happens, in this order
- The required protections in
VaultOptionsare checked against what this device provides. An unmet one fails here, before anything is written. - A random vault id and a random 32 byte data key are generated.
- The data key is wrapped under the password and the record is written and read back and unwrapped. Only a wrap that has been proven to reopen counts as written.
- Only then, if the policy asks for it, is the device wrap created -- also written and verified.
The ordering is the recoverable one. A crash after step 3 leaves a vault the password opens; a crash between 3 and 4 leaves the same. The reverse order would leave a window in which the vault opens on this device and nowhere else, and no password can rescue it.
Parameters
-
password: the user's password. Cleared by this method once it has been used -
opts: options, or null for the defaults
Returns
a resource completing when the vault is set up and unlocked, or erroring with
VaultError.CONFLICTif one already exists,VaultError.POLICY_NOT_METif a required protection is missing, orVaultError.STORAGE_UNAVAILABLEif nothing could be written.VaultError.LOCKEDmeans locking interrupted setup; a committed password record can remain, so checkstate()and unlock it instead of enrolling again. - The required protections in
-
unlockWithPassword
Unlocks with the user's password.
Parameters
password: the user's password, cleared by this method
Returns
a resource completing when the vault is unlocked, or erroring with
VaultError.AUTHENTICATION_FAILEDfor a wrong password,VaultError.KEY_MISSINGwhen there is no vault here, orVaultError.TEMPORARILY_UNREADABLEwhen the record could not be read -
unlockRemembered
Unlocks from this device's remembered key, without a password.
Fails with
VaultError.KEY_MISSINGwhen the device was never remembered or has been forgotten -- which is the ordinary case an application handles by asking for the password. UnderUnlockPolicy.REQUIRE_USER_VERIFICATIONthe platform prompts here and a dismissed prompt isVaultError.CANCELLED, which is not a failure to report as an error to the user. -
rememberDevice
Remembers this device, so the vault reopens without a password.
Requires the vault to be unlocked, because it is the data key that gets wrapped. Refused with
VaultError.POLICY_NOT_METwhen the policy isUnlockPolicy.REQUIRE_USER_VERIFICATIONand this device's key store cannot gate on user verification -- an unattended wrap under a policy that promises a prompt is the failure that policy exists to prevent. -
forgetDevice
Forgets this device: the local wrap is deleted and so is the device key behind it.
The vault still opens with the password, here and everywhere else. What this cannot do is reach a copy of the data key that was taken while the device was remembered -- see the table on this class.
-
lock
public void lock()Locks the vault: the data key is overwritten and dropped, and every handle this vault issued stops working.
An operation already in flight when this runs does not deliver its result -- it fails with
VaultError.LOCKEDinstead. What locking cannot do is reach a plaintext or a key already handed to a caller, including to hostile code that got one while the vault was open. -
destroyLocalData
Deletes everything this vault stores on this device: the record, the secrets, the device wrap and the device key.
This is "delete my data from this device", not "delete my data". It does not reach a sync server, another device, a backup or an operating system snapshot, and a browser that has already written the storage to disk may leave the blocks recoverable. Say "removed from this browser", not "erased".
-
putSecret
Stores a secret string under a name.
The value is taken as characters rather than a
Stringso the caller can clear it; this method clears the array it is given once the secret is sealed. -
getSecret
Reads a secret back. The caller owns the returned characters and should clear them.
Errors with
VaultError.KEY_MISSINGwhen there is no such secret, and withVaultError.AUTHENTICATION_FAILEDwhen there is one and it does not authenticate -- which means it was altered or was written under a different vault. -
removeSecret
Removes a secret. -
seal
Seals application data into a portable envelope, bound to a record id.
Nothing is stored: the bytes come back for the caller to put wherever the data belongs -- a file, a database column, a sync server. They are readable only by a vault holding this data key, which means the same user's other devices after they enroll from
exportSyncState(). -
open
Opens what
seal(String, byte[])produced, including envelopes written before a.invalid reference
#rotateDataKey()A record sealed under a retired key version is opened by walking the key chain in the vault record. A record whose key version is newer than this vault knows about is
VaultError.UNSUPPORTED_FORMAT: another device rotated and this one has not synced yet, which is a state to report rather than to guess through. -
operationalKey
An opaque key for a named purpose, derived from the data key.
Two purposes produce two independent keys -- the derivation is HMAC-SHA-256 over the data key, which is uniformly random, so this needs no salt and no stretching. Use it when a component wants its own key without being handed the vault's: a cache encryptor, a per-feature sealer, a MAC for an integrity check.
The handle stops working when the vault locks.
-
databaseKey
Raw key bytes for an encrypted database, which is the one deliberate exposure in this package.
Why this exists and why it is not hidden
SQLCipher keys from bytes. That is true of the native builds and of the WASM build the browser uses, and no opaque handle changes it: at some point 32 bytes have to reach the engine. The choices were to pretend otherwise by quietly exporting a
KeyHandlebehind the caller's back, or to have one method, named for what it does, that says so in its documentation and can be refused by policy. This is the second.What it actually costs
While the database is open the key is in the process: in this array until the caller clears it, inside the engine for as long as the connection lives, and in whatever the runtime copied it into. In a browser that means a heap any script in the origin shares. It is bounded by the vault being unlocked -- there is no key at all before that -- and it is not bounded by anything else.
The key is derived from the vault's data key, so it changes when
rotateDataKey(char[])runs and the database has to be rekeyed in the same operation. It is not the data key itself, so a leak of it does not open the vault's records.Parameters
alias: the database's alias, normally its name. Two aliases get two unrelated keys
Returns
a resource completing with 32 bytes the caller owns and should clear, or erroring with
VaultError.POLICY_NOT_METwhen the vault was configured withVaultOptions.requireOpaqueKeysOnly(), orVaultError.LOCKEDwhen it is locked -
databaseKey
Derives a database key from a specific current or retired data-key version.
Use the version stored alongside a database to open it after a local or imported rotation, then explicitly rekey it with the current version. The retired chain retains these keys across restarts. The same lock and opaque-key restrictions as
databaseKey(String)apply.alias: the alias originally used for this databaseversion: a positive data-key version; future versions fail withVaultError.UNSUPPORTED_FORMAT, unavailable retired keys withVaultError.KEY_MISSING
-
getDataKeyVersion
public int getDataKeyVersion()The current data-key version. Store this non-secret value alongside a database when creating or rekeying it, so an imported rotation cannot hide which key opens the file. Requires an unlocked vault. -
databaseKeyProtection
What protects the key
databaseKey(String)produces, reported honestly.It is the vault's own protection with one flag forced off: the bytes exist, so
Protection.NON_EXTRACTABLE_KEYisNOhere even where the vault reportsYES. A database keyed this way is protected at rest by whatever protects the vault, and not at all from code running while it is open. -
changePassword
Changes the password by rewrapping the data key. No record is re-encrypted, so this is constant time in the amount of data the vault holds.
Requires the old password even when the vault is already unlocked: the alternative is that anyone who finds an unlocked application can lock the real owner out of every other device.
-
createRecoveryCode
Creates a recovery code and wraps the data key under it.
The returned characters are the only copy: they are not stored anywhere, and a vault whose password is forgotten and whose recovery code was not written down is not recoverable by anybody, which is the property that makes the rest of this worth anything. Show them once, tell the user to keep them, and clear the array.
A code is twenty bytes of randomness in Base32 -- 160 bits, which needs no stretching, so
unlockWithRecoveryCode(char[])is fast whereunlockWithPassword(char[])is deliberately slow. -
unlockWithRecoveryCode
Unlocks with a recovery code fromcreateRecoveryCode(). -
rotateDataKey
Generates a new data key and retires the current one.
The outgoing key is sealed under the incoming one and kept in the vault record, so everything sealed before this call still opens through
open(String, byte[]). New records use the new key. The password and recovery wraps are rewritten to wrap the new key, which is why this needs the password.What rotation does not do is make an already-copied record unreadable. Anyone holding the old key and the old ciphertext keeps both.
An existing recovery code stops working. It wrapped the outgoing key, and rewrapping it would need the code, which is not stored anywhere -- by design. Call
createRecoveryCode()afterwards and tell the user the old one is void, or they will find out when they need it. -
exportSyncState
public byte[] exportSyncState()The vault record, as bytes to hand to a sync server or copy to a new device.
Contains the wrapped data key and nothing usable: a server storing this cannot open the vault and cannot help anybody else to. The device wrap is deliberately excluded, because it is the one piece that would let a copy of this blob open the vault without the password.
Keep the credentials the user logs into the sync server with separate from the vault password. A server that can verify the login must never be able to derive the vault key, which it could if they were the same string.
-
importSyncState
Enrolls this device from another device's
exportSyncState().Importing a rotation changes the current database key. Existing database files keep their previous keys: open them with
databaseKey(String,int)or a versionedDatabaseConfig.vaultand rekey explicitly. Keep the database's key version with its local metadata; the retired key chain remains available after this import.A policy failure rolls back an untouched import and locks this session. If rollback is unsafe or cannot be confirmed,
VaultError.IMPORT_COMMITTEDreports that the import reached committed state; reread it and finish policy setup rather than assuming no change.Replay and rollback
The record carries a counter that increases on every change. This refuses a record whose counter is below the one already stored, because accepting one is how a server that has been compromised, or that simply serves a stale replica, rolls a device back to a password the user has since changed. AEAD proves the record was not altered; it says nothing about whether it is the latest, and no amount of cryptography inside the record can establish that on its own. An application that needs stronger freshness has to get it from the server -- a monotonic version the server refuses to decrease, an authenticated timestamp -- and an offline client cannot detect a rollback at all beyond what this counter catches.
Parameters
-
state: bytes fromexportSyncState()on another device -
password: the vault password, cleared by this method
-
-
setPolicy
Changes the unlock policy, doing the work the new policy implies.
Moving to
UnlockPolicy.SESSION_ONLYorUnlockPolicy.REQUIRE_USER_VERIFICATIONdeletes the unattended device wrap. That deletion is the whole point: a policy that promises a prompt while an alternative unlock sits beside it promises nothing. -
getPolicy
The policy this device is operating under.
-