summaryrefslogtreecommitdiff
path: root/tools/gen_vectors.py
blob: fb7053aaa65426765ddaa809e0d4b6996a6909b7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
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()