diff --git a/docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md b/docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md new file mode 100644 index 0000000..d577d61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.md @@ -0,0 +1,2716 @@ +# relicario Extension 1C-β₁ (Typed-Item Forms) 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:** Add the 5 remaining typed-item forms (SecureNote, Identity, Card, Key, Totp incl. Steam Guard) so the relicario extension can daily-drive every typed item the Rust core supports except Document. + +**Architecture:** 5-slice bottom-up sequencing. Slice 1 patches the Rust core's `compute_totp_code` to emit Steam's 5-char alphabet output. Slice 2 extracts a shared `popup/components/fields.ts` helper module (row / concealed-row / signature-block primitives) and refactors Login onto it as the reference implementation. Slices 3-5 land the 5 new types in pairs: SecureNote+Identity (no signature block), Card+Key (signature block, no live state), Totp (signature block + countdown + Steam toggle). + +**Tech Stack:** Rust (`relicario-core`), TypeScript (extension popup), Vitest + happy-dom (existing test harness from α), Bun (package manager). + +**Reference spec:** `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md` (commit `1b51b7d`) + +**Branch:** Create `feature/typed-items-1c-beta1` off `main` (1C-α merged at `2b83105`, tag `plan-1c-alpha-complete`). + +--- + +## Pre-flight + +- [ ] **P1: Verify main is clean and tests are green** + +```bash +cd /home/alee/Sources/relicario +git status +git checkout main && git pull +cargo test --workspace 2>&1 | tail -3 +``` + +Expected: working tree clean, on `main`, all Rust tests pass. + +- [ ] **P2: Create the feature worktree** + +```bash +cd /home/alee/Sources/relicario +git worktree add .worktrees/typed-items-1c-beta1 -b feature/typed-items-1c-beta1 +cd .worktrees/typed-items-1c-beta1/extension +bun install +bun run test 2>&1 | tail -3 +``` + +Expected: 55 Vitest tests pass (the α baseline). + +--- + +## Slice 1 — Rust Steam encoding fix + +Goal: `compute_totp_code` in `crates/relicario-core/src/item_types/totp.rs` learns to emit Steam Guard's 5-character alphabet output for `kind: 'steam'`. Standard TOTP/HOTP outputs unchanged. + +### Task 1: Add Steam alphabet to `compute_totp_code` + +**Files:** +- Modify: `crates/relicario-core/src/item_types/totp.rs` + +- [ ] **Step 1: Read the current implementation** + +Read `crates/relicario-core/src/item_types/totp.rs`. The file already has `compute_totp_code` taking a `&TotpConfig` and returning `Result`. Locate the `format!("{:0width$}", ...)` final line — that's where the decimal-only output happens. + +- [ ] **Step 2: Write failing tests** + +Add the following test module **at the bottom** of `crates/relicario-core/src/item_types/totp.rs`, alongside the existing `#[cfg(test)] mod compute_tests` and `#[cfg(test)] mod tests`: + +```rust +#[cfg(test)] +mod steam_tests { + use super::*; + + /// Reference implementation of the Steam 5-character output, per the + /// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter). + /// Used by tests below to cross-check the production impl without + /// requiring a third-party vector. The algorithm is short enough to + /// be reproduced here in isolation. + fn steam_output_reference(truncated: u32) -> String { + const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY"; + let mut t = truncated; + let mut out = String::with_capacity(5); + for _ in 0..5 { + out.push(ALPHA[(t % 26) as usize] as char); + t /= 26; + } + out + } + + /// Compute the dynamic-truncated u32 the same way `compute_totp_code` + /// does internally — used to drive the reference impl. + fn truncated_for(secret: &[u8], counter: u64) -> u32 { + use hmac::{Hmac, Mac}; + use sha1::Sha1; + let mut mac = Hmac::::new_from_slice(secret).unwrap(); + mac.update(&counter.to_be_bytes()); + let bytes = mac.finalize().into_bytes(); + let offset = (bytes[bytes.len() - 1] & 0x0F) as usize; + ((bytes[offset] as u32 & 0x7F) << 24) + | ((bytes[offset + 1] as u32) << 16) + | ((bytes[offset + 2] as u32) << 8) + | (bytes[offset + 3] as u32) + } + + #[test] + fn steam_output_matches_reference_impl() { + let secret = b"12345678901234567890".to_vec(); + let cfg = TotpConfig { + secret: Zeroizing::new(secret.clone()), + algorithm: TotpAlgorithm::Sha1, + digits: 5, + period_seconds: 30, + kind: TotpKind::Steam, + }; + let code_at_30 = compute_totp_code(&cfg, 30).unwrap(); + let code_at_60 = compute_totp_code(&cfg, 60).unwrap(); + let code_at_120 = compute_totp_code(&cfg, 120).unwrap(); + assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1))); + assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2))); + assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4))); + } + + #[test] + fn steam_output_is_exactly_5_chars_regardless_of_digits() { + let secret = b"hello world!".to_vec(); + for digits in [4u8, 5, 6, 7, 8] { + let cfg = TotpConfig { + secret: Zeroizing::new(secret.clone()), + algorithm: TotpAlgorithm::Sha1, + digits, + period_seconds: 30, + kind: TotpKind::Steam, + }; + let code = compute_totp_code(&cfg, 0).unwrap(); + assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits); + } + } + + #[test] + fn steam_output_uses_only_alphabet_chars() { + const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY"; + let secret = b"hello world!".to_vec(); + let cfg = TotpConfig { + secret: Zeroizing::new(secret), + algorithm: TotpAlgorithm::Sha1, + digits: 5, + period_seconds: 30, + kind: TotpKind::Steam, + }; + for t in 0u64..1000 { + let code = compute_totp_code(&cfg, t * 30).unwrap(); + for ch in code.chars() { + assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})"); + } + } + } + + #[test] + fn steam_alphabet_excludes_ambiguous_glyphs() { + const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY"; + for ch in ['0', 'O', '1', 'I', 'L', 'S', '5', 'A', 'Z'] { + assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet"); + } + } +} +``` + +- [ ] **Step 3: Run the new tests — they should fail** + +Run: `cargo test -p relicario-core --lib item_types::totp::steam_tests 2>&1 | tail -20` +Expected: 3-4 failures (the alphabet-exclusion test passes trivially since it doesn't call the impl; the others all fail because Steam currently returns decimal output). + +- [ ] **Step 4: Implement the Steam alphabet output in `compute_totp_code`** + +In `crates/relicario-core/src/item_types/totp.rs`, near the top (after the `use` block), add the constant: + +```rust +/// Steam Mobile Authenticator's 5-character output alphabet. +/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z). +const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY"; +``` + +Then in `compute_totp_code`, replace the final `let modulus = ...; Ok(format!(...))` block with: + +```rust + if matches!(config.kind, TotpKind::Steam) { + let mut t = truncated; + let mut out = String::with_capacity(5); + for _ in 0..5 { + out.push(STEAM_ALPHABET[(t % 26) as usize] as char); + t /= 26; + } + return Ok(out); + } + + let modulus = 10u32.pow(config.digits as u32); + Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize)) +``` + +(Where `truncated` is the `u32` already computed by the existing dynamic-truncation logic above. If the existing code uses a different variable name, adapt accordingly — the variable representing the 31-bit truncated HMAC output.) + +- [ ] **Step 5: Re-run all totp tests — Steam tests pass, decimal tests still pass** + +Run: `cargo test -p relicario-core --lib item_types::totp 2>&1 | tail -10` +Expected: all tests in `compute_tests`, `tests`, and `steam_tests` modules pass. The pre-existing `rfc6238_sha1_vector_59` decimal test must still pass (assertion `code == "94287082"`). + +- [ ] **Step 6: Run the whole workspace — no regressions** + +Run: `cargo test --workspace 2>&1 | grep -E "test result"` +Expected: every line ends with `0 failed`. New tests bump total by 4. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1 +git add crates/relicario-core/src/item_types/totp.rs +git commit -m "$(cat <<'EOF' +feat(core/totp): emit Steam Guard alphabet for kind=Steam + +compute_totp_code previously produced decimal output for all three +TotpKind variants. Steam Guard requires a 5-character output drawn +from a 26-char alphabet (23456789BCDFGHJKMNPQRTVWXY) — deliberately +excluding ambiguous glyphs (0/O, 1/I/L, S/5, A/Z). + +Implementation: +- Iterate the 31-bit truncated HMAC value 5 times: push + STEAM_ALPHABET[t % 26] then divide by 26. +- TOTP / HOTP decimal output paths unchanged. + +Tests: +- steam_output_matches_reference_impl: cross-checks the production + impl against a separate reference implementation in the test + module (the algorithm is short enough that a parallel impl is the + cleanest spec). +- steam_output_is_exactly_5_chars_regardless_of_digits: Steam + ignores the `digits` field; output always 5 chars. +- steam_output_uses_only_alphabet_chars: 1000-iteration sweep + confirms no character outside the alphabet ever appears. +- steam_alphabet_excludes_ambiguous_glyphs. +- Existing RFC 6238 SHA1 test vector for kind=Totp still passes + byte-for-byte. +EOF +)" +``` + +--- + +## Slice 2 — Shared field helpers + Login refactor + +Goal: introduce `popup/components/fields.ts` with `renderRow` / `renderConcealedRow` / `renderSignatureBlock` / `wireFieldHandlers`, add the supporting CSS, write helper unit tests, then refactor the existing Login detail/form code onto the helpers as the reference implementation. + +### Task 2: Add the field helpers module + tests + CSS + +**Files:** +- Create: `extension/src/popup/components/fields.ts` +- Create: `extension/src/popup/components/__tests__/fields.test.ts` +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Write the failing helper tests** + +Create `extension/src/popup/components/__tests__/fields.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { + renderRow, + renderConcealedRow, + renderSignatureBlock, + wireFieldHandlers, +} from '../fields'; + +describe('renderRow', () => { + it('plain row contains label + value', () => { + const html = renderRow({ label: 'username', value: 'alice' }); + expect(html).toContain('username'); + expect(html).toContain('alice'); + expect(html).toContain('field-row'); + }); + + it('copyable row exposes a copy action', () => { + const html = renderRow({ label: 'email', value: 'alice@example.com', copyable: true }); + expect(html).toContain('data-field-action="copy"'); + }); + + it('href row wraps value in an external anchor', () => { + const html = renderRow({ label: 'url', value: 'https://example.com', href: 'https://example.com' }); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it('monospace flag toggles the monospace class', () => { + const html = renderRow({ label: 'fingerprint', value: 'AB:CD', monospace: true }); + expect(html).toContain('monospace'); + }); + + it('multiline value renders inside a
', () => {
+    const html = renderRow({ label: 'address', value: '1 Main\n2 Main', multiline: true });
+    expect(html).toContain(' {
+    const html = renderRow({ label: '', value: '"&<>' });
+    expect(html).not.toContain('