docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
Full-TDD per-stream plans for the v0.9.0 multi-agent train: - org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session + org config + grant-filtered manifest read. - org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner. - org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write handlers + UI. Spike-gated; NO-GO ships read-only. - keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint + WASM bindings + CLI init/unlock --key-file. - keyfile-ext-positioning (B3+B4): setup container choice + unlock + the README/DESIGN/CRYPTO/FORMATS positioning pivot. Cross-plan contracts pinned and self-reviewed for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
This commit is contained in:
397
docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md
Normal file
397
docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Key-File Second Factor — Core + CLI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the vault's 256-bit second factor pluggable — carryable by a plain key file as well as the stego image — at the core and CLI layers, chosen at `init` and recorded by a non-secret params hint.
|
||||
|
||||
**Architecture:** The second factor is already just 32 bytes (`image_secret`); stego is only the transport. We add a key-file armor format and a raw-secret unlock path in `relicario-core`, surface both over `relicario-wasm`, and branch the CLI's factor resolution on a new non-secret `second_factor` field in `params.json`. The Argon2id KDF and AEAD are byte-for-byte unchanged — only the *source* of the 32 bytes differs.
|
||||
|
||||
**Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), wasm-bindgen, clap.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Release target: v0.9.0.
|
||||
- No new crypto primitive: reuse `derive_master_key` (`crates/relicario-core/src/crypto.rs:207`). The key-file path must derive the *same* master key as the stego path for the same 32-byte secret (equivalence is the security argument).
|
||||
- `params.json` `second_factor` is **non-secret** and defaults to `image` when absent (every existing vault keeps working).
|
||||
- The key file (`.relkey`) holds the secret in the clear — it is the "something you have," protected by needing the passphrase too. Same posture as the reference JPEG. Do not imply it is encrypted.
|
||||
- Rust tests use fast Argon2id params (m=256, t=1, p=1).
|
||||
- Capitalize "Relicario" in prose; the binary/command stays lowercase `relicario`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `crates/relicario-core/src/crypto.rs` — add `SecondFactor` enum + `second_factor` field on `KdfParams` (the struct serialized to `params.json`); confirm the 1:1 mapping first.
|
||||
- `crates/relicario-core/src/keyfile.rs` *(new)* — `keyfile_encode` / `keyfile_decode` (armor format, `Zeroizing`).
|
||||
- `crates/relicario-core/src/lib.rs` — `pub mod keyfile;` re-export.
|
||||
- `crates/relicario-wasm/src/lib.rs` — `#[wasm_bindgen]` `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`.
|
||||
- `extension/src/wasm.d.ts` — declare the three (consumed by Plan 5).
|
||||
- `crates/relicario-cli/src/session.rs` — `get_keyfile_path()`; branch `unlock_interactive` on the params hint.
|
||||
- `crates/relicario-cli/src/commands/init.rs` (or wherever `init` lives — locate first) — `--key-file` path.
|
||||
- `crates/relicario-cli/src/main.rs` — clap `--key-file` flag on `init` (and `RELICARIO_KEYFILE` doc in help).
|
||||
- Tests: core unit tests in `keyfile.rs` + `crypto.rs`; `crates/relicario-wasm` equivalence test; `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `SecondFactor` hint in `KdfParams`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/relicario-core/src/crypto.rs:157` (`KdfParams`)
|
||||
- Test: `crates/relicario-core/src/crypto.rs` (`#[cfg(test)]`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pub enum SecondFactor { Image, Keyfile }` (serde `rename_all = "lowercase"`); `KdfParams.second_factor: SecondFactor` with `#[serde(default)]` (default `Image`).
|
||||
|
||||
- [ ] **Step 1: Confirm `KdfParams` ⟷ `params.json` is 1:1.** Grep where `params.json` is written/read in `crates/relicario-cli/src` and `backup.rs`; confirm it serializes `KdfParams`. If a wrapper struct is used instead, put `second_factor` there. Note the finding in a comment.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn params_default_second_factor_is_image_and_is_backcompat() {
|
||||
// Old params.json (no second_factor) must deserialize as Image.
|
||||
let old = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
||||
let p: KdfParams = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(p.second_factor, SecondFactor::Image);
|
||||
// New keyfile params round-trip.
|
||||
let kf = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1,"second_factor":"keyfile"}"#;
|
||||
let p2: KdfParams = serde_json::from_str(kf).unwrap();
|
||||
assert_eq!(p2.second_factor, SecondFactor::Keyfile);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-core params_default_second_factor`
|
||||
Expected: FAIL — `SecondFactor` not found.
|
||||
|
||||
- [ ] **Step 4: Implement**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SecondFactor {
|
||||
#[default]
|
||||
Image,
|
||||
Keyfile,
|
||||
}
|
||||
// in KdfParams:
|
||||
#[serde(default)]
|
||||
pub second_factor: SecondFactor,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-core params_default_second_factor` then `cargo test -p relicario-core` (no regressions; check `format_v2`/backup tests still pass since params.json gained an optional field).
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/relicario-core/src/crypto.rs
|
||||
git commit -m "feat(core): SecondFactor hint on KdfParams (default image, back-compat)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Key-file armor (`keyfile_encode` / `keyfile_decode`)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/relicario-core/src/keyfile.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs` (`pub mod keyfile;`)
|
||||
- Test: `crates/relicario-core/src/keyfile.rs` (`#[cfg(test)]`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8>`; `pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>>`. Format: literal line `relicario-keyfile-v1\n`, then base64 (standard, no-pad-agnostic) of the 32 bytes, then `\n`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let secret = [9u8; 32];
|
||||
let armored = keyfile_encode(&secret);
|
||||
assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n"));
|
||||
let back = keyfile_decode(&armored).unwrap();
|
||||
assert_eq!(*back, secret);
|
||||
}
|
||||
#[test]
|
||||
fn rejects_bad_header() {
|
||||
assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_wrong_length() {
|
||||
assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-core keyfile`
|
||||
Expected: FAIL — module/functions not found.
|
||||
|
||||
- [ ] **Step 3: Implement `keyfile.rs`**
|
||||
|
||||
```rust
|
||||
//! Key-file armor for the pluggable second factor. The file holds the raw
|
||||
//! 32-byte secret (base64) behind a version header — it is the "something
|
||||
//! you have", not an encrypted artifact (the passphrase is the other factor).
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use zeroize::Zeroizing;
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
const HEADER: &str = "relicario-keyfile-v1";
|
||||
|
||||
pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8> {
|
||||
format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
|
||||
}
|
||||
|
||||
pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|_| RelicarioError::InvalidFormat("key file is not UTF-8".into()))?;
|
||||
let mut lines = text.lines();
|
||||
if lines.next() != Some(HEADER) {
|
||||
return Err(RelicarioError::InvalidFormat("bad key-file header".into()));
|
||||
}
|
||||
let b64 = lines.next().unwrap_or("").trim();
|
||||
let decoded = STANDARD.decode(b64)
|
||||
.map_err(|_| RelicarioError::InvalidFormat("key-file body not base64".into()))?;
|
||||
let arr: [u8; 32] = decoded.as_slice().try_into()
|
||||
.map_err(|_| RelicarioError::InvalidFormat("key-file secret must be 32 bytes".into()))?;
|
||||
Ok(Zeroizing::new(arr))
|
||||
}
|
||||
```
|
||||
|
||||
(Match the real `RelicarioError` variant — use the existing invalid-format/parse variant; `grep RelicarioError crates/relicario-core/src/error.rs` first.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-core keyfile`
|
||||
Expected: PASS (all three).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/relicario-core/src/keyfile.rs crates/relicario-core/src/lib.rs
|
||||
git commit -m "feat(core): key-file armor (relicario-keyfile-v1) encode/decode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WASM bindings + master-key equivalence
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/relicario-wasm/src/lib.rs`
|
||||
- Modify: `extension/src/wasm.d.ts`
|
||||
- Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (consumed by Plan 5): `keyfile_encode(secret: Uint8Array): Uint8Array`; `keyfile_decode(bytes: Uint8Array): Uint8Array`; `unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (equivalence: same secret ⇒ same master key, proven by cross-decrypt)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn unlock_with_secret_matches_unlock_from_jpeg() {
|
||||
let secret = [3u8; 32];
|
||||
let salt = [1u8; 32];
|
||||
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
||||
let jpeg = relicario_core::imgsecret::embed(&make_test_jpeg(), &secret).unwrap();
|
||||
|
||||
let h_img = unlock(/*passphrase*/ "pw", &jpeg, &salt, params).unwrap();
|
||||
let h_key = unlock_with_secret("pw", &secret, &salt, params).unwrap();
|
||||
|
||||
// Same key ⇒ a blob encrypted under one handle decrypts under the other.
|
||||
let ct = item_encrypt(&h_img, r#"{"id":"a","core":{"type":"SecureNote","body":"z"}}"#).unwrap();
|
||||
let pt = item_decrypt(&h_key, &ct).unwrap();
|
||||
assert!(format!("{pt:?}").contains("SecureNote"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-wasm unlock_with_secret`
|
||||
Expected: FAIL — `unlock_with_secret` not found.
|
||||
|
||||
- [ ] **Step 3: Implement** (mirror `unlock` at `lib.rs:49`, skipping `imgsecret::extract`)
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn unlock_with_secret(
|
||||
passphrase: &str,
|
||||
secret: &[u8],
|
||||
salt: &[u8],
|
||||
params_json: &str,
|
||||
) -> Result<SessionHandle, JsError> {
|
||||
let params: KdfParams = serde_json::from_str(params_json)
|
||||
.map_err(|e| JsError::new(&format!("params: {e}")))?;
|
||||
let secret_arr: &[u8; 32] = secret.try_into()
|
||||
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
|
||||
let salt_arr: &[u8; 32] = salt.try_into()
|
||||
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), secret_arr, salt_arr, ¶ms)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let handle = session::insert(master_key, Zeroizing::new(*secret_arr));
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||
let arr: &[u8; 32] = secret.try_into().map_err(|_| JsError::new("secret must be 32 bytes"))?;
|
||||
Ok(relicario_core::keyfile::keyfile_encode(arr))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn keyfile_decode(bytes: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||
let s = relicario_core::keyfile::keyfile_decode(bytes).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(s.to_vec())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-wasm`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Declare in `wasm.d.ts` + build**
|
||||
|
||||
Add to `extension/src/wasm.d.ts`:
|
||||
```ts
|
||||
export function keyfile_encode(secret: Uint8Array): Uint8Array;
|
||||
export function keyfile_decode(bytes: Uint8Array): Uint8Array;
|
||||
export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle;
|
||||
```
|
||||
Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` + the project wasm-pack step.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts
|
||||
git commit -m "feat(wasm): unlock_with_secret + keyfile encode/decode bindings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CLI unlock branches on the params hint
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/relicario-cli/src/session.rs` (`get_keyfile_path`, `unlock_interactive`)
|
||||
- Test: `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `keyfile_decode`, `derive_master_key`, `KdfParams.second_factor`.
|
||||
- Produces: `pub fn get_keyfile_path() -> Result<PathBuf>` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `<vault_root>/vault.relkey` convention → interactive prompt). `unlock_interactive` reads `second_factor` from params and resolves the image OR the key file accordingly.
|
||||
|
||||
- [ ] **Step 1: Write the failing integration test**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn init_keyfile_then_unlock_keyfile_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// init with a key file (Task 5 wires the flag; here drive it once that lands)
|
||||
relicario(&dir).args(["init", "--key-file", "vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success();
|
||||
// unlock + add + get using the key file
|
||||
relicario(&dir)
|
||||
.args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"])
|
||||
.env("RELICARIO_PASSPHRASE","correct horse")
|
||||
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
|
||||
.assert().success();
|
||||
relicario(&dir).args(["get","gh","--show"])
|
||||
.env("RELICARIO_PASSPHRASE","correct horse")
|
||||
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
|
||||
.assert().success().stdout(predicates::str::contains("u"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||
Expected: FAIL — `--key-file` unknown / `RELICARIO_KEYFILE` ignored.
|
||||
|
||||
- [ ] **Step 3: Implement `get_keyfile_path` + the unlock branch**
|
||||
|
||||
```rust
|
||||
pub fn get_keyfile_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { return Ok(PathBuf::from(path)); }
|
||||
if let Some(root) = find_vault_root() { // mirror get_image_path's convention block
|
||||
let default = root.join("vault.relkey");
|
||||
if default.exists() { return Ok(default); }
|
||||
}
|
||||
let trimmed = prompt("key file path: ")?;
|
||||
if trimmed.is_empty() { bail!("no key file path provided"); }
|
||||
Ok(PathBuf::from(trimmed))
|
||||
}
|
||||
```
|
||||
|
||||
In `unlock_interactive` (`session.rs:33`): after loading `params`, branch — `SecondFactor::Image` keeps today's `get_image_path` + `imgsecret::extract`; `SecondFactor::Keyfile` does `keyfile_decode(fs::read(get_keyfile_path()?)?)` → `derive_master_key(passphrase, &secret, &salt, ¶ms)`. Map a missing/garbled key file to a clear `invalid_key_file` error distinct from the wrong-passphrase AEAD failure.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** (after Task 5 wires `init --key-file`; if executing in order, mark this test `#[ignore]` until Task 5, then un-ignore)
|
||||
|
||||
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/relicario-cli/src/session.rs crates/relicario-cli/tests/keyfile_flows.rs
|
||||
git commit -m "feat(cli): unlock resolves second factor from params hint (image|keyfile)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: CLI `init --key-file`
|
||||
|
||||
**Files:**
|
||||
- Modify: the `init` command handler (locate: `grep -rn 'Init\|fn.*init' crates/relicario-cli/src/`), `crates/relicario-cli/src/main.rs` (clap flag)
|
||||
- Test: `crates/relicario-cli/tests/keyfile_flows.rs` (un-ignore Task 4's test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `keyfile_encode`; `KdfParams { second_factor: Keyfile, .. }`.
|
||||
- Produces: `relicario init --key-file <path>` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to `<path>`, derives the master key from passphrase+secret, and writes `params.json` with `second_factor: "keyfile"`. The existing `--image`/`--output` path stays the default and writes `second_factor: "image"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** — un-ignore `init_keyfile_then_unlock_keyfile_round_trips` and add:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn init_keyfile_writes_relkey_and_keyfile_params() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
relicario(&dir).args(["init","--key-file","vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success();
|
||||
assert!(dir.path().join("vault.relkey").exists());
|
||||
let params = std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap();
|
||||
assert!(params.contains("\"second_factor\":\"keyfile\""));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-cli --test keyfile_flows init_keyfile_writes`
|
||||
Expected: FAIL — `--key-file` not a known arg.
|
||||
|
||||
- [ ] **Step 3: Implement** — add `#[arg(long, conflicts_with = "image")] key_file: Option<PathBuf>` to the init args in `main.rs`; in the init handler, when `key_file` is set: `let secret: [u8;32] = OsRng.gen();` → `fs::write(path, keyfile_encode(&secret))` → derive master key from `&secret` → set `KdfParams { second_factor: SecondFactor::Keyfile, ..default }` before writing `params.json`. Reuse the existing init crypto/write path otherwise.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-cli --test keyfile_flows`
|
||||
Expected: PASS (both tests).
|
||||
|
||||
- [ ] **Step 5: Full suite + commit**
|
||||
|
||||
Run: `cargo test` (workspace) — confirm no personal-vault init/unlock regressions.
|
||||
|
||||
```bash
|
||||
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/ crates/relicario-cli/tests/keyfile_flows.rs
|
||||
git commit -m "feat(cli): init --key-file generates a .relkey second factor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hand-off contract (consumed by Plan 5: extension + positioning)
|
||||
|
||||
- Core: `keyfile_encode(&[u8;32]) -> Vec<u8>`, `keyfile_decode(&[u8]) -> Result<Zeroizing<[u8;32]>>`, `SecondFactor { Image, Keyfile }` on `KdfParams` (absent ⇒ `Image`).
|
||||
- WASM / `wasm.d.ts`: `keyfile_encode(Uint8Array): Uint8Array`, `keyfile_decode(Uint8Array): Uint8Array`, `unlock_with_secret(passphrase, secret, salt, params_json): SessionHandle`.
|
||||
- Armor: `relicario-keyfile-v1\n` + base64(32 bytes) + `\n`; extension `.relkey`.
|
||||
- `params.json` carries `"second_factor": "image" | "keyfile"`. Plan 5's setup wizard writes the hint and its unlock reads it to choose the image picker vs the key-file picker, calling `unlock_with_secret` for the key-file path.
|
||||
Reference in New Issue
Block a user