Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

Browser client API

Complete reference for VaultClient configuration, lifecycle, encryption, recovery, devices, pairing, rotation, storage, results, and failure handling.

@ownfold/browser owns the client-side vault lifecycle. It is the only high-level package that holds an unlocked root-key handle, encrypts or decrypts application records, persists device material, imports Recovery Kits, and coordinates sibling tabs.

Installation

pnpm add @ownfold/browser@beta @ownfold/fetch@beta

Use any package that implements VaultTransport & VaultRotationTransport. The example below uses the fetch transport, but the lifecycle API is identical with tRPC, TanStack Start, or a custom transport.

import { createVaultClient } from "@ownfold/browser"
import { createFetchVaultTransport } from "@ownfold/fetch"

export const vault = createVaultClient({
  transport: createFetchVaultTransport({
    baseUrl: "/api/ownfold",
  }),
  autoLockMs: 10 * 60 * 1_000,
  deviceLabel: "Chrome on this device",
})

Call initialize() once before rendering lifecycle controls or encrypting records. In React, <VaultProvider> performs this initialization and subscribes to state changes.

createVaultClient(options)

Returns one VaultClient. A client represents the current browser profile, not a user account. Dispose it when replacing the instance during tests, hot-module replacement, or application teardown.

PropType
transportVaultTransport & VaultRotationTransport
TypeVaultTransport & VaultRotationTransport
storage?VaultStorage
TypeVaultStorage
cryptoEngine?CryptoEngine
TypeCryptoEngine
now?() => Date
Type() => Date
createVaultId?() => string
Type() => string
createDeviceId?() => string
Type() => string
createPairingId?() => string
Type() => string
createRotationId?() => string
Type() => string
deviceLabel?string
Typestring
pairingTtlMs?number
Typenumber
autoLockMs?number
Typenumber
lifecycleChannelFactory?VaultLifecycleChannelFactory
TypeVaultLifecycleChannelFactory
clientInstanceId?string
Typestring

Option guidance

Option Production guidance
transport Required. It must authenticate using the host application’s session. It must not accept a caller-supplied user ID.
storage Defaults to IndexedDbVaultStorage. Supply a custom implementation only when it offers equivalent protected device-key persistence and interruption recovery.
cryptoEngine Defaults to the lazy browser crypto engine. Use a worker engine for stronger main-thread isolation; use deterministic engines only in tests.
autoLockMs Defaults to 15 minutes. Set it from an explicit product security policy. It must be a positive duration.
deviceLabel Human-readable metadata visible to the account holder and application server. Never include secrets or decrypted content.
pairingTtlMs Defaults to 10 minutes. The server rejects offers longer than 15 minutes.
ID factories and now Test seams. Production should keep the secure crypto.randomUUID() and real clock defaults.
lifecycleChannelFactory Defaults to BroadcastChannel coordination. Replace only for testing or a browser runtime without that API.

Results and errors

Every expected failure is returned as Result<T, OwnfoldError> from better-result.

const encrypted = await vault.encryptJson({
  namespace: "notes",
  recordId: noteId,
  ownerId: session.user.id,
  value: note,
})

if (encrypted.status === "error") {
  switch (encrypted.error._tag) {
    case "VaultLockedError":
      // Ask the user to unlock; no record was written.
      break
    case "RotationInProgressError":
      // Resume or finish rotation before creating a new envelope.
      break
    default:
      // Map the stable error code to application UI and safe telemetry.
      break
  }
} else {
  await saveCiphertext(encrypted.value)
}

Do not log a whole input, Recovery Kit, envelope, decrypted result, or error cause. Error _tag, code, operation name, and a host request ID are sufficient for most diagnostics.

Initialization and state

initialize()

Reconciles IndexedDB state, pending pairing/replacement/rotation state, and remote vault metadata. It destroys any previously held root-key handle first. The returned state is also emitted to subscribers.

Common outcomes:

State Meaning Next action
not-created Neither local nor remote vault metadata exists. Call createVault().
sync-pending Local onboarding state exists, but remote metadata creation failed. Keep the Recovery Kit safe and call resumeSync().
recovery-unverified A vault and Recovery Kit exist, but the downloaded file has not been proven recoverable. Import the downloaded file and call verifyRecoveryKit().
ready-locked This device is enrolled; no root-key handle is active. Call unlock().
ready-unlocked Record operations are available until lock or inactivity timeout. Encrypt/decrypt, then lock when finished.
unavailable-on-device The account has a vault but this profile has no authorized local device identity. Restore or begin pairing.
pairing-pending This browser is waiting for approval from an unlocked device. Poll with refreshPairing() or cancel.
operation-error An operation failed while a stable prior state remains recoverable. Render the typed error; call dismissError() to return to previous.

