+
+
+
LogicSRC standards surface
+
OpenCreds
+
+ An open standard for credential records and portable vaults. It defines
+ what a credential item is, how a vault is encrypted, and what a vault looks like as a
+ file — so that moving a vault between two products is a supported operation rather than
+ a plaintext CSV export.
+
+
+
+ It exists because leaving a password manager currently means writing every secret you own
+ to disk in the clear, and losing whatever the spreadsheet had no column for. A CSV is
+ plaintext by construction, lossy by omission, and carries no integrity: nothing in it says
+ which rows were meant to be there, so a truncated import looks exactly like a complete one.
+
+
+ Status: 0.1 draft. Reference implementation:{" "}
+ @logicsrc/opencreds. A conforming vault is a file and a key — no
+ account, no server, no network call.
+
+
+
+
+
+
One record, six types
+
+ Logins, cards, identities, notes, keys and accounts are not six features. They are one
+ record with a type and a named field group, so everything the
+ user typed lives inside a single encrypted blob — which is what makes password history
+ free: it is an array in that blob, encrypted by construction rather than needing its own
+ protected table.
+
+
+
+
+
+
+ | Type |
+ Code |
+ What it holds |
+
+
+
+ {TYPES.map(([type, code, holds]) => (
+
+ | {type} |
+ {code} |
+ {holds} |
+
+ ))}
+
+
+
+
+ The type code is stored in plaintext beside the ciphertext so a server can filter and
+ paginate without decrypting. That is the metadata the design accepts leaking, and it says
+ so rather than obscuring it: a server learns you hold forty logins and two cards, never
+ which sites or what values.
+
+
{`{
+ "v": 1,
+ "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21",
+ "type": "login",
+ "name": "GitHub",
+ "folderId": null,
+ "login": {
+ "username": "anthony",
+ "password": "…",
+ "totp": "otpauth://totp/GitHub:anthony?secret=…",
+ "uris": [{ "uri": "https://github.com", "match": "domain" }]
+ },
+ "history": [],
+ "createdAt": "2026-08-29T00:00:00.000Z",
+ "updatedAt": "2026-08-29T00:00:00.000Z"
+}`}
+
+
+
+
+
One envelope, one key hierarchy
+
AES-256-GCM over the record, with the item id bound in as additional authenticated data.
+
+
{`master password
+ │ PBKDF2-HMAC-SHA256(salt, 600,000) ← the only expensive step
+ ▼
+ master key (32 bytes) never encrypts anything itself
+ │
+ ├─ HKDF(":vault:wrap:v1") → wrap key → AES-GCM → protected user key
+ ├─ HKDF(":vault:auth:v1") → auth hash → server (hashed again there)
+ └─ HKDF(":vault:recovery:v1") → recovery wrap → recovery blob
+
+ user key (32 random bytes) ← what every item is actually encrypted under
+ │
+ └─ AES-256-GCM(iv, item JSON, AAD = ":vault:item::")`}
+
+
+
Why the id is in the AAD.
+
+ Without it a ciphertext is portable between rows. Anyone with write access to the
+ storage could copy a low-value login’s ciphertext into a high-value one’s
+ row and watch what the user does next — they unlock, see the credential they expected
+ under a name they trust, and use it. With the id bound in, that swap fails to decrypt.
+
+
+
+
Why the user key is random, not derived.
+
+ A master password change re-wraps 32 bytes. Derive item keys from the password instead
+ and every change rewrites every item — a long window in which a partial failure leaves
+ half the vault openable by the old password and half by the new.
+
+
+
+
Why the auth hash cannot decrypt.
+
+ The wrap key and the auth hash come out of the same master key under different HKDF
+ labels, whose outputs are computationally independent. A server holding every auth hash
+ it has ever seen holds nothing that helps it derive a wrapping key. That is what makes
+ “the server cannot read the vault” a property rather than a promise.
+
+
+
+
Why the KDF floor is checked in the client.
+
+ Parameters arrive from a server, so they are attacker-controlled the moment it is
+ compromised. A client that trusted iterations: 1 would hand
+ an attacker who has been capturing auth hashes an offline guessing exercise with no
+ work factor. Conforming clients refuse below 100,000 before deriving anything.
+
+
+
+
+
+
+
+
One file
+
+ A vault exports as a single .opencreds JSON document,
+ encrypted by default, whose header is bound as additional authenticated data over the
+ payload.
+
+
+
{`{
+ "opencreds": "0.1",
+ "type": "opencreds.database",
+ "protected": true,
+ "namespace": "opencreds",
+ "exportedAt": "2026-08-29T18:00:00.000Z",
+ "generator": { "name": "@logicsrc/opencreds", "version": "0.1.0" },
+ "kdf": { "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "…" },
+ "manifest": {
+ "itemCount": 42,
+ "types": { "login": 38, "card": 2, "key": 1, "account": 1 },
+ "folderCount": 3,
+ "digest": "…"
+ },
+ "iv": "…",
+ "ciphertext": "…"
+}`}
+
+ Because the header is the AAD, the manifest is authenticated by the same tag as the data.
+ The counts can be shown in a preview before anyone types a passphrase, and they cannot be
+ lied about. After decrypting, a conforming implementation recomputes all four fields and
+ refuses the import if any disagrees.
+
+
+ That is the difference between an import you can trust and a CSV. A CSV truncated at 3,000
+ rows imports 3,000 rows and reports success. A database that was truncated does not decrypt
+ at all; one edited after decryption fails its digest. There is no state in which a
+ conforming implementation reports a complete import of an incomplete file.
+
+
+
The plaintext form exists, and it is loud.
+
+ Some people are moving to a product that reads nothing else, and an export
+ format that cannot express that gets worked around with a script that is worse — no
+ warning, no file mode, no label. So it is specified: never the default, an explicit flag
+ plus a confirmation, owner-only file mode, and{" "}
+ "protected": false in the header so tooling can
+ identify the file without parsing the rest of it.
+
+
+
+
+
+
+
Namespaces, and why they are data
+
+
+ Every domain-separation label is prefixed by the vault’s{" "}
+ namespace. This is not decoration. A label is compiled into the
+ additional authenticated data of every ciphertext a vault has ever written, and into the
+ HKDF derivation of its keys. Change a label string and every vault in the world that used
+ it becomes undecryptable — not corrupted, not recoverable, undecryptable.
+
+
+ So labels are append-only in the strongest sense available: superseded by a new{" "}
+ :v2 label, never edited. And because MarkSyncr’s vault
+ shipped with marksyncr:vault:* labels before this specification
+ existed, the prefix is carried as a per-vault property. A deployed vault declares its
+ namespace and is conformant; a new one uses opencreds.
+
+
+
+
+
+
Two profiles, one envelope
+
A profile is how the user key is managed. The item envelope is identical in both.
+
+
+
+
user
+
+ The user key is wrapped by a key derived from a master password. One person, one
+ password, one vault.
+
+
+
+
team
+
+ The vault key is random and sealed to each member’s X25519 public key. The server
+ holds one wrapped key per member and never the key itself; granting access is an
+ existing member unwrapping and re-sealing. This is the scheme{" "}
+ logicsrc credentials already implements — OpenCreds adds only
+ the observation that the thing being wrapped can be a vault of items rather than a bag
+ of strings.
+
+
+ Stated plainly: every member holding the vault key reads every item in it. Revoking a
+ member means rotating the key and re-encrypting, because a key they held is a key they
+ may have kept. Partial sharing is not a feature of a shared key; it is a second vault.
+
+
+
+
+
+
+
+
Using it
+
+ The same commands ship as logicsrc vault … and as the
+ standalone opencreds binary, from one implementation, so the
+ two cannot drift.
+
+
+
{`# Create a vault; prints a recovery key exactly once
+logicsrc vault init
+
+# Add items
+logicsrc vault add login --name GitHub --username anthony --url https://github.com
+logicsrc vault add card --name "Visa ending 4242"
+logicsrc vault add key --name "deploy key" --key-type ssh --file ~/.ssh/id_ed25519
+
+# List and read; never prints a secret unless you name one
+logicsrc vault list --type login
+logicsrc vault get GitHub --field login.password --reveal
+
+# Move the vault, encrypted, and preview before writing
+logicsrc vault export --out vault.opencreds
+logicsrc vault import vault.opencreds --dry-run
+
+# Arrive from somewhere else
+logicsrc vault import bitwarden-export.csv --source bitwarden --dry-run`}
+
+ Importers ship for Bitwarden, 1Password, Chrome, LastPass and KeePass. A row that cannot be
+ mapped is reported with its line number and a reason rather than dropped — the person still
+ has the source file, and only knows to go back for it if they are told.
+
+
+
+
+
+
Schemas
+
+ Published in @logicsrc/schemas as JSON Schema draft 2020-12,
+ so a third party can conform without reading LogicSRC source.
+
+
+
+
+
+
+ | Schema |
+ File |
+
+
+
+ {SCHEMAS.map(([name, file]) => (
+
+ | {name} |
+ {file} |
+
+ ))}
+
+
+
+
+
+
+
+
+
How it relates to the other specs
+
+
+
+ Credential Sharing moves secrets between providers — .env,
+ Doppler, Railway, GitHub, SSH — and models a key/value pair and a sync plan. OpenCreds
+ models the record and the vault file. They meet at the{" "}
+ key item: a synced .env entry, stored rather than moved.
+
+
+ OpenContext governs what an agent may read. An agent resolving
+ a context bundle may be entitled to one OpenCreds item and not the vault; the permission
+ decision is OpenContext’s, the record shape is OpenCreds’.
+
+
+ OpenOntology names the entities a credential belongs to. An{" "}
+ account item’s provider is an
+ ontology entity, not a free string, where an ontology is in use.
+
+
+
+
+ );
+}
diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts
index ec26fa3..46b9682 100644
--- a/apps/logicsrc-web/src/app/sitemap.ts
+++ b/apps/logicsrc-web/src/app/sitemap.ts
@@ -17,6 +17,7 @@ const STATIC_ROUTES: Array<{
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
{ path: "/docs", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology", changeFrequency: "weekly", priority: 0.9 },
+ { path: "/opencreds", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openprd", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology/explore", changeFrequency: "daily", priority: 0.7 },
{ path: "/openspec", changeFrequency: "weekly", priority: 0.8 },
diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx
index 13b5347..9375896 100644
--- a/apps/logicsrc-web/src/components/site-shell.tsx
+++ b/apps/logicsrc-web/src/components/site-shell.tsx
@@ -10,6 +10,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/agentbyte", label: "AgentByte" },
{ href: "/credential-sharing", label: "Credentials" },
{ href: "/openontology", label: "OpenOntology" },
+ { href: "/opencreds", label: "OpenCreds" },
{ href: "/openprd", label: "OpenPRD" },
{ href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" },
diff --git a/apps/logicsrc-web/src/lib/docs.ts b/apps/logicsrc-web/src/lib/docs.ts
index 924958e..1c02bb7 100644
--- a/apps/logicsrc-web/src/lib/docs.ts
+++ b/apps/logicsrc-web/src/lib/docs.ts
@@ -8,6 +8,7 @@ const DOCS_DIR = resolve(process.cwd(), "../../docs");
// Curated, public-facing reference docs. Internal notes (roadmap, positioning,
// arcade) are intentionally excluded.
export const DOC_SLUGS = [
+ "opencreds",
"openprd",
"openontology",
"openontology-governance",
diff --git a/docs/opencreds.md b/docs/opencreds.md
new file mode 100644
index 0000000..2a3caa0
--- /dev/null
+++ b/docs/opencreds.md
@@ -0,0 +1,129 @@
+# OpenCreds
+
+Status: 0.1 draft · reference implementation available (`@logicsrc/opencreds`)
+
+Slug: `opencreds`
+
+OpenCreds is a LogicSRC OpenSpec for **credential records and portable vaults**.
+It defines what a credential item is, how a vault is encrypted, and what a vault
+looks like as a file — so that moving a vault between two products is a
+supported operation rather than a plaintext CSV export.
+
+It exists because leaving a password manager currently means writing every
+secret you own to disk in the clear, and losing whatever the spreadsheet had no
+column for.
+
+- Full specification: [`docs/opencreds/spec.md`](./opencreds/spec.md)
+- Item model: [`docs/opencreds/item-model.md`](./opencreds/item-model.md)
+- Cryptography: [`docs/opencreds/crypto.md`](./opencreds/crypto.md)
+- Portable database: [`docs/opencreds/database.md`](./opencreds/database.md)
+- Importing from other products: [`docs/opencreds/interop.md`](./opencreds/interop.md)
+- CLI: [`docs/opencreds/cli.md`](./opencreds/cli.md)
+- Conformance: [`docs/opencreds/conformance.md`](./opencreds/conformance.md)
+- Security model: [`docs/opencreds/security.md`](./opencreds/security.md)
+- FAQ: [`docs/opencreds/faq.md`](./opencreds/faq.md)
+
+## What it defines
+
+**One record, six types.** Logins, cards, identities, notes, keys and accounts
+are not six features — they are one record with a `type` and a named field
+group. Everything the user typed lives inside a single encrypted blob, which is
+what makes password history free: it is an array in that blob, encrypted by
+construction rather than needing its own protected table.
+
+```json
+{
+ "v": 1,
+ "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21",
+ "type": "login",
+ "name": "GitHub",
+ "folderId": null,
+ "notes": "",
+ "login": {
+ "username": "anthony",
+ "password": "…",
+ "totp": "otpauth://totp/GitHub:anthony?secret=…",
+ "uris": [{ "uri": "https://github.com", "match": "domain" }]
+ },
+ "history": [],
+ "createdAt": "2026-08-29T00:00:00.000Z",
+ "updatedAt": "2026-08-29T00:00:00.000Z"
+}
+```
+
+**One envelope.** AES-256-GCM over that JSON, with the item id bound in as
+additional authenticated data. A ciphertext moved from one row to another fails
+to decrypt rather than quietly showing the wrong credential — without that,
+anyone with database write access could swap a low-value login's ciphertext into
+a high-value one and watch what the user does next.
+
+**One key hierarchy.** The master password is stretched once by PBKDF2-SHA256
+into a master key, and everything else is derived from it by HKDF with a
+distinct label. The only password-derived value that ever reaches a server comes
+out of a different label than the wrapping key, so holding it does not help
+decrypt anything.
+
+**One file.** A vault exports as a single `.opencreds` JSON document, encrypted
+by default, carrying a manifest — item count, type histogram, digest over the
+item ids — that is authenticated by the same tag as the data. A truncated
+import fails instead of looking like a complete one.
+
+## What it does not define
+
+Sync. Storage. Autofill. A conforming vault is a file and a key; how two devices
+reconcile, where the ciphertext lives, and how a browser fills a form are all
+left to the implementation.
+
+## Implementations
+
+| Implementation | Profile | Namespace | Notes |
+| --- | --- | --- | --- |
+| `@logicsrc/opencreds` | `user`, `team` | `opencreds` | Reference implementation; local store and CLI |
+| `logicsrc credentials` | `team` | `opencreds` | `.env` secrets and SSH keys as `key` items |
+| `@marksyncr/vault` | `user` | `marksyncr` | Pre-dates the spec; conformant via its declared namespace |
+
+MarkSyncr's vault shipped before OpenCreds and has domain-separation labels
+baked into every ciphertext already written. Labels cannot be edited — changing
+one makes every existing vault undecryptable — so the spec carries the label
+prefix as a declared per-vault `namespace` rather than mandating a single
+string. See [crypto.md](./opencreds/crypto.md#namespaces).
+
+## Quick start
+
+```bash
+# Create a vault (asks for a master password; prints a recovery key once)
+logicsrc vault init
+
+# Add items
+logicsrc vault add login --name GitHub --username anthony --url https://github.com
+logicsrc vault add card --name "Visa ending 4242"
+logicsrc vault add key --name "deploy key" --key-type ssh --file ~/.ssh/id_ed25519
+
+# List (never prints secret values)
+logicsrc vault list --type login
+
+# Move the vault somewhere else, encrypted
+logicsrc vault export --out vault.opencreds
+logicsrc vault import vault.opencreds --dry-run
+
+# Import from another product
+logicsrc vault import bitwarden-export.csv --source bitwarden --dry-run
+```
+
+The same commands ship as the standalone `opencreds` binary, so
+`logicsrc vault validate` and `opencreds validate` are the same contract.
+
+## Relationship to the other LogicSRC specs
+
+- **Credential Sharing** ([credential-sharing.md](./credential-sharing.md))
+ moves secrets *between providers* — `.env`, Doppler, Railway, GitHub, SSH. It
+ models a key/value pair and a sync plan. OpenCreds models the **record** and
+ the **vault file**. A `key` item is what a synced `.env` entry becomes when it
+ is stored rather than moved.
+- **OpenContext** ([opencontext.md](./opencontext.md)) governs what an agent may
+ *read*. An agent that resolves a context bundle may be entitled to one
+ OpenCreds item and not the vault; the permission decision is OpenContext's,
+ the record shape is OpenCreds'.
+- **OpenOntology** ([openontology.md](./openontology.md)) names the entities a
+ credential belongs to. An `account` item's `provider` is an ontology entity,
+ not a free string, where an ontology is in use.
diff --git a/docs/opencreds/cli.md b/docs/opencreds/cli.md
new file mode 100644
index 0000000..851d878
--- /dev/null
+++ b/docs/opencreds/cli.md
@@ -0,0 +1,155 @@
+# The OpenCreds CLI
+
+The CLI is part of the conformance surface: flags, output shapes and exit codes
+are specified, not incidental. The same commands ship twice — as
+`logicsrc vault …` and as the standalone `opencreds` binary — from one
+implementation, so the two can never drift.
+
+## Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success. |
+| 1 | Usage error — unknown flag, missing argument, unreadable file. |
+| 2 | Validation failure — a document did not conform. |
+| 3 | Crypto failure — wrong password, failed tag, manifest mismatch. |
+| 4 | Refused — the operation needs a confirmation that was not given. |
+
+## Vault
+
+```bash
+opencreds init [--namespace opencreds] [--iterations 600000] [--password-stdin]
+```
+
+Creates a vault. Prompts for a master password twice, prints a recovery key
+once, and never prints it again. Refuses if a vault already exists at the target
+unless `--force`. `--password-stdin` reads one line instead and skips the
+confirmation — for scripted provisioning, where there is nobody to mistype.
+
+```bash
+opencreds unlock # prints a session token to export
+opencreds unlock --persist [--timeout 15]
+opencreds lock # drops a persisted session
+opencreds status # vault present? locked? counts by type
+```
+
+Unlocking has two shapes, and the difference is a flag rather than a default
+because it is a real trade:
+
+- **Token (default).** `unlock` prints `export OPENCREDS_SESSION="…"`. Nothing
+ touches disk and the session dies with the shell.
+- **Persisted (`--persist`).** The same token in a 0600 file with an expiry, so
+ a script can unlock once and run many commands. A readable user key on disk is
+ the vault; the command says so when you use it, and `lock` removes it.
+
+`status` is the one command that works locked. It reports counts and never
+values, because counts are already observable to whoever holds the storage.
+
+```bash
+opencreds recover # unlock with the recovery key, set a new password
+```
+
+A password change re-wraps the user key. Not one item is re-encrypted, which is
+why it is instant on a vault of any size.
+
+## Items
+
+```bash
+opencreds add