feat: ML-DSA-65, did:key, named hash algorithm, model provenance, log tiles and witnesses

- hashing.algorithm names the hash function in every document; a consumer refuses one it does not implement (doc.hash.algorithm)
- ML-DSA-65 (FIPS 204) is an implemented signature scheme: pure-Python reference in tools/oracle/mldsa.py checked against NIST ACVP vectors, a receipt signed by the new roster key dana, and signature vectors for key generation and a deterministic signature
- roster keys may carry a did:key, which the validator checks against the public key (doc.roster.did), and a witness purpose for parties that cosign log checkpoints
- model provenance: a correction's author and an annotation's generator name the model with provider, version and the hashes of its weights, prompt and parameters, and where it ran
- the transparency log gains the tlog-tiles static layout (fixtures/records/log/), a project settings.log with origin, witnesses and threshold, and an acceptance rule; the fixture checkpoint is cosigned by a witness
- store.sql: roster_keys accepts cose and ml-dsa-65 and gains a did column
- spec chapters 01, 08, 11, 18, 20 and 23, the rationale, glossary, fixture, vector and tool guides updated; stress harness covers ML-DSA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
alexmandClaude committed 2026-10-15 04:39:12 -04:00
1 parent 977579285b
commit 3831b52a2b
127 files changed
+996 -141

No files matched your search

+98
View File
@@ -8,6 +8,7 @@ an origin line, the tree size, the root hash in Base64, an empty line, and one s
import base64
import hashlib
import os
from . import crypto, jcs
@@ -104,3 +105,100 @@ def verify_checkpoint(text, roster):
if key_hash(name, public) == kh and crypto.ed25519_verify(public, body, sig):
verified.append(name)
return note, verified
# --- acceptance (spec/23 §23.3) ---------------------------------------------------------------------------
def accept_checkpoint(text, roster, settings):
"""Whether a checkpoint note is one the project accepts: its origin is the project's log, a team key with the
`sign` purpose and the role `owner` signed it, and at least `threshold` of the project's witnesses cosigned it.
Returns (accepted, reasons); `settings` is the project's `log` setting. Never raises.
"""
note, verified = verify_checkpoint(text, roster)
reasons = []
if note is None:
return False, ["the note is malformed"]
if note["origin"] != settings.get("origin"):
reasons.append("the origin is not the project's log")
team = [n for n in verified if roster[n].get("role") == "owner" and "sign" in roster[n].get("purposes", [])]
if not team:
reasons.append("no owner key with the sign purpose signed it")
witnesses = [n for n in verified if n in settings.get("witnesses", []) and "witness" in roster[n].get("purposes", [])]
if len(witnesses) < settings.get("threshold", 0):
reasons.append("%d of %d witnesses cosigned" % (len(witnesses), settings.get("threshold", 0)))
return not reasons, reasons
# --- tiles (spec/23 §23.4) --------------------------------------------------------------------------------
TILE_HEIGHT = 8
TILE_WIDTH = 1 << TILE_HEIGHT
def tile_path(level, index, width=None):
"""The path of a tile: `tile/<level>/<N>`, with N as 3-digit groups, all but the last prefixed `x`, and
`.p/<W>` for a partial tile of W entries; `level` is a number or `entries`."""
parts, n = ["%03d" % (index % 1000)], index // 1000
while n:
parts.insert(0, "x%03d" % (n % 1000))
n //= 1000
path = "tile/%s/%s" % (level, "/".join(parts))
return path + (".p/%d" % width if width is not None else "")
def tiles(entries):
"""Every tile of a log over `entries`, as {path: bytes}: hash tiles per level (32-byte hashes of the complete
subtrees of 256^level entries, 256 to a tile) and entries tiles (canonical JSON Lines, 256 to a tile)."""
out = {}
leaves = [crypto._leaf(jcs.dumpb(list(e))) for e in entries]
level = 0
while True:
span = TILE_WIDTH ** level
nodes = [crypto._mth(leaves[i * span:(i + 1) * span]) for i in range(len(leaves) // span)]
if not nodes:
break
for t in range(len(nodes) // TILE_WIDTH):
out[tile_path(level, t)] = b"".join(nodes[t * TILE_WIDTH:(t + 1) * TILE_WIDTH])
rest = len(nodes) % TILE_WIDTH
if rest:
out[tile_path(level, len(nodes) // TILE_WIDTH, rest)] = b"".join(nodes[-rest:])
level += 1
for t in range(len(entries) // TILE_WIDTH):
out[tile_path("entries", t)] = b"".join(jcs.dumps(list(e)).encode("utf-8") + b"\n"
for e in entries[t * TILE_WIDTH:(t + 1) * TILE_WIDTH])
rest = len(entries) % TILE_WIDTH
if rest:
out[tile_path("entries", len(entries) // TILE_WIDTH, rest)] = b"".join(
jcs.dumps(list(e)).encode("utf-8") + b"\n" for e in entries[-rest:])
return out
def write_log(entries, origin, signers, out_dir):
"""Write a log as static files: `checkpoint` and every tile. Returns the checkpoint note."""
note = sign_checkpoint(origin, entries, signers)
for path, data in tiles(entries).items():
full = os.path.join(out_dir, *path.split("/"))
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "wb") as f:
f.write(data)
with open(os.path.join(out_dir, "checkpoint"), "w", encoding="utf-8") as f:
f.write(note)
return note
def read_log(log_dir):
"""The entries and the checkpoint note of a log written by `write_log`, read from its entries tiles."""
with open(os.path.join(log_dir, "checkpoint"), encoding="utf-8") as f:
note = f.read()
parsed = parse_checkpoint(note)
if parsed is None:
raise ValueError("the checkpoint is malformed")
size = parsed["size"]
entries = []
for t in range(size // TILE_WIDTH + (1 if size % TILE_WIDTH else 0)):
width = size % TILE_WIDTH if (t + 1) * TILE_WIDTH > size else None
with open(os.path.join(log_dir, *tile_path("entries", t, width).split("/")), "rb") as f:
entries += [json_line for json_line in (jcs.loads_line(ln) for ln in f.read().split(b"\n") if ln)]
return entries, note