summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 23:44:18 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 23:44:18 +0200
commite47ae684101981a7c20f3270994a61944059c825 (patch)
treec2baab8baafa1be446dad7f224f1a1cf68493825
parentb70cde45b67e1f6bb65d598d25c1a5184bdab520 (diff)
feat(crypto): add SHA-512, HMAC, PBKDF2, HKDF, AES-256 records and self-test
- Sha512: FIPS 180-4 in Monkey C (no native SHA-512), rotations inlined with precomputed masks; constants derived from the primes, not typed. - Hmac: RFC 2104 over SHA-1/256/512 (native HMAC only supports SHA-256). - Kdf: resumable PBKDF2-HMAC-SHA256 job with calibration bounds (2,000-200,000 iterations) and HKDF-SHA256. - Aes: CBC with PKCS#7 over the native cipher; AES-256 chosen. - SealedBox: encrypt-then-MAC records (HKDF-split keys, length-prefixed aad, constant-time tag check before decryption). - SelfTest: known-answer tests for every primitive at start-up. - Bytes (wipe, constant-time equal), Clock (2038-safe widening, trusted clock), Jobs (bounded steps against the watchdog, timer runner). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--source/crypto/Aes.mc72
-rw-r--r--source/crypto/Hashes.mc54
-rw-r--r--source/crypto/Hmac.mc69
-rw-r--r--source/crypto/Kdf.mc159
-rw-r--r--source/crypto/SealedBox.mc89
-rw-r--r--source/crypto/SelfTest.mc70
-rw-r--r--source/crypto/Sha512.mc182
-rw-r--r--source/util/Bytes.mc114
-rw-r--r--source/util/Clock.mc36
-rw-r--r--source/util/Jobs.mc78
10 files changed, 923 insertions, 0 deletions
diff --git a/source/crypto/Aes.mc b/source/crypto/Aes.mc
new file mode 100644
index 0000000..e77cb86
--- /dev/null
+++ b/source/crypto/Aes.mc
@@ -0,0 +1,72 @@
+// Author: cirakg & claude ai
+// Summary: AES-CBC encryption with PKCS#7 padding on top of the native Connect IQ cipher.
+//
+// The native Cipher offers AES-128 and AES-256 in ECB and CBC mode, without
+// padding (CBC input must be a multiple of 16 bytes). This module adds PKCS#7
+// padding. AES-256 is used for records (decided in M2: it works in the
+// simulator on all four test devices and costs nothing extra; the PIN, not
+// the cipher, limits the security of locked entries).
+//
+// Security note: CBC alone does not detect tampering. Records are always
+// sealed with an HMAC as well (SealedBox.mc), and the MAC is checked before
+// anything is decrypted, so padding errors never reach an attacker.
+// Verified with NIST SP 800-38A F.2.1 and F.2.5 (test/crypto/AesTest.mc).
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+module Aes {
+
+ const BLOCK_BYTES = 16;
+
+ // A CBC cipher for a 16- or 32-byte key and a 16-byte IV.
+ function cipher(key as ByteArray, iv as ByteArray) as Cryptography.Cipher {
+ if ((key.size() != 16 && key.size() != 32) || iv.size() != BLOCK_BYTES) {
+ throw new Lang.InvalidValueException("AES: key must be 16 or 32 bytes, IV 16 bytes");
+ }
+ return new Cryptography.Cipher({
+ :algorithm => key.size() == 32 ? Cryptography.CIPHER_AES256 : Cryptography.CIPHER_AES128,
+ :mode => Cryptography.MODE_CBC,
+ :key => key,
+ :iv => iv
+ });
+ }
+
+ // CBC without padding; data must be a multiple of 16 bytes.
+ function cbcRaw(key as ByteArray, iv as ByteArray, data as ByteArray, encrypt as Boolean) as ByteArray {
+ var c = cipher(key, iv);
+ return encrypt ? c.encrypt(data) : c.decrypt(data);
+ }
+
+ // Encrypt with PKCS#7 padding: 1 to 16 bytes, each holding the pad length.
+ function cbcEncrypt(key as ByteArray, iv as ByteArray, plaintext as ByteArray) as ByteArray {
+ var padLength = BLOCK_BYTES - (plaintext.size() % BLOCK_BYTES);
+ var padded = Bytes.copy(plaintext);
+ for (var i = 0; i < padLength; i++) {
+ padded.add(padLength);
+ }
+ var out = cbcRaw(key, iv, padded, true);
+ Bytes.wipe(padded);
+ return out;
+ }
+
+ // Decrypt and remove PKCS#7 padding; null when the length or the padding is
+ // wrong.
+ function cbcDecrypt(key as ByteArray, iv as ByteArray, ciphertext as ByteArray) as ByteArray? {
+ var size = ciphertext.size();
+ if (size == 0 || size % BLOCK_BYTES != 0) {
+ return null;
+ }
+ var padded = cbcRaw(key, iv, ciphertext, false);
+ var padLength = padded[size - 1];
+ var valid = padLength >= 1 && padLength <= BLOCK_BYTES;
+ if (valid) {
+ for (var i = size - padLength; i < size; i++) {
+ valid = valid && padded[i] == padLength;
+ }
+ }
+ var plaintext = valid ? padded.slice(0, size - padLength) : null;
+ Bytes.wipe(padded);
+ return plaintext;
+ }
+}
diff --git a/source/crypto/Hashes.mc b/source/crypto/Hashes.mc
new file mode 100644
index 0000000..14e2cf2
--- /dev/null
+++ b/source/crypto/Hashes.mc
@@ -0,0 +1,54 @@
+// Author: cirakg & claude ai
+// Summary: One interface over SHA-1, SHA-256 (native) and SHA-512 (pure Monkey C).
+//
+// Connect IQ has native SHA-1 and SHA-256 (Toybox.Cryptography.Hash) but no
+// SHA-512, which source/crypto/Sha512.mc implements from FIPS 180-4. OTP code
+// asks for an algorithm by its Hashes.Algorithm value and doesn't care which
+// implementation answers.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+module Hashes {
+
+ // OTP and HMAC hash algorithms. The values are stored in entry records, so
+ // never renumber them.
+ enum Algorithm {
+ SHA1 = 0,
+ SHA256 = 1,
+ SHA512 = 2
+ }
+
+ // Input block size in bytes (what HMAC pads its key to).
+ function blockSize(alg as Algorithm) as Number {
+ return alg == SHA512 ? 128 : 64;
+ }
+
+ // Digest length in bytes.
+ function digestSize(alg as Algorithm) as Number {
+ if (alg == SHA1) {
+ return 20;
+ }
+ return alg == SHA256 ? 32 : 64;
+ }
+
+ // True for the three supported values (input from settings or imports is
+ // checked with this before it becomes an Algorithm).
+ function isValid(value as Number) as Boolean {
+ return value == SHA1 || value == SHA256 || value == SHA512;
+ }
+
+ // The digest of data.
+ function digest(alg as Algorithm, data as ByteArray) as ByteArray {
+ if (alg == SHA512) {
+ return Sha512.digest(data);
+ }
+ var hash = new Cryptography.Hash({
+ :algorithm => alg == SHA1 ? Cryptography.HASH_SHA1 : Cryptography.HASH_SHA256
+ });
+ if (data.size() > 0) {
+ hash.update(data);
+ }
+ return hash.digest();
+ }
+}
diff --git a/source/crypto/Hmac.mc b/source/crypto/Hmac.mc
new file mode 100644
index 0000000..ff620a4
--- /dev/null
+++ b/source/crypto/Hmac.mc
@@ -0,0 +1,69 @@
+// Author: cirakg & claude ai
+// Summary: HMAC (RFC 2104) over SHA-1, SHA-256 and SHA-512; native HMAC-SHA256 where Connect IQ provides it.
+//
+// Connect IQ's native HMAC accepts only SHA-256 (HMAC-SHA1 is rejected with
+// "Invalid :algorithm option value", verified with SDK 9.2.0). Most TOTP
+// secrets use SHA-1, so compute() builds HMAC from the hash functions as
+// RFC 2104 defines it:
+// HMAC(K, m) = H((K' xor opad) || H((K' xor ipad) || m))
+// where K' is the key padded to the hash's block size (hashed first if it is
+// longer than a block), ipad = 0x36 and opad = 0x5c repeated.
+//
+// Security notes: the padded key copies are wiped after use; the caller's key
+// array is never modified. Verified with RFC 2202 (SHA-1) and RFC 4231
+// (SHA-256, SHA-512), test/crypto/HmacTest.mc.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+module Hmac {
+
+ const IPAD = 0x36;
+ const OPAD = 0x5c;
+
+ // HMAC of message under key. SHA-256 uses the native implementation; SHA-1
+ // and SHA-512 use generic().
+ function compute(alg as Hashes.Algorithm, key as ByteArray, message as ByteArray) as ByteArray {
+ if (alg == Hashes.SHA256) {
+ return nativeSha256(key, message);
+ }
+ return generic(alg, key, message);
+ }
+
+ // RFC 2104 HMAC built on Hashes.digest, for any supported algorithm.
+ function generic(alg as Hashes.Algorithm, key as ByteArray, message as ByteArray) as ByteArray {
+ var block = Hashes.blockSize(alg);
+ var hashedKey = key.size() > block ? Hashes.digest(alg, key) : null;
+ var k = hashedKey != null ? hashedKey : key;
+
+ var inner = new [block]b;
+ var outer = new [block]b;
+ for (var i = 0; i < block; i++) {
+ var keyByte = i < k.size() ? k[i] : 0;
+ inner[i] = keyByte ^ IPAD;
+ outer[i] = keyByte ^ OPAD;
+ }
+ inner.addAll(message);
+ var innerHash = Hashes.digest(alg, inner);
+ outer.addAll(innerHash);
+ var mac = Hashes.digest(alg, outer);
+
+ Bytes.wipe(inner);
+ Bytes.wipe(outer);
+ Bytes.wipe(innerHash);
+ Bytes.wipe(hashedKey);
+ return mac;
+ }
+
+ // Native HMAC-SHA256 (Toybox.Cryptography).
+ function nativeSha256(key as ByteArray, message as ByteArray) as ByteArray {
+ var mac = new Cryptography.HashBasedMessageAuthenticationCode({
+ :algorithm => Cryptography.HASH_SHA256,
+ :key => key
+ });
+ if (message.size() > 0) {
+ mac.update(message);
+ }
+ return mac.digest();
+ }
+}
diff --git a/source/crypto/Kdf.mc b/source/crypto/Kdf.mc
new file mode 100644
index 0000000..07c97ba
--- /dev/null
+++ b/source/crypto/Kdf.mc
@@ -0,0 +1,159 @@
+// Author: cirakg & claude ai
+// Summary: Key derivation: resumable PBKDF2-HMAC-SHA256 (RFC 8018) with calibration, and HKDF-SHA256 (RFC 5869).
+//
+// PBKDF2 turns the PIN into a key (README 7.2). It runs as a Jobs.Job, a
+// bounded number of iterations per step, because the iteration count is
+// calibrated to about one second on the watch, far beyond one event's
+// watchdog budget. It uses the native HMAC-SHA256: the object keeps the key
+// and digest() resets it, so each iteration is one native call.
+//
+// HKDF splits one master key into separate encryption and MAC keys
+// (README 7.3).
+//
+// Verified with RFC 7914 section 11 (PBKDF2) and RFC 5869 A.1-A.3 (HKDF),
+// plus seeded vectors from Python's hashlib (test/crypto/KdfTest.mc).
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+import Toybox.System;
+
+module Kdf {
+
+ const SHA256_BYTES = 32;
+ // Bounds for the calibrated PBKDF2 iteration count. The lower bound keeps
+ // a fast watch from choosing a trivially cheap PIN key; the upper bound
+ // keeps the simulator (a PC) from choosing a count a watch can't afford.
+ const MIN_ITERATIONS = 2000;
+ const MAX_ITERATIONS = 200000;
+ // Iterations per job step: small enough for the 120,000-bytecode watchdog.
+ const ITERATIONS_PER_STEP = 200;
+
+ // PBKDF2-HMAC-SHA256 as a resumable job (RFC 8018 section 5.2).
+ class Pbkdf2 extends Jobs.Job {
+
+ private var _mac as Cryptography.HashBasedMessageAuthenticationCode;
+ private var _salt as ByteArray;
+ private var _iterations as Number;
+ private var _length as Number;
+ private var _blockCount as Number;
+ private var _block as Number = 1; // current output block, 1-based
+ private var _done as Number = 0; // iterations done for the current block
+ private var _u as ByteArray = []b;
+ private var _t as ByteArray = []b;
+ private var _out as ByteArray = []b;
+
+ // iterations >= 1; 1 <= length <= 255 * 32 bytes.
+ function initialize(password as ByteArray, salt as ByteArray, iterations as Number, length as Number) {
+ Job.initialize();
+ if (iterations < 1 || length < 1 || length > 255 * SHA256_BYTES) {
+ throw new Lang.InvalidValueException("PBKDF2: bad iteration count or key length");
+ }
+ _mac = new Cryptography.HashBasedMessageAuthenticationCode({
+ :algorithm => Cryptography.HASH_SHA256,
+ :key => password
+ });
+ _salt = Bytes.copy(salt);
+ _iterations = iterations;
+ _length = length;
+ _blockCount = (length + SHA256_BYTES - 1) / SHA256_BYTES;
+ }
+
+ // Run up to `budget` iterations. Returns true when the key is complete.
+ function step(budget as Number) as Boolean {
+ var left = budget;
+ while (left > 0 && _block <= _blockCount) {
+ if (_done == 0) {
+ // U1 = HMAC(P, S || INT(i))
+ _mac.update(Bytes.concat(_salt, Bytes.uint32BigEndian(_block)));
+ _u = _mac.digest();
+ _t = Bytes.copy(_u);
+ } else {
+ // Uj = HMAC(P, Uj-1); T ^= Uj. The previous Uj isn't wiped
+ // every iteration: it is useless without the password, and
+ // a wipe loop per iteration would halve the iterations the
+ // watch can afford in its time budget. The final values
+ // are wiped below.
+ _mac.update(_u);
+ _u = _mac.digest();
+ var t = _t;
+ var u = _u;
+ for (var i = 0; i < SHA256_BYTES; i++) {
+ t[i] = t[i] ^ u[i];
+ }
+ }
+ _done++;
+ left--;
+ if (_done == _iterations) {
+ _out.addAll(_t);
+ Bytes.wipe(_t);
+ Bytes.wipe(_u);
+ _block++;
+ _done = 0;
+ }
+ }
+ return _block > _blockCount;
+ }
+
+ // The derived key; only valid after step() returned true.
+ function result() as ByteArray {
+ return _out.slice(0, _length);
+ }
+
+ // Wipe every intermediate value.
+ function wipe() as Void {
+ Bytes.wipe(_u);
+ Bytes.wipe(_t);
+ Bytes.wipe(_out);
+ Bytes.wipe(_salt);
+ }
+ }
+
+ // PBKDF2-HMAC-SHA256 in one call. Only for tests and small iteration counts.
+ function pbkdf2(password as ByteArray, salt as ByteArray, iterations as Number, length as Number) as ByteArray {
+ var job = new Pbkdf2(password, salt, iterations, length);
+ Jobs.runToCompletion(job, ITERATIONS_PER_STEP);
+ var key = job.result();
+ job.wipe();
+ return key;
+ }
+
+ // Iteration count for which one derivation takes about targetMs on this
+ // device, clamped to [MIN_ITERATIONS, MAX_ITERATIONS]. Measures a short
+ // run of `sample` iterations (call it again from a job if a longer sample
+ // would trip the watchdog).
+ function calibrate(targetMs as Number, sample as Number) as Number {
+ var job = new Pbkdf2([0, 0, 0, 0, 0, 0]b, new [16]b, sample, SHA256_BYTES);
+ var start = System.getTimer();
+ Jobs.runToCompletion(job, ITERATIONS_PER_STEP);
+ var elapsed = System.getTimer() - start;
+ job.wipe();
+ var estimate = elapsed > 0 ? (sample.toLong() * targetMs / elapsed) : MAX_ITERATIONS.toLong();
+ if (estimate < MIN_ITERATIONS) {
+ return MIN_ITERATIONS;
+ }
+ return estimate > MAX_ITERATIONS ? MAX_ITERATIONS : estimate.toNumber();
+ }
+
+ // HKDF-SHA256 (RFC 5869): extract with salt (empty means 32 zero bytes),
+ // then expand to `length` bytes with info. length <= 255 * 32.
+ function hkdf(ikm as ByteArray, salt as ByteArray, info as ByteArray, length as Number) as ByteArray {
+ if (length < 1 || length > 255 * SHA256_BYTES) {
+ throw new Lang.InvalidValueException("HKDF: bad key length");
+ }
+ var prk = Hmac.nativeSha256(salt.size() > 0 ? salt : new [SHA256_BYTES]b, ikm);
+ var okm = new [0]b;
+ var block = new [0]b;
+ for (var counter = 1; okm.size() < length; counter++) {
+ var input = Bytes.concat(block, info);
+ input.add(counter);
+ Bytes.wipe(block);
+ block = Hmac.nativeSha256(prk, input);
+ okm.addAll(block);
+ }
+ Bytes.wipe(prk);
+ Bytes.wipe(block);
+ var key = okm.slice(0, length);
+ Bytes.wipe(okm);
+ return key;
+ }
+}
diff --git a/source/crypto/SealedBox.mc b/source/crypto/SealedBox.mc
new file mode 100644
index 0000000..0804b96
--- /dev/null
+++ b/source/crypto/SealedBox.mc
@@ -0,0 +1,89 @@
+// Author: cirakg & claude ai
+// Summary: Authenticated encryption of records: AES-256-CBC plus HMAC-SHA256, encrypt-then-MAC (README 7.3).
+//
+// Format of a sealed box (one ByteArray):
+// version (1 byte, = 1) | IV (16 bytes) | AES-256-CBC ciphertext | tag (32 bytes)
+// tag = HMAC-SHA256(macKey, "OTPW-REC-1" || len(aad) as 4 bytes || aad || version || IV || ciphertext)
+//
+// * Two keys come from one master key with HKDF (labels "OTPW enc v1" and
+// "OTPW mac v1"), so encryption and authentication never share a key.
+// * aad ("additional authenticated data") is authenticated but not encrypted;
+// storage puts the record ID and header there (M3), so a record can't be
+// swapped for another one or have its header changed unnoticed. Its length
+// is part of the MAC input, so aad and ciphertext can't be shifted against
+// each other.
+// * open() checks the tag in constant time BEFORE decrypting, and returns
+// null for any problem (wrong key, tampering, truncation, unknown version)
+// without saying which.
+// * Every seal uses a fresh random IV from Cryptography.randomBytes.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+module SealedBox {
+
+ const VERSION = 1;
+ const IV_BYTES = 16;
+ const TAG_BYTES = 32;
+ const KEY_BYTES = 32;
+ const MIN_SIZE = 1 + IV_BYTES + 16 + TAG_BYTES; // ciphertext is at least one block
+
+ // The encryption and MAC keys derived from one master key.
+ class Keys {
+ var encKey as ByteArray;
+ var macKey as ByteArray;
+
+ function initialize(masterKey as ByteArray) {
+ encKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW enc v1"), KEY_BYTES);
+ macKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW mac v1"), KEY_BYTES);
+ }
+
+ // Wipe both keys (when the session locks).
+ function wipe() as Void {
+ Bytes.wipe(encKey);
+ Bytes.wipe(macKey);
+ }
+ }
+
+ // Seal plaintext with a fresh random IV.
+ function seal(keys as Keys, aad as ByteArray, plaintext as ByteArray) as ByteArray {
+ return sealWithIv(keys, aad, plaintext, Cryptography.randomBytes(IV_BYTES));
+ }
+
+ // Seal with a given IV. Only tests call this directly (fixed IVs make the
+ // output reproducible); reusing an IV in real use would be a mistake.
+ function sealWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray) as ByteArray {
+ var ciphertext = Aes.cbcEncrypt(keys.encKey, iv, plaintext);
+ var box = [VERSION]b;
+ box.addAll(iv);
+ box.addAll(ciphertext);
+ box.addAll(tag(keys, aad, box));
+ return box;
+ }
+
+ // The plaintext, or null if the box is malformed, was sealed with another
+ // key or aad, or was changed in any way.
+ function open(keys as Keys, aad as ByteArray, box as ByteArray) as ByteArray? {
+ var size = box.size();
+ if (size < MIN_SIZE || (size - 1 - IV_BYTES - TAG_BYTES) % Aes.BLOCK_BYTES != 0 || box[0] != VERSION) {
+ return null;
+ }
+ var body = box.slice(0, size - TAG_BYTES);
+ var expected = tag(keys, aad, body);
+ var valid = Bytes.equal(expected, box.slice(size - TAG_BYTES, size));
+ Bytes.wipe(expected);
+ if (!valid) {
+ return null;
+ }
+ return Aes.cbcDecrypt(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES));
+ }
+
+ // HMAC over the label, the aad (with its length) and the box body.
+ function tag(keys as Keys, aad as ByteArray, body as ByteArray) as ByteArray {
+ var input = Bytes.fromUtf8("OTPW-REC-1");
+ input.addAll(Bytes.uint32BigEndian(aad.size()));
+ input.addAll(aad);
+ input.addAll(body);
+ return Hmac.nativeSha256(keys.macKey, input);
+ }
+}
diff --git a/source/crypto/SelfTest.mc b/source/crypto/SelfTest.mc
new file mode 100644
index 0000000..0d7eae4
--- /dev/null
+++ b/source/crypto/SelfTest.mc
@@ -0,0 +1,70 @@
+// Author: cirakg & claude ai
+// Summary: Start-up known-answer tests for every crypto primitive, so wrong firmware crypto never shows wrong codes.
+//
+// run() checks SHA-1, SHA-256, SHA-512, HMAC (generic and native), AES-128
+// and AES-256 CBC, PBKDF2, HKDF and HOTP against published values. The app
+// refuses to show codes when anything fails (README 7.10). The expected
+// values below are published test vectors, not secrets.
+
+import Toybox.Lang;
+
+module SelfTest {
+
+ // Name of the first failing check, or null when everything passed.
+ function run() as String? {
+ var abc = Bytes.fromUtf8("abc");
+ if (!check(Hashes.digest(Hashes.SHA1, abc), "a9993e364706816aba3e25717850c26c9cd0d89d")) { // lint: allow-literal
+ return "SHA-1";
+ }
+ if (!check(Hashes.digest(Hashes.SHA256, abc), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")) { // lint: allow-literal
+ return "SHA-256";
+ }
+ if (!check(Hashes.digest(Hashes.SHA512, abc).slice(0, 32), "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a")) { // lint: allow-literal
+ return "SHA-512";
+ }
+ // RFC 2202 test case 1 (HMAC-SHA1) and RFC 4231 test case 1 (HMAC-SHA256).
+ var key = new [20]b;
+ for (var i = 0; i < 20; i++) {
+ key[i] = 0x0b;
+ }
+ var hiThere = Bytes.fromUtf8("Hi There");
+ if (!check(Hmac.generic(Hashes.SHA1, key, hiThere), "b617318655057264e28bc0b6fb378c8ef146be00")) { // lint: allow-literal
+ return "HMAC-SHA1";
+ }
+ var sha256Mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"; // lint: allow-literal
+ if (!check(Hmac.nativeSha256(key, hiThere), sha256Mac) || !check(Hmac.generic(Hashes.SHA256, key, hiThere), sha256Mac)) {
+ return "HMAC-SHA256";
+ }
+ // NIST SP 800-38A F.2.1 and F.2.5, first block.
+ var iv = Bytes.fromHex("000102030405060708090a0b0c0d0e0f"); // lint: allow-literal
+ var block = Bytes.fromHex("6bc1bee22e409f96e93d7e117393172a"); // lint: allow-literal
+ if (!check(Aes.cbcRaw(Bytes.fromHex("2b7e151628aed2a6abf7158809cf4f3c"), iv, block, true), "7649abac8119b246cee98e9b12e9197d")) { // lint: allow-literal
+ return "AES-128";
+ }
+ var aes256Key = Bytes.fromHex("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"); // lint: allow-literal
+ if (!check(Aes.cbcRaw(aes256Key, iv, block, true), "f58c4c04d6e5f1ba779eabfb5f7bfbd6")) { // lint: allow-literal
+ return "AES-256";
+ }
+ // RFC 7914 section 11, first vector (first 32 bytes).
+ if (!check(Kdf.pbkdf2(Bytes.fromUtf8("passwd"), Bytes.fromUtf8("salt"), 1, 32), "55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc")) { // lint: allow-literal
+ return "PBKDF2";
+ }
+ // RFC 5869 test case 3 (no salt, no info).
+ var ikm = new [22]b;
+ for (var i = 0; i < 22; i++) {
+ ikm[i] = 0x0b;
+ }
+ if (!check(Kdf.hkdf(ikm, []b, []b, 42), "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8")) { // lint: allow-literal
+ return "HKDF";
+ }
+ // RFC 4226 Appendix D, counter 0.
+ if (!Otp.hotp(Hashes.SHA1, Bytes.fromUtf8("12345678901234567890"), 0l, 6).equals("755224")) {
+ return "HOTP";
+ }
+ return null;
+ }
+
+ function check(actual as ByteArray, expectedHex as String) as Boolean {
+ return Bytes.equal(actual, Bytes.fromHex(expectedHex));
+ }
+}
diff --git a/source/crypto/Sha512.mc b/source/crypto/Sha512.mc
new file mode 100644
index 0000000..4248462
--- /dev/null
+++ b/source/crypto/Sha512.mc
@@ -0,0 +1,182 @@
+// Author: cirakg & claude ai
+// Summary: SHA-512 in pure Monkey C (FIPS 180-4), because Connect IQ has no native SHA-512.
+//
+// Only SHA-512 OTP entries use this (most services use SHA-1). It works on
+// 64-bit Long values: Monkey C Longs wrap around on overflow like unsigned
+// arithmetic modulo 2^64, but `>>` copies the sign bit, so every right shift
+// masks off the copied bits (see the Mn constants).
+//
+// Verified against the FIPS 180-4 example messages and 15 seeded messages
+// around the 128-byte padding boundary (test/crypto/HashTest.mc).
+//
+// Performance note: one 128-byte block is roughly ten thousand VM operations,
+// and an OTP code needs four blocks (HMAC). The cost is measured in M2's
+// benchmark tests; long inputs must be split into steps by the caller so the
+// watchdog never trips.
+
+import Toybox.Lang;
+
+module Sha512 {
+
+ // Round constants: the first 64 bits of the fractional parts of the cube
+ // roots of the first 80 primes (FIPS 180-4 section 4.2.3).
+ const K = [
+ 0x428a2f98d728ae22l, 0x7137449123ef65cdl, 0xb5c0fbcfec4d3b2fl,
+ 0xe9b5dba58189dbbcl, 0x3956c25bf348b538l, 0x59f111f1b605d019l,
+ 0x923f82a4af194f9bl, 0xab1c5ed5da6d8118l, 0xd807aa98a3030242l,
+ 0x12835b0145706fbel, 0x243185be4ee4b28cl, 0x550c7dc3d5ffb4e2l,
+ 0x72be5d74f27b896fl, 0x80deb1fe3b1696b1l, 0x9bdc06a725c71235l,
+ 0xc19bf174cf692694l, 0xe49b69c19ef14ad2l, 0xefbe4786384f25e3l,
+ 0x0fc19dc68b8cd5b5l, 0x240ca1cc77ac9c65l, 0x2de92c6f592b0275l,
+ 0x4a7484aa6ea6e483l, 0x5cb0a9dcbd41fbd4l, 0x76f988da831153b5l,
+ 0x983e5152ee66dfabl, 0xa831c66d2db43210l, 0xb00327c898fb213fl,
+ 0xbf597fc7beef0ee4l, 0xc6e00bf33da88fc2l, 0xd5a79147930aa725l,
+ 0x06ca6351e003826fl, 0x142929670a0e6e70l, 0x27b70a8546d22ffcl,
+ 0x2e1b21385c26c926l, 0x4d2c6dfc5ac42aedl, 0x53380d139d95b3dfl,
+ 0x650a73548baf63del, 0x766a0abb3c77b2a8l, 0x81c2c92e47edaee6l,
+ 0x92722c851482353bl, 0xa2bfe8a14cf10364l, 0xa81a664bbc423001l,
+ 0xc24b8b70d0f89791l, 0xc76c51a30654be30l, 0xd192e819d6ef5218l,
+ 0xd69906245565a910l, 0xf40e35855771202al, 0x106aa07032bbd1b8l,
+ 0x19a4c116b8d2d0c8l, 0x1e376c085141ab53l, 0x2748774cdf8eeb99l,
+ 0x34b0bcb5e19b48a8l, 0x391c0cb3c5c95a63l, 0x4ed8aa4ae3418acbl,
+ 0x5b9cca4f7763e373l, 0x682e6ff3d6b2b8a3l, 0x748f82ee5defb2fcl,
+ 0x78a5636f43172f60l, 0x84c87814a1f0ab72l, 0x8cc702081a6439ecl,
+ 0x90befffa23631e28l, 0xa4506cebde82bde9l, 0xbef9a3f7b2c67915l,
+ 0xc67178f2e372532bl, 0xca273eceea26619cl, 0xd186b8c721c0c207l,
+ 0xeada7dd6cde0eb1el, 0xf57d4f7fee6ed178l, 0x06f067aa72176fbal,
+ 0x0a637dc5a2c898a6l, 0x113f9804bef90dael, 0x1b710b35131c471bl,
+ 0x28db77f523047d84l, 0x32caab7b40c72493l, 0x3c9ebe0a15c9bebcl,
+ 0x431d67c49c100d4cl, 0x4cc5d4becb3e42b6l, 0x597f299cfc657e2al,
+ 0x5fcb6fab3ad6faecl, 0x6c44198c4a475817l
+ ];
+
+ // Initial hash value: the first 64 bits of the fractional parts of the
+ // square roots of the first 8 primes (FIPS 180-4 section 5.3.5).
+ const IV = [
+ 0x6a09e667f3bcc908l, 0xbb67ae8584caa73bl, 0x3c6ef372fe94f82bl,
+ 0xa54ff53a5f1d36f1l, 0x510e527fade682d1l, 0x9b05688c2b3e6c1fl,
+ 0x1f83d9abfb41bd6bl, 0x5be0cd19137e2179l
+ ];
+
+ const BLOCK_BYTES = 128;
+ const LENGTH_FIELD_BYTES = 16;
+
+ // Masks that clear the bits `>>` copies from the sign: Mn keeps the low
+ // 64 - n bits, so ((x >> n) & Mn) is a logical shift and
+ // (((x >> n) & Mn) | (x << (64 - n))) a right rotation by n. The rotations
+ // are written out inline in compress(), because function calls are
+ // expensive in the Monkey C VM and compress() is the hot loop.
+ const M1 = 0x7fffffffffffffffl;
+ const M6 = 0x03ffffffffffffffl;
+ const M7 = 0x01ffffffffffffffl;
+ const M8 = 0x00ffffffffffffffl;
+ const M14 = 0x0003ffffffffffffl;
+ const M18 = 0x00003fffffffffffl;
+ const M19 = 0x00001fffffffffffl;
+ const M28 = 0x0000000fffffffffl;
+ const M34 = 0x000000003fffffffl;
+ const M39 = 0x0000000001ffffffl;
+ const M41 = 0x00000000007fffffl;
+ const M61 = 0x0000000000000007l;
+
+ // Read 8 bytes at offset as a big-endian 64-bit value (two native 32-bit reads).
+ function read64(data as ByteArray, offset as Number) as Long {
+ var hi = data.decodeNumber(Lang.NUMBER_FORMAT_UINT32, {:offset => offset, :endianness => Lang.ENDIAN_BIG}) as Number or Long;
+ var lo = data.decodeNumber(Lang.NUMBER_FORMAT_UINT32, {:offset => offset + 4, :endianness => Lang.ENDIAN_BIG}) as Number or Long;
+ return (hi.toLong() << 32) | (lo.toLong() & 0xFFFFFFFFl);
+ }
+
+ // The message followed by FIPS 180-4 padding: a 1 bit, zeros, and the
+ // message length in bits as a 128-bit big-endian number.
+ function pad(data as ByteArray) as ByteArray {
+ var length = data.size();
+ var total = ((length + 1 + LENGTH_FIELD_BYTES + BLOCK_BYTES - 1) / BLOCK_BYTES) * BLOCK_BYTES;
+ var msg = new [total]b;
+ for (var i = 0; i < length; i++) {
+ msg[i] = data[i];
+ }
+ msg[length] = 0x80;
+ // Monkey C ByteArrays are limited far below 2^61 bytes, so the upper 64
+ // bits of the length field are always zero.
+ var bits = length.toLong() * 8l;
+ for (var i = 0; i < 8; i++) {
+ msg[total - 1 - i] = ((bits >> (8 * i)) & 0xFFl).toNumber();
+ }
+ return msg;
+ }
+
+ // Process one 128-byte block at offset into the hash state h.
+ function compress(h as Array<Long>, msg as ByteArray, offset as Number, w as Array<Long>) as Void {
+ for (var t = 0; t < 16; t++) {
+ w[t] = read64(msg, offset + 8 * t);
+ }
+ for (var t = 16; t < 80; t++) {
+ var x = w[t - 15];
+ var y = w[t - 2];
+ var s0 = (((x >> 1) & M1) | (x << 63)) ^ (((x >> 8) & M8) | (x << 56)) ^ ((x >> 7) & M7);
+ var s1 = (((y >> 19) & M19) | (y << 45)) ^ (((y >> 61) & M61) | (y << 3)) ^ ((y >> 6) & M6);
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1;
+ }
+ var a = h[0];
+ var b = h[1];
+ var c = h[2];
+ var d = h[3];
+ var e = h[4];
+ var f = h[5];
+ var g = h[6];
+ var hh = h[7];
+ for (var t = 0; t < 80; t++) {
+ var bigS1 = (((e >> 14) & M14) | (e << 50)) ^ (((e >> 18) & M18) | (e << 46)) ^ (((e >> 41) & M41) | (e << 23));
+ var choose = (e & f) ^ (~e & g);
+ var temp1 = hh + bigS1 + choose + (K[t] as Long) + w[t];
+ var bigS0 = (((a >> 28) & M28) | (a << 36)) ^ (((a >> 34) & M34) | (a << 30)) ^ (((a >> 39) & M39) | (a << 25));
+ var majority = (a & b) ^ (a & c) ^ (b & c);
+ hh = g;
+ g = f;
+ f = e;
+ e = d + temp1;
+ d = c;
+ c = b;
+ b = a;
+ a = temp1 + bigS0 + majority;
+ }
+ h[0] = h[0] + a;
+ h[1] = h[1] + b;
+ h[2] = h[2] + c;
+ h[3] = h[3] + d;
+ h[4] = h[4] + e;
+ h[5] = h[5] + f;
+ h[6] = h[6] + g;
+ h[7] = h[7] + hh;
+ }
+
+ // The 64-byte SHA-512 digest of data. The padded copy of the message is
+ // wiped before returning, because HMAC feeds keys through here.
+ function digest(data as ByteArray) as ByteArray {
+ var msg = pad(data);
+ var h = [] as Array<Long>;
+ for (var i = 0; i < 8; i++) {
+ h.add(IV[i] as Long);
+ }
+ var w = [] as Array<Long>;
+ for (var i = 0; i < 80; i++) {
+ w.add(0l);
+ }
+ for (var offset = 0; offset < msg.size(); offset += BLOCK_BYTES) {
+ compress(h, msg, offset, w);
+ }
+ Bytes.wipe(msg);
+ for (var i = 0; i < 80; i++) {
+ w[i] = 0l;
+ }
+ var out = new [64]b;
+ for (var i = 0; i < 8; i++) {
+ var word = h[i];
+ for (var j = 7; j >= 0; j--) {
+ out[8 * i + j] = (word & 0xFFl).toNumber();
+ word = word >> 8;
+ }
+ }
+ return out;
+ }
+}
diff --git a/source/util/Bytes.mc b/source/util/Bytes.mc
new file mode 100644
index 0000000..1008fd2
--- /dev/null
+++ b/source/util/Bytes.mc
@@ -0,0 +1,114 @@
+// Author: cirakg & claude ai
+// Summary: Byte-array helpers: hex and UTF-8 conversion, concatenation, wiping and constant-time comparison.
+//
+// Security notes:
+// * wipe() overwrites a buffer in place. Call it on every buffer that held a
+// key or a decrypted secret as soon as it is no longer needed. Monkey C
+// can't guarantee no other copy exists (README 7.8), so this is best effort.
+// * equal() always looks at every byte, so comparing a MAC doesn't reveal
+// through its running time how many leading bytes matched.
+
+import Toybox.Lang;
+import Toybox.StringUtil;
+
+module Bytes {
+
+ // Hex text (any case, even length) to bytes. Empty text gives an empty array.
+ function fromHex(hex as String) as ByteArray {
+ if (hex.length() == 0) {
+ return []b;
+ }
+ return StringUtil.convertEncodedString(hex, {
+ :fromRepresentation => StringUtil.REPRESENTATION_STRING_HEX,
+ :toRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY
+ }) as ByteArray;
+ }
+
+ // Bytes to lowercase hex text.
+ function toHex(data as ByteArray) as String {
+ if (data.size() == 0) {
+ return "";
+ }
+ return (StringUtil.convertEncodedString(data, {
+ :fromRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY,
+ :toRepresentation => StringUtil.REPRESENTATION_STRING_HEX
+ }) as String).toLower();
+ }
+
+ // The UTF-8 bytes of a string.
+ function fromUtf8(text as String) as ByteArray {
+ if (text.length() == 0) {
+ return []b;
+ }
+ return StringUtil.convertEncodedString(text, {
+ :fromRepresentation => StringUtil.REPRESENTATION_STRING_PLAIN_TEXT,
+ :toRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY,
+ :encoding => StringUtil.CHAR_ENCODING_UTF8
+ }) as ByteArray;
+ }
+
+ // A new array holding a followed by b; neither input is changed.
+ function concat(a as ByteArray, b as ByteArray) as ByteArray {
+ var out = new [0]b;
+ out.addAll(a);
+ out.addAll(b);
+ return out;
+ }
+
+ // A new array with a copy of data.
+ function copy(data as ByteArray) as ByteArray {
+ var out = new [0]b;
+ out.addAll(data);
+ return out;
+ }
+
+ // Overwrite every byte with zero. Accepts null so callers can wipe fields
+ // that may not have been set.
+ function wipe(data as ByteArray?) as Void {
+ if (data == null) {
+ return;
+ }
+ for (var i = 0; i < data.size(); i++) {
+ data[i] = 0;
+ }
+ }
+
+ // True when both arrays have the same length and content. The running time
+ // depends only on the length, not on where the first difference is.
+ function equal(a as ByteArray, b as ByteArray) as Boolean {
+ if (a.size() != b.size()) {
+ return false;
+ }
+ var diff = 0;
+ for (var i = 0; i < a.size(); i++) {
+ diff = diff | (a[i] ^ b[i]);
+ }
+ return diff == 0;
+ }
+
+ // XOR source into target, byte by byte (same length required).
+ function xorInto(target as ByteArray, source as ByteArray) as Void {
+ for (var i = 0; i < target.size(); i++) {
+ target[i] = target[i] ^ source[i];
+ }
+ }
+
+ // An unsigned 64-bit value as 8 bytes, most significant first (the HOTP
+ // counter encoding of RFC 4226).
+ function uint64BigEndian(value as Long) as ByteArray {
+ var out = new [8]b;
+ var v = value;
+ for (var i = 7; i >= 0; i--) {
+ out[i] = (v & 0xFFl).toNumber();
+ v = v >> 8;
+ }
+ return out;
+ }
+
+ // An unsigned 32-bit value as 4 bytes, most significant first.
+ function uint32BigEndian(value as Number) as ByteArray {
+ var out = new [4]b;
+ out.encodeNumber(value, Lang.NUMBER_FORMAT_UINT32, {:offset => 0, :endianness => Lang.ENDIAN_BIG});
+ return out;
+ }
+}
diff --git a/source/util/Clock.mc b/source/util/Clock.mc
new file mode 100644
index 0000000..4e2faf4
--- /dev/null
+++ b/source/util/Clock.mc
@@ -0,0 +1,36 @@
+// Author: cirakg & claude ai
+// Summary: UTC time for TOTP: the 2038-safe widening of the watch clock, the user's offset, and the trusted clock.
+//
+// Connect IQ stores time as a 32-bit signed integer, which turns negative on
+// 2038-01-19. Reading it as unsigned keeps it correct until 2106 (README 9).
+// The trusted clock (Time.CURRENT_TIME_RTC, API 3.0.10) is synced from GPS and
+// can't be changed by the user; the UI warns when it is unsynced or differs
+// from the normal clock.
+
+import Toybox.Lang;
+import Toybox.Time;
+
+module Clock {
+
+ const TWO_POW_32 = 4294967296l;
+
+ // A 32-bit clock value read as unsigned.
+ function toUnsigned(value as Number) as Long {
+ var v = value.toLong();
+ return v < 0 ? v + TWO_POW_32 : v;
+ }
+
+ // Current unix time (UTC seconds) plus the user's offset for clock skew.
+ function nowUnix(offsetSeconds as Number) as Long {
+ return toUnsigned(Time.now().value()) + offsetSeconds;
+ }
+
+ // The trusted clock's unix time, or null when it isn't synced yet.
+ function trustedUnix() as Long? {
+ try {
+ return toUnsigned(Time.getCurrentTime({:currentTimeType => Time.CURRENT_TIME_RTC}).value());
+ } catch (e instanceof Time.RealTimeClockNotValidException) {
+ return null;
+ }
+ }
+}
diff --git a/source/util/Jobs.mc b/source/util/Jobs.mc
new file mode 100644
index 0000000..d7342e3
--- /dev/null
+++ b/source/util/Jobs.mc
@@ -0,0 +1,78 @@
+// Author: cirakg & claude ai
+// Summary: Resumable jobs: long work split into small steps so no single event trips the watchdog.
+//
+// Connect IQ kills code that runs too long without returning to the system
+// ("Watchdog Tripped"). The device files allow 120,000 bytecodes per event on
+// the Forerunner 255/255S and 240,000 on the other targets; the simulator does
+// not enforce this in test runs, so the only protection is design: anything
+// that can take long (key derivation, SHA-512 batches, 2D barcode encoding,
+// large imports) is a Job whose step() does a bounded amount of work.
+//
+// JobRunner drives a job from a timer, one step per tick, and calls back when
+// it is done. Tests call Jobs.runToCompletion() instead, synchronously.
+
+import Toybox.Lang;
+import Toybox.Timer;
+
+module Jobs {
+
+ // Base class for resumable work.
+ class Job {
+
+ function initialize() {
+ }
+
+ // Do at most `budget` units of work. Returns true when the job is done.
+ function step(budget as Number) as Boolean {
+ return true;
+ }
+ }
+
+ // Run a job to the end without yielding. Only for tests and work that is
+ // known to be small.
+ function runToCompletion(job as Job, budget as Number) as Void {
+ while (!job.step(budget)) {
+ }
+ }
+
+ // Drives a job from a timer: one step of `budget` units every TICK_MS.
+ class JobRunner {
+
+ // Pause between steps, so the UI stays responsive.
+ static const TICK_MS = 50;
+
+ private var _job as Job;
+ private var _budget as Number;
+ private var _onDone as Method() as Void;
+ private var _timer as Timer.Timer?;
+
+ function initialize(job as Job, budget as Number, onDone as Method() as Void) {
+ _job = job;
+ _budget = budget;
+ _onDone = onDone;
+ }
+
+ // Start stepping; the first step runs on the first tick.
+ function start() as Void {
+ var timer = new Timer.Timer();
+ timer.start(method(:onTick), TICK_MS, true);
+ _timer = timer;
+ }
+
+ // Stop without finishing (for example, when the user leaves the screen).
+ function cancel() as Void {
+ var timer = _timer;
+ if (timer != null) {
+ timer.stop();
+ _timer = null;
+ }
+ }
+
+ function onTick() as Void {
+ if (_job.step(_budget)) {
+ cancel();
+ _onDone.invoke();
+ }
+ }
+ }
+}