public final class Vault
- Object
- 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 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), and the metadata
counter detects another writer’s changes.
Fields
public static final int NOT_ENROLLED = 0 | Vault is not set up on this device and has nothing stored. |
public static final int LOCKED = 1 | Vault exists and is locked. |
public static final int UNLOCKED = 2 | Vault exists and is unlocked. |
public static final int STATE_UNKNOWN = -1 | The store could not be read, so nothing is known. |
Methods
Inherited methods
Field details
NOT_ENROLLED
public static final int NOT_ENROLLED = 0LOCKED
public static final int LOCKED = 1UNLOCKED
public static final int UNLOCKED = 2STATE_UNKNOWN
public static final int STATE_UNKNOWN = -1Method details
named
public static Vault named(String name)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
nameString- a short stable name, e.g.
"notes". Must not be null or empty
getName
public String getName()configure
public Vault configure(VaultOptions newOptions)Applies options – required protections, KDF profile, unlock policy, auto lock.
Call before enroll. Changing the policy afterwards is setPolicy(UnlockPolicy), which
does the work of removing wraps the new policy forbids.
capabilities
public VaultCapabilities 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
public ProtectionReport 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_ONLY reports no device key because there is none, on the same
platform where capabilities() says one is available.
isEnrolled
public boolean isEnrolled()isUnlocked
public synchronized boolean isUnlocked()state
public int state()passwordNeedsRewrap
public synchronized boolean passwordNeedsRewrap()Whether the password wrap was written under a weaker KDF profile than KdfProfile.current().
True only after a successful unlockWithPassword. An application that sees this and
still holds the password should call changePassword with the same password on both
sides, which rewraps under today’s profile.
enroll
public AsyncResource<Boolean> enroll(char[] password, VaultOptions opts)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
passwordchar[]- the user’s password. Cleared by this method once it has been used
optsVaultOptions- options, or null for the defaults
Returns
VaultError.CONFLICT if one already exists, VaultError.POLICY_NOT_MET if a required
protection is missing, or VaultError.STORAGE_UNAVAILABLE if nothing could be written.
VaultError.LOCKED means locking interrupted setup; a committed password record can
remain, so check state() and unlock it instead of enrolling again.unlockWithPassword
public AsyncResource<Boolean> unlockWithPassword(char[] password)Parameters
passwordchar[]- the user’s password, cleared by this method
Returns
VaultError.AUTHENTICATION_FAILED for a wrong password, VaultError.KEY_MISSING when
there is no vault here, or VaultError.TEMPORARILY_UNREADABLE when the record could not
be readunlockRemembered
public AsyncResource<Boolean> unlockRemembered()Unlocks from this device’s remembered key, without a password.
Fails with VaultError.KEY_MISSING when the device was never remembered or has been
forgotten – which is the ordinary case an application handles by asking for the password.
Under UnlockPolicy.REQUIRE_USER_VERIFICATION the platform prompts here and a dismissed
prompt is VaultError.CANCELLED, which is not a failure to report as an error to the
user.
rememberDevice
public AsyncResource<Boolean> 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_MET when the policy is
UnlockPolicy.REQUIRE_USER_VERIFICATION and 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
public AsyncResource<Boolean> 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 synchronized 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.LOCKED instead. 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
public AsyncResource<Boolean> 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
public AsyncResource<Boolean> putSecret(String secretName, char[] value)Stores a secret string under a name.
The value is taken as characters rather than a String so the caller can clear it; this
method clears the array it is given once the secret is sealed.
getSecret
public AsyncResource<char[]> getSecret(String secretName)Reads a secret back. The caller owns the returned characters and should clear them.
Errors with VaultError.KEY_MISSING when there is no such secret, and with
VaultError.AUTHENTICATION_FAILED when there is one and it does not authenticate – which
means it was altered or was written under a different vault.
removeSecret
public AsyncResource<Boolean> removeSecret(String secretName)seal
public AsyncResource<byte[]> seal(String recordId, byte[] plaintext)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
public AsyncResource<byte[]> open(String recordId, byte[] sealed)Opens what seal produced, including envelopes written before a 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
public AsyncResource<KeyHandle> operationalKey(String purpose)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
public AsyncResource<byte[]> databaseKey(String alias)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 KeyHandle behind
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 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
aliasString- the database’s alias, normally its name. Two aliases get two unrelated keys
Returns
VaultError.POLICY_NOT_MET when the vault was configured with
VaultOptions.requireOpaqueKeysOnly(), or VaultError.LOCKED when it is lockeddatabaseKey
public AsyncResource<byte[]> databaseKey(String alias, int version)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()databaseKeyProtection
public ProtectionReport 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_KEY is NO here even where the vault reports YES. 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
public AsyncResource<Boolean> changePassword(char[] oldPassword, char[] newPassword)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
public AsyncResource<char[]> 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 is fast where unlockWithPassword is deliberately slow.
unlockWithRecoveryCode
public AsyncResource<Boolean> unlockWithRecoveryCode(char[] code)createRecoveryCode().rotateDataKey
public AsyncResource<Boolean> rotateDataKey(char[] password)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. 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
public AsyncResource<Boolean> importSyncState(byte[] state, char[] password)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 versioned
DatabaseConfig.vault and 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_COMMITTED reports 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
statebyte[]- bytes from
exportSyncState()on another device passwordchar[]- the vault password, cleared by this method
setPolicy
public AsyncResource<Boolean> setPolicy(UnlockPolicy policy)Changes the unlock policy, doing the work the new policy implies.
Moving to UnlockPolicy.SESSION_ONLY or UnlockPolicy.REQUIRE_USER_VERIFICATION deletes
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
public UnlockPolicy getPolicy()