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:
127 files changed
+996
-141
No files matched your search
@@ -215,3 +215,35 @@ def ed25519_verify(public, msg, signature):
|
||||
except (InvalidSignature, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- did:key (W3C did:key method, multicodec and base58btc) ---------------------------
|
||||
|
||||
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
MULTICODEC = {"ed25519": 0xED, "ml-dsa-65": 0x1207}
|
||||
|
||||
|
||||
def _varint(n):
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
out.append(b | (0x80 if n else 0))
|
||||
if not n:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def base58btc(data):
|
||||
n = int.from_bytes(data, "big")
|
||||
out = ""
|
||||
while n:
|
||||
n, r = divmod(n, 58)
|
||||
out = BASE58[r] + out
|
||||
return "1" * (len(data) - len(data.lstrip(b"\x00"))) + out
|
||||
|
||||
|
||||
def did_key(scheme, public):
|
||||
"""The did:key of a public key: `z` and the base58btc of the multicodec prefix and the key bytes."""
|
||||
if scheme not in MULTICODEC:
|
||||
raise ValueError("no multicodec code for " + scheme)
|
||||
return "did:key:z" + base58btc(_varint(MULTICODEC[scheme]) + public)
|
||||
@@ -144,3 +144,12 @@ def _exact(value):
|
||||
def dumpb(value):
|
||||
"""Canonical JSON as UTF-8 bytes."""
|
||||
return dumps(value).encode("utf-8")
|
||||
|
||||
|
||||
def loads_line(data):
|
||||
"""Parse one canonical JSON line of bytes, refusing a line that is not canonical."""
|
||||
import json as _json
|
||||
value = _json.loads(data.decode("utf-8"))
|
||||
if dumpb(value) != data:
|
||||
raise ValueError("not canonical JSON")
|
||||
return value
|
||||
@@ -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
|
||||
@@ -0,0 +1,446 @@
|
||||
"""ML-DSA-65, the module-lattice digital signature algorithm of FIPS 204, in plain Python.
|
||||
|
||||
Non-normative; see spec/08-fidelity.md §8.4.
|
||||
This is the reference form of the standard, slow and clear, for the signature scheme `ml-dsa-65`: key generation
|
||||
from a 32-byte seed, the deterministic signing variant (the hedging bytes are zero), and verification. Keys and
|
||||
signatures are the standard's byte encodings, so a production verifier can use any FIPS 204 library. The pure
|
||||
(non pre-hashed) variant with an empty context is used, as the standard's ML-DSA.Sign defines it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
Q = 8380417
|
||||
N = 256
|
||||
D = 13
|
||||
ZETA = 1753
|
||||
K, L = 6, 5
|
||||
ETA = 4
|
||||
TAU = 49
|
||||
LAMBDA = 192
|
||||
GAMMA1 = 1 << 19
|
||||
GAMMA2 = (Q - 1) // 32
|
||||
BETA = TAU * ETA
|
||||
OMEGA = 55
|
||||
PK_SIZE = 32 + 32 * K * (23 - D)
|
||||
SK_SIZE = 32 + 32 + 64 + 32 * ((L + K) * 4 + D * K)
|
||||
SIG_SIZE = LAMBDA // 4 + L * 32 * 20 + OMEGA + K
|
||||
F_INV256 = 8347681 # 256^-1 mod q
|
||||
|
||||
|
||||
def _bitrev8(m):
|
||||
return int("{:08b}".format(m)[::-1], 2)
|
||||
|
||||
|
||||
ZETAS = [pow(ZETA, _bitrev8(m), Q) for m in range(256)]
|
||||
|
||||
|
||||
def _shake256(data, n):
|
||||
return hashlib.shake_256(data).digest(n)
|
||||
|
||||
|
||||
def _shake128(data, n):
|
||||
return hashlib.shake_128(data).digest(n)
|
||||
|
||||
|
||||
# --- bit packing (FIPS 204 §7.1) ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bitlen(x):
|
||||
return x.bit_length()
|
||||
|
||||
|
||||
def simple_bit_pack(w, b):
|
||||
"""Coefficients in [0, b] as bitlen(b)-bit little-endian fields."""
|
||||
bits = _bitlen(b)
|
||||
acc = 0
|
||||
for i, c in enumerate(w):
|
||||
acc |= c << (bits * i)
|
||||
return acc.to_bytes(32 * bits, "little")
|
||||
|
||||
|
||||
def simple_bit_unpack(v, b):
|
||||
bits = _bitlen(b)
|
||||
acc = int.from_bytes(v, "little")
|
||||
mask = (1 << bits) - 1
|
||||
return [(acc >> (bits * i)) & mask for i in range(N)]
|
||||
|
||||
|
||||
def bit_pack(w, a, b):
|
||||
"""Coefficients in [-a, b] stored as b - w_i in bitlen(a + b) bits."""
|
||||
bits = _bitlen(a + b)
|
||||
acc = 0
|
||||
for i, c in enumerate(w):
|
||||
acc |= (b - c) << (bits * i)
|
||||
return acc.to_bytes(32 * bits, "little")
|
||||
|
||||
|
||||
def bit_unpack(v, a, b):
|
||||
bits = _bitlen(a + b)
|
||||
acc = int.from_bytes(v, "little")
|
||||
mask = (1 << bits) - 1
|
||||
return [b - ((acc >> (bits * i)) & mask) for i in range(N)]
|
||||
|
||||
|
||||
def hint_bit_pack(h):
|
||||
y = bytearray(OMEGA + K)
|
||||
index = 0
|
||||
for i in range(K):
|
||||
for j in range(N):
|
||||
if h[i][j]:
|
||||
y[index] = j
|
||||
index += 1
|
||||
y[OMEGA + i] = index
|
||||
return bytes(y)
|
||||
|
||||
|
||||
def hint_bit_unpack(y):
|
||||
h = [[0] * N for _ in range(K)]
|
||||
index = 0
|
||||
for i in range(K):
|
||||
if y[OMEGA + i] < index or y[OMEGA + i] > OMEGA:
|
||||
return None
|
||||
first = index
|
||||
while index < y[OMEGA + i]:
|
||||
if index > first and y[index - 1] >= y[index]:
|
||||
return None
|
||||
h[i][y[index]] = 1
|
||||
index += 1
|
||||
if any(y[i] != 0 for i in range(index, OMEGA)):
|
||||
return None
|
||||
return h
|
||||
|
||||
|
||||
# --- arithmetic ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mod_pm(r, m):
|
||||
"""r mod± m: the representative in (-m/2, m/2]."""
|
||||
r %= m
|
||||
return r - m if r > m // 2 else r
|
||||
|
||||
|
||||
def power2round(r):
|
||||
rp = r % Q
|
||||
r0 = _mod_pm(rp, 1 << D)
|
||||
return (rp - r0) >> D, r0
|
||||
|
||||
|
||||
def decompose(r):
|
||||
rp = r % Q
|
||||
r0 = _mod_pm(rp, 2 * GAMMA2)
|
||||
if rp - r0 == Q - 1:
|
||||
return 0, r0 - 1
|
||||
return (rp - r0) // (2 * GAMMA2), r0
|
||||
|
||||
|
||||
def high_bits(r):
|
||||
return decompose(r)[0]
|
||||
|
||||
|
||||
def low_bits(r):
|
||||
return decompose(r)[1]
|
||||
|
||||
|
||||
def make_hint(z, r):
|
||||
return 1 if high_bits(r) != high_bits(r + z) else 0
|
||||
|
||||
|
||||
def use_hint(h, r):
|
||||
m = (Q - 1) // (2 * GAMMA2)
|
||||
r1, r0 = decompose(r)
|
||||
if h and r0 > 0:
|
||||
return (r1 + 1) % m
|
||||
if h and r0 <= 0:
|
||||
return (r1 - 1) % m
|
||||
return r1
|
||||
|
||||
|
||||
def _inf_norm(w):
|
||||
return max(abs(_mod_pm(c, Q)) for c in w)
|
||||
|
||||
|
||||
def ntt(w):
|
||||
w = list(w)
|
||||
m, length = 0, 128
|
||||
while length >= 1:
|
||||
start = 0
|
||||
while start < N:
|
||||
m += 1
|
||||
z = ZETAS[m]
|
||||
for j in range(start, start + length):
|
||||
t = z * w[j + length] % Q
|
||||
w[j + length] = (w[j] - t) % Q
|
||||
w[j] = (w[j] + t) % Q
|
||||
start += 2 * length
|
||||
length //= 2
|
||||
return w
|
||||
|
||||
|
||||
def ntt_inv(w):
|
||||
w = list(w)
|
||||
m, length = 256, 1
|
||||
while length < N:
|
||||
start = 0
|
||||
while start < N:
|
||||
m -= 1
|
||||
z = (-ZETAS[m]) % Q
|
||||
for j in range(start, start + length):
|
||||
t = w[j]
|
||||
w[j] = (t + w[j + length]) % Q
|
||||
w[j + length] = z * (t - w[j + length]) % Q
|
||||
start += 2 * length
|
||||
length *= 2
|
||||
return [c * F_INV256 % Q for c in w]
|
||||
|
||||
|
||||
def _mul(a, b):
|
||||
return [x * y % Q for x, y in zip(a, b)]
|
||||
|
||||
|
||||
def _add(a, b):
|
||||
return [(x + y) % Q for x, y in zip(a, b)]
|
||||
|
||||
|
||||
def _sub(a, b):
|
||||
return [(x - y) % Q for x, y in zip(a, b)]
|
||||
|
||||
|
||||
# --- sampling (FIPS 204 §7.3) -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _coeff_from_three_bytes(b0, b1, b2):
|
||||
z = ((b2 & 0x7F) << 16) | (b1 << 8) | b0
|
||||
return z if z < Q else None
|
||||
|
||||
|
||||
def rej_ntt_poly(rho):
|
||||
out, j, n = [], 0, 3 * 300
|
||||
stream = _shake128(rho, n)
|
||||
pos = 0
|
||||
while j < N:
|
||||
if pos + 3 > len(stream):
|
||||
n *= 2
|
||||
stream = _shake128(rho, n)
|
||||
c = _coeff_from_three_bytes(stream[pos], stream[pos + 1], stream[pos + 2])
|
||||
pos += 3
|
||||
if c is not None:
|
||||
out.append(c)
|
||||
j += 1
|
||||
return out
|
||||
|
||||
|
||||
def _coeff_from_half_byte(b):
|
||||
return 4 - b if b < 9 else None # eta = 4
|
||||
|
||||
|
||||
def rej_bounded_poly(rho):
|
||||
out, j, n = [], 0, 200
|
||||
stream = _shake256(rho, n)
|
||||
pos = 0
|
||||
while j < N:
|
||||
if pos >= len(stream):
|
||||
n *= 2
|
||||
stream = _shake256(rho, n)
|
||||
z = stream[pos]
|
||||
pos += 1
|
||||
z0 = _coeff_from_half_byte(z & 15)
|
||||
z1 = _coeff_from_half_byte(z >> 4)
|
||||
if z0 is not None:
|
||||
out.append(z0)
|
||||
j += 1
|
||||
if z1 is not None and j < N:
|
||||
out.append(z1)
|
||||
j += 1
|
||||
return out
|
||||
|
||||
|
||||
def expand_a(rho):
|
||||
return [[rej_ntt_poly(rho + bytes([s, r])) for s in range(L)] for r in range(K)]
|
||||
|
||||
|
||||
def expand_s(rho_prime):
|
||||
s1 = [rej_bounded_poly(rho_prime + r.to_bytes(2, "little")) for r in range(L)]
|
||||
s2 = [rej_bounded_poly(rho_prime + (r + L).to_bytes(2, "little")) for r in range(K)]
|
||||
return s1, s2
|
||||
|
||||
|
||||
def expand_mask(rho, mu):
|
||||
c = 1 + _bitlen(GAMMA1 - 1)
|
||||
return [bit_unpack(_shake256(rho + (mu + r).to_bytes(2, "little"), 32 * c), GAMMA1 - 1, GAMMA1) for r in range(L)]
|
||||
|
||||
|
||||
def sample_in_ball(rho):
|
||||
c = [0] * N
|
||||
n = 8 + TAU * 4
|
||||
stream = _shake256(rho, n)
|
||||
signs = int.from_bytes(stream[:8], "little")
|
||||
pos = 8
|
||||
for i in range(N - TAU, N):
|
||||
while True:
|
||||
if pos >= len(stream):
|
||||
n *= 2
|
||||
stream = _shake256(rho, n)
|
||||
j = stream[pos]
|
||||
pos += 1
|
||||
if j <= i:
|
||||
break
|
||||
c[i] = c[j]
|
||||
c[j] = 1 if (signs >> (i + TAU - N)) & 1 == 0 else Q - 1
|
||||
return c
|
||||
|
||||
|
||||
# --- encodings (FIPS 204 §7.2) ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pk_encode(rho, t1):
|
||||
return rho + b"".join(simple_bit_pack(t, (1 << (23 - D)) - 1) for t in t1)
|
||||
|
||||
|
||||
def pk_decode(pk):
|
||||
rho, rest = pk[:32], pk[32:]
|
||||
step = 32 * (23 - D)
|
||||
return rho, [simple_bit_unpack(rest[i * step:(i + 1) * step], (1 << (23 - D)) - 1) for i in range(K)]
|
||||
|
||||
|
||||
def sk_encode(rho, k, tr, s1, s2, t0):
|
||||
out = rho + k + tr
|
||||
out += b"".join(bit_pack(s, ETA, ETA) for s in s1)
|
||||
out += b"".join(bit_pack(s, ETA, ETA) for s in s2)
|
||||
out += b"".join(bit_pack(t, (1 << (D - 1)) - 1, 1 << (D - 1)) for t in t0)
|
||||
return out
|
||||
|
||||
|
||||
def sk_decode(sk):
|
||||
rho, k, tr = sk[:32], sk[32:64], sk[64:128]
|
||||
pos = 128
|
||||
s1, s2, t0 = [], [], []
|
||||
for _ in range(L):
|
||||
s1.append(bit_unpack(sk[pos:pos + 128], ETA, ETA))
|
||||
pos += 128
|
||||
for _ in range(K):
|
||||
s2.append(bit_unpack(sk[pos:pos + 128], ETA, ETA))
|
||||
pos += 128
|
||||
for _ in range(K):
|
||||
t0.append(bit_unpack(sk[pos:pos + 32 * D], (1 << (D - 1)) - 1, 1 << (D - 1)))
|
||||
pos += 32 * D
|
||||
return rho, k, tr, s1, s2, t0
|
||||
|
||||
|
||||
def sig_encode(c_tilde, z, h):
|
||||
return c_tilde + b"".join(bit_pack(zi, GAMMA1 - 1, GAMMA1) for zi in z) + hint_bit_pack(h)
|
||||
|
||||
|
||||
def sig_decode(sigma):
|
||||
c_tilde = sigma[:LAMBDA // 4]
|
||||
pos = LAMBDA // 4
|
||||
z = []
|
||||
for _ in range(L):
|
||||
z.append(bit_unpack(sigma[pos:pos + 32 * 20], GAMMA1 - 1, GAMMA1))
|
||||
pos += 32 * 20
|
||||
h = hint_bit_unpack(sigma[pos:])
|
||||
return c_tilde, z, h
|
||||
|
||||
|
||||
def w1_encode(w1):
|
||||
return b"".join(simple_bit_pack(w, (Q - 1) // (2 * GAMMA2) - 1) for w in w1)
|
||||
|
||||
|
||||
# --- the scheme (FIPS 204 §6) -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _matvec(a_hat, v_hat):
|
||||
out = []
|
||||
for i in range(K):
|
||||
acc = [0] * N
|
||||
for j in range(L):
|
||||
acc = _add(acc, _mul(a_hat[i][j], v_hat[j]))
|
||||
out.append(acc)
|
||||
return out
|
||||
|
||||
|
||||
def keygen(seed):
|
||||
"""ML-DSA.KeyGen_internal: (public key, secret key) bytes for a 32-byte seed."""
|
||||
if len(seed) != 32:
|
||||
raise ValueError("the seed is 32 bytes")
|
||||
h = _shake256(seed + bytes([K, L]), 128)
|
||||
rho, rho_prime, kk = h[:32], h[32:96], h[96:]
|
||||
a_hat = expand_a(rho)
|
||||
s1, s2 = expand_s(rho_prime)
|
||||
t = [_add(ntt_inv(row), s2i) for row, s2i in zip(_matvec(a_hat, [ntt(s) for s in s1]), s2)]
|
||||
t1, t0 = [], []
|
||||
for poly in t:
|
||||
hi, lo = zip(*(power2round(c) for c in poly))
|
||||
t1.append(list(hi))
|
||||
t0.append(list(lo))
|
||||
pk = pk_encode(rho, t1)
|
||||
tr = _shake256(pk, 64)
|
||||
return pk, sk_encode(rho, kk, tr, s1, s2, t0)
|
||||
|
||||
|
||||
def public_key(seed):
|
||||
return keygen(seed)[0]
|
||||
|
||||
|
||||
def _message_representative(message, ctx=b""):
|
||||
if len(ctx) > 255:
|
||||
raise ValueError("the context is at most 255 bytes")
|
||||
return bytes([0, len(ctx)]) + ctx + message
|
||||
|
||||
|
||||
def sign(sk, message, ctx=b"", rnd=bytes(32)):
|
||||
"""ML-DSA.Sign with the deterministic variant (rnd = 0): the 3309-byte signature."""
|
||||
rho, kk, tr, s1, s2, t0 = sk_decode(sk)
|
||||
s1_hat = [ntt(s) for s in s1]
|
||||
s2_hat = [ntt(s) for s in s2]
|
||||
t0_hat = [ntt(t) for t in t0]
|
||||
a_hat = expand_a(rho)
|
||||
mu = _shake256(tr + _message_representative(message, ctx), 64)
|
||||
rho_pp = _shake256(kk + rnd + mu, 64)
|
||||
kappa = 0
|
||||
while True:
|
||||
y = expand_mask(rho_pp, kappa)
|
||||
w = [ntt_inv(row) for row in _matvec(a_hat, [ntt(yi) for yi in y])]
|
||||
w1 = [[high_bits(c) for c in wi] for wi in w]
|
||||
c_tilde = _shake256(mu + w1_encode(w1), LAMBDA // 4)
|
||||
c = sample_in_ball(c_tilde)
|
||||
c_hat = ntt(c)
|
||||
cs1 = [ntt_inv(_mul(c_hat, s)) for s in s1_hat]
|
||||
cs2 = [ntt_inv(_mul(c_hat, s)) for s in s2_hat]
|
||||
z = [_add(yi, ci) for yi, ci in zip(y, cs1)]
|
||||
r0 = [[low_bits(c) for c in _sub(wi, ci)] for wi, ci in zip(w, cs2)]
|
||||
kappa += L
|
||||
if max(_inf_norm(zi) for zi in z) >= GAMMA1 - BETA or max(max(abs(c) for c in r) for r in r0) >= GAMMA2 - BETA:
|
||||
continue
|
||||
ct0 = [ntt_inv(_mul(c_hat, t)) for t in t0_hat]
|
||||
h = []
|
||||
for i in range(K):
|
||||
base = _add(_sub(w[i], cs2[i]), ct0[i])
|
||||
h.append([make_hint((-ct0[i][j]) % Q, base[j]) for j in range(N)])
|
||||
if max(_inf_norm(t) for t in ct0) >= GAMMA2 or sum(sum(hi) for hi in h) > OMEGA:
|
||||
continue
|
||||
z_signed = [[_mod_pm(c, Q) for c in zi] for zi in z]
|
||||
return sig_encode(c_tilde, z_signed, h)
|
||||
|
||||
|
||||
def verify(pk, message, signature, ctx=b""):
|
||||
"""ML-DSA.Verify: True for a valid signature of `message` under `pk`. Never raises."""
|
||||
try:
|
||||
if len(pk) != PK_SIZE or len(signature) != SIG_SIZE or len(ctx) > 255:
|
||||
return False
|
||||
rho, t1 = pk_decode(pk)
|
||||
c_tilde, z, h = sig_decode(signature)
|
||||
if h is None:
|
||||
return False
|
||||
a_hat = expand_a(rho)
|
||||
tr = _shake256(pk, 64)
|
||||
mu = _shake256(tr + _message_representative(message, ctx), 64)
|
||||
c_hat = ntt(sample_in_ball(c_tilde))
|
||||
az = _matvec(a_hat, [ntt(zi) for zi in z])
|
||||
ct1 = [_mul(c_hat, ntt([c << D for c in t])) for t in t1]
|
||||
w_approx = [ntt_inv(_sub(a, b)) for a, b in zip(az, ct1)]
|
||||
w1 = [[use_hint(h[i][j], w_approx[i][j]) for j in range(N)] for i in range(K)]
|
||||
if max(_inf_norm(zi) for zi in z) >= GAMMA1 - BETA:
|
||||
return False
|
||||
return _shake256(mu + w1_encode(w1), LAMBDA // 4) == c_tilde
|
||||
except (ValueError, TypeError, IndexError):
|
||||
return False
|
||||
@@ -280,6 +280,8 @@ def _validate(root, store, key, rep, limit=SIZE_LIMIT):
|
||||
# Policy and hashing.
|
||||
withheld = [n["id"] for n in nodes if n.get("withheld")]
|
||||
scheme = document["hashing"]["scheme"]
|
||||
if document["hashing"].get("algorithm") != "sha-256":
|
||||
rep.add("doc.hash.algorithm", "document.json", "the hash algorithm is not one this validator implements")
|
||||
if document["policy"] == "full" and withheld:
|
||||
rep.add("doc.policy.withheld", "nodes.jsonl", "a full package withholds no text")
|
||||
if document["policy"] == "checks-only":
|
||||
@@ -402,6 +404,11 @@ def _validate(root, store, key, rep, limit=SIZE_LIMIT):
|
||||
|
||||
# Receipts and fidelity.
|
||||
roster = {k["id"]: k for k in data.get("roster.json", {}).get("keys", [])}
|
||||
for k in roster.values():
|
||||
if k.get("did") and k["scheme"] in crypto.MULTICODEC:
|
||||
pub = R._b64(k.get("public", ""))
|
||||
if pub is None or crypto.did_key(k["scheme"], pub) != k["did"]:
|
||||
rep.add("doc.roster.did", k["id"], "the did:key is not the one the public key determines")
|
||||
recs = data["receipts.jsonl"]
|
||||
rorder = sorted(recs, key=lambda r: (r["at"], r["id"]))
|
||||
if rorder != recs:
|
||||
|
||||
@@ -10,7 +10,7 @@ import math
|
||||
from fractions import Fraction
|
||||
from math import comb
|
||||
|
||||
from . import crypto, jcs, vrf
|
||||
from . import crypto, jcs, mldsa, vrf
|
||||
from .model import LEVELS
|
||||
from .text import record_id, sha256_hex
|
||||
|
||||
@@ -48,20 +48,38 @@ def _b64(s):
|
||||
|
||||
|
||||
def verify_signature(rec, roster):
|
||||
"""Whether the record's Ed25519 signature verifies under the roster key it names. Never raises."""
|
||||
"""Whether the record's signature verifies under the roster key it names, for the schemes the tools implement
|
||||
(`ed25519` and `ml-dsa-65`); any other scheme does not verify. Never raises."""
|
||||
sig = rec.get("signature") or {}
|
||||
if not isinstance(sig, dict):
|
||||
return False
|
||||
key = roster.get(sig.get("key"))
|
||||
if not key or sig.get("scheme") != "ed25519" or key.get("scheme") != "ed25519":
|
||||
if not key or sig.get("scheme") != key.get("scheme"):
|
||||
return False
|
||||
pub, value = _b64(key.get("public", "")), _b64(sig.get("value", ""))
|
||||
if pub is None or value is None or len(pub) != 32 or len(value) != 64:
|
||||
if pub is None or value is None:
|
||||
return False
|
||||
try:
|
||||
return crypto.ed25519_verify(pub, signing_payload(rec), value)
|
||||
if sig["scheme"] == "ed25519":
|
||||
return len(pub) == 32 and len(value) == 64 and crypto.ed25519_verify(pub, signing_payload(rec), value)
|
||||
if sig["scheme"] == "ml-dsa-65":
|
||||
return mldsa.verify(pub, signing_payload(rec), value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def seal_mldsa(rec, sk, key_id):
|
||||
"""Fill in a record's root (for receipts), ID and ML-DSA-65 signature, deterministically."""
|
||||
rec = dict(rec)
|
||||
if rec["format"] == "scienting-receipt/1":
|
||||
rec["root"] = crypto.merkle_root(crypto.receipt_items(rec))
|
||||
rec.pop("id", None)
|
||||
rec.pop("signature", None)
|
||||
sig = mldsa.sign(sk, signing_payload(rec))
|
||||
rec["id"] = record_id(record_hash(rec))
|
||||
rec["signature"] = {"scheme": "ml-dsa-65", "key": key_id, "value": base64.b64encode(sig).decode("ascii")}
|
||||
return rec
|
||||
|
||||
|
||||
def key_valid_at(key, at):
|
||||
|
||||
Reference in new issue
Block a user