diff options
Diffstat (limited to 'source/storage/KeyManager.mc')
| -rw-r--r-- | source/storage/KeyManager.mc | 188 |
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(); + } +} |