State accessors

const current = vault.getState()
const unsubscribe = vault.subscribe((state) => render(state))

const rotation = vault.getRotationProgress()
const stopRotationUpdates = vault.subscribeRotationProgress((progress) => {
  renderRotationProgress(progress)
})

// Later
unsubscribe()
stopRotationUpdates()
vault.dispose()

subscribe() and subscribeRotationProgress() synchronously invoke the listener with the current value. dispose() clears listeners, timers, lifecycle channels, activity handlers, and active key access. It does not delete IndexedDB or revoke the device.

Create and verify a vault

The strict onboarding sequence is intentionally non-skippable at the client API level.

Create an in-memory draft

createVault() generates a random root key locally and moves from not-created to draft-unprotected. No root key is sent to the server.

Create and download the Recovery Kit

createRecoveryKit(secret) encrypts the root key, saves pending local state, and creates remote metadata. Download the returned object with RecoveryKitFile.download().

Re-import the same file

Read the user’s selected file. Pass its text and the secret to verifyRecoveryKit().

Enroll this device

Successful verification compares the recovered root key with the in-memory key, creates a device key, uploads only its public key and encrypted device envelope, and marks recovery verified.

import { RecoveryKitFile } from "@ownfold/browser"

const draft = await vault.createVault()
if (draft.status === "error") return showError(draft.error)

const created = await vault.createRecoveryKit(recoverySecret)
if (created.status === "error") return showError(created.error)

RecoveryKitFile.download(created.value)

const imported = await RecoveryKitFile.read(fileInput.files[0])
if (imported.status === "error") return showError(imported.error)

const verified = await vault.verifyRecoveryKit(
  JSON.stringify(imported.value),
  recoverySecret,
)

If remote creation fails after local persistence, the client enters sync-pending. Do not restart onboarding or generate a second kit. Call resumeSync(); creation is guarded by vault identity and conflict handling.

Record encryption

PropType
namespacestring
Typestring
recordIdstring
Typestring
ownerId?string
Typestring
valueJsonValue
TypeJsonValue

encryptJson(input) and decryptJson(input)

JSON input must be acyclic plain JSON: strings, booleans, finite numbers, null, arrays, and plain objects. undefined, BigInt, functions, symbols, class instances, cyclic values, non-finite numbers, and excessive nesting return InvalidInputError.

const encrypted = await vault.encryptJson({
  namespace: "notes",
  recordId: note.id,
  ownerId: session.user.id,
  value: {
    title: note.title,
    body: note.body,
    tags: note.tags,
  },
})

if (encrypted.status === "error") return showError(encrypted.error)

await notesApi.put({
  id: note.id,
  encryptedPayload: encrypted.value,
})

Decrypt using the exact same context:

const decrypted = await vault.decryptJson({
  namespace: "notes",
  recordId: row.id,
  ownerId: session.user.id,
  payload: row.encryptedPayload,
})

if (decrypted.status === "error") return showError(decrypted.error)
renderNote(decrypted.value)

namespace, recordId, ownerId, vaultId, key version, encoding, envelope format, and algorithm identifiers are authenticated. Changing a database row ID, moving ciphertext between namespaces or users, or editing envelope metadata causes decryption to fail.

Binary operations

encryptBinary() accepts a Uint8Array; decryptBinary() returns a new Uint8Array. The entire value is currently held in memory. Do not use it for unbounded files or streams. Enforce host upload limits before allocating the buffer.

Locking and unlocking

  • lock() destroys active root-key access, clears the inactivity timer, moves to ready-locked, and broadcasts the lock to sibling tabs.
  • unlock() normally opens the local encrypted device envelope. Passing a secret instead opens the locally retained Recovery Kit when one exists.
  • recordActivity() resets the inactivity deadline only while unlocked. The default browser activity listeners call it for trusted user interaction.
  • getAutoLockMs() exposes the configured duration for UI copy.

Locking is not deletion. Encrypted device material remains in IndexedDB. Reloading always starts from a locked state after initialize().

Restore and local-state deletion

restore(contents, secret) is valid only from unavailable-on-device. It verifies the active vaultId and recoveryKitId, opens the kit locally, creates a new device identity, registers its encrypted device envelope, persists local state, and returns ready-unlocked.

