public final class Vault

  1. Object
  2. 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

AgainstProtected
A stolen ciphertext file, a stolen database, a backupYes. Everything at rest is AEAD ciphertext under a key that is not stored beside it
A copied browser profile or device image, vault lockedYes 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 unlockedNo. The data key is in memory
Malicious script in the application’s own origin, vault unlockedNo. 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 lockedIt 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 deviceNo
A compromised server serving new application codeNo. New code runs in the origin and inherits everything the application has
Data already copied elsewhereNo. 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 = 0Vault is not set up on this device and has nothing stored.
public static final int LOCKED = 1Vault exists and is locked.
public static final int UNLOCKED = 2Vault exists and is unlocked.
public static final int STATE_UNKNOWN = -1The store could not be read, so nothing is known.

Methods

public static Vault named(String name)Returns the vault with this name, creating the object but not the vault.
public String getName()The name this vault was opened with.
public Vault configure(VaultOptions newOptions)Applies options – required protections, KDF profile, unlock policy, auto lock.
public VaultCapabilities capabilities()What this device can actually provide, per unlock policy.
public ProtectionReport protection()What currently protects this vault on this device, as observed.
public boolean isEnrolled()Whether this vault has been set up on this device.
public synchronized boolean isUnlocked()Whether the data key is currently available.
public int state()One of NOT_ENROLLED, LOCKED, UNLOCKED or STATE_UNKNOWN.
public synchronized boolean passwordNeedsRewrap()Whether the password wrap was written under a weaker KDF profile than KdfProfile.current().
public AsyncResource<Boolean> enroll(char[] password, VaultOptions opts)Sets up the vault for the first time.
public AsyncResource<Boolean> unlockWithPassword(char[] password)Unlocks with the user’s password.
public AsyncResource<Boolean> unlockRemembered()Unlocks from this device’s remembered key, without a password.
public AsyncResource<Boolean> rememberDevice()Remembers this device, so the vault reopens without a password.
public AsyncResource<Boolean> forgetDevice()Forgets this device: the local wrap is deleted and so is the device key behind it.
public synchronized void lock()Locks the vault: the data key is overwritten and dropped, and every handle this vault issued stops working.
public AsyncResource<Boolean> destroyLocalData()Deletes everything this vault stores on this device: the record, the secrets, the device wrap and the device key.
public AsyncResource<Boolean> putSecret(String secretName, char[] value)Stores a secret string under a name.
public AsyncResource<char[]> getSecret(String secretName)Reads a secret back.
public AsyncResource<Boolean> removeSecret(String secretName)Removes a secret.
public AsyncResource<byte[]> seal(String recordId, byte[] plaintext)Seals application data into a portable envelope, bound to a record id.
public AsyncResource<byte[]> open(String recordId, byte[] sealed)Opens what seal produced, including envelopes written before a rotateDataKey().
public AsyncResource<KeyHandle> operationalKey(String purpose)An opaque key for a named purpose, derived from the data key.
public AsyncResource<byte[]> databaseKey(String alias)Raw key bytes for an encrypted database, which is the one deliberate exposure in this package.
public AsyncResource<byte[]> databaseKey(String alias, int version)Derives a database key from a specific current or retired data-key version.
public int getDataKeyVersion()The current data-key version.
public ProtectionReport databaseKeyProtection()What protects the key databaseKey(String) produces, reported honestly.
public AsyncResource<Boolean> changePassword(char[] oldPassword, char[] newPassword)Changes the password by rewrapping the data key.
public AsyncResource<char[]> createRecoveryCode()Creates a recovery code and wraps the data key under it.
public AsyncResource<Boolean> unlockWithRecoveryCode(char[] code)Unlocks with a recovery code from createRecoveryCode().
public AsyncResource<Boolean> rotateDataKey(char[] password)Generates a new data key and retires the current one.
public byte[] exportSyncState()The vault record, as bytes to hand to a sync server or copy to a new device.
public AsyncResource<Boolean> importSyncState(byte[] state, char[] password)Enrolls this device from another device’s exportSyncState().
public AsyncResource<Boolean> setPolicy(UnlockPolicy policy)Changes the unlock policy, doing the work the new policy implies.
public UnlockPolicy getPolicy()The policy this device is operating under.

Inherited methods

Field details

NOT_ENROLLED

public static final int NOT_ENROLLED = 0
Vault is not set up on this device and has nothing stored.

LOCKED

public static final int LOCKED = 1
Vault exists and is locked.

UNLOCKED

public static final int UNLOCKED = 2
Vault exists and is unlocked.

STATE_UNKNOWN

public static final int STATE_UNKNOWN = -1
The store could not be read, so nothing is known. Callers must not enroll over this.

Method 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

name String
a short stable name, e.g. "notes". Must not be null or empty

getName

public String getName()
The name this vault was opened with.

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()
Whether this vault has been set up on this device.

isUnlocked

public synchronized boolean isUnlocked()
Whether the data key is currently available.

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

  1. The required protections in VaultOptions are checked against what this device provides. An unmet one fails here, before anything is written.
  2. A random vault id and a random 32 byte data key are generated.
  3. 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.
  4. 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 char[]
the user’s password. Cleared by this method once it has been used
opts VaultOptions
options, or null for the defaults

Returns

a resource completing when the vault is set up and unlocked, or erroring with 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)
Unlocks with the user’s password.

Parameters

password char[]
the user’s password, cleared by this method

Returns

a resource completing when the vault is unlocked, or erroring with 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 read

unlockRemembered

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)
Removes a secret.

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

alias String
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_MET when the vault was configured with VaultOptions.requireOpaqueKeysOnly(), or VaultError.LOCKED when it is locked

databaseKey

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.

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

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)
Unlocks with a recovery code from 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

state byte[]
bytes from exportSyncState() on another device
password char[]
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()
The policy this device is operating under.