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

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