Pre-stages the A5 living-docs sweep for the already-merged A (relicario-core org module) + C (relicario-server pre-receive hook) + CLI admin/rotate/status-audit work, so the final A5 sweep (after Dev-B B9-B14 merges) is fast. Adds org sections to docs/FORMATS.md (org repo wire formats + wrapped-key blob layout), docs/CRYPTO.md (ECIES X25519 wrap/unwrap, no-Argon2id contrast, rotate re-encryption), docs/SECURITY.md (signature-verifying hook, owner-only elevation, audit vocabulary, honest limitations), DESIGN.md (org-master-key secrets row + server org mode + deps), core/cli ARCHITECTURE.md (org module + org_session), and an Unreleased CHANGELOG entry. B item-CRUD (org add/get/list/edit/rm/restore/purge + main.rs wiring) and extension parity are left as explicit TODO. STATUS/ROADMAP mark-shipped and extension/ARCHITECTURE are deferred to the full A5 (track not yet landed; Dev-D deferred). All cited code constants pinned with file:line per living-docs discipline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
163 lines
9.6 KiB
Markdown
163 lines
9.6 KiB
Markdown
# Relicario Wire Formats
|
||
|
||
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see [CRYPTO.md](CRYPTO.md)), threat model around them (see [SECURITY.md](SECURITY.md)), or Rust struct internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
|
||
|
||
> Quick-reference for the load-bearing binary and JSON formats. Check this file before touching serialization, versioning, or storage layout code. Full diagrams and invariants live in the per-crate `ARCHITECTURE.md` files.
|
||
|
||
## Encrypted blob (`.enc` files)
|
||
|
||
Every encrypted file — `manifest.enc`, `settings.enc`, `items/<id>.enc`, `attachments/<item-id>/<aid>.enc` — uses the layout produced by `relicario_core::crypto::encrypt` (`crypto.rs`):
|
||
|
||
```
|
||
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
|
||
│ version │ nonce │ ciphertext │ auth tag │
|
||
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
|
||
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
|
||
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
|
||
```
|
||
|
||
- `VERSION_BYTE = 0x02` (`crypto.rs:59`). Any blob starting with `0x01` is rejected with `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
|
||
- Minimum valid blob length: 41 bytes (1 + 24 + 0 + 16).
|
||
- Nonces are always fresh from `OsRng` — no caller-supplied nonces.
|
||
- Full diagram: `docs/CRYPTO.md` § "Encrypted File Format".
|
||
|
||
## `.relicario/params.json`
|
||
|
||
```json
|
||
{
|
||
"format_version": 2,
|
||
"aead": "xchacha20-poly1305",
|
||
"salt_path": ".relicario/salt",
|
||
"kdf": {
|
||
"argon2_m": 65536,
|
||
"argon2_t": 3,
|
||
"argon2_p": 4
|
||
}
|
||
}
|
||
```
|
||
|
||
Parsed via `ParamsFile { kdf: KdfParams }` in `session.rs`. The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`.
|
||
|
||
## `.relicario/salt`
|
||
|
||
32 raw bytes. Not secret. Generated once at vault init via `OsRng`. Feeds Argon2id as the KDF salt.
|
||
|
||
## Manifest (`manifest.enc`)
|
||
|
||
Decrypts to JSON matching the `Manifest` struct (`manifest.rs`).
|
||
|
||
- **Schema version:** `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`). v1 manifests (pre-typed-items) fail to parse and are not supported.
|
||
- **`ManifestEntry` fields** (declared order in `manifest.rs:21-38`): `id`, `type`, `title`, `tags`, `favorite`, `group`, `icon_hint`, `modified`, `trashed_at`, `attachment_summaries`. The `type` field is `r#type: ItemType` in Rust but serializes as the bare JSON key `"type"` (no serde rename — `r#` is just the raw-identifier escape). `group`, `icon_hint`, and `trashed_at` are `#[serde(skip_serializing_if = "Option::is_none")]`; `tags`, `favorite`, and `attachment_summaries` use `#[serde(default)]`.
|
||
- The manifest is rebuilt from scratch on every `upsert` — it can never drift from the source-of-truth item files.
|
||
- Supports case-insensitive title/tag search without decrypting any item.
|
||
|
||
## `.relicario/devices.json`
|
||
|
||
```json
|
||
[
|
||
{ "name": "laptop", "public_key": "<hex-encoded ed25519 public key>" }
|
||
]
|
||
```
|
||
|
||
An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes accepted). Both `devices.json` and `revoked.json` must be empty for bootstrap mode to activate — a non-empty `revoked.json` alone forces strict verification.
|
||
|
||
## `.relicario/revoked.json`
|
||
|
||
```json
|
||
[
|
||
{ "name": "old-laptop", "public_key": "<hex>", "revoked_at": 1746000000 }
|
||
]
|
||
```
|
||
|
||
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
|
||
|
||
## Org vault repo formats
|
||
|
||
The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout:
|
||
|
||
```
|
||
org.json # OrgMeta (schema_version, org_id, display_name, created_at)
|
||
members.json # PUBLIC/unencrypted member directory
|
||
collections.json # collection definitions
|
||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||
```
|
||
|
||
### `org.json` — OrgMeta
|
||
|
||
Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds).
|
||
|
||
### `members.json` — OrgMembers
|
||
|
||
Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures.
|
||
|
||
### `collections.json` — OrgCollections
|
||
|
||
Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`.
|
||
|
||
### `keys/<member-id>.enc` — wrapped org master key
|
||
|
||
Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`):
|
||
|
||
```
|
||
┌──────────────────────────┬─────────┬────────┬──────────────────────┐
|
||
│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │
|
||
│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │
|
||
└──────────────────────────┴─────────┴────────┴──────────────────────┘
|
||
```
|
||
|
||
- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing<Vec<u8>>`.
|
||
- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too.
|
||
- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange.
|
||
|
||
### `manifest.enc` — OrgManifest
|
||
|
||
Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault.
|
||
|
||
Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema.
|
||
|
||
### `items/<collection-slug>/<item-id>.enc`
|
||
|
||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob.
|
||
|
||
**TODO (pending Dev-B B9–B14):** CLI commands for creating, reading, editing, and deleting org items (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`) are not yet wired in `main.rs`.
|
||
|
||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items.
|
||
|
||
## Item IDs and Field IDs
|
||
|
||
| Kind | Length | Entropy | Source |
|
||
|---|---|---|---|
|
||
| `ItemId` | 16 hex chars | 64 bits | `OsRng` |
|
||
| `FieldId` | 16 hex chars | 64 bits | `OsRng` |
|
||
| `AttachmentId` | 32 hex chars | 128 bits | first 16 bytes (32 hex chars) of `SHA-256` over the plaintext |
|
||
|
||
`AttachmentId` is content-addressed — identical plaintexts deduplicate in git automatically. The 128-bit truncation (`ids.rs:59-69`) was widened from 64 bits per audit I2/B4 to put birthday-collision risk out of reach.
|
||
|
||
## `.relbak` backup format
|
||
|
||
A zstd-compressed tar archive containing a bare git clone of the vault. Designed for `relicario backup export/restore`.
|
||
|
||
Full spec: `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`.
|
||
|
||
## `ItemCore` JSON (internal)
|
||
|
||
`ItemCore` uses `#[serde(tag = "type")]` — the outer JSON object gets a `"type"` discriminator key. No `*Core` struct may have a field named `"type"` (use `"kind"` instead — see `CardKind`, `TotpKind`).
|
||
|
||
Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map".
|
||
|
||
## KDF input construction
|
||
|
||
The password fed to Argon2id is length-prefixed to prevent extension attacks:
|
||
|
||
```
|
||
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret
|
||
```
|
||
|
||
NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
|
||
|
||
---
|
||
|
||
**Next:** [SECURITY.md](SECURITY.md) — the threat model.
|