summaryrefslogtreecommitdiff
path: root/source/storage/KeyManager.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-25 12:46:42 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-25 12:46:42 +0100
commit096b5a4d17c85fa5f87db3e72364e9c3506d1e64 (patch)
tree9846544fe7d76d849df4a1ee2058f4847f674e7e /source/storage/KeyManager.mc
parente0384274bbeab999279bdc35bdddbd8a3a451aeb (diff)
feat(pin): exactly 4 digits, entered on the 4th digit
The keypad has no OK key; the 4th digit submits, so a tap that also arrives as START can no longer submit a partial PIN (the likely cause of "Wrong PIN" before the code opened). PINs set by earlier versions still unlock and are then replaced by a 4-digit one. Opening an entry ignores a second request from the same tap. A Require PIN sent from the phone while the watch is locked now applies at the next PIN instead of waiting for Confirm all. The linter skips .claude. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/storage/KeyManager.mc')
-rw-r--r--source/storage/KeyManager.mc41
1 files changed, 37 insertions, 4 deletions
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
index 9b2b3c6..2dd3712 100644
--- a/source/storage/KeyManager.mc
+++ b/source/storage/KeyManager.mc
@@ -15,6 +15,12 @@
// and iteration count are in the aad, so changing either fails the MAC.
// Changing the PIN re-wraps the same LMK; entries aren't re-encrypted.
//
+// A new PIN has exactly PIN_DIGITS (4) digits and is stored with the
+// marker "wl" = PIN_DIGITS next to the wrap (the wrap format is unchanged).
+// A wrap without the marker is a legacy PIN of 4 to 10 digits from an
+// earlier version: it still unlocks, and the PIN screen then asks for a new
+// 4-digit PIN (isLegacyPin).
+//
// PBKDF2 runs as a PinJob (a Jobs.Job) because one derivation is calibrated
// to about a second, far beyond one event's watchdog budget.
@@ -28,8 +34,13 @@ class KeyManager {
static const KEY_BYTES = 32;
static const SALT_BYTES = 16;
static const WRAP_LABEL = "OTPW-WRAP-1";
+ // Every new PIN has exactly this many digits.
+ static const PIN_DIGITS = 4;
+ // Legacy PINs (set before the 4-digit rule) have 4 to 10 digits.
static const MIN_PIN_DIGITS = 4;
static const MAX_PIN_DIGITS = 10;
+ // The marker of a PIN set with PIN_DIGITS digits (see the header).
+ static const PIN_LENGTH_KEY = "wl";
private var _store as Persist.Backend;
private var _deviceKeys as SealedBox.Keys? = null;
@@ -113,12 +124,20 @@ class KeyManager {
return _store.getValue(WRAP_KEY) != null;
}
+ // True when the stored PIN predates the 4-digit rule (no marker), so it
+ // may have 4 to 10 digits and should be replaced by a 4-digit one.
+ function isLegacyPin() as Boolean {
+ var marker = _store.getValue(PIN_LENGTH_KEY);
+ return hasPin() && !(marker instanceof Number && marker == PIN_DIGITS);
+ }
+
// A fresh random master key (when the first PIN is created).
function newMasterKey() as ByteArray {
return Cryptography.randomBytes(KEY_BYTES);
}
- // True for 4 to 10 ASCII digits.
+ // True for 4 to 10 ASCII digits: any PIN that can unlock, legacy
+ // lengths included.
static function isValidPin(pin as ByteArray) as Boolean {
if (pin.size() < MIN_PIN_DIGITS || pin.size() > MAX_PIN_DIGITS) {
return false;
@@ -131,6 +150,11 @@ class KeyManager {
return true;
}
+ // True for exactly PIN_DIGITS ASCII digits: what a new PIN must be.
+ static function isValidNewPin(pin as ByteArray) as Boolean {
+ return pin.size() == PIN_DIGITS && isValidPin(pin);
+ }
+
// The derivation for checking `pin` against the stored wrap. Throws
// NO_PIN when no PIN is set, DAMAGED when the wrap is malformed.
function unlockJob(pin as ByteArray) as PinJob {
@@ -149,8 +173,12 @@ class KeyManager {
return new PinJob(pin, wrap.slice(0, SALT_BYTES), iterations);
}
- // The derivation for a new PIN, with a fresh salt.
+ // The derivation for a new PIN, with a fresh salt. Throws VaultError
+ // INVALID unless the PIN has exactly PIN_DIGITS digits.
function newPinJob(pin as ByteArray, iterations as Number) as PinJob {
+ if (!isValidNewPin(pin)) {
+ throw new VaultError(VaultError.INVALID, "PIN: " + PIN_DIGITS + " digits");
+ }
if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
throw new Lang.InvalidValueException("PIN: iteration count out of range");
}
@@ -177,7 +205,8 @@ class KeyManager {
}
// After a finished newPinJob: wrap masterKey under the new PIN and store
- // it, replacing any previous wrap. Wipes the PIN key, not masterKey.
+ // it, replacing any previous wrap, and mark the PIN as a 4-digit one.
+ // Wipes the PIN key, not masterKey.
function storeWrap(job as PinJob, masterKey as ByteArray) as Void {
var keys = job.keys();
var wrap = Bytes.copy(job.salt);
@@ -186,11 +215,15 @@ class KeyManager {
keys.wipe();
job.wipe();
_store.setValue(WRAP_KEY, wrap);
+ // After the wrap: a crash in between leaves the PIN unmarked, which
+ // only means being asked for a new PIN once more.
+ _store.setValue(PIN_LENGTH_KEY, PIN_DIGITS);
}
// Forget the PIN (reset or erase). The master key is then gone for good.
function deleteWrap() as Void {
_store.deleteValue(WRAP_KEY);
+ _store.deleteValue(PIN_LENGTH_KEY);
}
private function wrapAad(job as PinJob) as ByteArray {
@@ -211,7 +244,7 @@ class PinJob extends Jobs.Job {
function initialize(pin as ByteArray, salt as ByteArray, iterations as Number) {
Job.initialize();
if (!KeyManager.isValidPin(pin)) {
- throw new Lang.InvalidValueException("PIN: 4 to 10 digits");
+ throw new Lang.InvalidValueException("PIN: " + KeyManager.MIN_PIN_DIGITS + " to " + KeyManager.MAX_PIN_DIGITS + " digits");
}
self.salt = salt;
self.iterations = iterations;