aboutsummaryrefslogtreecommitdiff
path: root/source/util
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 /source/util
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>
Diffstat (limited to 'source/util')
-rw-r--r--source/util/Bytes.mc114
-rw-r--r--source/util/Clock.mc36
-rw-r--r--source/util/Jobs.mc78
3 files changed, 228 insertions, 0 deletions
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();
+ }
+ }
+ }
+}