pyrtan / sourcev0.19.0 · main

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
3Python's src/pyrtan/identity.py says it; the console's
4src/pyrtan/js/identity.js must say the same: SHA3-256 digests,
5identifiers from keys, identifiers to refuse, canonical JSON, request
6messages, and request headers signed with fixed keys at fixed times
7(Ed25519 is deterministic, so the signatures are too).
8tests/conformance/identity.cjs checks it; tests/test_conformance.py keeps
9this file from drifting.
10
11 uv run python tests/conformance/make_identity_vectors.py
12"""
13
14from __future__ import annotations
15
16import hashlib
17import json
18import sys
19from pathlib import Path
20
21ROOT = Path(__file__).resolve().parents[2]
22sys.path.insert(0, str(ROOT / "src"))
23
24from pyrtan import identity # noqa: E402
25from pyrtan.store import canonical # noqa: E402
26
27DATE = 1_793_000_000
28# RFC 8032 §7.1 tests 1 and 2, and a third seed of our own.
29SEEDS = [
30 "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
31 "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
32 "00" * 31 + "07",
33]
34VALUES = [
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
40def 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
88def 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
95if __name__ == "__main__":
96 sys.exit(main())