aboutsummaryrefslogtreecommitdiff
path: root/source/storage/Lockout.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
commitdd5d0f68726fe56ee4d199867d414aea81905cc4 (patch)
tree1cb40b024ac13f454633198ad50cbc55f7283f39 /source/storage/Lockout.mc
parent94181d58cf66c5b7b263fd435a344f1cd8872cff (diff)
feat(storage): add the encrypted vault, PIN keys, lockout, session and migrations
Records are one ByteArray each: a readable TLV header plus an encrypt-then-MAC box whose aad binds the entry ID and the header. The device key seals unlocked entries and pending items; a random master key, wrapped under a PBKDF2 PIN key, seals locked ones. Wrong PINs are counted before the check, with delays on the trusted clock and an uptime fallback. Bytes.toUtf8 validates UTF-8 first, because the native conversion crashes on invalid input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/storage/Lockout.mc')
-rw-r--r--source/storage/Lockout.mc138
1 files changed, 138 insertions, 0 deletions
diff --git a/source/storage/Lockout.mc b/source/storage/Lockout.mc
new file mode 100644
index 0000000..939b91b
--- /dev/null
+++ b/source/storage/Lockout.mc
@@ -0,0 +1,138 @@
+// Author: cirakg & claude ai
+// Summary: Wrong-PIN policy: persistent failure count, growing delays on a trusted clock, and the erase threshold.
+//
+// Rules (README 7.6):
+// * Every attempt is counted and written to storage BEFORE the PIN is
+// checked (recordAttempt), and the count is cleared on success. Killing
+// the app mid-check therefore costs the attacker an attempt.
+// * Four free attempts. After the 5th failure the next attempt waits 30 s,
+// then 1 min, 2 min, 4 min and so on, capped at 1 hour.
+// * Delays are timed with the trusted clock when it is synced. Otherwise
+// they count system uptime from the moment this app instance started
+// (or from the attempt, if later), so restarting the app restarts the
+// delay in full: never shorter, sometimes longer.
+// * failures() >= ERASE_AFTER is the point where the optional "erase locked
+// entries" setting applies; the vault does the erasing.
+//
+// Stored under "lk" as a dictionary:
+// "f" failures (attempts not followed by a success), "d" delay in seconds
+// before the next attempt, "t" trusted unix time of the last attempt (only
+// when the trusted clock was synced). The uptime anchor is kept in memory
+// only: every app start sets it anew.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+class Lockout {
+
+ static const STORAGE_KEY = "lk";
+ static const FREE_ATTEMPTS = 4;
+ static const BASE_DELAY_S = 30;
+ static const MAX_DELAY_S = 3600;
+ static const ERASE_AFTER = 10;
+
+ private var _store as Persist.Backend;
+ private var _time as TimeSource;
+ private var _failures as Number = 0;
+ private var _delay as Number = 0;
+ private var _trusted as Long? = null;
+ private var _uptime as Number = 0;
+
+ // Loads the state. A running delay restarts its uptime count here, which
+ // is what makes an app restart unable to shorten it.
+ function initialize(store as Persist.Backend, time as TimeSource) {
+ _store = store;
+ _time = time;
+ var state = store.getValue(STORAGE_KEY);
+ if (state instanceof Dictionary) {
+ var f = state["f"];
+ var d = state["d"];
+ var t = state["t"];
+ if (f instanceof Number && f >= 0 && d instanceof Number && d >= 0) {
+ _failures = f;
+ _delay = d > MAX_DELAY_S ? MAX_DELAY_S : d;
+ _trusted = t instanceof Long ? t : null;
+ } else {
+ // Unreadable state is not a reset: no free attempts are left,
+ // so the next failure starts the delays.
+ _failures = FREE_ATTEMPTS;
+ }
+ }
+ _uptime = time.uptimeMs();
+ }
+
+ // The delay after `failures` consecutive wrong PINs, in seconds.
+ static function delayFor(failures as Number) as Number {
+ if (failures <= FREE_ATTEMPTS) {
+ return 0;
+ }
+ var doublings = failures - FREE_ATTEMPTS - 1;
+ if (doublings >= 7) { // 30 s * 2^7 is already over the cap
+ return MAX_DELAY_S;
+ }
+ var delay = BASE_DELAY_S << doublings;
+ return delay > MAX_DELAY_S ? MAX_DELAY_S : delay;
+ }
+
+ function failures() as Number {
+ return _failures;
+ }
+
+ // Seconds until the next attempt is allowed; 0 when it is allowed now.
+ function remainingSeconds() as Number {
+ if (_delay == 0) {
+ return 0;
+ }
+ var elapsed = 0;
+ var trustedNow = _time.trustedUnix();
+ var trustedThen = _trusted;
+ if (trustedNow != null && trustedThen != null && trustedNow >= trustedThen) {
+ elapsed = (trustedNow - trustedThen).toNumber();
+ } else {
+ var now = _time.uptimeMs();
+ if (now < _uptime) {
+ // The timer restarted or wrapped: count from now.
+ _uptime = now;
+ }
+ elapsed = (now - _uptime) / 1000;
+ }
+ var remaining = _delay - elapsed;
+ return remaining > 0 ? remaining : 0;
+ }
+
+ // Count an attempt before the PIN is checked. Throws VaultError
+ // LOCKED_OUT, without counting, while a delay runs.
+ function recordAttempt() as Void {
+ var remaining = remainingSeconds();
+ if (remaining > 0) {
+ throw new VaultError(VaultError.LOCKED_OUT, "wait " + remaining + " s");
+ }
+ _failures++;
+ _delay = delayFor(_failures);
+ _trusted = _time.trustedUnix();
+ _uptime = _time.uptimeMs();
+ save();
+ }
+
+ // The PIN was right: forget all failures.
+ function recordSuccess() as Void {
+ clear();
+ }
+
+ // Forget all failures (after success, a PIN reset or an erase).
+ function clear() as Void {
+ _failures = 0;
+ _delay = 0;
+ _trusted = null;
+ _store.deleteValue(STORAGE_KEY);
+ }
+
+ private function save() as Void {
+ var state = {"f" => _failures, "d" => _delay} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ var t = _trusted;
+ if (t != null) {
+ state["t"] = t;
+ }
+ _store.setValue(STORAGE_KEY, state);
+ }
+}