docs: re-lead positioning on the two-factor-KDF thesis; document the key-file second factor
README: open with the two-independent-secrets-into-KDF thesis; reframe the
second factor as pluggable (reference image or key file); keep the stego
deep-dive as a distinctive option; update all scenario tables, recovery section,
env-var hint, and roadmap line to use "second-factor secret" language.
docs/CRYPTO.md: add "Pluggable second-factor containers" subsection explaining
that the 32-byte input to Argon2id is identical regardless of container;
reference-image / key-file / recovery-QR are interchangeable carriers.
Update org-vs-personal comparison table and entropy labels.
docs/FORMATS.md: document the .relkey armor format (relicario-keyfile-v1 +
base64 line) and the params.json second_factor field ("image"|"keyfile",
absent means "image" for back-compat). Cite crates/relicario-core/src/keyfile.rs
keyfile_encode/keyfile_decode with TODO-pin comments. Update KDF input formula
from image_secret to second_factor_secret.
DESIGN.md: split secrets-map "Reference image bytes" row to distinguish image
and key-file containers; add key-file row noting keyfileBase64 in
chrome.storage.local has the same in-the-clear posture as imageBase64.
Update vault-layout note and KDF conventions row.
docs/SECURITY.md: add "Second-factor storage posture" subsection explicitly
stating .relkey and keyfileBase64 are the second factor in the clear — same
posture as the reference JPEG / imageBase64, not weaker, protected by the
passphrase invariant. Add RELICARIO_KEYFILE to the env-var trust surface table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB
This commit is contained in:
@@ -63,6 +63,20 @@
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Pluggable second-factor containers
|
||||
|
||||
The second factor input to Argon2id is always **32 bytes** (the `image_secret`
|
||||
field in the KDF call). The *container* that holds those 32 bytes is chosen at
|
||||
vault creation and is interchangeable:
|
||||
|
||||
| Container | How the 32 bytes are stored | Extract step |
|
||||
|---|---|---|
|
||||
| Reference image (default) | Embedded in JPEG DCT coefficients via QIM steganography | `imgsecret::extract()` |
|
||||
| Key file (`.relkey`) | base64-encoded in a plain armored text file | `keyfile_decode()` (`crates/relicario-core/src/keyfile.rs` — <!-- TODO(dev-e): pin exact line once Dev-D's keyfile.rs lands -->) |
|
||||
| Recovery QR | XChaCha20-Poly1305 envelope keyed by a separate recovery passphrase | Unwrap then read the raw 32 bytes |
|
||||
|
||||
The Argon2id KDF input — `u64_be(len(passphrase)) || passphrase || u64_be(32) || secret_32_bytes` — is **identical regardless of container**. Master-key derivation is unaffected by the container choice. The diagrams below show the reference-image path; substitute the appropriate extract step for other containers.
|
||||
|
||||
## Vault Creation Flow
|
||||
|
||||
```
|
||||
@@ -228,7 +242,7 @@ Unlike the personal vault, **org crypto bypasses Argon2id entirely**:
|
||||
| | Personal vault | Org vault |
|
||||
|---|---|---|
|
||||
| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random |
|
||||
| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob |
|
||||
| Key transport | Embedded in reference JPEG (stego) or stored in a `.relkey` key file | X25519 ECIES wrap blob |
|
||||
| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) |
|
||||
| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) |
|
||||
|
||||
@@ -414,13 +428,13 @@ Input JPEG (possibly re-encoded or cropped)
|
||||
|
||||
```
|
||||
Server breach only: ████████████████████████████ 256+ bits (infeasible)
|
||||
passphrase + image_secret
|
||||
passphrase + second-factor secret
|
||||
|
||||
Server + stolen image: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words)
|
||||
Server + stolen 2nd factor: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words)
|
||||
passphrase through Argon2id ~7 million years
|
||||
|
||||
Shoulder-surfed passphrase: ████████████████████████████ 256 bits (infeasible)
|
||||
image_secret
|
||||
second-factor secret
|
||||
|
||||
Stolen device: ████░░░░░░░░░░░░░░░░░░░░░░ ~51 bits (4 diceware words)
|
||||
passphrase through Argon2id ~7 million years
|
||||
|
||||
@@ -32,11 +32,35 @@ Every encrypted file — `manifest.enc`, `settings.enc`, `items/<id>.enc`, `atta
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
}
|
||||
},
|
||||
"second_factor": "image"
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
Parsed via `ParamsFile { kdf: KdfParams }` in `crates/relicario-core/src/crypto.rs`
|
||||
(`ParamsFile` struct; `second_factor` field added by
|
||||
<!-- TODO(dev-e): pin exact line once Dev-D's keyfile.rs lands -->
|
||||
`crates/relicario-core/src/crypto.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`.
|
||||
|
||||
### `second_factor` field
|
||||
|
||||
Top-level field on the `ParamsFile` wrapper. Value: `"image"` or `"keyfile"`. **Absent means `"image"`** — all existing vaults missing this field use the steganographic reference-image path (back-compatible). Clients read this field before the unlock prompt to decide whether to request a JPEG path or a `.relkey` file path.
|
||||
|
||||
## Key-file second factor (`.relkey`)
|
||||
|
||||
When `second_factor` is `"keyfile"`, the user holds a plain armored file instead of a steganographic JPEG. The file format is:
|
||||
|
||||
```
|
||||
relicario-keyfile-v1
|
||||
<base64 of 32 secret bytes>
|
||||
```
|
||||
|
||||
Exactly two lines, each terminated by a newline (`\n`). The first line is the literal ASCII string `relicario-keyfile-v1`. The second line is the standard base64 encoding (RFC 4648) of the 32-byte second-factor secret with no line wrapping.
|
||||
|
||||
- Suggested file extension: `.relkey`
|
||||
- The 32 bytes are the second-factor secret **in the clear** — this file is the "something you have". It is not itself encrypted. See [SECURITY.md](SECURITY.md) for the threat-model implications.
|
||||
- Source: `crates/relicario-core/src/keyfile.rs` — `keyfile_encode` / `keyfile_decode`
|
||||
<!-- TODO(dev-e): pin exact line once Dev-D's keyfile.rs lands -->
|
||||
|
||||
## `.relicario/salt`
|
||||
|
||||
@@ -159,10 +183,10 @@ Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map
|
||||
The password fed to Argon2id is length-prefixed to prevent extension attacks:
|
||||
|
||||
```
|
||||
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret
|
||||
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || second_factor_secret
|
||||
```
|
||||
|
||||
NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
|
||||
`second_factor_secret` is always 32 bytes extracted from whichever container the vault uses (reference image via `imgsecret::extract()` or key file via `keyfile_decode()`). The KDF input is byte-for-byte identical regardless of container. NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,11 +6,24 @@
|
||||
|
||||
Relicario uses two-factor vault decryption:
|
||||
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
|
||||
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography
|
||||
2. **Second factor** — a 32-byte secret carried by a pluggable container chosen at vault creation:
|
||||
- **Reference image** (default): a JPEG with the secret embedded in DCT coefficients via QIM steganography
|
||||
- **Key file**: the same 32-byte secret in a plain `.relkey` armored file
|
||||
|
||||
The second-factor input to the KDF is always 32 bytes regardless of container. The Argon2id input and master-key derivation are identical for both container types.
|
||||
|
||||
Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
|
||||
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)
|
||||
|
||||
### Second-factor storage posture
|
||||
|
||||
Both containers hold the second-factor secret **in the clear** — they are the "something you have", not an encrypted artifact. This is the expected and correct posture:
|
||||
|
||||
- **Reference image** (`reference.jpg`): the 32-byte secret is embedded in the JPEG DCT coefficients. The file itself is not encrypted. In the browser extension the image bytes are stored as `imageBase64` in `chrome.storage.local` — also in the clear.
|
||||
- **Key file** (`.relkey`): the 32-byte secret is base64-encoded in a plain text file. The file itself is not encrypted. In the browser extension the key-file bytes are stored as `keyfileBase64` in `chrome.storage.local` — also in the clear.
|
||||
|
||||
The key file is **not weaker** than the reference image. Both containers are protected by the same invariant: the passphrase is also required, and the server holds only opaque ciphertext sealed under a key derived from both. A stolen second factor without the passphrase yields nothing actionable (256 bits of Argon2id to brute-force). A stolen passphrase without the second factor is equally unactionable.
|
||||
|
||||
## Manifest Integrity
|
||||
|
||||
The manifest (`manifest.enc`) is encrypted with AEAD, which provides:
|
||||
@@ -212,7 +225,8 @@ reviewers to audit the surface in one place.
|
||||
|
||||
| Variable | Purpose | Trust |
|
||||
|---|---|---|
|
||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock (for image-path vaults). | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||
| `RELICARIO_KEYFILE` | Override the `.relkey` key-file path used during vault unlock (for key-file-path vaults). | Trusted: filesystem path under the user's control. Read-only; its bytes feed `keyfile_decode()`. The file holds the second-factor secret in the clear — same trust surface as `RELICARIO_IMAGE`. |
|
||||
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||
|
||||
Reference in New Issue
Block a user