diff options
Diffstat (limited to 'source/util')
| -rw-r--r-- | source/util/Bytes.mc | 114 | ||||
| -rw-r--r-- | source/util/Clock.mc | 36 | ||||
| -rw-r--r-- | source/util/Jobs.mc | 78 |
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(); + } + } + } +} |
