fix(core): disable HOTP with clear error (audit I6)

HOTP requires incrementing and persisting the counter after each use.
Without vault-save machinery in compute_totp_code, HOTP would desync
immediately. Now returns HotpNotSupported error.

TOTP and Steam codes continue to work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 01:36:31 -04:00
parent 466efe4b8a
commit 628e2bd636
2 changed files with 22 additions and 4 deletions

View File

@@ -109,6 +109,12 @@ pub enum RelicarioError {
/// rotating the passphrase or reference image.
#[error("device key error: {0}")]
DeviceKey(String),
/// HOTP requires incrementing and persisting the counter after each use.
/// Without vault-save machinery in compute_totp_code, HOTP would desync
/// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported,
}
/// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -64,14 +64,14 @@ impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// For HOTP: uses the `counter` carried in the variant.
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter,
TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
};
let counter_bytes = counter.to_be_bytes();
@@ -165,7 +165,7 @@ mod tests {
}
#[test]
fn hotp_carries_counter() {
fn hotp_kind_roundtrips_through_json() {
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
@@ -173,6 +173,18 @@ mod tests {
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
other => panic!("expected Hotp, got {:?}", other),
}
// Note: compute_totp_code will reject this — HOTP not supported
}
#[test]
fn hotp_returns_not_supported_error() {
let cfg = TotpConfig {
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
kind: TotpKind::Hotp { counter: 0 },
..TotpConfig::default()
};
let result = compute_totp_code(&cfg, 0);
assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
}
#[test]