pyrtan / tests / conformance / make_identity_vectors.py
5,336 bytes · last changed 2026-10-28 in identity.js: the documents a root signs, and encrypted backups, agreeing with Python
| 1 | """Write tests/conformance/identity.json: what both identity codes must say. |
| 2 | |
| 3 | Python's src/pyrtan/identity.py says it; the console's |
| 4 | src/pyrtan/js/identity.js must say the same: SHA3-256 digests, |
| 5 | identifiers from keys, identifiers to refuse, canonical JSON, request |
| 6 | messages, and request headers signed with fixed keys at fixed times |
| 7 | (Ed25519 is deterministic, so the signatures are too). |
| 8 | tests/conformance/identity.cjs checks it; tests/test_conformance.py keeps |
| 9 | this file from drifting. |
| 10 | |
| 11 | uv run python tests/conformance/make_identity_vectors.py |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import hashlib |
| 17 | import json |
| 18 | import sys |
| 19 | from pathlib import Path |
| 20 | |
| 21 | ROOT = Path(__file__).resolve().parents[2] |
| 22 | sys.path.insert(0, str(ROOT / "src")) |
| 23 | |
| 24 | from pyrtan import identity # noqa: E402 |
| 25 | from pyrtan.store import canonical # noqa: E402 |
| 26 | |
| 27 | DATE = 1_793_000_000 |
| 28 | # RFC 8032 §7.1 tests 1 and 2, and a third seed of our own. |
| 29 | SEEDS = [ |
| 30 | "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", |
| 31 | "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", |
| 32 | "00" * 31 + "07", |
| 33 | ] |
| 34 | VALUES = [ |
| 35 | None, True, 0, -12, 12345678901, "", "plain", "é ü 日本 🙂", "quote \" back \\ slash\nline", |
| 36 | [], {}, [1, "two", [3]], {"b": 1, "a": {"d": [], "c": None}}, {"text": "echo hi", "trace": True}, |
| 37 | ] |
| 38 | |
| 39 | |
| 40 | def build() -> dict: |
| 41 | keys = [identity.Key(bytes.fromhex(s)) for s in SEEDS] |
| 42 | public = [identity.decode(k.id).hex() for k in keys] |
| 43 | root, phone = keys[0], keys[1] |
| 44 | note = identity.make_device(root, phone.id, ["ask", "chat"], label="browser", issued=DATE - 100, expires=DATE + 86400) |
| 45 | good = keys[2].id |
| 46 | requests = [] |
| 47 | for key, note_for, method, path, body in [ |
| 48 | (root, None, "POST", "/intent", {"text": "echo hi"}), |
| 49 | (root, None, "GET", "/capabilities", None), |
| 50 | (phone, note, "POST", "/chat/start", {"want": "bot", "interests": ["disk"]}), |
| 51 | (phone, note, "POST", "/inbox", {"type": "Follow", "actor": phone.id, "secret": "ab" * 32, "inbox": "mailbox", "name": "café"}), |
| 52 | ]: |
| 53 | audience = "https://hub.example/" |
| 54 | requests.append({ |
| 55 | "seed": SEEDS[keys.index(key)], "public": public[keys.index(key)], "audience": audience, "method": method, "path": path, "date": DATE, |
| 56 | "body": body, "note": note_for, "message": identity.request_message(audience, method, path, str(DATE), body).decode("utf-8"), |
| 57 | "headers": identity.sign_request(key, audience, method, path, body, note=note_for, date=DATE), |
| 58 | }) |
| 59 | # What a root signs, at fixed times: identical bytes from both, Ed25519 being deterministic. |
| 60 | other = keys[2] |
| 61 | doc = identity.make_identity(root, routes=["hub.example"], devices=[note], revoked=[other.id], updated=DATE) |
| 62 | documents = [ |
| 63 | {"kind": "identity", "seed": SEEDS[0], "public": public[0], "make": {"routes": ["hub.example"], "devices": [note], "revoked": [other.id], "updated": DATE}, "expect": doc}, |
| 64 | {"kind": "device", "seed": SEEDS[0], "public": public[0], "make": {"device": phone.id, "may": ["ask", "chat"], "label": "browser", "issued": DATE - 100, "expires": DATE + 86400}, "expect": note}, |
| 65 | {"kind": "name", "seed": SEEDS[0], "public": public[0], "make": {"name": "Alice@Example.org", "issued": DATE, "extra": {"github": 42}}, "expect": identity.make_name(root, "Alice@Example.org", issued=DATE, github=42)}, |
| 66 | {"kind": "handover", "seed": SEEDS[1], "public": public[1], "from_seed": SEEDS[0], "from_public": public[0], "make": {"name": "alice@example.org", "issued": DATE}, |
| 67 | "expect": identity.make_name(phone, "alice@example.org", issued=DATE, handover_from=root)}, |
| 68 | {"kind": "release", "seed": SEEDS[0], "public": public[0], "make": {"name": "alice@example.org", "issued": DATE}, "expect": identity.make_release(root, "alice@example.org", issued=DATE)}, |
| 69 | ] |
| 70 | # A backup at the fewest rounds a backup may claim, so the test is quick; the format is the same at 600,000. |
| 71 | salt, iv = bytes(range(16)), bytes(range(100, 112)) |
| 72 | backups = [{"seed": SEEDS[0], "public": public[0], "passphrase": "correct horse battery staple é", "salt": salt.hex(), "iv": iv.hex(), "iterations": identity.MIN_ITERATIONS, "made": DATE, |
| 73 | "expect": identity.make_backup(root, "correct horse battery staple é", document=doc, salt=salt, iv=iv, iterations=identity.MIN_ITERATIONS, made=DATE)}] |
| 74 | samples = [b"", b"abc", bytes(range(256)), b"x" * 135, b"x" * 136, b"x" * 137] |
| 75 | return { |
| 76 | "sha3_256": [{"in": s.hex(), "out": hashlib.sha3_256(s).hexdigest()} for s in samples], |
| 77 | "ids": [{"key": p, "id": k.id} for p, k in zip(public, keys)], |
| 78 | "refuse": [good[:-1], good + "a", "pyx:" + good[4:], good[:10] + "1" + good[11:], good[:20] + ("b" if good[20] != "b" else "c") + good[21:], good[:-2] + "aa"], |
| 79 | "accept": [good, good.upper()], |
| 80 | "canonical": [{"value": v, "canonical": canonical(v).decode("utf-8")} for v in VALUES], |
| 81 | "requests": requests, |
| 82 | "note": note, |
| 83 | "documents": documents, |
| 84 | "backups": backups, |
| 85 | } |
| 86 | |
| 87 | |
| 88 | def main() -> int: |
| 89 | out = ROOT / "tests" / "conformance" / "identity.json" |
| 90 | out.write_text(json.dumps(build(), indent=1, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8") |
| 91 | print(f"wrote {out}") |
| 92 | return 0 |
| 93 | |
| 94 | |
| 95 | if __name__ == "__main__": |
| 96 | sys.exit(main()) |