From 1591fc782511f9f6a9985df8a819a28353bc210f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:24:06 -0400 Subject: [PATCH] docs: re-lead positioning on the two-factor-KDF thesis; document the key-file second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB --- DESIGN.md | 9 ++++---- README.md | 58 +++++++++++++++++++++++++++--------------------- docs/CRYPTO.md | 22 ++++++++++++++---- docs/FORMATS.md | 32 ++++++++++++++++++++++---- docs/SECURITY.md | 18 +++++++++++++-- 5 files changed, 100 insertions(+), 39 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ab6c4a5..a644b4c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -106,7 +106,7 @@ Every relicario vault — whether on disk for the CLI or in a git remote read by └── .enc # encrypted attachment blob; aid is content-addressed SHA-256 ``` -The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git. +The second-factor container (either `reference.jpg` for the reference-image path or a `.relkey` file for the key-file path) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git. This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking. @@ -142,8 +142,9 @@ The threat model differs by codebase. This is the per-secret per-codebase reside | Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup | |---|---|---|---|---| | Passphrase (UTF-8 bytes) | `Zeroizing` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a ``, sent to SW via `unlock` message, immediately forgotten | -| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes | -| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it | +| Reference image bytes (image-path vaults) | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to call `imgsecret::extract()` | Same (stored as `imageBase64` in `chrome.storage.local` — in the clear, same posture as the key file below) | Setup wizard holds the bytes briefly during create/attach modes | +| Key file bytes (key-file-path vaults) | Held by caller; core only reads via `keyfile_decode()` | Held by `UnlockedVault::unlock_interactive` long enough to decode the secret | Stored as `keyfileBase64` in `chrome.storage.local` — in the clear, same posture as `imageBase64` above | Setup wizard holds the bytes briefly during create mode | +| Second-factor secret (32 B) — extracted from either container | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it | | Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it | | Item secret (password, card number, etc.) | `Zeroizing` / `Zeroizing>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes | | Device private key | — | Filesystem under `~/.config/relicario/devices/.key` (mode 0600) | `chrome.storage.local.device_private_key` | — | @@ -200,7 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for | AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly | | Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak | | Attachment IDs are content-addressed (first 32 hex chars / 128 bits of SHA-256) | `core/ids.rs` | Dedup; integrity check | -| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions | +| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || second_factor_secret` collisions | | Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature | | Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log | | Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive | diff --git a/README.md b/README.md index 8686b01..80d4062 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ > **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md). -A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient. +A git-backed, self-hostable password manager. The security thesis: two independent secrets feed the Argon2id KDF, the vault server holds only opaque ciphertext, and every mutation is a git commit — a built-in audit log. Compromise of either factor alone is insufficient to decrypt the vault. + +The **second factor** (something you have) is pluggable: at vault creation you choose a **reference image** (a JPEG that carries a 32-byte secret hidden in its DCT coefficients via steganography) or a **key file** (the same 32-byte secret in a plain armored text file). Either container enters the KDF identically; the choice is how you store and transport it. The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof. @@ -15,7 +17,7 @@ The server only ever sees opaque ciphertext. There is nothing else going on. Thi ``` Your passphrase (something you know) + -Your reference photo (something you have) +Your second factor — reference image or key file (something you have) | v [ Argon2id KDF ] --> master_key --> [ XChaCha20-Poly1305 ] --> encrypted vault @@ -25,9 +27,9 @@ Your reference photo (something you have) your device (opaque ciphertext) ``` -At vault creation, Relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping). +At vault creation you choose a **second-factor container**. The default is a **reference image**: Relicario generates a random 32-byte secret and embeds it into a carrier JPEG using DCT steganography. The resulting photo lives on your devices — and optionally as a "dead drop" on social media, since the embedding survives JPEG re-encoding and mild cropping. The alternative is a **key file**: the same 32-byte secret written to a plain armored `.relkey` file, suited to users who prefer copying a file to a USB drive rather than distributing a photo. -To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there. +To unlock the vault, you provide your passphrase and your second-factor container (reference image or key file). The client extracts the 32-byte secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. The KDF input is byte-for-byte identical regardless of which container you chose. Everything else follows from there. ## Security model @@ -42,16 +44,16 @@ A git repository containing: - `.relicario/params.json` — Argon2id parameters (not secret) - `.relicario/devices.json` — authorized device public keys -That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images. +That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no second-factor containers (no reference images, no key files). ### What an attacker needs | Scenario | Has | Needs | Result | |---|---|---|---| -| Server breach | Encrypted vault + salt | Passphrase AND image secret | 256+ bits of entropy. Infeasible. | -| Server breach + stolen image | Vault + image secret | Passphrase | Passphrase entropy through Argon2id. 4 diceware words = ~7 million years. | -| Shoulder-surfed passphrase | Passphrase | Image secret | 256 bits. Infeasible. | -| Stolen device | Image + vault | Passphrase | Argon2id brute-force. Strong passphrase = safe. | +| Server breach | Encrypted vault + salt | Passphrase AND second-factor secret | 256+ bits of entropy. Infeasible. | +| Server breach + stolen second factor | Vault + second-factor secret | Passphrase | Passphrase entropy through Argon2id. 4 diceware words = ~7 million years. | +| Shoulder-surfed passphrase | Passphrase | Second-factor secret | 256 bits. Infeasible. | +| Stolen device (image or key file + vault) | Second factor + vault | Passphrase | Argon2id brute-force. Strong passphrase = safe. | No single point of failure. The two-factor design means the passphrase alone can't decrypt the vault, and the image alone can't decrypt the vault. @@ -62,12 +64,12 @@ No single point of failure. The two-factor design means the passphrase alone can | LastPass | ~40-60 bits (master password only) | 1 | | Bitwarden | ~40-60 bits (master password only) | 1 | | 1Password | password + 128-bit Secret Key | 2 | -| **Relicario** | **password + 256-bit image secret** | **2** | +| **Relicario** | **password + 256-bit second-factor secret (image or key file)** | **2** | ### What we don't protect against - A compromised device with active malware. No software password manager can. -- Weak passphrases with a stolen reference image. Use 4+ diceware words. +- Weak passphrases with a stolen second factor (reference image or key file). Use 4+ diceware words. - Rubber-hose cryptanalysis. ## Quick start @@ -76,8 +78,10 @@ No single point of failure. The two-factor design means the passphrase alone can # Build from source cargo build --release -# Create a vault (pick any JPEG as the carrier) +# Create a vault with a reference image (default — stego second factor) relicario init --image vacation.jpg --output reference.jpg +# Or use a plain key file as the second factor instead +relicario init --key-file /path/to/secret.relkey # Add a credential relicario add @@ -94,7 +98,7 @@ relicario sync # Pack the vault into a single encrypted backup file relicario backup export -o vault.relbak -# Print a recovery QR for your image_secret (see "Recovery" below) +# Print a recovery QR for your second-factor secret (see "Recovery" below) relicario recovery-qr generate # Generate a random password @@ -103,31 +107,35 @@ relicario generate -l 32 ### Environment variable -Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command. +Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the reference-image path on every command. If you use a key file instead, set `RELICARIO_KEYFILE=/path/to/secret.relkey`. -## The reference image +## The second factor: reference image and key file -The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies. +Relicario's second factor is a 32-byte secret. The **container** for that secret is your choice at vault creation. -The embedding survives: +**Reference image (default):** The JPEG carrier is generated during `relicario init`. It looks like a normal photo — because it is one. The 32-byte secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies. + +The steganographic embedding survives: - JPEG recompression (tested down to quality 85) - Up to ~10-15% cropping from any edge - Social media re-encoding (Instagram, Discord, etc.) -This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase. +This means your reference image can live on your Instagram, your personal website, or anywhere else — a "dead drop" approach. It is useless without your passphrase; the stego embedding is its transport mechanism. -## Recovery: what if I lose my reference image? +**Key file (alternative):** A plain armored `.relkey` file holding the same 32-byte secret in base64 — no steganography. Suited to users who prefer copying a small file to a USB drive, a hardware security module, or an offline storage medium. Wire format: see [docs/FORMATS.md](docs/FORMATS.md). -Without your reference image, the vault is undecryptable — that's the security model. But it also makes a lost or corrupted image a single point of failure. +## Recovery: what if I lose my second factor? -The mitigation is the **recovery QR**: a printable QR code that wraps your image secret behind a separate recovery passphrase you choose. If you ever lose access to the reference JPEG, scan or transcribe the QR, provide the recovery passphrase, and recover the 256-bit image secret. Combined with your normal vault passphrase, this restores access to the vault. +Without your second-factor container (reference image or key file), the vault is undecryptable — that's the security model. But it also makes a lost or corrupted container a single point of failure. + +The mitigation is the **recovery QR**: a printable QR code that wraps the 32-byte second-factor secret behind a separate recovery passphrase you choose. It applies regardless of which container you chose at vault creation. If you ever lose access to your reference image or key file, scan or transcribe the QR, provide the recovery passphrase, and recover the second-factor secret. Combined with your normal vault passphrase, this restores access to the vault. ```bash # Print a recovery QR (after the vault is unlocked). # You'll be prompted for a separate recovery passphrase. relicario recovery-qr generate -# Recover the image_secret from a stored QR payload. +# Recover the second-factor secret from a stored QR payload. relicario recovery-qr unwrap ``` @@ -145,7 +153,7 @@ A short tour of the four codebases and how they fit together lives in [DESIGN.md | Primitive | Purpose | Why this one | |---|---|---| -| Argon2id (64 MiB, 3 iter, 4 parallel) | Key derivation from passphrase + image secret | Memory-hard, GPU-resistant, OWASP recommended | +| Argon2id (64 MiB, 3 iter, 4 parallel) | Key derivation from passphrase + second-factor secret | Memory-hard, GPU-resistant, OWASP recommended | | XChaCha20-Poly1305 | Authenticated encryption of vault entries | 192-bit nonce (no collision risk), fast in WASM/ARM without AES-NI | | ed25519 | Device key signing | Per-device commit authorization, revocable without KDF rotation | @@ -182,7 +190,7 @@ Item IDs are random 16-char hex strings (64 bits of entropy). Git history is pre Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption. -Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed. +Revoking a device: remove its key from `devices.json` and commit. No passphrase or second-factor rotation needed. ```bash relicario device add --name laptop @@ -210,7 +218,7 @@ The binary is at `target/release/relicario`. - [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP - [x] Secure document storage (encrypted file attachments) - [x] Backup & restore (`.relbak` encrypted envelope) -- [x] Recovery QR (paper-printable image_secret backup with separate passphrase) +- [x] Recovery QR (paper-printable second-factor secret backup with separate passphrase) - [x] LastPass CSV import - [x] Device authentication (ed25519 commit signing + pre-receive hook) - [ ] Import from Bitwarden / 1Password diff --git a/docs/CRYPTO.md b/docs/CRYPTO.md index 18e6aba..6f44769 100644 --- a/docs/CRYPTO.md +++ b/docs/CRYPTO.md @@ -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` — ) | +| 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 diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 2890ec2..f7e43b3 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -32,11 +32,35 @@ Every encrypted file — `manifest.enc`, `settings.enc`, `items/.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 + +`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 + +``` + +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` + ## `.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`. --- diff --git a/docs/SECURITY.md b/docs/SECURITY.md index dca0c52..f4e50f9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. |