summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/gen_vectors.py361
-rw-r--r--tools/sim_screenshot.py5
2 files changed, 364 insertions, 2 deletions
diff --git a/tools/gen_vectors.py b/tools/gen_vectors.py
new file mode 100644
index 0000000..fb7053a
--- /dev/null
+++ b/tools/gen_vectors.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+# Author: cirakg & claude ai
+# Summary: Generate test/data/Vectors.mc: official crypto/OTP test vectors, cross-checked, plus seeded extra cases.
+"""Generate the Monkey C test vectors in test/data/Vectors.mc.
+
+Sources, and how each is double-checked before it is written:
+
+* Published vectors (FIPS 180-4 examples, RFC 2202, RFC 4231, RFC 7914 §11,
+ RFC 5869, RFC 4648 §10, RFC 4226 Appendix D, RFC 6238 Appendix B, NIST SP
+ 800-38A F.2.1/F.2.5) are written into this file from the documents. Python's
+ standard library (hashlib, hmac, base64) recomputes each one, and the
+ generator stops if the two disagree. AES isn't in the standard library, so
+ the NIST AES vectors are checked with `openssl enc` instead.
+* Extra cases (random messages around the SHA padding boundaries, random OTP
+ secrets, digit counts, periods, times around 2038, Steam codes) come from a
+ fixed seed and are computed with the standard library only, so they are
+ reproducible and independent of the Monkey C code under test.
+
+Usage: gen_vectors.py [--out test/data/Vectors.mc]
+The output is committed; rerun after changing this file. All data is fake.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import hmac
+import random
+import struct
+import subprocess
+from collections.abc import Sequence
+from pathlib import Path
+
+SEED = 20261018
+ALGS = {"SHA1": 0, "SHA256": 1, "SHA512": 2}
+STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY"
+
+
+def h(data: bytes) -> str:
+ return data.hex()
+
+
+# --------------------------------------------------------------------- hashes
+
+FIPS_MESSAGES = [
+ b"",
+ b"abc",
+ b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
+ b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu",
+]
+# FIPS 180-4 example digests of "abc", as printed in the standard's examples.
+FIPS_ABC = {
+ "SHA1": "a9993e364706816aba3e25717850c26c9cd0d89d",
+ "SHA256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+ "SHA512": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a"
+ "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
+}
+
+
+def hash_vectors(rng: random.Random) -> list[tuple[int, str, str]]:
+ for alg, expected in FIPS_ABC.items():
+ assert hashlib.new(alg.lower(), b"abc").hexdigest() == expected, alg
+ messages = list(FIPS_MESSAGES)
+ # Lengths around the padding boundaries of 64-byte (SHA-1/256) and
+ # 128-byte (SHA-512) blocks, where implementations usually break.
+ for length in (55, 56, 63, 64, 65, 111, 112, 119, 120, 127, 128, 129, 200, 255, 256):
+ messages.append(bytes(rng.randrange(256) for _ in range(length)))
+ return [(ALGS[a], h(m), hashlib.new(a.lower(), m).hexdigest()) for a in ALGS for m in messages]
+
+
+# ----------------------------------------------------------------------- HMAC
+
+KEY_20 = b"\x0b" * 20
+KEY_AA131 = b"\xaa" * 131
+RFC4231_CASES = [ # (key, data); case 5's output is truncated to 128 bits in the RFC
+ (KEY_20, b"Hi There"),
+ (b"Jefe", b"what do ya want for nothing?"),
+ (b"\xaa" * 20, b"\xdd" * 50),
+ (bytes(range(1, 26)), b"\xcd" * 50),
+ (b"\x0c" * 20, b"Test With Truncation"),
+ (KEY_AA131, b"Test Using Larger Than Block-Size Key - Hash Key First"),
+ (
+ KEY_AA131,
+ b"This is a test using a larger than block-size key and a larger than block-size data."
+ b" The key needs to be hashed before being used by the HMAC algorithm.",
+ ),
+]
+RFC2202_SHA1_CASES = [
+ (KEY_20, b"Hi There"),
+ (b"Jefe", b"what do ya want for nothing?"),
+ (b"\xaa" * 20, b"\xdd" * 50),
+ (bytes(range(1, 26)), b"\xcd" * 50),
+ (b"\x0c" * 20, b"Test With Truncation"),
+ (b"\xaa" * 80, b"Test Using Larger Than Block-Size Key - Hash Key First"),
+ (b"\xaa" * 80, b"Test Using Larger Than Block-Size Key and Larger Than One Block-Size Data"),
+]
+# Spot checks copied from the RFCs.
+RFC_HMAC_CHECKS = [
+ ("SHA1", RFC2202_SHA1_CASES[0], "b617318655057264e28bc0b6fb378c8ef146be00"),
+ ("SHA256", RFC4231_CASES[0], "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"),
+ (
+ "SHA512",
+ RFC4231_CASES[0],
+ "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde"
+ "daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854",
+ ),
+ ("SHA256", RFC4231_CASES[5], "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"),
+]
+
+
+def hmac_vectors() -> list[tuple[int, str, str, str]]:
+ for alg, (key, data), expected in RFC_HMAC_CHECKS:
+ assert hmac.new(key, data, alg.lower()).hexdigest() == expected, alg
+ out = [(ALGS["SHA1"], h(k), h(d), hmac.new(k, d, "sha1").hexdigest()) for k, d in RFC2202_SHA1_CASES]
+ out += [
+ (ALGS[a], h(k), h(d), hmac.new(k, d, a.lower()).hexdigest())
+ for a in ("SHA256", "SHA512")
+ for k, d in RFC4231_CASES
+ ]
+ return out
+
+
+# --------------------------------------------------------------- PBKDF2, HKDF
+
+RFC7914_PBKDF2 = (
+ b"passwd",
+ b"salt",
+ 1,
+ 64,
+ "55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc"
+ "49ca9cccf179b645991664b39d77ef317c71b845b1e30bd509112041d3a19783",
+)
+
+
+def pbkdf2_vectors() -> list[tuple[str, str, int, int, str]]:
+ p, s, c, n, expected = RFC7914_PBKDF2
+ assert hashlib.pbkdf2_hmac("sha256", p, s, c, n).hex() == expected
+ cases = [
+ (p, s, c, n),
+ (b"password", b"salt", 2, 32),
+ (b"password", b"salt", 100, 32),
+ (b"123456", b"\x00" * 16, 1000, 32),
+ ]
+ cases.append((b"passwordPASSWORDpassword", b"saltSALTsaltSALTsaltSALTsaltSALTsalt", 300, 40))
+ return [(h(p), h(s), c, n, hashlib.pbkdf2_hmac("sha256", p, s, c, n).hex()) for p, s, c, n in cases]
+
+
+def hkdf(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
+ prk = hmac.new(salt or b"\x00" * 32, ikm, "sha256").digest()
+ okm, block = b"", b""
+ for counter in range(1, -(-length // 32) + 1):
+ block = hmac.new(prk, block + info + bytes([counter]), "sha256").digest()
+ okm += block
+ return okm[:length]
+
+
+RFC5869_CASES = [
+ (KEY_20 + b"\x0b" * 2, bytes(range(13)), bytes(range(0xF0, 0xFA)), 42),
+ (bytes(range(0x50)), bytes(range(0x60, 0xB0)), bytes(range(0xB0, 0x100)), 82),
+ (KEY_20 + b"\x0b" * 2, b"", b"", 42),
+]
+RFC5869_OKM = {
+ 0: "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865",
+ 2: "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8",
+}
+
+
+def hkdf_vectors() -> list[tuple[str, str, str, int, str]]:
+ for index, expected in RFC5869_OKM.items():
+ ikm, salt, info, n = RFC5869_CASES[index]
+ assert hkdf(ikm, salt, info, n).hex() == expected, index
+ return [(h(i), h(s), h(f), n, hkdf(i, s, f, n).hex()) for i, s, f, n in RFC5869_CASES]
+
+
+# ------------------------------------------------------------------------ AES
+
+NIST_IV = "000102030405060708090a0b0c0d0e0f"
+NIST_PT = (
+ "6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"
+ "30c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710"
+)
+NIST_CBC = [ # SP 800-38A F.2.1 (AES-128) and F.2.5 (AES-256)
+ (
+ "2b7e151628aed2a6abf7158809cf4f3c",
+ "7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b2"
+ "73bed6b8e3c1743b7116e69e222295163ff1caa1681fac09120eca307586e1a7",
+ ),
+ (
+ "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4",
+ "f58c4c04d6e5f1ba779eabfb5f7bfbd69cfc4e967edb808d679f777bc6702c7d"
+ "39f23369a9d9bacfa530e26304231461b2eb05e2c39be9fcda6c19078c6a9d1b",
+ ),
+]
+
+
+def openssl_cbc(key: str, iv: str, plaintext: str) -> str:
+ cipher = "aes-128-cbc" if len(key) == 32 else "aes-256-cbc"
+ result = subprocess.run(
+ ["openssl", "enc", f"-{cipher}", "-K", key, "-iv", iv, "-nopad"],
+ input=bytes.fromhex(plaintext),
+ capture_output=True,
+ check=True,
+ )
+ return result.stdout.hex()
+
+
+def aes_vectors() -> list[tuple[str, str, str, str]]:
+ for key, ciphertext in NIST_CBC:
+ assert openssl_cbc(key, NIST_IV, NIST_PT) == ciphertext, key
+ return [(key, NIST_IV, NIST_PT, ct) for key, ct in NIST_CBC]
+
+
+# --------------------------------------------------------------------- Base32
+
+RFC4648_BASE32 = [
+ (b"", ""),
+ (b"f", "MY======"),
+ (b"fo", "MZXQ===="),
+ (b"foo", "MZXW6==="),
+ (b"foob", "MZXW6YQ="),
+ (b"fooba", "MZXW6YTB"),
+ (b"foobar", "MZXW6YTBOI======"),
+]
+
+
+def base32_vectors(rng: random.Random) -> list[tuple[str, str]]:
+ cases = list(RFC4648_BASE32)
+ for data, encoded in cases:
+ assert base64.b32encode(data).decode() == encoded
+ for length in (10, 16, 20, 32, 64):
+ data = bytes(rng.randrange(256) for _ in range(length))
+ cases.append((data, base64.b32encode(data).decode()))
+ return [(h(d), e) for d, e in cases]
+
+
+# ------------------------------------------------------------------------ OTP
+
+RFC4226_SECRET = b"12345678901234567890"
+RFC4226_CODES = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"]
+RFC6238_SEEDS = {
+ "SHA1": b"12345678901234567890",
+ "SHA256": b"12345678901234567890123456789012",
+ "SHA512": b"1234567890" * 6 + b"1234",
+}
+RFC6238_TIMES = [59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000]
+RFC6238_CODES = {
+ "SHA1": ["94287082", "07081804", "14050471", "89005924", "69279037", "65353130"],
+ "SHA256": ["46119246", "68084774", "67062674", "91819424", "90698825", "77737706"],
+ "SHA512": ["90693936", "25091201", "99943326", "93441116", "38618901", "47863826"],
+}
+
+
+def truncated(alg: str, secret: bytes, counter: int) -> int:
+ mac = hmac.new(secret, struct.pack(">Q", counter), alg.lower()).digest()
+ offset = mac[-1] & 0x0F
+ return int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF
+
+
+def hotp(alg: str, secret: bytes, counter: int, digits: int) -> str:
+ return str(truncated(alg, secret, counter) % 10**digits).zfill(digits)
+
+
+def steam(secret: bytes, counter: int) -> str:
+ value = truncated("SHA1", secret, counter)
+ chars = []
+ for _ in range(5):
+ chars.append(STEAM_ALPHABET[value % len(STEAM_ALPHABET)])
+ value //= len(STEAM_ALPHABET)
+ return "".join(chars)
+
+
+def otp_vectors(rng: random.Random) -> tuple[list[tuple[int, str, int, int, int, str]], list[tuple[str, int, str]]]:
+ """(alg, secret hex, unix time, period, digits, code) and Steam (secret hex, unix time, code).
+
+ HOTP cases use period 0, meaning "time is the counter".
+ """
+ assert [hotp("SHA1", RFC4226_SECRET, c, 6) for c in range(10)] == RFC4226_CODES
+ for alg, codes in RFC6238_CODES.items():
+ assert [hotp(alg, RFC6238_SEEDS[alg], t // 30, 8) for t in RFC6238_TIMES] == codes, alg
+
+ cases = [(ALGS["SHA1"], h(RFC4226_SECRET), c, 0, 6, code) for c, code in enumerate(RFC4226_CODES)]
+ for alg, codes in RFC6238_CODES.items():
+ cases += [
+ (ALGS[alg], h(RFC6238_SEEDS[alg]), t, 30, 8, code) for t, code in zip(RFC6238_TIMES, codes, strict=True)
+ ]
+ boundary_times = [0, 29, 30, 31, 2**31 - 1, 2**31, 2**32 - 1, 4102444800] # incl. 2038 and the year 2100
+ for alg in list(ALGS):
+ for _ in range(4):
+ secret = bytes(rng.randrange(256) for _ in range(rng.choice((10, 16, 20, 32, 64))))
+ for digits in (6, 7, 8):
+ period = rng.choice((15, 30, 60, 90, 37))
+ for t in (rng.choice(boundary_times), rng.randrange(0, 2**32)):
+ cases.append((ALGS[alg], h(secret), t, period, digits, hotp(alg, secret, t // period, digits)))
+ steam_cases = []
+ for _ in range(12):
+ secret = bytes(rng.randrange(256) for _ in range(20))
+ t = rng.randrange(0, 2**32)
+ steam_cases.append((h(secret), t, steam(secret, t // 30)))
+ return cases, steam_cases
+
+
+# --------------------------------------------------------------------- output
+
+
+def mc_value(value: object) -> str:
+ if isinstance(value, str):
+ return f'"{value}"'
+ if isinstance(value, int):
+ return f"{value}l" if value > 2**31 - 1 or value < -(2**31) else str(value)
+ raise TypeError(value)
+
+
+def mc_table(name: str, comment: str, rows: Sequence[Sequence[object]]) -> str:
+ body = ",\n".join(" [" + ", ".join(mc_value(v) for v in row) + "]" for row in rows)
+ return f" // {comment}\n const {name} = [\n{body}\n ];\n"
+
+
+HEADER = """\
+// Author: cirakg & claude ai
+// Summary: Crypto and OTP test vectors (official RFC/FIPS/NIST values plus seeded cases); generated, do not edit.
+//
+// Generated by tools/gen_vectors.py. Every published vector was recomputed with
+// Python's standard library (or openssl for AES) before being written here.
+// Algorithm IDs: 0 = SHA-1, 1 = SHA-256, 2 = SHA-512. All data is fake.
+
+import Toybox.Lang;
+
+(:test)
+module TestVectors {
+
+"""
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Generate test/data/Vectors.mc")
+ parser.add_argument("--out", type=Path, default=Path("test/data/Vectors.mc"))
+ args = parser.parse_args()
+ rng = random.Random(SEED)
+ otp, steam_cases = otp_vectors(rng)
+ parts = [
+ mc_table("HASH", "[alg, message hex, digest hex]", list(hash_vectors(rng))),
+ mc_table("HMAC", "[alg, key hex, data hex, mac hex] (RFC 2202, RFC 4231)", list(hmac_vectors())),
+ mc_table(
+ "PBKDF2", "[password hex, salt hex, iterations, length, key hex] (RFC 7914 + seeded)", pbkdf2_vectors()
+ ),
+ mc_table("HKDF", "[ikm hex, salt hex, info hex, length, okm hex] (RFC 5869 A.1-A.3)", hkdf_vectors()),
+ mc_table(
+ "AES_CBC", "[key hex, iv hex, plaintext hex, ciphertext hex] (SP 800-38A F.2.1, F.2.5)", aes_vectors()
+ ),
+ mc_table("BASE32", "[bytes hex, encoded] (RFC 4648 section 10 + seeded)", base32_vectors(rng)),
+ mc_table("OTP", "[alg, secret hex, time or counter, period (0 = HOTP), digits, code]", list(otp)),
+ mc_table("STEAM", "[secret hex, unix time, code]", steam_cases),
+ ]
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+ args.out.write_text(HEADER + "\n".join(parts) + "}\n", encoding="utf-8")
+ print(f"wrote {args.out}: {len(otp)} OTP, {len(steam_cases)} Steam vectors")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/sim_screenshot.py b/tools/sim_screenshot.py
index 81b41b6..5517b78 100644
--- a/tools/sim_screenshot.py
+++ b/tools/sim_screenshot.py
@@ -16,7 +16,8 @@ Steps:
black out the corners of round displays, which show bezel, not screen.
3. Fail if the screen is blank (the app drew nothing).
4. With --golden, compare with the approved image: the check fails when more
- than 0.5 % of the pixels differ by more than 24 levels in any channel. A
+ than 0.01 % of the pixels differ by more than 24 levels in any channel (less
+ than one changed character; rendering is deterministic). A
diff image (differences in red) is written next to --out.
5. With --approve, save the screen as the new golden image instead.
@@ -40,7 +41,7 @@ MAX_OFFSET_X, MAX_OFFSET_Y = 16, 64 # search range for the device drawing
MIN_OVERLAP = 0.9 # the window may clip the drawing's edge, but not more than this
MAX_BEZEL_DIFF = 6.0 # mean absolute difference per channel at the best offset
PIXEL_TOLERANCE = 24 # per-channel difference that still counts as equal
-MAX_DIFF_FRACTION = 0.005 # share of pixels allowed to differ
+MAX_DIFF_FRACTION = 0.0001 # share of pixels allowed to differ: below one changed character
MIN_SCREEN_STDDEV = 2.0 # below this the screen counts as blank