summaryrefslogtreecommitdiff
path: root/source/storage/KeyManager.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/storage/KeyManager.mc')
-rw-r--r--source/storage/KeyManager.mc188
1 files changed, 188 insertions, 0 deletions
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
new file mode 100644
index 0000000..ebb0a62
--- /dev/null
+++ b/source/storage/KeyManager.mc
@@ -0,0 +1,188 @@
+// Author: cirakg & claude ai
+// Summary: The device key and the PIN-wrapped locked master key: creation, PIN derivation jobs, wrap and unwrap.
+//
+// Keys (README 7.1, 7.2):
+// * Device key "dk": 32 random bytes, created on first use, stored as is.
+// It seals unlocked entries, pending items and the import buffer.
+// * Locked master key (LMK): 32 random bytes, stored only wrapped under
+// the PIN key, in "w".
+// * PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes).
+//
+// Wrap format, version 1 (one ByteArray under "w"):
+// salt (16 bytes) | n (4 bytes, big-endian) | SealedBox(Keys(PIN key), aad, LMK)
+// aad = "OTPW-WRAP-1" || salt || n
+// A correct PIN is exactly "the box verifies"; no PIN hash exists. The salt
+// 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.
+//
+// PBKDF2 runs as a PinJob (a Jobs.Job) because one derivation is calibrated
+// to about a second, far beyond one event's watchdog budget.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+class KeyManager {
+
+ static const DEVICE_KEY = "dk";
+ static const WRAP_KEY = "w";
+ static const KEY_BYTES = 32;
+ static const SALT_BYTES = 16;
+ static const WRAP_LABEL = "OTPW-WRAP-1";
+ static const MIN_PIN_DIGITS = 4;
+ static const MAX_PIN_DIGITS = 10;
+
+ private var _store as Persist.Backend;
+ private var _deviceKeys as SealedBox.Keys? = null;
+
+ function initialize(store as Persist.Backend) {
+ _store = store;
+ }
+
+ // The device key's derived keys, creating the device key on first use.
+ function deviceKeys() as SealedBox.Keys {
+ var keys = _deviceKeys;
+ if (keys != null) {
+ return keys;
+ }
+ var stored = _store.getValue(DEVICE_KEY);
+ var raw = null;
+ if (stored instanceof ByteArray && stored.size() == KEY_BYTES) {
+ raw = stored;
+ } else {
+ // First start (or a lost key, which makes unlocked records
+ // unreadable; they then show as damaged).
+ raw = Cryptography.randomBytes(KEY_BYTES);
+ _store.setValue(DEVICE_KEY, raw);
+ }
+ keys = new SealedBox.Keys(raw);
+ Bytes.wipe(raw);
+ _deviceKeys = keys;
+ return keys;
+ }
+
+ // True when a PIN has been set.
+ function hasPin() as Boolean {
+ return _store.getValue(WRAP_KEY) != null;
+ }
+
+ // 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.
+ static function isValidPin(pin as ByteArray) as Boolean {
+ if (pin.size() < MIN_PIN_DIGITS || pin.size() > MAX_PIN_DIGITS) {
+ return false;
+ }
+ for (var i = 0; i < pin.size(); i++) {
+ if (pin[i] < 0x30 || pin[i] > 0x39) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // 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 {
+ var wrap = _store.getValue(WRAP_KEY);
+ if (wrap == null) {
+ throw new VaultError(VaultError.NO_PIN, "no PIN set");
+ }
+ if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
+ throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
+ }
+ var iterations = wrap.decodeNumber(Lang.NUMBER_FORMAT_UINT32, {:offset => SALT_BYTES, :endianness => Lang.ENDIAN_BIG}) as Number;
+ if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
+ // Also stops a tampered count from making the derivation endless.
+ throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
+ }
+ return new PinJob(pin, wrap.slice(0, SALT_BYTES), iterations);
+ }
+
+ // The derivation for a new PIN, with a fresh salt.
+ function newPinJob(pin as ByteArray, iterations as Number) as PinJob {
+ if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
+ throw new Lang.InvalidValueException("PIN: iteration count out of range");
+ }
+ return new PinJob(pin, Cryptography.randomBytes(SALT_BYTES), iterations);
+ }
+
+ // After a finished unlockJob: the master key, or null for a wrong PIN.
+ // Wipes the PIN key.
+ function unwrap(job as PinJob) as ByteArray? {
+ var wrap = _store.getValue(WRAP_KEY);
+ if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
+ job.wipe();
+ return null;
+ }
+ var keys = job.keys();
+ var masterKey = SealedBox.open(keys, wrapAad(job), wrap.slice(SALT_BYTES + 4, wrap.size()));
+ keys.wipe();
+ job.wipe();
+ if (masterKey != null && masterKey.size() != KEY_BYTES) {
+ Bytes.wipe(masterKey);
+ return null;
+ }
+ return masterKey;
+ }
+
+ // After a finished newPinJob: wrap masterKey under the new PIN and store
+ // it, replacing any previous wrap. 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);
+ wrap.addAll(Bytes.uint32BigEndian(job.iterations));
+ wrap.addAll(SealedBox.seal(keys, wrapAad(job), masterKey));
+ keys.wipe();
+ job.wipe();
+ _store.setValue(WRAP_KEY, wrap);
+ }
+
+ // Forget the PIN (reset or erase). The master key is then gone for good.
+ function deleteWrap() as Void {
+ _store.deleteValue(WRAP_KEY);
+ }
+
+ private function wrapAad(job as PinJob) as ByteArray {
+ var aad = Bytes.fromUtf8(WRAP_LABEL);
+ aad.addAll(job.salt);
+ aad.addAll(Bytes.uint32BigEndian(job.iterations));
+ return aad;
+ }
+}
+
+// PBKDF2 of a PIN as a resumable job, remembering its salt and count.
+class PinJob extends Jobs.Job {
+
+ var salt as ByteArray;
+ var iterations as Number;
+ private var _pbkdf2 as Kdf.Pbkdf2;
+
+ 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");
+ }
+ self.salt = salt;
+ self.iterations = iterations;
+ _pbkdf2 = new Kdf.Pbkdf2(pin, salt, iterations, KeyManager.KEY_BYTES);
+ }
+
+ function step(budget as Number) as Boolean {
+ return _pbkdf2.step(budget);
+ }
+
+ // The wrap keys derived from the PIN key; only after step() returned true.
+ function keys() as SealedBox.Keys {
+ var pinKey = _pbkdf2.result();
+ var keys = new SealedBox.Keys(pinKey);
+ Bytes.wipe(pinKey);
+ return keys;
+ }
+
+ function wipe() as Void {
+ _pbkdf2.wipe();
+ }
+}