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 |
|---|---|---|
| login | 1 | Username, password, TOTP seed, matching URIs, password history |
| card | 2 | Cardholder, brand, number, expiry, security code |
| identity | 3 | Name, address, and identity document numbers |
| note | 4 | Free text, plus any custom fields |
| key | 5 | SSH and PGP keys, API tokens, certificates, .env secrets |
| account | 6 | A 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>")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.
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.
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.
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.
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.
The user key is wrapped by a key derived from a master password. One person, one password, one vault.
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 |
|---|---|
| Item | logicsrc-opencreds-item.schema.json |
| Item envelope | logicsrc-opencreds-envelope.schema.json |
| Vault metadata | logicsrc-opencreds-vault-meta.schema.json |
| Database | logicsrc-opencreds-database.schema.json |
| Manifest | logicsrc-opencreds-manifest.schema.json |
| Audit event | logicsrc-opencreds-audit-event.schema.json |
How it relates to the other specs
key item: a synced .env entry, stored rather than moved.account item’s provider is an ontology entity, not a free string, where an ontology is in use.