// Author: cirakg & claude ai // Summary: Computes OTP codes for display and caches each until it expires, so secrets are decrypted once per period. // // README 7.8: to show a code the entry is decrypted, the code computed and the // secret wiped at once; only the short code string is kept, with the unix // time it stops being valid. The 1 Hz redraw then reuses the string. // // Watchdog: an SHA-512 code costs about 50 times an SHA-1 code (PERFORMANCE.md), // so callers that compute many codes in one event (the list) spend a budget // in cost units and continue in the next event. import Toybox.Lang; class CodeCache { // Cost units per code: SHA-512 runs in Monkey C, the others natively. static const COST_NATIVE = 1; static const COST_SHA512 = 4; // Units the list may spend per event. static const BUDGET_PER_EVENT = 4; // A code that can't be computed (damaged entry) is shown as this. static const UNAVAILABLE = "------"; static const FOREVER = 0x7FFFFFFFFFFFFFFFl; private var _vault as Vault; private var _codes as Dictionary = {} as Dictionary; private var _validUntil as Dictionary = {} as Dictionary; // What the encrypted part revealed when the code was computed: the period // (for countdowns) and the cost (for the list's budget). Not secret. private var _periods as Dictionary = {} as Dictionary; private var _costs as Dictionary = {} as Dictionary; function initialize(vault as Vault) { _vault = vault; } // The cached code, or null when there is none or it has expired. function cached(key as String, now as Long) as String? { var until = _validUntil[key]; if (until == null || now >= until) { return null; } return _codes[key]; } // Cost units computing this entry's code takes. The algorithm is // encrypted, so until the code was computed once, assume the worst (Steam // is always SHA-1). function costOf(header as Entry) as Number { var known = _costs[header.id]; if (known != null) { return known; } return header.type == Entry.OTP_STEAM ? COST_NATIVE : COST_SHA512; } // The entry's period in seconds, once its code was computed (else 30). function periodOf(id as String) as Number { var p = _periods[id]; return p != null ? p : Otp.STEAM_PERIOD; } // The current code (offset 0) or the next one (offset 1) of an OTP entry, // computing and caching it when needed. Throws VaultError LOCKED for a // locked entry while the session is locked. function code(id as String, now as Long, offset as Number) as String { var key = offset == 0 ? id : id + "+"; var hit = cached(key, now); if (hit != null) { return hit; } var text = UNAVAILABLE; var until = now + 1; try { var entry = _vault.open(id); var period = entry.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : entry.period; var secret = entry.secret as ByteArray; var at = now + offset * period; _periods[id] = period; _costs[id] = entry.algorithm == Hashes.SHA512 ? COST_SHA512 : COST_NATIVE; if (entry.type == Entry.OTP_HOTP) { text = Otp.hotp(entry.algorithm as Hashes.Algorithm, secret, entry.counter + offset, entry.digits); until = FOREVER; } else { text = entry.type == Entry.OTP_STEAM ? Otp.steam(secret, at) : Otp.totp(entry.algorithm as Hashes.Algorithm, secret, at, period, entry.digits); until = now + Otp.secondsRemaining(now, period); } entry.wipeSecrets(); } catch (e instanceof VaultError) { if (e.code == VaultError.LOCKED) { throw e; } // Damaged: show the placeholder, try again in a second. } _codes[key] = text; _validUntil[key] = until; return text; } // Forget one entry's codes (after a HOTP "next" or an edit). function invalidate(id as String) as Void { _codes.remove(id); _validUntil.remove(id); _codes.remove(id + "+"); _validUntil.remove(id + "+"); } // Forget everything (on lock, so no locked entry's code stays in memory). function clear() as Void { _codes = {} as Dictionary; _validUntil = {} as Dictionary; } // The code split for reading: 3+3, 3+4 or 4+4 digits; other lengths // (Steam's 5 characters) stay whole. static function split(text as String) as Array { var n = text.length(); if (n == 6 || n == 7) { return [text.substring(0, 3) as String, text.substring(3, n) as String]; } if (n == 8) { return [text.substring(0, 4) as String, text.substring(4, 8) as String]; } return [text]; } }