# 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('