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.

TypeCodeWhat it holds
login1Username, password, TOTP seed, matching URIs, password history
card2Cardholder, brand, number, expiry, security code
identity3Name, address, and identity document numbers
note4Free text, plus any custom fields
key5SSH and PGP keys, API tokens, certificates, .env secrets
account6A provider account and the OAuth tokens that act as it

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("<ns>:vault:wrap:v1")     → wrap key  → AES-GCM → protected user key
      ├─ HKDF("<ns>:vault:auth:v1")     → auth hash → server (hashed again there)
      └─ HKDF("<ns>: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 = "<ns>:vault:item:<v>:<id>")
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 :v2label, 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.

SchemaFile
Itemlogicsrc-opencreds-item.schema.json
Item envelopelogicsrc-opencreds-envelope.schema.json
Vault metadatalogicsrc-opencreds-vault-meta.schema.json
Databaselogicsrc-opencreds-database.schema.json
Manifestlogicsrc-opencreds-manifest.schema.json
Audit eventlogicsrc-opencreds-audit-event.schema.json
Overview

What the standard defines, and what it deliberately does not.

Credential Sharing

The sync half: moving a key/value pair between providers.

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.