Add the LogicSRC OpenCreds specification (#140)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

* Add the LogicSRC OpenCreds specification

Leaving a password manager 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.

The same gap showed up inside LogicSRC. `logicsrc credentials` moves
.env secrets and SSH keys through end-to-end-encrypted team vaults, but
it can only model a key/value pair. A card, a passport, a login with a
TOTP seed, or an OAuth account with a refresh token are all things
people already keep in a vault, and none of them are a key/value pair.

OpenCreds defines three things: the item, the vault, and the database.

- Six item types (login, card, identity, note, key, account) as one
  record with a type and a named field group, so everything the user
  typed lives in a single encrypted blob. Codes 1-4 match MarkSyncr's
  deployed vault and are not renumbered; compatibility is cheaper than
  elegance.
- AES-256-GCM over that record with the item id bound as AAD. Without
  it, anyone with storage write access could move a low-value login's
  ciphertext into a high-value row and watch what the user does next.
- A key hierarchy where the user key is random, not derived, so a
  password change re-wraps 32 bytes rather than re-encrypting a vault.
  The auth hash comes out of a different HKDF label than the wrap key,
  which is what lets it reach a server at all.
- A portable .opencreds file, encrypted by default, whose header is the
  AAD over the payload -- so the manifest is authenticated by the same
  tag as the data and a truncated import fails rather than reporting
  success. The plaintext form exists because people move to products
  that read nothing else; it is opt-in, confirmed, 0600, and labelled
  "protected": false in its own header.

Namespaces are carried as data, not fixed by the spec: labels are
compiled into every ciphertext a vault has written, so editing one does
not migrate a vault, it makes it undecryptable. MarkSyncr's deployed
vault is conformant by declaring `marksyncr`.

Ships: prd/0004, nine spec pages under docs/opencreds/, six JSON
Schemas, the @logicsrc/opencreds reference implementation with CSV
importers for five products, `logicsrc vault` and the standalone
`opencreds` binary, and the spec page at logicsrc.com/opencreds.

`vault` rather than `creds` because `creds` is already an alias of
`logicsrc credentials`, and the two are different: one moves a pair
between providers, the other stores a record.

@logicsrc/validators now registers every schema by $id before
compiling, so the database schema can $ref the item and manifest
schemas rather than restating them.

120 tests, including CLI end-to-end coverage of the masking rules,
exit codes, and the manifest-mismatch path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5

* Make the OpenCreds conformance claim executable

The conformance page described a fixture suite and an `opencreds
conformance` command that did not exist. A specification that documents
a conformance surface it cannot run is a specification nobody can hold
to, including us.

`opencreds conformance` now runs the requirement list as code -- one
check per C-number, carrying its own id and level -- and emits the
report shape the spec publishes. It exits 2 when a MUST does not pass,
so it can gate CI directly. The reference implementation reports 29
passed, 0 failed, 1 skipped; the skip is C19, because key management for
the team profile lives in @logicsrc/plugin-credential-sharing rather
than in this package, and a skipped MAY does not affect conformance.

Fixtures are generated (`--emit-fixtures <dir>`) rather than
hand-written. A vector produced by an implementation and then verified
by it is worth more than a JSON file someone typed: the typed file
drifts silently when the format moves, and the generated one cannot.
Fourteen files, including an invalid/ set every conforming reader must
reject -- a wrong field group, a weak KDF, an unregistered namespace, a
short payload and a tampered manifest.

The CLI requirements stay with the end-to-end tests that drive the real
binary through a child process; a command cannot meaningfully check its
own exit codes, and a masked value that is only masked in the library is
not masked.

conformance.md and cli.md now describe what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5

* Add @logicsrc/opencreds to the lockfile

`npm ci` refuses a lockfile that does not match package.json, and the
new workspace package plus the CLI's dependency on it were never
recorded: the worktree was bootstrapped by hardlinking node_modules
rather than installing, so npm was never asked to update the lock.

Adds the workspace link and the package entry. No dependency versions
move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5

* Register PRD 0004, and stop the fixtures looking like real secrets

Two CI failures, both mine.

`prd/README.md` is generated by `logicsrc prd index --write` and the
scaffold test asserts it is current, so adding a PRD without
regenerating it leaves the repo's own conformance check failing.
Regenerated. The MCP test asserts the next free PRD id against the live
prd/ directory — its comment says it advances with every PRD added — so
it moves to 0005.

ThreatCrush flagged three of the example strings: a PEM header in the
item-model docs and in the conformance fixture, and an `sk_live_`
prefixed token. All placeholders, none real, but the finding is the
scanner working. A fixture only has to exercise the field, and a
real-looking private key header or live-key prefix sitting in the tree
trains both the scanner and the people reading its output to shrug at
exactly the shape that matters. Replaced with obvious placeholders
rather than suppressing the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-08-29 04:11:38 -07:00 committed by GitHub
parent b1805d08e5
commit 80a36269bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 8903 additions and 9 deletions

129
docs/opencreds.md Normal file
View file

@ -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.

155
docs/opencreds/cli.md Normal file
View file

@ -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 <type> --name <name> [type flags…]
opencreds list [--type <type>] [--folder <name>] [--search <text>] [--json]
opencreds get <id|name> [--field <path>] [--reveal]
opencreds edit <id|name> [flags…]
opencreds rm <id|name> [--purge]
opencreds restore <id>
```
`list` and `get` MUST NOT print secret values by default. `get` prints the item
with every secret field masked; `--reveal` prints one field named by `--field`,
so revealing is always a deliberate act naming a single value. `--json` output is
masked identically — a pipeline is not an authorization.
Type flags follow the field group names, kebab-cased:
`--username`, `--password`, `--totp`, `--url`,
`--cardholder-name`, `--number`, `--exp-month`, `--exp-year`, `--code`,
`--first-name`, `--last-name`, `--email`, `--phone`, `--address1`, …,
`--key-type`, `--algorithm`, `--public-key`, `--private-key`, `--file`, `--path`, `--mode`,
`--provider`, `--account-id`, `--handle`, `--access-token`, `--refresh-token`, `--scope`.
`--password -` and every other secret flag read from stdin when given `-`, so a
secret need not appear in the shell history or the process list.
## Database
```bash
opencreds export [--out vault.opencreds] [--passphrase-stdin]
opencreds export --plaintext --out vault.json --yes
opencreds export --format bitwarden-csv --out vault.csv --yes
opencreds import <file> [--dry-run] [--merge skip|replace|duplicate]
opencreds import <file> --source bitwarden|onepassword|chrome|lastpass|keepass
```
`export` writes the encrypted form. `--plaintext` prints what it is about to do
and exits 4 without `--yes`.
`import` with `--dry-run` reports counts by type, folders to be created,
duplicates detected and rows that could not be mapped, and writes nothing. A
manifest mismatch exits 3 and writes nothing regardless of flags.
Output of a dry run:
```
opencreds import vault.opencreds --dry-run
Source vault.opencreds (opencreds 0.1, encrypted, namespace opencreds)
Exported 2026-08-29T18:00:00.000Z by @logicsrc/opencreds 0.1.0
Manifest verified — 42 items, 3 folders
login 38 2 already present (skip)
card 2
key 1
account 1
Folders Work, Personal (new), Archive
Skipped 0
Nothing written. Re-run without --dry-run to import.
```
## Validation
```bash
opencreds validate <file> # a database, or a plaintext item document
opencreds validate --stdin
```
Exits 0 when the document conforms, 2 when it does not, and prints one diagnostic
per failure with a JSON pointer into the document:
```
/items/17/login/uris/0/match "fuzzy" is not a valid match rule
/manifest/itemCount says 42, payload has 41
```
## Conformance
```bash
opencreds conformance # a table, one row per requirement
opencreds conformance --json # the report, for CI
opencreds conformance --emit-fixtures <dir> # generate the fixture set
```
Runs the suite against this implementation and reports each requirement as pass,
fail or skip. Exits 2 when a MUST does not pass, so it can gate CI directly. An
implementation claiming conformance SHOULD run it there.
`--emit-fixtures` writes the generated fixture set, so another implementation
can be tested against exactly what this one produces and accepts. See
[conformance.md](./conformance.md).
## What the CLI never does
- It never prints a secret value except through `get --reveal --field`.
- It never writes a plaintext file without an explicit flag and a confirmation.
- It never sends anything anywhere. There is no telemetry, no account, and no
network call in any command listed on this page.

View file

@ -0,0 +1,138 @@
# OpenCreds conformance
An implementation claims conformance by satisfying the requirements below and
passing the fixture suite. Run it with `opencreds conformance`.
## Requirement checklist
### Items
| # | Requirement | Level |
| --- | --- | --- |
| C1 | Reads and writes all six item types with their field groups. | MUST |
| C2 | Stamps `v`, `id`, `type`, `name`, `createdAt`, `updatedAt` on every item. | MUST |
| C3 | Preserves unknown top-level item fields on round trip. | MUST |
| C4 | Distinguishes an empty-string field from an absent one, both directions. | MUST |
| C5 | Caps password history at 20 entries, newest first. | MUST |
| C6 | Rejects a field group that does not match the item's `type`. | MUST |
| C7 | Round-trips attachment references without storing blobs. | SHOULD |
### Crypto
| # | Requirement | Level |
| --- | --- | --- |
| C10 | AES-256-GCM with a fresh 96-bit IV per encryption. | MUST |
| C11 | Binds `<ns>:vault:item:<v>:<id>` as AAD; a swapped ciphertext fails. | MUST |
| C12 | Verifies the decrypted `id` against the envelope `id`. | MUST |
| C13 | Refuses to derive below 100,000 PBKDF2 iterations. | MUST |
| C14 | Derives wrap, auth and recovery keys under distinct HKDF labels. | MUST |
| C15 | Generates the user key randomly; a password change re-wraps, not re-encrypts. | MUST |
| C16 | Returns partial results with a failure list when one item fails to decrypt. | MUST |
| C17 | Rejects an unregistered namespace unless explicitly opted in. | MUST |
| C18 | Refuses a vault whose `profile` it does not implement. | MUST |
| C19 | Supports the `team` profile. | MAY |
### Database
| # | Requirement | Level |
| --- | --- | --- |
| C20 | Writes the encrypted form by default. | MUST |
| C21 | Binds the header as AAD, so the manifest is authenticated. | MUST |
| C22 | Recomputes and verifies `itemCount`, `types`, `folderCount`, `digest`. | MUST |
| C23 | Writes nothing on a manifest mismatch. | MUST |
| C24 | Requires an explicit opt-in for the plaintext form. | MUST |
| C25 | Writes `protected: false` in a plaintext file's header. | MUST |
| C26 | Export → import → export produces byte-identical item records. | MUST |
| C27 | Does not restamp `createdAt` / `updatedAt` on import. | MUST |
| C28 | Reports per-strategy merge outcomes rather than one total. | SHOULD |
### CLI
| # | Requirement | Level |
| --- | --- | --- |
| C30 | Exit codes per [cli.md](./cli.md#exit-codes). | MUST |
| C31 | Never prints a secret value except `get --reveal --field`. | MUST |
| C32 | Masks secrets identically in `--json` output. | MUST |
| C33 | `--dry-run` writes nothing. | MUST |
| C34 | `status` works while locked and reports counts only. | SHOULD |
### Importers
| # | Requirement | Level |
| --- | --- | --- |
| C40 | CSV reader handles quotes, escaped quotes, embedded newlines and commas, CRLF, and a BOM. | MUST |
| C41 | Reports unmappable rows with row number and reason; never drops silently. | MUST |
| C42 | Detects sources most-specific first. | MUST |
## Running the suite
```bash
opencreds conformance # a table, one row per requirement
opencreds conformance --json # the report, for CI
```
Every requirement above with a C-number in the Items, Crypto, Database and
Importers tables is executed. The CLI requirements (C30C34) are asserted by the
reference implementation's own end-to-end tests, which drive the real binary
through a child process — a masked value that is only masked in the library is
not masked — rather than by this command, which cannot meaningfully check its
own exit codes.
## Fixtures
Fixtures are **generated**, not hand-written:
```bash
opencreds conformance --emit-fixtures ./fixtures
```
A vector produced by an implementation and then verified by it is worth more
than a JSON file someone typed: the typed file drifts silently when the format
moves, and the generated one cannot. Emit them from the reference
implementation and test your own code against exactly what it accepts.
| Path | Holds |
| --- | --- |
| `README.txt` | The fixture passphrase and what each directory is for. |
| `items/one-of-each.json` | One valid item per type — C1, C2. |
| `items/history-cap.json` | 25 changes in, 20 entries out, newest kept — C5. |
| `items/unknown-fields.json` | A v1 item carrying a field from a later version — C3. |
| `vault/meta.json` | Vault metadata; opens with the fixture passphrase. |
| `vault/envelopes.json` | One encrypted envelope per item type. |
| `vault/user-key.txt` | The base64 key those envelopes are under. |
| `database/encrypted.opencreds` | A six-item encrypted database — C20C22. |
| `database/plaintext.json` | The same vault, unprotected — C25. |
| `invalid/wrong-group.json` | A `card` group on a `login` item — C6. |
| `invalid/weak-kdf.json` | `kdfIterations: 1` — C13. |
| `invalid/unknown-namespace.json` | An unregistered namespace — C17. |
| `invalid/short-payload.json` | A plaintext database missing three items — C22. |
| `invalid/tampered-manifest.opencreds` | An edited `itemCount` — C21. |
Everything under `invalid/` MUST be rejected. The fixture passphrase is
`opencreds-fixture`; the fixture vaults derive at 100,000 iterations so a test
run is not dominated by PBKDF2.
## Reporting
`opencreds conformance --json` emits:
```json
{
"type": "opencreds.conformance_report",
"opencreds": "0.1",
"implementation": { "name": "@logicsrc/opencreds", "version": "0.1.0" },
"results": [
{ "id": "C11", "level": "MUST", "title": "Binds the item id as AAD, so a swapped ciphertext fails", "status": "pass" }
],
"summary": { "pass": 29, "fail": 0, "skip": 1 },
"conformant": true
}
```
`conformant` is true only when every MUST passes. A skipped MAY does not affect
it; a skipped or failed MUST does. The command exits 2 when the report is not
conformant, so it can gate CI directly.
The reference implementation reports 29 passed, 0 failed, 1 skipped: the skip is
C19, because key management for the `team` profile lives in
`@logicsrc/plugin-credential-sharing` rather than in this package.

147
docs/opencreds/crypto.md Normal file
View file

@ -0,0 +1,147 @@
# OpenCreds cryptography
The normative rules are [spec.md §4](./spec.md#4-the-vault). This page explains
why the construction is shaped the way it is, and what an implementer will get
wrong if they skip a step.
## The shape
```
master password
│ PBKDF2-HMAC-SHA256(salt, iterations) ← 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 user key is random, not derived
Because a master password change must not be a re-encryption of the vault. The
user key is generated once and wrapped; changing the password re-wraps 32 bytes.
Derive item keys from the password instead and every password change rewrites
every item — which, on a vault of any size, is a long window in which a partial
failure leaves half the vault openable by the old password and half by the new.
It also means the recovery path costs nothing extra: a second wrapping of the
same 32 bytes under a random recovery key, and a forgotten password is
survivable without the server learning anything it did not already hold.
## Why the auth hash cannot decrypt
The wrap key and the auth hash come out of the same master key through HKDF with
*different labels*. HKDF's guarantee is exactly this: outputs under distinct
info strings are computationally independent. A server holding every auth hash
it has ever seen holds nothing that helps it derive a wrap key.
That is what allows the auth hash to be sent at all. A scheme that sent the
wrapping key, or anything from which it could be recovered, would be a scheme
where "the server cannot read the vault" is a promise rather than a property.
## Why the item id is in the AAD
Without it, a ciphertext is portable between rows. Anyone with write access to
the storage — a compromised server, an operator, a leaked backup restored
somewhere writable — could copy the ciphertext of a low-value login into the row
of a high-value one and watch what the user does next. The user unlocks, sees
the credential they expected to see under a name they trust, and uses it.
With the id bound in, that swap fails to decrypt. The tag covers the id, and the
id is not in the ciphertext's control.
The version is in the AAD for the same reason at a different scale: it stops a
v2 record from being replayed as a v1 record once v2 exists.
## Why the KDF floor is a client-side check
KDF parameters are stored with the vault and, in a hosted deployment, are served
to the client at unlock time. That makes them attacker-controlled the moment the
server is compromised. A client that trusts `{"iterations": 1}` performs one
round of PBKDF2, derives an auth hash almost free, and hands an attacker who has
been capturing auth hashes an offline guessing exercise with no work factor at
all.
So the floor is enforced where it matters — in the client, before deriving —
and, in a database-backed deployment, again as a constraint on the column.
Defence in depth on the one value the user cannot see.
## Namespaces
Every label above is prefixed by the vault's declared `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 rather than fixed by the spec. A deployed vault
declares `"namespace": "marksyncr"` and is conformant; a new one uses
`opencreds`.
Registered: `opencreds`, `marksyncr`. Pattern: `^[a-z][a-z0-9-]{1,31}$`.
An implementation MUST reject an unregistered namespace on import unless the
operator opts in, because accepting an arbitrary prefix is accepting an
arbitrary derivation.
## Profiles
### `user`
Everything above. One person, one master password.
### `team`
The vault key is random and is wrapped to each member with `crypto_box_seal`
(X25519 anonymous sealed box) against that member's public key. The server holds
one wrapped key per member and never the key. Granting access is an existing
member unwrapping with their secret key and re-sealing to the new member's public
key — the plaintext key exists only in memory, on a machine that was already
authorized.
`logicsrc credentials` implements this today for `.env` secrets and SSH keys; see
[credential-sharing.md](../credential-sharing.md). OpenCreds adds nothing to it
except the observation that the thing being wrapped can be a vault of items
rather than a bag of strings.
The item envelope is identical under both profiles. That is the whole point: an
item exported from a personal vault imports into a team vault without
re-encoding, because only the key management differed.
**Threat model difference, stated plainly.** In the `team` profile, every member
who holds the vault key can read every item in it. Revoking a member means
rotating the key and re-encrypting, because the 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.
## Randomness
Every IV, salt, user key and recovery key MUST come from a cryptographic RNG
(`crypto.getRandomValues`, `crypto.randomBytes`). An IV MUST NOT be reused under
one key — with GCM, a repeated IV under the same key is not a weakness, it is a
break, and it leaks the XOR of two plaintexts along with the authentication
subkey.
Because the user key is per vault and IVs are per write, the safe construction
is simply: generate a fresh 12-byte IV on every single encryption, never derive
it, never count with it.
## What is not covered
- **Key stretching for the export passphrase** uses the same PBKDF2 parameters
as a vault. An export passphrase is typed once and often weaker than a master
password; an implementation SHOULD say so rather than silently accepting four
characters.
- **Memory hygiene.** Zeroing key material after use is out of scope for the
format and worth doing anyway where the runtime allows it. In a browser it
mostly does not.
- **Side channels.** Comparisons of secret-derived values (auth hashes, tags)
MUST be constant-time. Everything else in the format compares public data.

127
docs/opencreds/database.md Normal file
View file

@ -0,0 +1,127 @@
# The OpenCreds portable database
Normative rules: [spec.md §5](./spec.md#5-the-portable-database).
A database is a vault as one file. It is what you hand to another product, what
you keep as a backup, and what an implementation reads to import.
## Encrypted by default
```json
{
"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": "…"
}
```
Everything above `iv` is readable without the passphrase, and all of it is
authenticated by the tag on `ciphertext` — the header is the AAD. So the counts
can be shown in a preview before anyone types a passphrase, and they cannot be
lied about.
## The export key is not the vault key
An export is encrypted under a key derived from an **export passphrase**, not
under the vault's user key. A file encrypted under the user key would only open
inside the vault it came from, which is the opposite of portable.
```
export passphrase ─PBKDF2(fresh salt, iterations)─► HKDF("<ns>:database:v1") ─► export key
```
An implementation MAY accept a raw 32-byte key instead, for machine-to-machine
transfer; then `kdf` is absent from the file.
## The manifest is the integrity check
```
digest = base64( SHA-256( item ids, sorted lexicographically, joined by "\n" ) )
```
After decrypting, recompute `itemCount`, `types`, `folderCount` and `digest`.
Any disagreement fails the import.
This is the difference between an import you can trust and a CSV. A CSV that was
truncated at 3,000 rows imports 3,000 rows and reports success. A database that
was truncated does not decrypt at all; one that was edited after decryption
fails its digest. There is no state in which an implementation reports a
complete import of an incomplete file.
## The plaintext form
```json
{
"opencreds": "0.1",
"type": "opencreds.database",
"protected": false,
"namespace": "opencreds",
"exportedAt": "…",
"generator": { … },
"manifest": { … },
"folders": [ { "id": "…", "name": "Work" } ],
"items": [ … ]
}
```
Every secret you own, in a file, in the clear.
It exists because the products people move *to* frequently read nothing else,
and an export format that cannot express "give me the CSV" is an export format
people work around with a script that is worse. So it is specified, and it is
made loud:
- `protected: false` sits in the header, so tooling can identify the file
without parsing it.
- The CLI requires `--plaintext` and a confirmation.
- The file is written `0600` where the platform has modes.
- The manifest is still present and still verified. Unauthenticated, but it
still catches a truncated copy or a half-finished edit.
## Merging on import
The spec does not mandate a merge strategy, but it names the three that exist
and what each does to an id:
| Strategy | Behaviour |
| --- | --- |
| `skip` | An incoming item whose id already exists is skipped. The safe default. |
| `replace` | The existing item is overwritten. |
| `duplicate` | The incoming item is given a fresh id and both are kept. |
`duplicate` is the only one that never loses data and the only one that can
double a vault. An implementation SHOULD default to `skip` and SHOULD report the
count of each outcome rather than a single "imported N".
Folder ids collide the same way. An incoming folder whose id exists and whose
name differs is a conflict; the reference implementation keeps the existing
folder and remaps incoming `folderId`s onto it.
## Round-tripping
A conforming export → import → export cycle MUST produce byte-identical item
records. Specifically:
- Unknown top-level item fields survive. An item written by a future version
passes through an older implementation without losing what it did not
understand.
- Empty-string fields are not dropped and not invented. `""` and absent are
distinguishable and both are preserved as they arrived.
- Timestamps are not restamped. `createdAt` and `updatedAt` belong to the item,
not to the transfer; an importer that touches them destroys the only evidence
of when a password was last rotated.
The `exportedAt` and `generator` of the *file* do change, of course. They
describe the transfer, which is the one thing that is genuinely new each time.

91
docs/opencreds/faq.md Normal file
View file

@ -0,0 +1,91 @@
# OpenCreds FAQ
### Why not just use the Bitwarden JSON export?
It is the closest thing that exists, and it is a product's export format rather
than a specification: undocumented, versioned by the product, plaintext-only in
practice, and with no integrity check. OpenCreds keeps the parts that work — the
item-with-a-type-group shape, the type codes — and adds the parts that are
missing: an encrypted form as the default, an authenticated manifest, a declared
crypto construction, published schemas and a conformance suite.
The type codes 14 are deliberately the same. Compatibility is cheaper than
elegance.
### Why is there a plaintext form at all?
Because people move *to* products that read nothing else, and a format that
refuses to express that gets worked around with a script that is worse — no
warning, no file mode, no label. Specifying it means it can be made loud: an
explicit flag, a confirmation, `protected: false` in the header, and an owner-only
file mode.
### Why PBKDF2 rather than Argon2id?
Argon2id is better and needs WASM in a browser, which means adding
`wasm-unsafe-eval` to an extension's content security policy. That is a real cost
paid by every user of a product to benefit the KDF. The parameters are carried
per vault specifically so the switch is a migration later rather than a break
now, and `argon2id` is already a registered value.
### Why is `namespace` a property instead of a constant?
Because MarkSyncr's vault shipped first, with `marksyncr:vault:*` labels baked
into the additional authenticated data of every ciphertext it has written.
Changing a label does not migrate a vault; it makes it undecryptable. Carrying
the prefix as data is what lets a deployed vault be conformant without
re-encrypting a single item. New vaults use `opencreds`.
### Why is an `account` not a `login`?
A login is what a person types at a sign-in form. An account is what a machine
presents to an API. They expire differently, they are revoked differently, and
they are rotated by different actors. Conflating them is how a rotated refresh
token ends up in a password history array, and how an expiry date ends up in a
notes field.
### Can I keep `.env` secrets in an OpenCreds vault?
Yes — that is what `key` items with `keyType: "env"` are. The variable name is
the item `name` and the secret is `key.value`. `logicsrc credentials` moves those
values *between providers*; OpenCreds is what one looks like when it is stored
rather than moved.
### Does this replace `logicsrc credentials`?
No. Credential Sharing is a sync spec: providers, plans, diffs, dry runs,
rollback and audit. OpenCreds is a record and a file. They meet at the `key`
item — a synced `.env` entry, stored — and at the `team` profile, which is the
credential-sharing key scheme applied to a vault of items.
### What happens if two devices edit the same item?
The specification does not say, because it does not specify storage. The
reference implementation and both Profullstack products store one row per item
with a monotonic revision, so a client that writes with a stale revision is
rejected rather than overwriting. That is a recommendation, not a requirement.
### Why cap password history at twenty?
The item is one blob, rewritten in full on every save. An uncapped history array
grows that blob without bound, and the growth is invisible until a vault sync
starts timing out. Twenty entries is more history than anyone consults.
### Can I import a vault without the passphrase, just to see what is in it?
You can see the header: version, namespace, export time, generator, and the
manifest — item count, counts by type, folder count. That is enough for a
preview and it is authenticated, so it cannot be lied about. Nothing else is
readable, which is the point.
### Is there a hosted OpenCreds service?
No, and the specification does not describe one. A conforming vault is a file
and a key. Products built on it may be hosted; the standard is not.
### How do I claim conformance?
Run `opencreds conformance` against the published fixtures, pass every MUST, and
say which profile and namespace you implement. The suite is in
`packages/opencreds/fixtures/` and ships in the published package, so nobody has
to read our source to verify their own implementation.

129
docs/opencreds/interop.md Normal file
View file

@ -0,0 +1,129 @@
# Importing from other products
OpenCreds is meant to be arrived at, not just left from. This page specifies the
mappings from the exports people actually have.
Every source below exports CSV, so the work is one correct CSV reader plus a
column mapping per product. The reader matters more than the mappings: a naive
`split(',')` mangles any export containing a note with a comma in it, which is
most of them.
## The reader
A conforming CSV reader MUST handle quoted fields, escaped quotes (`""`),
embedded newlines inside quotes, embedded commas, both CRLF and LF, and a
leading UTF-8 BOM. Chrome and Excel both emit a BOM, and unhandled it becomes
part of the first header name and breaks every column lookup in the file.
Header names are compared lowercased and trimmed, because column casing differs
between versions of the same product.
## Detection
An importer SHOULD identify the source from the header row so a person can drop
in a file without first telling us where it came from. Detection is ordered
most-specific first: Chrome's columns are a subset of 1Password's, so asking in
the wrong order misidentifies every Chrome export.
Order: `bitwarden`, `lastpass`, `keepass`, `onepassword`, `chrome`.
## Mappings
### Bitwarden
Header contains `login_uri` or `login_password`. Row `type` selects the item type.
| Bitwarden column | OpenCreds |
| --- | --- |
| `name` | `name` |
| `notes` | `notes` |
| `folder` | folder by name |
| `favorite` | `favorite` (`1` → true) |
| `login_username` | `login.username` |
| `login_password` | `login.password` |
| `login_totp` | `login.totp` |
| `login_uri` | `login.uris[0].uri`, `match: "domain"` |
| `card_*` | `card.*` |
| `identity_*` | `identity.*` |
| `type: securenote` | `note` |
### 1Password
Header contains `url`, `username` and `type`.
| 1Password column | OpenCreds |
| --- | --- |
| `title` | `name` |
| `url`/`website` | `login.uris[0].uri` |
| `username`, `password` | `login.*` |
| `otpauth` | `login.totp` |
| `notes` | `notes` |
### Chrome
Header contains `url`, `username`, `password`. Logins only.
| Chrome column | OpenCreds |
| --- | --- |
| `name` | `name`, falling back to the URL host |
| `url` | `login.uris[0].uri` |
| `username`, `password` | `login.*` |
| `note` | `notes` |
### LastPass
Header contains `url` and `grouping`. LastPass writes `http://sn` in `url` for
secure notes, which is the only reliable way to tell one from a login.
| LastPass column | OpenCreds |
| --- | --- |
| `name` | `name` |
| `grouping` | folder by name |
| `url` | `login.uris[0].uri`, unless `http://sn` |
| `username`, `password` | `login.*` |
| `totp` | `login.totp` |
| `extra` | `notes` |
| `fav` | `favorite` |
### KeePass (CSV export)
Header contains `account` and `login name`, or `group` and `password`.
| KeePass column | OpenCreds |
| --- | --- |
| `account`/`title` | `name` |
| `login name`/`user name` | `login.username` |
| `password` | `login.password` |
| `web site`/`url` | `login.uris[0].uri` |
| `comments`/`notes` | `notes` |
| `group` | folder by name |
## Rules that apply to every importer
**Report, never drop.** A row that cannot be mapped is returned in a `skipped`
list with its row number and a reason. An import that silently loses credentials
is worse than one that says what it could not read — the person still has the
source file, and only knows to go back for it if they are told.
**Name from the host when the export had none.** Chrome in particular writes
rows with an empty name; `github.com` is a better label than a blank line.
**An empty row is not a failure.** A login with no username, no password and no
name is a trailing blank line. It is skipped with the reason `Empty row`, which
is different from `could not map` and should read differently in a report.
**Nothing here touches crypto or the network.** An importer turns text into
plain item objects. The caller encrypts them. That separation is what lets the
same importer run in a browser extension's service worker and in a CLI.
## Going the other way
`opencreds export --format bitwarden-csv` writes a Bitwarden-shaped CSV, because
that is the format most other products import best. It is a plaintext export and
carries every warning that implies — see
[database.md](./database.md#the-plaintext-form).
The lossy fields are named in the output rather than discovered later: password
history, custom fields, attachments, URI match rules, `key` items and `account`
items have no column in any product's CSV. The CLI prints what it dropped and
the count for each.

View file

@ -0,0 +1,180 @@
# The OpenCreds item model
One record, six types, one field group each. This page specifies the groups
field by field. The record shape around them is [spec.md §3](./spec.md#3-the-item).
Every field in every group is a string unless stated otherwise, and every field
is OPTIONAL with an empty string as its default. A vault holds half-filled
records — someone knows the card number and not the issuing bank — and a model
that requires fields produces importers that invent them.
## login (code 1)
| Field | Type | Notes |
| --- | --- | --- |
| `username` | string | |
| `password` | string | |
| `totp` | string | An `otpauth://` URI, or a bare base32 seed. Store the URI where you have it: it carries the algorithm, digits and period, and a bare seed loses them. |
| `uris` | array | Matching URIs, see below. |
```json
{ "uri": "https://github.com", "match": "domain" }
```
`match` MUST be one of `domain`, `host`, `startsWith`, `exact`, `regex`, or
`never`. It is carried so that a move does not silently widen where a credential
will be offered; an implementation that does not autofill still round-trips it.
`login` is the only type with `history` ([spec.md §3.5](./spec.md#35-password-history)).
## card (code 2)
| Field | Notes |
| --- | --- |
| `cardholderName` | |
| `brand` | `Visa`, `Mastercard`, `Amex`, … Free text; issuers add brands. |
| `number` | Full PAN. |
| `expMonth` | `1``12`, no leading zero required. |
| `expYear` | Four digits. Two-digit years from an import are expanded to 20xx. |
| `code` | CVV/CVC. |
## identity (code 3)
| Field | Notes |
| --- | --- |
| `title` | Mr, Ms, Dr, … |
| `firstName`, `middleName`, `lastName` | |
| `username` | An identity's handle, distinct from a login's. |
| `company` | |
| `email`, `phone` | |
| `address1`, `address2`, `address3` | |
| `city`, `state`, `postalCode`, `country` | |
| `ssn` | National identity number. Named `ssn` for import compatibility; it is not US-specific. |
| `passportNumber` | |
| `licenseNumber` | |
## note (code 4)
No field group. The content is the record's `notes` field. A `note` item that
also carries custom `fields` is valid and common — it is how people store the
things a vault has no type for.
## key (code 5)
Introduced by OpenCreds. Covers SSH keys, PGP keys, API tokens, certificates,
and the `.env` secrets that `logicsrc credentials` synchronizes.
| Field | Notes |
| --- | --- |
| `keyType` | One of `ssh`, `pgp`, `api`, `symmetric`, `certificate`, `env`. |
| `algorithm` | `ed25519`, `rsa-4096`, `ecdsa-p256`, … |
| `publicKey` | Armoured/OpenSSH public key text. |
| `privateKey` | Armoured/PEM private key text. |
| `passphrase` | The private key's own passphrase, where it has one. |
| `fingerprint` | `SHA256:…` — a public, non-secret identifier. |
| `value` | The secret for key types that are one opaque string (`api`, `env`, `symmetric`). |
| `path` | Where the key belongs on disk, e.g. `~/.ssh/id_ed25519`. |
| `mode` | POSIX mode as an octal string, e.g. `"0600"`. |
| `expiresAt` | RFC 3339, where the key expires. |
`path` and `mode` exist so a restore is total: a private key written back with
the wrong mode is a key `ssh` will refuse to use, and a key written to the wrong
path is a key nothing finds. They carry the same information as the
self-describing envelope the `ssh` credential provider already writes.
An `.env` secret becomes `{ "keyType": "env", "value": "…" }` with the variable
name as the item `name`. That is the bridge between the two specs: Credential
Sharing moves a key/value pair between providers, OpenCreds is what it looks
like when stored.
## account (code 6)
Introduced by OpenCreds. A provider account and the tokens that authorize acting
as it — a connected Google account, a social account, a service account.
| Field | Notes |
| --- | --- |
| `provider` | `google`, `github`, `x`, `stripe`, … An OpenOntology entity id where one is in use. |
| `accountId` | The provider's own id for the account. |
| `handle` | The username or handle at that provider. |
| `email` | |
| `accessToken` | |
| `refreshToken` | |
| `tokenType` | `bearer`, … |
| `scopes` | Array of strings. |
| `expiresAt` | RFC 3339 expiry of `accessToken`. |
| `environment` | `production`, `sandbox`, … A test key and a live key look identical and are not. |
An `account` is deliberately not a `login`. A login is what a *person* types at
a sign-in form; an account is what a *machine* presents to an API. They expire
differently, they are revoked differently, and conflating them is how a rotated
refresh token ends up in a password history array.
## Folders
A vault MAY carry folders. A folder is `{ "id": "<uuid>", "name": "Work" }` and
is referenced by an item's `folderId`. Folders are flat: a name MAY contain `/`
and an implementation MAY render that as a hierarchy, but the model does not
nest, because every product that nests them disagrees about what a move does.
Folder names are **not** encrypted by the item envelope — they live in the
database payload, which is encrypted as a whole, and in a vault's own storage
they are wherever that implementation puts them. An implementation that stores
folder names in the clear MUST say so; a folder list is a good description of
someone's life.
## Worked examples
A login with history:
```json
{
"v": 1, "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21", "type": "login",
"name": "GitHub", "favorite": true, "folderId": null, "notes": "",
"login": {
"username": "anthony",
"password": "correct-horse-battery-staple",
"totp": "otpauth://totp/GitHub:anthony?secret=JBSWY3DPEHPK3PXP&issuer=GitHub",
"uris": [{ "uri": "https://github.com", "match": "domain" }]
},
"history": [{ "password": "hunter2", "changedAt": "2026-01-04T09:12:00.000Z" }],
"createdAt": "2025-11-02T10:00:00.000Z",
"updatedAt": "2026-01-04T09:12:00.000Z"
}
```
An SSH deploy key:
```json
{
"v": 1, "id": "0c0f7a2e-9d1a-4f6e-b2b7-1f3d5a7c9e11", "type": "key",
"name": "deploy@railway", "notes": "Rotated quarterly",
"key": {
"keyType": "ssh", "algorithm": "ed25519",
"publicKey": "ssh-ed25519 AAAAC3Nza… deploy@railway",
"privateKey": "<the armoured private key body>",
"passphrase": "", "fingerprint": "SHA256:9Vt…",
"path": "~/.ssh/id_ed25519_railway", "mode": "0600", "expiresAt": ""
},
"createdAt": "2026-03-01T00:00:00.000Z",
"updatedAt": "2026-06-01T00:00:00.000Z"
}
```
A connected account:
```json
{
"v": 1, "id": "3a8c1d55-77e2-4b0a-9d3c-2b6e5f8a1c04", "type": "account",
"name": "Stripe (live)",
"account": {
"provider": "stripe", "accountId": "acct_1P…", "handle": "profullstack",
"email": "billing@profullstack.com",
"accessToken": "<access token>", "refreshToken": "", "tokenType": "bearer",
"scopes": ["charges:write", "customers:read"],
"expiresAt": "", "environment": "production"
},
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z"
}
```

120
docs/opencreds/security.md Normal file
View file

@ -0,0 +1,120 @@
# OpenCreds security model
## What the format protects
An attacker holding **the storage** — the database, its backups, an operator's
console, a restored snapshot — learns:
- how many items a vault holds, and how many of each type;
- when each item was created and last changed;
- the vault's KDF parameters, salt, and wrapped keys;
- folder ids, and folder names in implementations that store them in the clear.
They do not learn any field of any item. The user key is never present in
storage in a form the storage can open.
An attacker holding **the storage and write access** additionally cannot move a
ciphertext between rows: the item id is bound as additional authenticated data,
so a swapped ciphertext fails to decrypt rather than showing the wrong
credential under a trusted name.
An attacker holding **the auth hash** — every one ever sent, in full — cannot
derive the wrapping key. The two come out of the same master key under distinct
HKDF labels.
## What it does not protect
**Metadata.** Item counts by type are visible by design, because the type code
is stored in plaintext so a server can filter and paginate without decrypting.
That means a server learns you hold forty logins and two cards. Hiding it costs
padding and blind indexes and buys less than it appears to; the specification
states the leak rather than obscuring it.
**A weak master password.** PBKDF2 at 600,000 iterations raises the cost of a
guess; it does not make a common password safe. Nothing in the format can.
**A compromised client.** Every value is decrypted somewhere. An attacker who
runs code in the process that holds the user key has the vault, and no format
choice changes that.
**A plaintext export.** It is exactly what it says. See below.
**Deletion.** `deleted_at` is a trash bin, not an erasure. A purge removes the
row; whether it removes the bytes is a property of the storage engine and its
backups, not of this specification.
## Deliberate decisions
### The type code is plaintext
So the server can paginate. The alternative — decrypting every row to answer
"show me page 2 of the logins" — either moves the whole vault to the client on
every read or gives the server a key. Both are worse.
### Key material is base64 text
Where this travels as JSON over an HTTP API, binary round-trips as an escaped
hex string and invites encoding mistakes on exactly the values that must not be
corrupted. Text that is wrong is visibly wrong.
### One item is one blob
Password history, custom fields and attachment keys all live inside the item's
single ciphertext. That makes history encrypted by construction rather than by a
second protected table someone can forget to protect. It costs a full rewrite of
the item on every save, which is why history is capped — an uncapped array grows
the ciphertext without bound.
### One row per item
Not one blob per vault. Two devices editing two *different* passwords at the
same moment must not cost anyone a credential, and with a single blob the later
write silently discards the earlier. Per-item rows with a revision make that a
detectable conflict instead of a silent loss.
### The KDF floor is enforced client-side
Parameters arrive from a server. If the server is compromised, they are
attacker-controlled, and `iterations: 1` turns captured auth hashes into a free
offline attack. The client refuses below its own floor before deriving anything,
and a database-backed implementation SHOULD repeat the constraint in the schema.
## The plaintext export
This is the most dangerous operation in the specification, and it is specified
because the alternative is people writing worse versions of it themselves.
Requirements, restated:
- Never the default.
- An explicit flag, plus a confirmation.
- `"protected": false` in the header so tooling can identify the file.
- Owner-only file mode where the platform has one.
- A warning that says what it means: this file cannot be un-leaked, and every
password in it should be considered exposed if it is.
An implementation SHOULD offer to delete the file after a successful import
elsewhere, and MUST NOT do so automatically — the person may still need it.
## Threats specific to the `team` profile
Every member holding the vault key reads every item in the vault. This is a
property of a shared symmetric key, not a gap in the implementation.
Consequences worth stating in a product's own docs:
- **Revocation requires rotation.** Removing a member's wrapped key stops them
fetching new ciphertext. It does not un-know the key they held. A member who
leaves means a new vault key and a re-encryption of every item.
- **Partial sharing is a second vault.** There is no way to share three items out
of forty under one key. Split the vault.
- **Granting is a client-side act.** An existing member unwraps and re-seals to
the new member's public key. The server can add a member to a list; it cannot
give them access, because it does not have the key. This is a feature, and it
means a grant requires an authorized member to be online.
## Reporting
Security issues in the specification or the reference implementation:
`security@profullstack.com`, or the security policy published at
`logicsrc.com/.well-known/security.txt`.

421
docs/opencreds/spec.md Normal file
View file

@ -0,0 +1,421 @@
# The OpenCreds Specification
Version: **0.1** (draft)
Status: draft — the wire formats below are implemented by `@logicsrc/opencreds`
and are expected to change only additively before 1.0.
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be
interpreted as described in RFC 2119.
## 1. Scope
OpenCreds specifies three things:
1. **The item** — the record shape for a stored credential (§3).
2. **The vault** — how items are encrypted and how keys are derived (§4).
3. **The database** — the portable file a vault exports to and imports from (§5).
It does not specify storage, synchronization, transport, autofill, or user
interface. An implementation that produces and consumes conforming items and
databases is conforming regardless of where it puts the bytes.
## 2. Terminology
| Term | Meaning |
| --- | --- |
| **item** | One credential record: a login, a card, an identity, a note, a key or an account. |
| **vault** | A set of items sharing one *user key*. |
| **user key** | The 256-bit symmetric key that every item in a vault is encrypted under. |
| **master password** | The human secret from which a `user`-profile vault's wrapping key is derived. |
| **wrap key** | Derived from the master password; encrypts the user key. Never leaves the device. |
| **auth hash** | Derived from the master password under a different label; the only password-derived value sent to a server. |
| **recovery key** | A random 128-bit value that wraps a second copy of the user key. |
| **namespace** | The domain-separation label prefix for a vault (§4.6). |
| **profile** | How the user key is managed: `user` or `team` (§4.7). |
| **database** | A vault serialized to a single file (§5). |
| **manifest** | The authenticated inventory of a database (§5.3). |
## 3. The item
### 3.1 Record shape
An item is a JSON object. Every item MUST carry:
| Field | Type | Notes |
| --- | --- | --- |
| `v` | integer | Item schema version. `1` for this specification. |
| `id` | string | A UUID, generated by the client. See §4.4 — it is bound into the ciphertext. |
| `type` | string | One of `login`, `card`, `identity`, `note`, `key`, `account`. |
| `name` | string | Display name. MAY be empty. |
| `createdAt` | string | RFC 3339 timestamp. |
| `updatedAt` | string | RFC 3339 timestamp. |
An item MAY carry:
| Field | Type | Notes |
| --- | --- | --- |
| `favorite` | boolean | Defaults to `false`. |
| `folderId` | string \| null | A folder id declared in the same vault, or `null`. |
| `notes` | string | Free text. For `note` items this is the content. |
| `fields` | array | Custom fields (§3.3). |
| `attachments` | array | Attachment references (§3.4). |
| `history` | array | Password history (§3.5). `login` items only. |
| `<type>` | object | The field group named by `type` (§3.2). |
An item MUST NOT carry a field group other than the one named by its `type`. An
implementation reading an unknown top-level field MUST preserve it on round trip
rather than dropping it; this is what makes the format forward-compatible.
### 3.2 Type codes and field groups
The type code is the integer an implementation MAY store in plaintext alongside
the ciphertext so a server can filter and paginate without decrypting.
| `type` | Code | Group | Purpose |
| --- | --- | --- | --- |
| `login` | 1 | `login` | Username, password, TOTP, matching URIs |
| `card` | 2 | `card` | Payment card |
| `identity` | 3 | `identity` | Name, address and identity document numbers |
| `note` | 4 | — | Free text only; content lives in `notes` |
| `key` | 5 | `key` | SSH/PGP/API keys, certificates, `.env` secrets |
| `account` | 6 | `account` | A provider account and its OAuth tokens |
Codes 14 are fixed by MarkSyncr's deployed vault and MUST NOT be renumbered.
Codes 5 and 6 are introduced by this specification. Codes 7+ are reserved.
The groups are specified field by field in [item-model.md](./item-model.md).
### 3.3 Custom fields
```json
{ "name": "Employee ID", "value": "A-4417", "type": "text", "hidden": false }
```
`type` MUST be one of `text`, `hidden`, `boolean`, or `linked`. A `hidden` field
is displayed masked; it is not encrypted differently — everything in the item is
already inside one ciphertext.
### 3.4 Attachments
An item carries attachment *references*, not bytes:
```json
{ "id": "…", "name": "passport.pdf", "size": 148213,
"contentType": "application/pdf", "digest": "sha256-…", "key": "…" }
```
`key` is the base64 AES-256 key the blob was encrypted under, held inside the
item ciphertext so the blob store never sees it. An implementation that does not
store blobs MUST still round-trip the references.
### 3.5 Password history
```json
{ "password": "the previous value", "changedAt": "2026-08-01T12:00:00.000Z" }
```
Newest first. An implementation MUST cap history at 20 entries: the item blob is
rewritten on every save, and an uncapped array grows the ciphertext without
bound. History is only defined for `login` items.
## 4. The vault
### 4.1 Primitives
| Purpose | Algorithm |
| --- | --- |
| Password stretching | PBKDF2-HMAC-SHA256 |
| Key derivation | HKDF-SHA256 |
| Symmetric encryption | AES-256-GCM, 96-bit IV, 128-bit tag |
| Digests | SHA-256 |
All four are available in WebCrypto. An implementation MUST NOT substitute
another cipher for AES-GCM in this version.
### 4.2 KDF parameters
Parameters travel *with* the vault so they can be strengthened later without
invalidating anyone's data:
```json
{ "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "<base64, 16 bytes>" }
```
- `iterations` MUST default to 600,000 for a vault created under this version.
- A client MUST refuse to derive below 100,000 iterations. Parameters arrive
from a server, which makes them attacker-controlled if the server is
compromised: serving `iterations: 1` would turn every captured auth hash into
an offline guessing exercise with no work factor.
- `argon2id` is a REGISTERED value and is not yet specified. A client that does
not implement it MUST refuse the vault rather than fall back.
### 4.3 Key hierarchy
```
master password ─PBKDF2(salt, iterations)─► master key (32 bytes)
┌────────────────────────────────┼────────────────────────────┐
HKDF(<ns>:vault:wrap:v1) HKDF(<ns>:vault:auth:v1) HKDF(<ns>:vault:recovery:v1)
│ │ │
wrap key ──AES-GCM──► protected user key recovery wrap key
│ │
auth hash ──► server recovery blob
```
- The user key is 32 random bytes generated on the client. It is what items are
encrypted under, and it is never derived from the password — so changing the
master password re-wraps one key rather than re-encrypting every item.
- The master key is never used to encrypt anything directly.
- The auth hash is the only password-derived value that may leave the device. A
server storing it MUST hash it again before storage.
- A recovery key is 16 random bytes, presented to the user in a grouped
base32-style encoding, and wraps a second copy of the user key. It exists so a
forgotten master password is survivable without the server learning anything.
### 4.4 The item envelope
To encrypt an item:
1. Serialize the item to UTF-8 JSON with `v` present.
2. Generate a fresh 96-bit IV. An IV MUST NOT be reused under one key.
3. Compute the additional authenticated data:
`AAD = UTF8("<namespace>:vault:item:<v>:<id>")`
4. `AES-GCM(userKey, iv, plaintext, AAD)`.
The stored envelope is:
```json
{ "id": "<uuid>", "type": 1, "ciphertext": "<base64>", "iv": "<base64>" }
```
Binding the id means a ciphertext cannot be moved between rows without
decryption failing. On decryption an implementation MUST additionally verify
that the decrypted `id` equals the envelope `id`; the AAD already makes a
mismatch unreachable, and the check costs nothing.
A vault read MUST be tolerant per item: when one item fails to decrypt, the
implementation MUST return the items that succeeded together with a list of the
ids that failed, rather than failing the whole read. A single corrupt row must
not hide someone's vault from them.
### 4.5 Vault metadata
```json
{
"opencreds": "0.1",
"namespace": "opencreds",
"profile": "user",
"kdf": "pbkdf2-sha256",
"kdfIterations": 600000,
"kdfSalt": "<base64>",
"protectedUserKey": "<base64>",
"protectedUserKeyIv": "<base64>",
"recoveryKeyBlob": "<base64>",
"recoveryKeyIv": "<base64>",
"authHash": "<base64>",
"createdAt": "…",
"updatedAt": "…"
}
```
Key material is base64 text rather than binary. Where this travels as JSON over
an HTTP API, binary round-trips as an escaped hex string and invites encoding
mistakes on exactly the values that must not be corrupted.
### 4.6 Namespaces
Every domain-separation label in this specification is prefixed by the vault's
`namespace`:
```
<namespace>:vault:wrap:v1
<namespace>:vault:auth:v1
<namespace>:vault:recovery:v1
<namespace>:vault:item:<v>:<id>
<namespace>:database:v1
```
A vault created under this specification MUST use the namespace `opencreds`. A
vault created before it MAY declare its own — `marksyncr` is registered — and is
conformant with that declaration.
This exists because labels are baked into every ciphertext already written.
Editing one makes every existing vault in the world undecryptable, so a label is
append-only in the strongest sense: it can be superseded, never changed. Carrying
the prefix as data is what lets a deployed vault become conformant without
re-encrypting a single item.
Registered namespaces: `opencreds`, `marksyncr`. A namespace MUST match
`^[a-z][a-z0-9-]{1,31}$`.
### 4.7 Profiles
A profile is how the user key is managed. The item envelope (§4.4) is identical
in both.
**`user`** — the user key is wrapped by a key derived from a master password, as
in §4.3. One person, one password, one vault.
**`team`** — the user key (there called the vault key) is generated randomly and
wrapped to each member with an anonymous sealed box against that member's X25519
public key. The server stores one wrapped key per member and never sees the key
itself. Granting access is an existing member unwrapping and re-sealing to the
new member's public key. This is the scheme `logicsrc credentials` already
implements; see [credential-sharing.md](../credential-sharing.md).
A vault MUST declare exactly one profile. An implementation MAY support one
profile and remain conforming; it MUST refuse a vault whose profile it does not
implement rather than attempting to open it.
## 5. The portable database
### 5.1 Shape
A database is a single JSON document. It exists in two forms, distinguished by
`protected`.
**Encrypted (default):**
```json
{
"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": "<base64>" },
"manifest": { "itemCount": 42, "types": { "login": 40, "card": 2 },
"folderCount": 3, "digest": "<base64 SHA-256>" },
"iv": "<base64>",
"ciphertext": "<base64>"
}
```
**Plaintext:**
```json
{
"opencreds": "0.1",
"type": "opencreds.database",
"protected": false,
"namespace": "opencreds",
"exportedAt": "…",
"generator": { … },
"manifest": { … },
"folders": [ { "id": "…", "name": "Work" } ],
"items": [ { "v": 1, "id": "…", "type": "login", … } ]
}
```
### 5.2 Export key
An export is encrypted under its own key, not the vault's user key. A database
that reused the user key would be undecryptable anywhere except the vault it
came from, which defeats the point of the file.
```
export passphrase ─PBKDF2(export salt, iterations)─► HKDF("<ns>:database:v1") ─► export key
```
The export salt is fresh per export and carried in the file. An implementation
MAY instead accept a raw 32-byte key, in which case `kdf` is omitted.
The payload encrypted is the UTF-8 JSON of `{ "folders": [...], "items": [...] }`,
under AES-256-GCM with a fresh IV and:
```
AAD = UTF8(JSON of the header: opencreds, type, protected, namespace,
exportedAt, generator, kdf, manifest — keys in that order)
```
Binding the header means the manifest is authenticated by the same tag as the
data. An attacker cannot restate the item count, swap the generator, or downgrade
`protected` without the decryption failing.
### 5.3 Manifest
| Field | Meaning |
| --- | --- |
| `itemCount` | Number of items in the payload. |
| `types` | Item count per type name. Types with zero items are omitted. |
| `folderCount` | Number of folders in the payload. |
| `digest` | Base64 SHA-256 over the item ids, sorted lexicographically and joined by `\n`. |
After decrypting, an implementation MUST recompute all four and MUST refuse the
import if any disagrees. A truncated file, a dropped item and a re-ordered
payload are all detectable; without the manifest a partial import is
indistinguishable from a complete one.
For a plaintext database the manifest is still REQUIRED and MUST still be
verified. It is not authenticated — nothing in a plaintext file is — but it
still catches truncation and accidental editing.
### 5.4 The plaintext form
A plaintext database is every secret in a vault, in a file, in the clear. It
exists because people move to products that read nothing else.
An implementation:
- MUST default to the encrypted form.
- MUST require an explicit opt-in for the plaintext form, and SHOULD require a
second confirmation.
- MUST write `"protected": false` in the header, so the file is identifiable as
unprotected without parsing the rest of it.
- SHOULD write the file with owner-only permissions where the platform has them.
- SHOULD warn that the file cannot be un-leaked.
### 5.5 Extension and media type
The conventional extension is `.opencreds`. The media type is
`application/vnd.logicsrc.opencreds+json`.
## 6. Audit
An implementation that records vault operations SHOULD emit events of the form:
```json
{ "type": "opencreds.audit_event", "id": "…", "action": "item.create",
"itemId": "…", "itemType": "login", "principal": { … },
"fingerprint": "<salted SHA-256, truncated>", "createdAt": "…" }
```
An audit event MUST NOT contain a secret value. Where a value must be referenced,
it is referenced by salted fingerprint — an equality marker, not secret storage.
Registered actions: `vault.create`, `vault.unlock`, `vault.unlock_failed`,
`vault.rekey`, `vault.recovery_reset`, `item.create`, `item.update`,
`item.delete`, `item.restore`, `item.purge`, `database.export`,
`database.export_plaintext`, `database.import`.
## 7. Conformance
See [conformance.md](./conformance.md) for the requirement checklist and the
fixture suite. In summary, a conforming implementation:
1. Reads and writes items per §3 without dropping unknown fields.
2. Implements the envelope and key hierarchy of §4 for at least one profile.
3. Reads and writes both database forms per §5, verifying the manifest.
4. Refuses KDF parameters below the floor, unknown profiles, and unknown
namespaces.
5. Passes the published fixtures.
## 8. Schemas
Published in `@logicsrc/schemas`:
| Schema | File |
| --- | --- |
| Item | `logicsrc-opencreds-item.schema.json` |
| Vault meta | `logicsrc-opencreds-vault-meta.schema.json` |
| Item envelope | `logicsrc-opencreds-envelope.schema.json` |
| Database | `logicsrc-opencreds-database.schema.json` |
| Manifest | `logicsrc-opencreds-manifest.schema.json` |
| Audit event | `logicsrc-opencreds-audit-event.schema.json` |
## 9. Version history
| Version | Date | Change |
| --- | --- | --- |
| 0.1 | 2026-08-29 | Initial draft. Six item types, one envelope, two profiles, one database format. |