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,favoriteand 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_attimestamp.
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:
| Property | Personal cloud sync | Team vault |
|---|---|---|
| Key | personal vault key (per identity) | team key (per team) |
| Key escrow | none — wrap under your passphrase via Witnessed Recovery | escrowed under your personal vault key so a clean reinstall finds the team key |
| Concurrency | the same identity on multiple devices | many identities, each potentially editing concurrently |
| Reconciliation | per-entry synced_at + remote_rev, pull/push | compare-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?" |
| Loop | sync once per visibility/focus, then idle | 8-second background poll while the tab is visible |
| Concurrent-edit guard | last-write-wins, conflict copies kept | advisory edit-locks (~30 s TTL, heartbeated while the editor is open) |
| Surfaced state | cloud sync stat tile · <synced>/<total> · last-sync time | liveness 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.
One-time share links
Sharing a single entry does not reuse the vault key. The share flow:
- draws a fresh 32-byte AES key and 12-byte nonce,
- encrypts the chosen entry's fields under that key,
- uploads only the ciphertext to
/api/share, and - 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
| Package | Role |
|---|---|
@orangecheck/lock-crypto | X25519, HKDF-SHA256, AES-256-GCM, b64url, random — the primitives. |
@orangecheck/lock-core | The .lock envelope shapes used by signed single-entry export. |
@orangecheck/lock-device | Per-browser X25519 device key — the basis for multi-device sync. |