destroyLocalState() destroys active key access and clears this browser’s IndexedDB records, including pending pairing and rotation data. It does not delete remote vault metadata, ciphertext, another device, or the account. Afterward the state is normally unavailable-on-device.

Recovery Kit replacement

Replacement is a two-step, interruption-safe operation:

  1. prepareRecoveryKitReplacement(secret) creates and persists a replacement while the old kit remains authoritative.
  2. Download the returned kit and have the user import it.
  3. completeRecoveryKitReplacement(contents, secret) proves the exact pending kit decrypts to the current root key, updates remote metadata with the expected revision, saves it locally, and clears pending state.

Use getPendingRecoveryKitReplacement() after reload to resume the verification UI. Replacement is rejected while root rotation is pending.

Devices and pairing

Method Valid state Behavior
listDevices() Any authenticated client state Returns server-visible device summaries. Labels and activity timestamps are metadata.
getCurrentDeviceId() Any state Reads the local enrolled ID, or null.
revokeDevice(id, revision) Locked or unlocked ready state Uses optimistic concurrency. Revoking the current device destroys local state immediately.
beginPairing() unavailable-on-device Creates a short-lived X25519 device key and offer, then persists the private key locally.
refreshPairing() pairing-pending Checks for approval and opens the returned device envelope locally.
cancelPairing() pairing-pending Cancels pending remote state when necessary and deletes the pending local key.
approvePairing(serializedOffer) ready-unlocked Validates the exact offer and expiry, then wraps the root key to the new device public key.

Treat a serialized offer like a capability: display it only to the authenticated user, expire it quickly, and confirm the target device label. The offer contains no root key, but approval grants future vault access.

Root-key rotation

Rotation requires an application-owned RotationRecordStore; Ownfold cannot discover the host’s encrypted record table.

  1. prepareRootKeyRotation(secret) creates the next root key, replacement Recovery Kit, current device envelope, pending local checkpoint, and remote rotation record.
  2. Download and re-import that exact kit.
  3. completeRootKeyRotation({ recoveryKit, secret, records, batchSize }) rewraps record data keys, creates replacement envelopes for every active device, and performs the atomic cutover.
  4. Subscribe to progress to show rewrapping-records, updating-devices, and committing phases.

The default batch size is 100; valid values are 1–1000. Interrupted runs resume from the remote checkpoint. Content ciphertext is preserved; only wrapped data keys and root-key envelopes change. See Key rotation for the RotationRecordStore contract and deployment sequence.

Recovery helper APIs

RecoveryCode

Member Result
entropyBits 192
generate() A cryptographically random ownfold- prefixed code or CryptoEngineError.
parse(input) The exact valid code or InvalidInputError.

Parsing is strict and does not normalize partial or mistyped codes. Generated temporary bytes are cleared after use. The application owns display, confirmation, printing, password-manager, and clipboard behavior.

RecoveryKitFile

Member Behavior
parse(contents) Strictly parses a JSON string into RecoveryKitV1.
read(file) Reads a browser File, then applies the same strict parser and size ceiling.
download(kit, filename?) Downloads canonical JSON; default name includes the vault ID.

download is the only helper that performs a UI-adjacent side effect. It does not mark recovery verified. The application must ask the user to select the saved file and pass its contents to the verification operation.

Storage API

PropType
load() => Promise<Result$1<LocalVaultRecord | null, StorageAdapterError>>
Type() => Promise<Result$1<LocalVaultRecord | null, StorageAdapterError>>
save(record: LocalVaultRecord) => Promise<Result$1<void, StorageAdapterError>>
Type(record: LocalVaultRecord) => Promise<Result$1<void, StorageAdapterError>>
loadPendingPairing() => Promise<Result$1<PendingPairingIdentity | null, StorageAdapterError>>
Type() => Promise<Result$1<PendingPairingIdentity | null, StorageAdapterError>>
savePendingPairing(record: PendingPairingIdentity) => Promise<Result$1<void, StorageAdapterError>>
Type(record: PendingPairingIdentity) => Promise<Result$1<void, StorageAdapterError>>
clearPendingPairing() => Promise<Result$1<void, StorageAdapterError>>
Type() => Promise<Result$1<void, StorageAdapterError>>
loadPendingRecoveryKitReplacement() => Promise<Result$1<PendingRecoveryKitReplacement | null, StorageAdapterError>>
Type() => Promise<Result$1<PendingRecoveryKitReplacement | null, StorageAdapterError>>
savePendingRecoveryKitReplacement(record: PendingRecoveryKitReplacement) => Promise<Result$1<void, StorageAdapterError>>
Type(record: PendingRecoveryKitReplacement) => Promise<Result$1<void, StorageAdapterError>>
clearPendingRecoveryKitReplacement() => Promise<Result$1<void, StorageAdapterError>>
Type() => Promise<Result$1<void, StorageAdapterError>>
loadPendingRotation() => Promise<Result$1<PendingRotationIdentity | null, StorageAdapterError>>
Type() => Promise<Result$1<PendingRotationIdentity | null, StorageAdapterError>>
savePendingRotation(record: PendingRotationIdentity) => Promise<Result$1<void, StorageAdapterError>>
Type(record: PendingRotationIdentity) => Promise<Result$1<void, StorageAdapterError>>
clearPendingRotation() => Promise<Result$1<void, StorageAdapterError>>
Type() => Promise<Result$1<void, StorageAdapterError>>
clear() => Promise<Result$1<void, StorageAdapterError>>
Type() => Promise<Result$1<void, StorageAdapterError>>

