# 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. | | `` | 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 1–4 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": "" } ``` - `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(:vault:wrap:v1) HKDF(:vault:auth:v1) HKDF(: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(":vault:item::")` 4. `AES-GCM(userKey, iv, plaintext, AAD)`. The stored envelope is: ```json { "id": "", "type": 1, "ciphertext": "", "iv": "" } ``` 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": "", "protectedUserKey": "", "protectedUserKeyIv": "", "recoveryKeyBlob": "", "recoveryKeyIv": "", "authHash": "", "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`: ``` :vault:wrap:v1 :vault:auth:v1 :vault:recovery:v1 :vault:item:: :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": "" }, "manifest": { "itemCount": 42, "types": { "login": 40, "card": 2 }, "folderCount": 3, "digest": "" }, "iv": "", "ciphertext": "" } ``` **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(":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": "", "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. |