oc · docs
docs / protocol

OC Vault · Protocol

OC Vault is the consumer productization of OC Lock Flow 4 — the self-vault pattern. This page describes how vault entries are sealed, how cloud sync stays metadata-blind, and what the portable export actually contains. For the normative envelope rules see the oc-lock-protocol spec.

Why Flow 4 is shaped differently from Flows 1–3

OC Lock's Flows 1–3 (device-to-device, group, time-locked) seal each message as a full BIP-322-signed .lock envelope. That requires a wallet sign-prompt per seal — fine for a one-off file drop, wrong for password-manager UX where a user adds fifty entries in a sitting.

Flow 4 keeps the Lock primitives (X25519, HKDF-SHA256, AES-256-GCM, RFC 8785 canonicalization) but defers envelope construction to export time. Day-to-day writes are sealed under a symmetric vault key; a full signed .lock envelope is produced only when the user explicitly chooses to share or back up an entry.

The vault key

On first load the browser generates a vault key: 32 cryptographically-random bytes, persisted in IndexedDB.

  • It is the AES-256-GCM key-encryption key for every entry.
  • It never leaves the device. OC never receives it, and the protocol has no mechanism by which it could.
  • Its sha256[:8] fingerprint is surfaced in settings so a user can verify device parity.

Lose the vault key with no export and no cloud sync, and the entries are unrecoverable — by construction. There is no escrow.

The entry envelope

Each entry is a small JSON record. The secret payload is encrypted; a thin layer of metadata is left in clear so the local UI can index it.

{
    "id": "9f2c…", // 16 random bytes, hex
    "type": "password",
    "name": "github · primary", // plaintext — local index only
    "nonce": "b64url(12-byte nonce)",
    "ciphertext": "b64url(AES-256-GCM(JSON(fields)))",
    "created_at": "2026-05-15T…Z",
    "updated_at": "2026-05-15T…Z",
    "favorite": false,
    "tags": ["work"],
    "deleted_at": null // set ⇒ tombstoned (in trash)
}

The fields object inside the ciphertext is type-specific — for a password entry it holds username, password, url, an optional totp secret, notes, a passwordHistory array of superseded values, and any user-defined custom fields. A fresh 12-byte nonce is drawn per encryption, so re-saving an unchanged entry still produces fresh ciphertext.

Local-only metadata. name, type, tags, favorite and the timestamps are not encrypted in the local IndexedDB record — they index the dashboard. They are, however, sealed before they ever reach the server. See below.

Cloud sync · the double-encrypted blob

Cloud sync (a paid tier) mirrors encrypted entries to an OC-hosted blob store so they reach every device signed in with the same Bitcoin identity. To keep OC blind even to entry metadata, the entire entry record is wrapped a second time before transit:

VaultEntry JSON  (id + type + name + nonce + ciphertext + dates + tags)
        │
        ▼  AES-256-GCM(  ·  , vault_key, fresh blob_nonce)     ← outer wrap
        │
        ▼
{ "v": 1, "blob_nonce": "b64url(…)", "blob_ct": "b64url(…)" }  ← what is uploaded

The server therefore stores, per entry, only:

  • a random 16-byte envelope id, and
  • an updated_at timestamp.

It cannot read the entry name, the type, the tag list, or even the inner ciphertext's length-class. Both layers use the same vault key with independent nonces; that is safe — AES-GCM is IND-CPA secure under fresh nonces.

Reconciliation is last-write-wins on updated_at. Deletes are soft: a deleted entry keeps its row with deleted_at set and a bumped updated_at, so the tombstone propagates like any other change — a delete on one device reaches the others. A purge from the trash is the only hard delete.

Team vaults · a different sync signal

A team vault is a second, parallel vault sealed under a separate 32-byte team key rather than your personal vault key. Family Circle creates one team you own; the creating browser generates the team key locally, and every new member receives it through an invite URL whose fragment (#…) carries the key. URL fragments are never sent to a server, so vault.ochk.io never holds the team key any more than it holds the personal vault key.

Team entries reuse the same packEntryForCloud envelope as the personal double-encrypted blob, only with the team key in both layers. They are stored on a separate endpoint family (/api/teams/<id>/blobs) and keyed by random envelope id, so the server still sees ciphertext plus an updated_at.

The collaborative signal is different from personal sync, and the client-side machinery reflects that:

PropertyPersonal cloud syncTeam vault
Keypersonal vault key (per identity)team key (per team)
Key escrownone — wrap under your passphrase via Witnessed Recoveryescrowed under your personal vault key so a clean reinstall finds the team key
Concurrencythe same identity on multiple devicesmany identities, each potentially editing concurrently
Reconciliationper-entry synced_at + remote_rev, pull/pushcompare-and-swap on the server updated_at, plus a poll loop
Freshness model"are my local edits backed up yet?""how fresh is what I'm looking at relative to my teammates' writes?"
Loopsync once per visibility/focus, then idle8-second background poll while the tab is visible
Concurrent-edit guardlast-write-wins, conflict copies keptadvisory edit-locks (~30 s TTL, heartbeated while the editor is open)
Surfaced statecloud sync stat tile · <synced>/<total> · last-sync timeliveness stat tile · last-refresh time · live across teammates

The polling loop is index-gated and cheap, but it's still a poll — that's why the team page surfaces its own liveness tile (last-refresh time, with a subtle "refreshing…" while a tick is in flight) and a manual refresh button. While an entry is open in the editor, a tick still refreshes the advisory edit-locks (so the "being edited by …" badge stays live for teammates) but skips the entry-list reload — that preserves the invariant that a background poll never yanks the list out from under an open editor.

The portable export

"Zero lock-in" is a protocol property, not a marketing line. Export produces a single JSON snapshot:

{
    "format": "oc-vault-export",
    "version": 1,
    "exported_at": "2026-05-15T…Z",
    "identity": "bc1q…",
    "entries": [
        /* VaultEntry records, ciphertext intact */
    ]
}

The entries carry the same per-entry ciphertext as IndexedDB. Anyone holding the vault key can decrypt them with @orangecheck/lock-crypto directly — the AES-256-GCM, b64url and JSON conventions are all documented and stable. If vault.ochk.io shut down tomorrow, this file would still open.

Sharing a single entry does not reuse the vault key. The share flow:

  1. draws a fresh 32-byte AES key and 12-byte nonce,
  2. encrypts the chosen entry's fields under that key,
  3. uploads only the ciphertext to /api/share, and
  4. hands the user a URL whose fragment (#…) carries the AES key.

Because URL fragments are never sent to a server, vault.ochk.io sees ciphertext only. The row is retrieve-once: it self-destructs on first successful read or at TTL expiry (1 h / 24 h / 7 d).

SDK surface

PackageRole
@orangecheck/lock-cryptoX25519, HKDF-SHA256, AES-256-GCM, b64url, random — the primitives.
@orangecheck/lock-coreThe .lock envelope shapes used by signed single-entry export.
@orangecheck/lock-devicePer-browser X25519 device key — the basis for multi-device sync.