Main vault, pending pairing, pending Recovery Kit replacement, and pending rotation state have separate load/save/clear operations so interrupted workflows remain recoverable. Every method returns StorageAdapterError; a custom implementation must parse persisted values on read rather than trusting its own previous writes.

IndexedDbVaultStorage(databaseName?)

The default database name is ownfold-vault-v1; the current IndexedDB schema version is managed by the package. Supply a custom name to isolate multiple test/application profiles on one origin, not to create one database per user session.

The implementation stores structured-cloneable protected device material and canonical serialized domain values in one object store. clear() clears Ownfold local state in that named database only.

Lifecycle channel API

createBroadcastVaultLifecycleChannel(name) implements VaultLifecycleChannel with the browser BroadcastChannel API. The client uses it to notify sibling tabs about lock and lifecycle changes.

PropType
post(message: VaultLifecycleMessage) => void
Type(message: VaultLifecycleMessage) => void
subscribe(listener: (message: unknown) => void) => () => void
Type(listener: (message: unknown) => void) => () => void
close() => void
Type() => void

VaultLifecycleMessage.parse(input) validates untrusted channel messages. Unknown, malformed, or self-originated messages must not mutate state. A custom channel factory must support subscribe, post, and close semantics without treating delivery as durable storage.

Worker engine API

PropType
workerCryptoWorkerPort
TypeCryptoWorkerPort
operationTimeoutMs?number
Typenumber

createWorkerCryptoEngine(options) and new WorkerCryptoEngine(options) implement CryptoEngine over a dedicated worker port. operationTimeoutMs defaults to 60 seconds and must be a positive safe integer. A worker error, message-deserialization failure, timeout, or invalid response fails the engine closed with a typed error.

WorkerCryptoEngine.dispose() removes listeners, terminates the worker, rejects pending operations, and invalidates worker root-key references. Call it when replacing the engine or destroying the client that exclusively owns it.

The worker entry point exports exposeOwnfoldCryptoWorker({ cryptoEngine? }). It must run in a dedicated Worker global and returns a cleanup function that removes its message listener. Supplying cryptoEngine is a test/custom-engine seam; production defaults to the sodium engine.

Storage failures and private browsing

IndexedDbVaultStorage uses database ownfold-vault-v1 by default. Browsers may deny, evict, or partition IndexedDB. Surface StorageAdapterError as a restore-oriented failure, not as a generic retry loop. If state is unavailable but remote metadata exists, the user must restore or pair.

Never fall back to localStorage, cookies, session storage, URL parameters, or a server endpoint for private key material. A custom VaultStorage must preserve the discriminated local states, pending-operation checkpoints, structured errors, and atomic write behavior.

Troubleshooting

Encryption returns VaultLockedError

Call initialize(), render the resulting state, and let the user unlock. Do not silently open a Recovery Kit or cache plaintext while waiting.

Decryption reports authentication or context failure

Verify the immutable namespace, record ID, owner ID, and stored envelope are from the same row. Never retry with weakened or omitted context.

The vault is unavailable after clearing site data

This is expected: the device key lived in browser storage. Restore with the saved Recovery Kit or approve pairing from another unlocked device.

Onboarding is stuck in sync-pending

Preserve the locally generated Recovery Kit and call resumeSync(). Do not call createVault() again or overwrite local state.

A paired browser has no local Recovery Kit

Unlock with its device envelope. Recovery Kit replacement and recovery-secret unlock require importing the user’s saved kit; pairing intentionally does not copy the kit.

Last updated on August 4, 2026

Was this page helpful?