Files
picoforge/src/hal/applets/oath.rs
T
2026-07-28 16:04:11 +05:30

684 lines
22 KiB
Rust

//! YKOATH (Yubico OATH) applet client — TOTP/HOTP account management.
//!
//! Speaks the standard Yubico OATH wire protocol over a
//! [`CcidSession`], so it is
//! firmware-agnostic (RS-Key emulates the same AID and command set). The device
//! holds the secrets and computes the codes; the host only frames commands,
//! parses responses, and — for password-protected devices — derives the access
//! key (PBKDF2-HMAC-SHA1) and proves knowledge of it (HMAC-SHA1).
// Full YKOATH surface; the Accounts screen wires most of it, and the rest
// (rename, bare LIST) is exercised by the inline tests or the follow-up UI.
#![allow(dead_code)]
use crate::error::PFError;
use crate::hal::apdu::{Apdu, CLA_ISO, tlv};
use crate::hal::transport::ccid::CcidSession;
use ring::rand::{SecureRandom, SystemRandom};
use ring::{hmac, pbkdf2};
use std::num::NonZeroU32;
use std::time::{SystemTime, UNIX_EPOCH};
/// Standard Yubico OATH applet AID.
pub const OATH_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01];
// Instructions.
const INS_PUT: u8 = 0x01;
const INS_DELETE: u8 = 0x02;
const INS_SET_CODE: u8 = 0x03;
const INS_RESET: u8 = 0x04;
const INS_RENAME: u8 = 0x05;
const INS_LIST: u8 = 0xA1;
const INS_CALCULATE: u8 = 0xA2;
const INS_VALIDATE: u8 = 0xA3;
const INS_CALCULATE_ALL: u8 = 0xA4;
// Data-object tags.
const TAG_NAME: u32 = 0x71;
const TAG_NAME_LIST: u32 = 0x72;
const TAG_KEY: u32 = 0x73;
const TAG_CHALLENGE: u32 = 0x74;
const TAG_RESPONSE_FULL: u32 = 0x75;
const TAG_RESPONSE_TRUNC: u32 = 0x76;
const TAG_NO_RESPONSE: u32 = 0x77;
const TAG_PROPERTY: u32 = 0x78;
const TAG_VERSION: u32 = 0x79;
const TAG_IMF: u32 = 0x7A;
const TAG_TOUCH_RESPONSE: u32 = 0x7C;
const PROP_TOUCH: u8 = 0x02;
const DEFAULT_PERIOD: u32 = 30;
const ACCESS_KEY_LEN: usize = 16;
const PBKDF2_ITERS: u32 = 1000;
/// OATH credential kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OathType {
Totp,
Hotp,
}
impl OathType {
fn wire(self) -> u8 {
match self {
Self::Hotp => 0x10,
Self::Totp => 0x20,
}
}
fn from_wire(b: u8) -> Self {
if b & 0xF0 == 0x10 {
Self::Hotp
} else {
Self::Totp
}
}
}
/// HMAC hash used by a credential.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAlgo {
Sha1,
Sha256,
Sha512,
}
impl HashAlgo {
fn wire(self) -> u8 {
match self {
Self::Sha1 => 0x01,
Self::Sha256 => 0x02,
Self::Sha512 => 0x03,
}
}
fn from_wire(b: u8) -> Self {
match b & 0x0F {
0x02 => Self::Sha256,
0x03 => Self::Sha512,
_ => Self::Sha1,
}
}
/// Label used in `otpauth://` URIs.
pub fn label(self) -> &'static str {
match self {
Self::Sha1 => "SHA1",
Self::Sha256 => "SHA256",
Self::Sha512 => "SHA512",
}
}
}
/// The current code state for one credential after CALCULATE ALL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeState {
/// A computed TOTP code, valid for `period` seconds from its window start.
Code { value: String, period: u32 },
/// HOTP — counter-based; compute on demand with [`calculate`].
Hotp,
/// Requires a physical touch before the device will compute the code.
Touch,
}
/// A parsed OATH account (identity + current code state).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
/// Raw credential id as stored on the device (the wire `0x71` name).
pub id: String,
pub issuer: Option<String>,
pub account: String,
pub oath_type: OathType,
pub period: u32,
pub state: CodeState,
}
/// A new credential to enroll via [`put`].
#[derive(Debug, Clone)]
pub struct NewCredential {
pub issuer: Option<String>,
pub account: String,
pub secret: Vec<u8>,
pub oath_type: OathType,
pub algorithm: HashAlgo,
pub digits: u8,
pub period: u32,
pub counter: u32,
pub touch: bool,
}
/// Applet identity parsed from the SELECT response.
#[derive(Debug, Clone)]
pub struct OathInfo {
pub version: [u8; 3],
/// PBKDF2 salt (device id) used to derive the access key.
pub device_id: Vec<u8>,
/// SELECT challenge — present only when an access code is set.
pub challenge: Option<Vec<u8>>,
}
impl OathInfo {
pub fn password_set(&self) -> bool {
self.challenge.is_some()
}
}
/// Parse the OATH SELECT response.
pub fn parse_select(resp: &[u8]) -> OathInfo {
let mut version = [0u8; 3];
if let Some(v) = tlv::find(resp, TAG_VERSION) {
for (i, b) in v.iter().take(3).enumerate() {
version[i] = *b;
}
}
OathInfo {
version,
device_id: tlv::find(resp, TAG_NAME).unwrap_or(&[]).to_vec(),
challenge: tlv::find(resp, TAG_CHALLENGE).map(|c| c.to_vec()),
}
}
/// Open an OATH session (SELECT the applet).
pub fn open() -> Result<(CcidSession, OathInfo), PFError> {
let session = CcidSession::open(OATH_AID)?;
let info = parse_select(&session.select_resp);
Ok((session, info))
}
/// Derive the 16-byte access key from a password and the device id (salt).
pub fn derive_access_key(password: &str, device_id: &[u8]) -> [u8; ACCESS_KEY_LEN] {
let mut key = [0u8; ACCESS_KEY_LEN];
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(PBKDF2_ITERS).unwrap(),
device_id,
password.as_bytes(),
&mut key,
);
key
}
fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
let k = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key);
hmac::sign(&k, msg).as_ref().to_vec()
}
fn random8() -> Result<[u8; 8], PFError> {
let mut c = [0u8; 8];
SystemRandom::new()
.fill(&mut c)
.map_err(|_| PFError::Device("RNG failure".into()))?;
Ok(c)
}
/// Unlock a password-protected applet with the derived access key and the
/// SELECT challenge, using YKOATH mutual authentication.
pub fn validate(session: &CcidSession, key: &[u8], select_challenge: &[u8]) -> Result<(), PFError> {
let response = hmac_sha1(key, select_challenge);
let host_challenge = random8()?;
let mut data = Vec::new();
tlv::write(&mut data, TAG_RESPONSE_FULL, &response);
tlv::write(&mut data, TAG_CHALLENGE, &host_challenge);
session.transceive_full(&Apdu::read(CLA_ISO, INS_VALIDATE, 0, 0, &data))?;
Ok(())
}
/// Set (or replace) the applet access code. The device stores the key and
/// re-locks on the next SELECT.
pub fn set_code(session: &CcidSession, key: &[u8]) -> Result<(), PFError> {
let challenge = random8()?;
let response = hmac_sha1(key, &challenge);
let mut key_tlv = vec![HashAlgo::Sha1.wire()];
key_tlv.extend_from_slice(key);
let mut data = Vec::new();
tlv::write(&mut data, TAG_KEY, &key_tlv);
tlv::write(&mut data, TAG_CHALLENGE, &challenge);
tlv::write(&mut data, TAG_RESPONSE_FULL, &response);
session.transceive_full(&Apdu::write(CLA_ISO, INS_SET_CODE, 0, 0, &data))?;
Ok(())
}
/// Remove the access code (an empty key TLV).
pub fn clear_code(session: &CcidSession) -> Result<(), PFError> {
let mut data = Vec::new();
tlv::write(&mut data, TAG_KEY, &[]);
session.transceive_full(&Apdu::write(CLA_ISO, INS_SET_CODE, 0, 0, &data))?;
Ok(())
}
/// Add or overwrite a credential.
pub fn put(session: &CcidSession, cred: &NewCredential) -> Result<(), PFError> {
let id = build_cred_id(
cred.issuer.as_deref(),
&cred.account,
cred.oath_type,
cred.period,
);
let mut key_tlv = vec![cred.oath_type.wire() | cred.algorithm.wire(), cred.digits];
key_tlv.extend_from_slice(&cred.secret);
let mut data = Vec::new();
tlv::write(&mut data, TAG_NAME, id.as_bytes());
tlv::write(&mut data, TAG_KEY, &key_tlv);
if cred.touch {
// Yubico quirk: the property object is a bare value with no length octet.
data.push(TAG_PROPERTY as u8);
data.push(PROP_TOUCH);
}
if cred.oath_type == OathType::Hotp && cred.counter != 0 {
tlv::write(&mut data, TAG_IMF, &cred.counter.to_be_bytes());
}
session.transceive_full(&Apdu::write(CLA_ISO, INS_PUT, 0, 0, &data))?;
Ok(())
}
/// Delete a credential by its raw id.
pub fn delete(session: &CcidSession, id: &str) -> Result<(), PFError> {
let mut data = Vec::new();
tlv::write(&mut data, TAG_NAME, id.as_bytes());
session.transceive_full(&Apdu::write(CLA_ISO, INS_DELETE, 0, 0, &data))?;
Ok(())
}
/// Rename a credential (YubiKey 5.3+ / RS-Key).
pub fn rename(session: &CcidSession, old_id: &str, new_id: &str) -> Result<(), PFError> {
let mut data = Vec::new();
tlv::write(&mut data, TAG_NAME, old_id.as_bytes());
tlv::write(&mut data, TAG_NAME, new_id.as_bytes());
session.transceive_full(&Apdu::write(CLA_ISO, INS_RENAME, 0, 0, &data))?;
Ok(())
}
/// Factory-reset the applet (destroys all credentials and the access code).
pub fn reset(session: &CcidSession) -> Result<(), PFError> {
session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET, 0xDE, 0xAD, &[]))?;
Ok(())
}
/// Compute one credential's code for a given period (used for HOTP and for
/// TOTP credentials whose period is not 30 s).
pub fn calculate(session: &CcidSession, id: &str, period: u32) -> Result<String, PFError> {
let counter = time_counter(period.max(1));
let mut data = Vec::new();
tlv::write(&mut data, TAG_NAME, id.as_bytes());
tlv::write(&mut data, TAG_CHALLENGE, &counter.to_be_bytes());
// P2 = 1 → truncated response.
let resp = session.transceive_full(&Apdu::read(CLA_ISO, INS_CALCULATE, 0, 1, &data))?;
let (_, value) = tlv::TlvIter::new(&resp)
.next()
.ok_or_else(|| PFError::Device("Empty CALCULATE response".into()))?;
format_response(value)
}
/// List every credential and its current code in one CALCULATE ALL round-trip,
/// re-computing any non-30 s TOTP credential individually (matches ykman).
pub fn calculate_all(session: &CcidSession) -> Result<Vec<Account>, PFError> {
let counter = time_counter(DEFAULT_PERIOD);
let mut req = Vec::new();
tlv::write(&mut req, TAG_CHALLENGE, &counter.to_be_bytes());
let resp = session.transceive_oath(&Apdu::read(CLA_ISO, INS_CALCULATE_ALL, 0, 1, &req))?;
let mut accounts = Vec::new();
let mut pending: Option<(String, OathType)> = None;
for (tag, value) in tlv::TlvIter::new(&resp) {
if tag == TAG_NAME {
let id = String::from_utf8_lossy(value).to_string();
pending = Some((id, OathType::Totp));
continue;
}
let Some((id, _)) = pending.take() else {
continue;
};
let (issuer, account, period) = parse_cred_id(&id);
let (oath_type, state) = match tag {
TAG_NO_RESPONSE => (OathType::Hotp, CodeState::Hotp),
TAG_TOUCH_RESPONSE => (OathType::Totp, CodeState::Touch),
TAG_RESPONSE_TRUNC | TAG_RESPONSE_FULL => {
let value_str = format_response(value)?;
(
OathType::Totp,
CodeState::Code {
value: value_str,
period,
},
)
}
_ => continue,
};
accounts.push(Account {
id,
issuer,
account,
oath_type,
period,
state,
});
}
// Non-30 s TOTP codes computed above used the 30 s window — fix them up.
for acc in accounts.iter_mut() {
if acc.oath_type == OathType::Totp
&& acc.period != DEFAULT_PERIOD
&& matches!(acc.state, CodeState::Code { .. })
&& let Ok(code) = calculate(session, &acc.id, acc.period)
{
acc.state = CodeState::Code {
value: code,
period: acc.period,
};
}
}
Ok(accounts)
}
/// Seconds remaining in the current window for a period.
pub fn seconds_remaining(period: u32) -> u32 {
let p = period.max(1);
let now = unix_now();
p - (now % p as u64) as u32
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn time_counter(period: u32) -> u64 {
unix_now() / period.max(1) as u64
}
/// Format a CALCULATE/CALCULATE ALL response value into the numeric code.
/// Handles both truncated (`0x76`: `[digits, 4-byte int]`) and full
/// (`0x75`: `[digits, full HMAC]`, truncated host-side) forms.
fn format_response(value: &[u8]) -> Result<String, PFError> {
let digits = *value
.first()
.ok_or_else(|| PFError::Device("Short code response".into()))?;
let body = &value[1..];
let num = if body.len() == 4 {
u32::from_be_bytes([body[0], body[1], body[2], body[3]]) & 0x7FFF_FFFF
} else if body.len() >= 20 {
// Dynamic truncation of a full HMAC (RFC 4226).
let offset = (body[body.len() - 1] & 0x0F) as usize;
u32::from_be_bytes([
body[offset],
body[offset + 1],
body[offset + 2],
body[offset + 3],
]) & 0x7FFF_FFFF
} else {
return Err(PFError::Device("Malformed code response".into()));
};
let modulo = 10u32.pow(digits.min(9) as u32);
Ok(format!("{:0width$}", num % modulo, width = digits as usize))
}
/// Build the Yubico credential id: `[<period>/]<issuer>:<account>`, with the
/// period prefix only for non-30 s TOTP credentials.
pub fn build_cred_id(
issuer: Option<&str>,
account: &str,
oath_type: OathType,
period: u32,
) -> String {
let base = match issuer {
Some(i) if !i.is_empty() => format!("{i}:{account}"),
_ => account.to_string(),
};
if oath_type == OathType::Totp && period != DEFAULT_PERIOD && period != 0 {
format!("{period}/{base}")
} else {
base
}
}
/// Split a credential id back into `(issuer, account, period)`.
pub fn parse_cred_id(id: &str) -> (Option<String>, String, u32) {
let (period, rest) = match id.split_once('/') {
Some((p, r)) if p.parse::<u32>().is_ok() => (p.parse().unwrap(), r),
_ => (DEFAULT_PERIOD, id),
};
match rest.split_once(':') {
Some((issuer, account)) => (Some(issuer.to_string()), account.to_string(), period),
None => (None, rest.to_string(), period),
}
}
/// Parse an `otpauth://` URI into a [`NewCredential`].
pub fn parse_otpauth(uri: &str) -> Result<NewCredential, String> {
let rest = uri
.strip_prefix("otpauth://")
.ok_or("Not an otpauth:// URI")?;
let (type_part, after) = rest.split_once('/').ok_or("Missing credential type")?;
let oath_type = match type_part.to_ascii_lowercase().as_str() {
"totp" => OathType::Totp,
"hotp" => OathType::Hotp,
_ => return Err("Type must be totp or hotp".into()),
};
let (label, query) = match after.split_once('?') {
Some((l, q)) => (l, q),
None => (after, ""),
};
let label = url_decode(label);
let (mut issuer, account) = match label.split_once(':') {
Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()),
None => (None, label.trim().to_string()),
};
let mut secret_b32 = None;
let mut algorithm = HashAlgo::Sha1;
let mut digits = 6u8;
let mut period = DEFAULT_PERIOD;
let mut counter = 0u32;
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let v = url_decode(v);
match k.to_ascii_lowercase().as_str() {
"secret" => secret_b32 = Some(v),
"issuer" => {
if !v.is_empty() {
issuer = Some(v);
}
}
"algorithm" => {
algorithm = match v.to_ascii_uppercase().as_str() {
"SHA256" => HashAlgo::Sha256,
"SHA512" => HashAlgo::Sha512,
_ => HashAlgo::Sha1,
}
}
"digits" => digits = v.parse().unwrap_or(6),
"period" => period = v.parse().unwrap_or(DEFAULT_PERIOD),
"counter" => counter = v.parse().unwrap_or(0),
_ => {}
}
}
let secret =
base32_decode(&secret_b32.ok_or("Missing secret")?).ok_or("Invalid base32 secret")?;
if secret.is_empty() {
return Err("Empty secret".into());
}
if account.is_empty() {
return Err("Missing account name".into());
}
Ok(NewCredential {
issuer,
account,
secret,
oath_type,
algorithm,
digits: digits.clamp(6, 8),
period,
counter,
touch: false,
})
}
/// Decode RFC 4648 base32 (case-insensitive, padding and spaces ignored).
pub fn base32_decode(s: &str) -> Option<Vec<u8>> {
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut bits = 0u32;
let mut nbits = 0u32;
let mut out = Vec::new();
for c in s.chars() {
if c == '=' || c.is_whitespace() || c == '-' {
continue;
}
let up = (c as u8).to_ascii_uppercase();
let v = ALPHABET.iter().position(|&x| x == up)? as u32;
bits = (bits << 5) | v;
nbits += 5;
if nbits >= 8 {
nbits -= 8;
out.push((bits >> nbits) as u8);
}
}
Some(out)
}
/// Minimal percent-decoding (`+` → space, `%XX` → byte) for URI labels/params.
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
/// Parse a bare LIST response into credential ids + kinds (used when a code
/// isn't needed). `0x72` value = `[type|algo, name…]`.
pub fn parse_list(resp: &[u8]) -> Vec<(String, OathType, HashAlgo)> {
let mut out = Vec::new();
for (tag, value) in tlv::TlvIter::new(resp) {
if tag == TAG_NAME_LIST && !value.is_empty() {
let props = value[0];
let name = String::from_utf8_lossy(&value[1..]).to_string();
out.push((name, OathType::from_wire(props), HashAlgo::from_wire(props)));
}
}
out
}
/// LIST every credential id on the device (chained via SEND REMAINING).
pub fn list(session: &CcidSession) -> Result<Vec<(String, OathType, HashAlgo)>, PFError> {
let resp = session.transceive_oath(&Apdu::read(CLA_ISO, INS_LIST, 0, 0, &[]))?;
Ok(parse_list(&resp))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_decodes_known_vectors() {
assert_eq!(
base32_decode("JBSWY3DPEHPK3PXP").unwrap(),
b"Hello!\xde\xad\xbe\xef"
);
assert_eq!(base32_decode("").unwrap(), Vec::<u8>::new());
// Lowercase, spaces and padding tolerated.
assert_eq!(base32_decode("nb sw y3=dp").unwrap(), b"hello");
assert!(base32_decode("0189").is_none()); // invalid symbols
}
#[test]
fn cred_id_roundtrips() {
assert_eq!(
build_cred_id(Some("GitHub"), "alice", OathType::Totp, 30),
"GitHub:alice"
);
assert_eq!(
build_cred_id(Some("AWS"), "bob", OathType::Totp, 60),
"60/AWS:bob"
);
assert_eq!(build_cred_id(None, "solo", OathType::Hotp, 30), "solo");
assert_eq!(
parse_cred_id("GitHub:alice"),
(Some("GitHub".into()), "alice".into(), 30)
);
assert_eq!(
parse_cred_id("60/AWS:bob"),
(Some("AWS".into()), "bob".into(), 60)
);
assert_eq!(parse_cred_id("solo"), (None, "solo".into(), 30));
// A slash that isn't a period prefix stays part of the account.
assert_eq!(parse_cred_id("a/b:c"), (Some("a/b".into()), "c".into(), 30));
}
#[test]
fn parses_otpauth_uri() {
let c = parse_otpauth(
"otpauth://totp/ACME%20Co:john@example.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA256&digits=8&period=60",
)
.unwrap();
assert_eq!(c.issuer.as_deref(), Some("ACME Co"));
assert_eq!(c.account, "john@example.com");
assert_eq!(c.oath_type, OathType::Totp);
assert_eq!(c.algorithm, HashAlgo::Sha256);
assert_eq!(c.digits, 8);
assert_eq!(c.period, 60);
assert_eq!(c.secret, b"Hello!\xde\xad\xbe\xef");
}
#[test]
fn otpauth_requires_secret_and_type() {
assert!(parse_otpauth("otpauth://totp/x").is_err());
assert!(parse_otpauth("https://totp/x?secret=AA").is_err());
assert!(parse_otpauth("otpauth://sms/x?secret=AA").is_err());
}
#[test]
fn truncated_response_formats_code() {
// digits=6, truncated int 0x0000_04F0 = 1264 → "001264".
let value = [6u8, 0x00, 0x00, 0x04, 0xF0];
assert_eq!(format_response(&value).unwrap(), "001264");
}
#[test]
fn full_hmac_response_dynamic_truncation() {
// RFC 4226 §5.4 canonical HMAC → 6-digit code 872921.
let hmac = [
0x1f, 0x86, 0x98, 0x69, 0x0e, 0x02, 0xca, 0x16, 0x61, 0x85, 0x50, 0xef, 0x7f, 0x19,
0xda, 0x8e, 0x94, 0x5b, 0x55, 0x5a,
];
let mut value = vec![6u8];
value.extend_from_slice(&hmac);
assert_eq!(format_response(&value).unwrap(), "872921");
}
#[test]
fn list_parses_type_and_algo() {
let mut resp = Vec::new();
// 0x72: [0x21, "A:b"] → TOTP + SHA1.
tlv::write(&mut resp, TAG_NAME_LIST, &[0x21, b'A', b':', b'b']);
tlv::write(&mut resp, TAG_NAME_LIST, &[0x12, b'h']); // HOTP + SHA256
let list = parse_list(&resp);
assert_eq!(list.len(), 2);
assert_eq!(list[0], ("A:b".to_string(), OathType::Totp, HashAlgo::Sha1));
assert_eq!(list[1], ("h".to_string(), OathType::Hotp, HashAlgo::Sha256));
}
}