diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 58ce1f8..f645fbe 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -8,6 +8,10 @@ use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; +/// 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"; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TotpCore { pub config: TotpConfig, @@ -96,6 +100,16 @@ pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result 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() { + // Authoritative Steam Guard alphabet from Valve's Steam Mobile + // Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z. + // (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.) + const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY"; + for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] { + assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet"); + } + } +} diff --git a/extension/src/popup/components/__tests__/fields.test.ts b/extension/src/popup/components/__tests__/fields.test.ts new file mode 100644 index 0000000..f6cf0ab --- /dev/null +++ b/extension/src/popup/components/__tests__/fields.test.ts @@ -0,0 +1,149 @@ +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('