leviathan-crypto 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (296) hide show
  1. package/CLAUDE.md +86 -443
  2. package/README.md +198 -65
  3. package/dist/aes/aes-cbc.d.ts +40 -0
  4. package/dist/aes/aes-cbc.js +158 -0
  5. package/dist/aes/aes-ctr.d.ts +50 -0
  6. package/dist/aes/aes-ctr.js +141 -0
  7. package/dist/aes/aes-gcm-siv.d.ts +67 -0
  8. package/dist/aes/aes-gcm-siv.js +217 -0
  9. package/dist/aes/aes-gcm.d.ts +61 -0
  10. package/dist/aes/aes-gcm.js +226 -0
  11. package/dist/aes/cipher-suite.d.ts +21 -0
  12. package/dist/aes/cipher-suite.js +179 -0
  13. package/dist/aes/embedded.d.ts +1 -0
  14. package/dist/aes/embedded.js +26 -0
  15. package/dist/aes/generator.d.ts +14 -0
  16. package/dist/aes/generator.js +103 -0
  17. package/dist/aes/index.d.ts +58 -0
  18. package/dist/aes/index.js +125 -0
  19. package/dist/aes/ops.d.ts +60 -0
  20. package/dist/aes/ops.js +164 -0
  21. package/dist/aes/pool-worker.d.ts +1 -0
  22. package/dist/aes/pool-worker.js +92 -0
  23. package/dist/aes/types.d.ts +1 -0
  24. package/dist/aes/types.js +23 -0
  25. package/dist/aes.wasm +0 -0
  26. package/dist/blake3/embedded.d.ts +1 -0
  27. package/dist/blake3/embedded.js +26 -0
  28. package/dist/blake3/index.d.ts +143 -0
  29. package/dist/blake3/index.js +620 -0
  30. package/dist/blake3/types.d.ts +102 -0
  31. package/dist/blake3/types.js +31 -0
  32. package/dist/blake3/validate.d.ts +29 -0
  33. package/dist/blake3/validate.js +80 -0
  34. package/dist/blake3.wasm +0 -0
  35. package/dist/chacha20/cipher-suite.js +47 -25
  36. package/dist/chacha20/generator.d.ts +2 -2
  37. package/dist/chacha20/generator.js +4 -4
  38. package/dist/chacha20/index.d.ts +16 -15
  39. package/dist/chacha20/index.js +52 -46
  40. package/dist/chacha20/ops.d.ts +7 -7
  41. package/dist/chacha20/ops.js +34 -34
  42. package/dist/chacha20/pool-worker.js +5 -3
  43. package/dist/cte-wasm.d.ts +1 -0
  44. package/dist/cte-wasm.js +3 -0
  45. package/dist/curve25519.wasm +0 -0
  46. package/dist/ecdsa/der.d.ts +23 -0
  47. package/dist/ecdsa/der.js +192 -0
  48. package/dist/ecdsa/ecprivatekey-der.d.ts +32 -0
  49. package/dist/ecdsa/ecprivatekey-der.js +230 -0
  50. package/dist/ecdsa/embedded.d.ts +1 -0
  51. package/dist/ecdsa/embedded.js +25 -0
  52. package/dist/ecdsa/index.d.ts +124 -0
  53. package/dist/ecdsa/index.js +366 -0
  54. package/dist/ecdsa/types.d.ts +31 -0
  55. package/dist/ecdsa/types.js +28 -0
  56. package/dist/ecdsa/validate.d.ts +18 -0
  57. package/dist/ecdsa/validate.js +92 -0
  58. package/dist/ed25519/embedded.d.ts +1 -0
  59. package/dist/ed25519/embedded.js +31 -0
  60. package/dist/ed25519/index.d.ts +70 -0
  61. package/dist/ed25519/index.js +308 -0
  62. package/dist/ed25519/types.d.ts +27 -0
  63. package/dist/ed25519/types.js +27 -0
  64. package/dist/ed25519/validate.d.ts +7 -0
  65. package/dist/ed25519/validate.js +77 -0
  66. package/dist/embedded/aes-pool-worker.d.ts +1 -0
  67. package/dist/embedded/aes-pool-worker.js +5 -0
  68. package/dist/embedded/aes.d.ts +1 -0
  69. package/dist/embedded/aes.js +3 -0
  70. package/dist/embedded/blake3.d.ts +1 -0
  71. package/dist/embedded/blake3.js +3 -0
  72. package/dist/embedded/chacha20-pool-worker.d.ts +1 -1
  73. package/dist/embedded/chacha20-pool-worker.js +2 -2
  74. package/dist/embedded/chacha20.d.ts +1 -1
  75. package/dist/embedded/chacha20.js +2 -2
  76. package/dist/embedded/curve25519.d.ts +1 -0
  77. package/dist/embedded/curve25519.js +3 -0
  78. package/dist/embedded/mldsa.d.ts +1 -0
  79. package/dist/embedded/mldsa.js +3 -0
  80. package/dist/embedded/mlkem.d.ts +1 -0
  81. package/dist/embedded/mlkem.js +3 -0
  82. package/dist/embedded/p256.d.ts +1 -0
  83. package/dist/embedded/p256.js +3 -0
  84. package/dist/embedded/serpent-pool-worker.d.ts +1 -1
  85. package/dist/embedded/serpent-pool-worker.js +2 -2
  86. package/dist/embedded/serpent.d.ts +1 -1
  87. package/dist/embedded/serpent.js +2 -2
  88. package/dist/embedded/sha2.d.ts +1 -1
  89. package/dist/embedded/sha2.js +2 -2
  90. package/dist/embedded/sha3.d.ts +1 -1
  91. package/dist/embedded/sha3.js +2 -2
  92. package/dist/embedded/slhdsa.d.ts +1 -0
  93. package/dist/embedded/slhdsa.js +3 -0
  94. package/dist/errors.d.ts +92 -1
  95. package/dist/errors.js +111 -1
  96. package/dist/fortuna.d.ts +5 -5
  97. package/dist/fortuna.js +37 -64
  98. package/dist/index.d.ts +38 -9
  99. package/dist/index.js +63 -19
  100. package/dist/init.d.ts +1 -1
  101. package/dist/init.js +11 -25
  102. package/dist/keccak/embedded.js +1 -1
  103. package/dist/keccak/index.d.ts +2 -0
  104. package/dist/keccak/index.js +4 -2
  105. package/dist/loader.d.ts +1 -24
  106. package/dist/loader.js +13 -16
  107. package/dist/merkle/blake3-tree.d.ts +35 -0
  108. package/dist/merkle/blake3-tree.js +187 -0
  109. package/dist/merkle/checkpoint.d.ts +58 -0
  110. package/dist/merkle/checkpoint.js +217 -0
  111. package/dist/merkle/index.d.ts +19 -0
  112. package/dist/merkle/index.js +37 -0
  113. package/dist/merkle/merkle-log.d.ts +130 -0
  114. package/dist/merkle/merkle-log.js +207 -0
  115. package/dist/merkle/merkle-verifier.d.ts +126 -0
  116. package/dist/merkle/merkle-verifier.js +296 -0
  117. package/dist/merkle/proof.d.ts +70 -0
  118. package/dist/merkle/proof.js +300 -0
  119. package/dist/merkle/sha256-tree.d.ts +33 -0
  120. package/dist/merkle/sha256-tree.js +145 -0
  121. package/dist/merkle/signed-log.d.ts +156 -0
  122. package/dist/merkle/signed-log.js +356 -0
  123. package/dist/merkle/signed-note.d.ts +309 -0
  124. package/dist/merkle/signed-note.js +648 -0
  125. package/dist/merkle/sth.d.ts +31 -0
  126. package/dist/merkle/sth.js +31 -0
  127. package/dist/merkle/storage.d.ts +40 -0
  128. package/dist/merkle/storage.js +71 -0
  129. package/dist/merkle/tree.d.ts +68 -0
  130. package/dist/merkle/tree.js +94 -0
  131. package/dist/mldsa/embedded.d.ts +1 -0
  132. package/dist/{kyber → mldsa}/embedded.js +5 -5
  133. package/dist/mldsa/expand.d.ts +53 -0
  134. package/dist/mldsa/expand.js +188 -0
  135. package/dist/mldsa/format.d.ts +16 -0
  136. package/dist/mldsa/format.js +68 -0
  137. package/dist/mldsa/hashvariant.d.ts +32 -0
  138. package/dist/mldsa/hashvariant.js +248 -0
  139. package/dist/mldsa/index.d.ts +142 -0
  140. package/dist/mldsa/index.js +463 -0
  141. package/dist/mldsa/keygen.d.ts +16 -0
  142. package/dist/mldsa/keygen.js +232 -0
  143. package/dist/mldsa/params.d.ts +21 -0
  144. package/dist/mldsa/params.js +55 -0
  145. package/dist/mldsa/sha3-helpers.d.ts +30 -0
  146. package/dist/mldsa/sha3-helpers.js +124 -0
  147. package/dist/mldsa/sign.d.ts +36 -0
  148. package/dist/mldsa/sign.js +380 -0
  149. package/dist/mldsa/types.d.ts +91 -0
  150. package/dist/mldsa/types.js +25 -0
  151. package/dist/mldsa/validate.d.ts +55 -0
  152. package/dist/mldsa/validate.js +125 -0
  153. package/dist/mldsa/verify.d.ts +29 -0
  154. package/dist/mldsa/verify.js +269 -0
  155. package/dist/mldsa.wasm +0 -0
  156. package/dist/mlkem/embedded.d.ts +1 -0
  157. package/dist/mlkem/embedded.js +27 -0
  158. package/dist/mlkem/indcpa.d.ts +49 -0
  159. package/dist/{kyber → mlkem}/indcpa.js +44 -44
  160. package/dist/mlkem/index.d.ts +37 -0
  161. package/dist/{kyber → mlkem}/index.js +24 -34
  162. package/dist/mlkem/kem.d.ts +21 -0
  163. package/dist/{kyber → mlkem}/kem.js +44 -64
  164. package/dist/{kyber → mlkem}/params.d.ts +4 -4
  165. package/dist/{kyber → mlkem}/params.js +2 -2
  166. package/dist/mlkem/suite.d.ts +12 -0
  167. package/dist/{kyber → mlkem}/suite.js +17 -12
  168. package/dist/{kyber → mlkem}/types.d.ts +3 -3
  169. package/dist/{kyber → mlkem}/types.js +1 -1
  170. package/dist/{kyber → mlkem}/validate.d.ts +7 -7
  171. package/dist/{kyber → mlkem}/validate.js +7 -7
  172. package/dist/{kyber.wasm → mlkem.wasm} +0 -0
  173. package/dist/p256.wasm +0 -0
  174. package/dist/ratchet/index.d.ts +2 -0
  175. package/dist/ratchet/index.js +1 -0
  176. package/dist/ratchet/kdf-chain.js +3 -3
  177. package/dist/ratchet/ratchet-keypair.js +2 -2
  178. package/dist/ratchet/root-kdf.js +7 -7
  179. package/dist/ratchet/skipped-key-store.js +4 -4
  180. package/dist/ratchet/types.d.ts +1 -1
  181. package/dist/serpent/cipher-suite.js +20 -17
  182. package/dist/serpent/generator.d.ts +1 -1
  183. package/dist/serpent/generator.js +2 -2
  184. package/dist/serpent/index.d.ts +8 -7
  185. package/dist/serpent/index.js +18 -27
  186. package/dist/serpent/pool-worker.js +7 -5
  187. package/dist/serpent/serpent-cbc.d.ts +4 -4
  188. package/dist/serpent/serpent-cbc.js +11 -8
  189. package/dist/serpent/shared-ops.d.ts +3 -23
  190. package/dist/serpent/shared-ops.js +50 -85
  191. package/dist/serpent.wasm +0 -0
  192. package/dist/sha2/hkdf.js +5 -5
  193. package/dist/sha2/index.d.ts +21 -1
  194. package/dist/sha2/index.js +65 -10
  195. package/dist/sha2/types.d.ts +41 -2
  196. package/dist/sha2.wasm +0 -0
  197. package/dist/sha3/index.d.ts +72 -3
  198. package/dist/sha3/index.js +240 -14
  199. package/dist/sha3/kmac.d.ts +121 -0
  200. package/dist/sha3/kmac.js +800 -0
  201. package/dist/sha3.wasm +0 -0
  202. package/dist/shared/pkcs7.d.ts +22 -0
  203. package/dist/shared/pkcs7.js +84 -0
  204. package/dist/sign/ctx.d.ts +41 -0
  205. package/dist/sign/ctx.js +102 -0
  206. package/dist/sign/envelope.d.ts +45 -0
  207. package/dist/sign/envelope.js +152 -0
  208. package/dist/sign/hasher.d.ts +9 -0
  209. package/dist/sign/hasher.js +132 -0
  210. package/dist/sign/index.d.ts +11 -0
  211. package/dist/sign/index.js +34 -0
  212. package/dist/sign/sign-stream.d.ts +25 -0
  213. package/dist/sign/sign-stream.js +112 -0
  214. package/dist/sign/suites/ecdsa-p256.d.ts +2 -0
  215. package/dist/sign/suites/ecdsa-p256.js +120 -0
  216. package/dist/sign/suites/ed25519.d.ts +3 -0
  217. package/dist/sign/suites/ed25519.js +165 -0
  218. package/dist/sign/suites/hybrid-classical.d.ts +23 -0
  219. package/dist/sign/suites/hybrid-classical.js +526 -0
  220. package/dist/sign/suites/hybrid-pq.d.ts +4 -0
  221. package/dist/sign/suites/hybrid-pq.js +234 -0
  222. package/dist/sign/suites/mldsa.d.ts +7 -0
  223. package/dist/sign/suites/mldsa.js +161 -0
  224. package/dist/sign/suites/slhdsa.d.ts +7 -0
  225. package/dist/sign/suites/slhdsa.js +176 -0
  226. package/dist/sign/types.d.ts +106 -0
  227. package/dist/sign/types.js +28 -0
  228. package/dist/sign/verify-stream.d.ts +30 -0
  229. package/dist/sign/verify-stream.js +227 -0
  230. package/dist/slhdsa/embedded.d.ts +1 -0
  231. package/dist/slhdsa/embedded.js +26 -0
  232. package/dist/slhdsa/index.d.ts +149 -0
  233. package/dist/slhdsa/index.js +493 -0
  234. package/dist/slhdsa/params.d.ts +26 -0
  235. package/dist/slhdsa/params.js +70 -0
  236. package/dist/slhdsa/prehash.d.ts +68 -0
  237. package/dist/slhdsa/prehash.js +307 -0
  238. package/dist/slhdsa/sign.d.ts +39 -0
  239. package/dist/slhdsa/sign.js +116 -0
  240. package/dist/slhdsa/types.d.ts +129 -0
  241. package/dist/slhdsa/types.js +27 -0
  242. package/dist/slhdsa/validate.d.ts +60 -0
  243. package/dist/slhdsa/validate.js +127 -0
  244. package/dist/slhdsa/verify.d.ts +32 -0
  245. package/dist/slhdsa/verify.js +107 -0
  246. package/dist/slhdsa.wasm +0 -0
  247. package/dist/stream/header.js +3 -3
  248. package/dist/stream/index.d.ts +1 -0
  249. package/dist/stream/index.js +1 -0
  250. package/dist/stream/open-stream.js +31 -10
  251. package/dist/stream/seal-stream-pool.d.ts +1 -0
  252. package/dist/stream/seal-stream-pool.js +63 -26
  253. package/dist/stream/seal-stream.d.ts +1 -1
  254. package/dist/stream/seal-stream.js +20 -9
  255. package/dist/stream/seal.js +6 -6
  256. package/dist/stream/types.d.ts +3 -1
  257. package/dist/stream/types.js +1 -1
  258. package/dist/types.d.ts +1 -1
  259. package/dist/types.js +1 -1
  260. package/dist/utils.d.ts +3 -3
  261. package/dist/utils.js +46 -54
  262. package/dist/wasm-source.d.ts +7 -7
  263. package/dist/wasm-source.js +1 -1
  264. package/dist/x25519/embedded.d.ts +1 -0
  265. package/dist/x25519/embedded.js +31 -0
  266. package/dist/x25519/index.d.ts +43 -0
  267. package/dist/x25519/index.js +159 -0
  268. package/dist/x25519/types.d.ts +25 -0
  269. package/dist/x25519/types.js +27 -0
  270. package/dist/x25519/validate.d.ts +2 -0
  271. package/dist/x25519/validate.js +39 -0
  272. package/package.json +70 -26
  273. package/SECURITY.md +0 -163
  274. package/dist/ct-wasm.d.ts +0 -1
  275. package/dist/ct-wasm.js +0 -3
  276. package/dist/docs/aead.md +0 -363
  277. package/dist/docs/architecture.md +0 -1011
  278. package/dist/docs/argon2id.md +0 -305
  279. package/dist/docs/chacha20.md +0 -781
  280. package/dist/docs/exports.md +0 -277
  281. package/dist/docs/fortuna.md +0 -530
  282. package/dist/docs/init.md +0 -301
  283. package/dist/docs/loader.md +0 -256
  284. package/dist/docs/serpent.md +0 -617
  285. package/dist/docs/sha2.md +0 -671
  286. package/dist/docs/sha3.md +0 -612
  287. package/dist/docs/types.md +0 -416
  288. package/dist/docs/utils.md +0 -457
  289. package/dist/embedded/kyber.d.ts +0 -1
  290. package/dist/embedded/kyber.js +0 -3
  291. package/dist/kyber/embedded.d.ts +0 -1
  292. package/dist/kyber/indcpa.d.ts +0 -49
  293. package/dist/kyber/index.d.ts +0 -38
  294. package/dist/kyber/kem.d.ts +0 -21
  295. package/dist/kyber/suite.d.ts +0 -12
  296. /package/dist/{ct.wasm → cte.wasm} +0 -0
@@ -0,0 +1,648 @@
1
+ // ▄▄▄▄▄▄▄▄▄▄
2
+ // ▄████████████████████▄▄ ▒ ▄▀▀ ▒ ▒ █ ▄▀▄ ▀█▀ █ ▒ ▄▀▄ █▀▄
3
+ // ▄██████████████████████ ▀████▄ ▓ ▓▀ ▓ ▓ ▓ ▓▄▓ ▓ ▓▀▓ ▓▄▓ ▓ ▓
4
+ // ▄█████████▀▀▀ ▀███████▄▄███████▌ ▀▄ ▀▄▄ ▀▄▀ ▒ ▒ ▒ ▒ ▒ █ ▒ ▒ ▒ █
5
+ // ▐████████▀ ▄▄▄▄ ▀████████▀██▀█▌
6
+ // ████████ ███▀▀ ████▀ █▀ █▀ Leviathan Crypto Library
7
+ // ███████▌ ▀██▀ ███
8
+ // ███████ ▀███ ▀██ ▀█▄ Repository & Mirror:
9
+ // ▀██████ ▄▄██ ▀▀ ██▄ github.com/xero/leviathan-crypto
10
+ // ▀█████▄ ▄██▄ ▄▀▄▀ unpkg.com/leviathan-crypto
11
+ // ▀████▄ ▄██▄
12
+ // ▐████ ▐███ Author: xero (https://x-e.ro)
13
+ // ▄▄██████████ ▐███ ▄▄ License: MIT
14
+ // ▄██▀▀▀▀▀▀▀▀▀▀ ▄████ ▄██▀
15
+ // ▄▀ ▄▄█████████▄▄ ▀▀▀▀▀ ▄███ This file is provided completely
16
+ // ▄██████▀▀▀▀▀▀██████▄ ▀▄▄▄▄████▀ free, "as is", and without
17
+ // ████▀ ▄▄▄▄▄▄▄ ▀████▄ ▀█████▀ ▄▄▄▄ warranty of any kind. The author
18
+ // █████▄▄█████▀▀▀▀▀▀▄ ▀███▄ ▄████ assumes absolutely no liability
19
+ // ▀██████▀ ▀████▄▄▄████▀ for its {ab,mis,}use.
20
+ // ▀█████▀▀
21
+ //
22
+ // src/ts/merkle/signed-note.ts
23
+ //
24
+ // Envelope codec for c2sp.org/signed-note §Format and the
25
+ // `key_id = SHA-256(name || 0x0A || algo || pubkey)[:4]` derivation
26
+ // from c2sp.org/tlog-cosignature §Format.
27
+ //
28
+ // Algorithm-byte registry, confirmed against C2SP commit
29
+ // 3752ba5b3590dc3754e04fcc8369bd3612897c02 (github.com/C2SP/C2SP,
30
+ // 2026-04-23):
31
+ //
32
+ // Ed25519Suite (formatEnum 0x01) → C2SP algo byte 0x04
33
+ // MlDsa44Suite (formatEnum 0x03) → C2SP algo byte 0x06
34
+ //
35
+ // 0x04 = timestamped Ed25519 cosignatures, 0x06 = timestamped ML-DSA-44
36
+ // (sub)tree cosignatures per c2sp.org/tlog-cosignature §Format. Other
37
+ // registry bytes (0x01 base Ed25519, 0x02 ECDSA witness, 0x05 RFC 6962
38
+ // TreeHeadSignature) are unwired; new suites need an authoritative
39
+ // C2SP byte (raise an issue per AGENTS.md, never mint locally).
40
+ //
41
+ // Cosignature signed messages: `buildCosigSignedMessage` builds the
42
+ // Ed25519 form per §"Ed25519 signed message". §"ML-DSA-44 signed
43
+ // message" specifies a separate `cosigned_message` TLS-Presentation
44
+ // struct; the registry entry below carries
45
+ // messageConstruction='cosigned-message' so consumers can branch.
46
+ import { MerkleCodecError } from '../errors.js';
47
+ import { SHA256 } from '../sha2/index.js';
48
+ import { utf8ToBytes, bytesToUtf8, base64ToBytes, bytesToBase64, concat, } from '../utils.js';
49
+ // ── algorithm-byte registry ─────────────────────────────────────────────────
50
+ /**
51
+ * c2sp.org/signed-note §Format signature type for plain Ed25519
52
+ * signatures over the raw note text per RFC 8032. Listed for spec
53
+ * completeness; no leviathan SignatureSuite currently routes here
54
+ * because Phase 7 cosignatures use the timestamped Ed25519 variant
55
+ * (`ALGO_BYTE_ED25519_COSIG`) per c2sp.org/tlog-cosignature §Format.
56
+ */
57
+ export const ALGO_BYTE_ED25519_NOTE = 0x01;
58
+ /**
59
+ * c2sp.org/tlog-cosignature §Format signature type for timestamped
60
+ * Ed25519 checkpoint cosignatures. The signature payload is
61
+ * `u64_be(timestamp) || ed25519_signature(64)` for a total of 72
62
+ * bytes, base64-encoded together with the 4-byte key ID on the
63
+ * signature line.
64
+ */
65
+ export const ALGO_BYTE_ED25519_COSIG = 0x04;
66
+ /**
67
+ * c2sp.org/tlog-cosignature §Format signature type for timestamped
68
+ * ML-DSA-44 (sub)tree cosignatures. The signature payload is
69
+ * `u64_be(timestamp) || ml_dsa_44_signature(2420)` for a total of
70
+ * 2428 bytes, base64-encoded together with the 4-byte key ID.
71
+ */
72
+ export const ALGO_BYTE_MLDSA44_COSIG = 0x06;
73
+ /**
74
+ * c2sp.org/tlog-cosignature §Format algorithm-byte catalog. Future
75
+ * spec additions extend the constants and this array. Both the
76
+ * emitter and the verifier consult this single table; suites without
77
+ * an entry cannot be used to derive a signed-note key ID and cannot
78
+ * be cosigned through this codec.
79
+ */
80
+ const ALGO_REGISTRY = Object.freeze([
81
+ Object.freeze({
82
+ formatEnum: 0x01,
83
+ algoByte: ALGO_BYTE_ED25519_COSIG,
84
+ messageConstruction: 'cosig',
85
+ signaturePayload: 'timestamped',
86
+ sigSize: 64,
87
+ }),
88
+ Object.freeze({
89
+ formatEnum: 0x03,
90
+ algoByte: ALGO_BYTE_MLDSA44_COSIG,
91
+ messageConstruction: 'cosigned-message',
92
+ signaturePayload: 'timestamped',
93
+ sigSize: 2420,
94
+ }),
95
+ ]);
96
+ /**
97
+ * Look up the algo-entry for a leviathan `SignatureSuite.formatEnum`.
98
+ * Returns `undefined` for suites not registered in the catalog;
99
+ * callers that need a hard guarantee should check the return value
100
+ * and raise an issue per AGENTS.md rather than locally mint a byte
101
+ * for a suite the C2SP spec has not registered.
102
+ */
103
+ export function lookupAlgoEntryByFormatEnum(formatEnum) {
104
+ for (const e of ALGO_REGISTRY)
105
+ if (e.formatEnum === formatEnum)
106
+ return e;
107
+ return undefined;
108
+ }
109
+ /**
110
+ * Look up the algo-entry for a wire-format C2SP algorithm byte. Used
111
+ * by verifiers that see an unknown signature line and need to decide
112
+ * how to reshape the payload (or whether to defer to
113
+ * `parseSignedNote`'s "unknown signatures MUST be ignored" rule).
114
+ */
115
+ export function lookupAlgoEntryByByte(algoByte) {
116
+ for (const e of ALGO_REGISTRY)
117
+ if (e.algoByte === algoByte)
118
+ return e;
119
+ return undefined;
120
+ }
121
+ /**
122
+ * Resolve a leviathan SignatureSuite formatEnum to its C2SP signed-note
123
+ * algorithm byte. Thin shim over `lookupAlgoEntryByFormatEnum`; kept for
124
+ * the call sites that only need the byte (e.g. `deriveKeyId` callers).
125
+ */
126
+ export function suiteFormatEnumToAlgoByte(formatEnum) {
127
+ return lookupAlgoEntryByFormatEnum(formatEnum)?.algoByte;
128
+ }
129
+ // ── key-ID derivation ───────────────────────────────────────────────────────
130
+ const LF = 0x0a;
131
+ const SPACE = 0x20;
132
+ const PLUS = 0x2b;
133
+ // UTF-8 encoding of em dash (U+2014) followed by space (U+0020), the
134
+ // fixed prefix on every signed-note signature line per
135
+ // c2sp.org/signed-note §Format.
136
+ const EMDASH_SPACE = new Uint8Array([0xe2, 0x80, 0x94, 0x20]);
137
+ /**
138
+ * Per c2sp.org/signed-note §Signatures and c2sp.org/tlog-cosignature
139
+ * §Format, the recommended key ID is:
140
+ *
141
+ * key_id = SHA-256(utf8(name) || 0x0A || algo_byte || pubkey)[:4]
142
+ *
143
+ * The leading newline byte is U+000A (0x0A); `algo_byte` is the
144
+ * signature-type identifier from `c2sp.org/signed-note` §Signatures
145
+ * §Signature types. The key ID is intentionally short (4 bytes); it
146
+ * is an identifier, not a collision-resistant hash, and key ID
147
+ * collisions only produce verification failures, not forgeries (the
148
+ * verifier holds the authoritative public key).
149
+ *
150
+ * Acquires the sha2 module per call inside try / finally and disposes;
151
+ * does not hold long-lived state. The `name` argument must satisfy the
152
+ * signed-note key-name MUSTs (non-empty, no Unicode whitespace, no
153
+ * plus characters).
154
+ */
155
+ export function deriveKeyId(name, algoByte, pubkey) {
156
+ if (name.length === 0)
157
+ throw new RangeError('deriveKeyId: name must be non-empty');
158
+ if (/\s/.test(name) || name.includes('+'))
159
+ throw new RangeError('deriveKeyId: name must not contain whitespace or plus characters');
160
+ if (!Number.isInteger(algoByte) || algoByte < 0 || algoByte > 0xff)
161
+ throw new RangeError(`deriveKeyId: algoByte must be a byte in [0, 255], got ${algoByte}`);
162
+ if (!(pubkey instanceof Uint8Array))
163
+ throw new TypeError('deriveKeyId: pubkey must be a Uint8Array');
164
+ const nameBytes = utf8ToBytes(name);
165
+ const preimage = new Uint8Array(nameBytes.length + 1 + 1 + pubkey.length);
166
+ let off = 0;
167
+ preimage.set(nameBytes, off);
168
+ off += nameBytes.length;
169
+ preimage[off++] = LF;
170
+ preimage[off++] = algoByte;
171
+ preimage.set(pubkey, off);
172
+ const h = new SHA256();
173
+ try {
174
+ const digest = h.hash(preimage);
175
+ return digest.subarray(0, 4);
176
+ }
177
+ finally {
178
+ h.dispose();
179
+ }
180
+ }
181
+ // ── envelope emit ───────────────────────────────────────────────────────────
182
+ /**
183
+ * Emit a signed-note envelope per c2sp.org/signed-note §Format. The
184
+ * caller supplies the body bytes (which MUST end in U+000A; the
185
+ * checkpoint body codec already enforces this) and one or more
186
+ * signature lines. The wire layout is:
187
+ *
188
+ * body || '\n' || (— name b64(keyId||sig) '\n')+
189
+ *
190
+ * The blank line that separates body from signature lines is the
191
+ * extra newline between the body's own trailing newline and the
192
+ * first signature line; both `serializeCheckpointBody` and this
193
+ * function MUST agree on this convention.
194
+ *
195
+ * Throws RangeError on a body that does not end in U+000A, on an
196
+ * empty signatures array, or on any signature whose key name violates
197
+ * the signed-note key-name MUSTs.
198
+ */
199
+ export function emitSignedNote(body, sigs) {
200
+ if (!(body instanceof Uint8Array))
201
+ throw new TypeError('emitSignedNote: body must be a Uint8Array');
202
+ if (body.length === 0 || body[body.length - 1] !== LF)
203
+ throw new RangeError('emitSignedNote: body must end with U+000A');
204
+ if (sigs.length === 0)
205
+ throw new RangeError('emitSignedNote: at least one signature line is required');
206
+ const sigLines = [];
207
+ for (const s of sigs) {
208
+ validateSigName(s.name);
209
+ if (s.keyId.length !== 4)
210
+ throw new RangeError(`emitSignedNote: keyId must be 4 bytes, got ${s.keyId.length}`);
211
+ const payload = concat(s.keyId, s.signature);
212
+ const lineText = `${bytesToUtf8(EMDASH_SPACE)}${s.name} ${bytesToBase64(payload)}\n`;
213
+ sigLines.push(utf8ToBytes(lineText));
214
+ }
215
+ // Body's trailing 0x0A is line N's terminator; the explicit extra
216
+ // 0x0A here is the blank separator line required by signed-note
217
+ // §Format.
218
+ return concat(body, new Uint8Array([LF]), ...sigLines);
219
+ }
220
+ function validateSigName(name) {
221
+ if (name.length === 0)
222
+ throw new RangeError('signed-note: signature name must be non-empty');
223
+ if (/\s/.test(name) || name.includes('+'))
224
+ throw new RangeError('signed-note: signature name must not contain whitespace or plus characters');
225
+ }
226
+ // ── envelope parse ──────────────────────────────────────────────────────────
227
+ /**
228
+ * Parse a signed-note envelope per c2sp.org/signed-note §Format. The
229
+ * input must be valid UTF-8 and MUST NOT contain ASCII control
230
+ * characters below U+0020 other than newline. The body is everything
231
+ * up to and including the first blank line, MINUS the blank line
232
+ * itself, MINUS the newline that immediately precedes the blank line
233
+ * (no, including it; see body convention below).
234
+ *
235
+ * Per the body convention in `emitSignedNote`, the returned `body`
236
+ * field includes the body's terminating U+000A but excludes the
237
+ * blank-line separator.
238
+ *
239
+ * Signature-line parsing is permissive: a line that does not match
240
+ * `— <name> <base64>\n` exactly, or whose base64 payload decodes to
241
+ * fewer than 4 bytes (no room for a key ID), is counted in
242
+ * `ignoredCount` and discarded rather than throwing. The signed-note
243
+ * §Signatures rule is that unknown signatures MUST be ignored, and
244
+ * "unknown" subsumes any line a future spec extension might add in
245
+ * a format leviathan does not recognize.
246
+ *
247
+ * Whole-envelope structural errors (missing blank separator, body
248
+ * not ending in newline, ASCII control bytes, invalid UTF-8) throw
249
+ * RangeError. The behaviour of "throw on envelope, ignore on line"
250
+ * is what makes the codec forward-compatible with future cosignature
251
+ * algorithms without changing the byte-stable body region.
252
+ */
253
+ export function parseSignedNote(bytes) {
254
+ if (!(bytes instanceof Uint8Array))
255
+ throw new TypeError('parseSignedNote: input must be a Uint8Array');
256
+ if (bytes.length === 0)
257
+ throw new RangeError('parseSignedNote: empty input');
258
+ for (const b of bytes) {
259
+ if (b < 0x20 && b !== LF)
260
+ throw new RangeError('parseSignedNote: input contains non-newline ASCII control bytes');
261
+ if (b === 0x7f)
262
+ throw new RangeError('parseSignedNote: input contains DEL (0x7F)');
263
+ }
264
+ // Locate the LAST blank line that separates body from signatures.
265
+ // Per c2sp.org/signed-note §Format: "The note text MAY contain
266
+ // empty lines; the text is separated from the signatures by the
267
+ // last empty line in the note." A blank line is a 0x0A immediately
268
+ // followed by another 0x0A within the envelope. Scan forward
269
+ // looking at every 0x0A 0x0A pair, but keep updating to the last
270
+ // one whose successor line opens with the em dash prefix; the
271
+ // signatures region MUST be non-empty per spec.
272
+ const sigStart = locateSignaturesStart(bytes);
273
+ // Body includes the LF that terminates its last text line; this
274
+ // matches the c2sp.org/signed-note §Format requirement that the
275
+ // note text "ends in newline (U+000A)".
276
+ const body = bytes.subarray(0, sigStart - 1);
277
+ if (body.length === 0 || body[body.length - 1] !== LF)
278
+ throw new RangeError('parseSignedNote: body must end with U+000A');
279
+ // Sanity-check the body region for valid UTF-8 here so a partial
280
+ // envelope is caught before any signature work happens.
281
+ try {
282
+ bytesToUtf8(body);
283
+ }
284
+ catch {
285
+ throw new RangeError('parseSignedNote: body is not valid UTF-8');
286
+ }
287
+ const sigRegion = bytes.subarray(sigStart);
288
+ if (sigRegion.length === 0)
289
+ throw new RangeError('parseSignedNote: signature region is empty');
290
+ if (sigRegion[sigRegion.length - 1] !== LF)
291
+ throw new RangeError('parseSignedNote: signature region must end with U+000A');
292
+ const signatures = [];
293
+ let ignoredCount = 0;
294
+ let lineStart = 0;
295
+ for (let i = 0; i < sigRegion.length; i++) {
296
+ if (sigRegion[i] !== LF)
297
+ continue;
298
+ const line = sigRegion.subarray(lineStart, i);
299
+ lineStart = i + 1;
300
+ // An empty line inside the signatures region is impossible by
301
+ // construction: `locateSignaturesStart` already advanced past
302
+ // the LAST blank line per c2sp.org/signed-note §Format, so any
303
+ // remaining 0x0A 0x0A pair would have been chosen as the
304
+ // separator instead.
305
+ if (line.length === 0)
306
+ continue;
307
+ const parsed = tryParseSignatureLine(line);
308
+ if (parsed)
309
+ signatures.push(parsed);
310
+ else
311
+ ignoredCount++;
312
+ }
313
+ return { body, signatures, ignoredCount };
314
+ }
315
+ /**
316
+ * Locate the byte offset where the signatures region starts. Per
317
+ * c2sp.org/signed-note §Format the note text "MAY contain empty lines;
318
+ * the text is separated from the signatures by the last empty line in
319
+ * the note." Concretely: walk every 0x0A 0x0A pair in the input, take
320
+ * the LAST one, and the signatures region begins at the byte after
321
+ * the second 0x0A.
322
+ *
323
+ * The choice of "last blank line" is what makes the codec accept
324
+ * bodies that themselves contain empty lines (e.g., a free-form text
325
+ * note with a stanza break). The signatures region MUST be non-empty
326
+ * per §Format ("followed by one or more signature lines"), so a final
327
+ * blank line with no following bytes throws.
328
+ */
329
+ function locateSignaturesStart(bytes) {
330
+ let last = -1;
331
+ for (let i = 0; i + 1 < bytes.length; i++) {
332
+ if (bytes[i] === LF && bytes[i + 1] === LF)
333
+ last = i + 2;
334
+ }
335
+ if (last < 0)
336
+ throw new RangeError('parseSignedNote: no blank-line separator between body and signatures');
337
+ if (last >= bytes.length)
338
+ throw new RangeError('parseSignedNote: signature region is empty');
339
+ return last;
340
+ }
341
+ function lineStartsWithPrefix(buf, start, prefix) {
342
+ if (start + prefix.length > buf.length)
343
+ return false;
344
+ for (let i = 0; i < prefix.length; i++)
345
+ if (buf[start + i] !== prefix[i])
346
+ return false;
347
+ return true;
348
+ }
349
+ /**
350
+ * Attempt to parse one signature line per c2sp.org/signed-note §Format:
351
+ *
352
+ * — <key name> <base64(key_id || signature)>
353
+ *
354
+ * Returns `null` on any structural defect (no em dash + space prefix,
355
+ * empty key name, no second space, malformed base64, base64 payload
356
+ * shorter than 4 bytes). The caller counts `null` returns in
357
+ * `ignoredCount` per the signed-note §Signatures rule that unknown
358
+ * signatures MUST be ignored.
359
+ */
360
+ function tryParseSignatureLine(line) {
361
+ if (!lineStartsWithPrefix(line, 0, EMDASH_SPACE))
362
+ return null;
363
+ const rest = line.subarray(EMDASH_SPACE.length);
364
+ // Name is everything up to the first 0x20; the base64 payload
365
+ // follows. Per signed-note §Format the key name MUST be non-empty
366
+ // and MUST NOT contain Unicode spaces or plus characters; rejecting
367
+ // here keeps the parser symmetric with the emitter.
368
+ let spaceAt = -1;
369
+ for (let i = 0; i < rest.length; i++) {
370
+ if (rest[i] === SPACE) {
371
+ spaceAt = i;
372
+ break;
373
+ }
374
+ if (rest[i] === PLUS)
375
+ return null;
376
+ }
377
+ if (spaceAt <= 0)
378
+ return null;
379
+ const nameBytes = rest.subarray(0, spaceAt);
380
+ const b64Bytes = rest.subarray(spaceAt + 1);
381
+ if (b64Bytes.length === 0)
382
+ return null;
383
+ let name;
384
+ try {
385
+ name = bytesToUtf8(nameBytes);
386
+ }
387
+ catch {
388
+ return null;
389
+ }
390
+ // One last guard: bytes-level scan caught SP/PLUS in the name; a
391
+ // UTF-8 codepoint that decodes to a different whitespace class
392
+ // (NBSP, ideographic space, etc.) is still spec-forbidden.
393
+ if (/\s/.test(name))
394
+ return null;
395
+ let b64;
396
+ try {
397
+ b64 = bytesToUtf8(b64Bytes);
398
+ }
399
+ catch {
400
+ return null;
401
+ }
402
+ // Standard alphabet per RFC 4648 §4 only. URL-safe characters are
403
+ // rejected for consistency with the checkpoint body codec; the
404
+ // signed-note spec also references §4, not §5.
405
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64))
406
+ return null;
407
+ let payload;
408
+ try {
409
+ payload = base64ToBytes(b64);
410
+ }
411
+ catch {
412
+ return null;
413
+ }
414
+ if (payload.length < 4)
415
+ return null;
416
+ return {
417
+ name,
418
+ keyId: payload.subarray(0, 4),
419
+ signature: payload.subarray(4),
420
+ };
421
+ }
422
+ // ── cosignature signed-message construction ─────────────────────────────────
423
+ // `cosignature/v1\ntime ` UTF-8, the fixed prefix of the Ed25519
424
+ // cosignature signed message per c2sp.org/tlog-cosignature §"Ed25519
425
+ // signed message" (header + timestamp opener).
426
+ const COSIG_V1_PREFIX = utf8ToBytes('cosignature/v1\ntime ');
427
+ const TIME_LINE_TERMINATOR = new Uint8Array([LF]);
428
+ /**
429
+ * Reject timestamps that cannot round-trip through u64-BE without
430
+ * precision loss. Spec allows `<= 2^63 - 1`
431
+ * (c2sp.org/tlog-cosignature §Format); leviathan uses Number, so the
432
+ * effective cap is Number.MAX_SAFE_INTEGER (2^53 - 1).
433
+ */
434
+ function assertSafeTimestamp(timestamp) {
435
+ if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > Number.MAX_SAFE_INTEGER)
436
+ throw new MerkleCodecError('timestamp-out-of-range', `timestamp ${timestamp} must be a non-negative safe integer`);
437
+ }
438
+ /**
439
+ * Build the bytes a cosigner signs when issuing a cosignature for a
440
+ * checkpoint, per c2sp.org/tlog-cosignature §"Ed25519 signed message".
441
+ *
442
+ * Layout (each `\n` is U+000A):
443
+ *
444
+ * cosignature/v1\n
445
+ * time <decimal_timestamp>\n
446
+ * <body>
447
+ *
448
+ * `body` is the canonical checkpoint body produced by
449
+ * `serializeCheckpointBody` and already terminates in `\n`; the
450
+ * function adds no separator between the timestamp line and the
451
+ * body. Decimal carries no leading zeroes per the §Format rule on
452
+ * the timestamp line (mirrored from checkpoint §Note text).
453
+ *
454
+ * Spec-correct only for Ed25519 cosignatures (C2SP algo byte 0x04).
455
+ * ML-DSA-44 cosignatures sign the separate `cosigned_message` struct
456
+ * defined in §"ML-DSA-44 signed message" (codec not in this patch);
457
+ * callers reaching for this function with an ML-DSA-44 suite are
458
+ * producing the wrong wire format and should branch on the
459
+ * `messageConstruction` field of the suite's `AlgoEntry`.
460
+ *
461
+ * Throws `MerkleCodecError('timestamp-out-of-range')` if `timestamp`
462
+ * is not a non-negative safe integer.
463
+ */
464
+ export function buildCosigSignedMessage(body, timestamp) {
465
+ if (!(body instanceof Uint8Array))
466
+ throw new TypeError('buildCosigSignedMessage: body must be a Uint8Array');
467
+ if (body.length === 0 || body[body.length - 1] !== LF)
468
+ throw new RangeError('buildCosigSignedMessage: body must end with U+000A');
469
+ assertSafeTimestamp(timestamp);
470
+ const tsBytes = utf8ToBytes(timestamp.toString(10));
471
+ return concat(COSIG_V1_PREFIX, tsBytes, TIME_LINE_TERMINATOR, body);
472
+ }
473
+ // ── timestamped_signature payload codec ─────────────────────────────────────
474
+ /**
475
+ * Encode the `timestamped_signature` struct payload per
476
+ * c2sp.org/tlog-cosignature §Format. Layout (per RFC 8446 §3.3,
477
+ * Presentation Language; integers in network byte order):
478
+ *
479
+ * u64_be(timestamp) || signature[N]
480
+ *
481
+ * The result is the opaque payload portion of a signed-note signature
482
+ * line: prefixed by the 4-byte key ID and then base64-encoded by
483
+ * `emitSignedNote`. `signature` length is suite-dependent (64 for
484
+ * Ed25519, 2420 for ML-DSA-44); the encoder does not validate length
485
+ * here because both registry-allowed sizes round-trip correctly.
486
+ *
487
+ * Throws `MerkleCodecError('timestamp-out-of-range')` if `timestamp`
488
+ * is not a non-negative safe integer.
489
+ */
490
+ export function emitCosigSignaturePayload(timestamp, signature) {
491
+ if (!(signature instanceof Uint8Array))
492
+ throw new TypeError('emitCosigSignaturePayload: signature must be a Uint8Array');
493
+ assertSafeTimestamp(timestamp);
494
+ const out = new Uint8Array(8 + signature.length);
495
+ writeU64Be(out, 0, timestamp);
496
+ out.set(signature, 8);
497
+ return out;
498
+ }
499
+ function writeU64Be(out, off, value) {
500
+ const hi = Math.floor(value / 0x100000000);
501
+ const lo = value >>> 0;
502
+ out[off] = (hi >>> 24) & 0xff;
503
+ out[off + 1] = (hi >>> 16) & 0xff;
504
+ out[off + 2] = (hi >>> 8) & 0xff;
505
+ out[off + 3] = hi & 0xff;
506
+ out[off + 4] = (lo >>> 24) & 0xff;
507
+ out[off + 5] = (lo >>> 16) & 0xff;
508
+ out[off + 6] = (lo >>> 8) & 0xff;
509
+ out[off + 7] = lo & 0xff;
510
+ }
511
+ /**
512
+ * Decode a `timestamped_signature` payload per c2sp.org/tlog-cosignature
513
+ * §Format. Inverse of `emitCosigSignaturePayload`; round-trips
514
+ * byte-for-byte.
515
+ *
516
+ * `sigSize` is suite-locked (64 for Ed25519, 2420 for ML-DSA-44); the
517
+ * caller supplies it via the suite's `AlgoEntry.sigSize`. The decoder
518
+ * asserts `payload.length === 8 + sigSize` and throws
519
+ * `MerkleCodecError('cosig-payload-length-mismatch')` otherwise so a
520
+ * wrong-length payload fails loudly rather than producing a silently
521
+ * truncated signature.
522
+ *
523
+ * The wire timestamp is u64-BE; values exceeding `Number.MAX_SAFE_INTEGER`
524
+ * cannot round-trip through JavaScript Number and throw
525
+ * `MerkleCodecError('timestamp-exceeds-safe-integer')`. The cutoff is
526
+ * `tsHi >= 0x200000` (i.e. `2^53 / 2^32`).
527
+ */
528
+ export function parseCosigSignaturePayload(payload, sigSize) {
529
+ if (!(payload instanceof Uint8Array))
530
+ throw new TypeError('parseCosigSignaturePayload: payload must be a Uint8Array');
531
+ if (!Number.isInteger(sigSize) || sigSize < 0)
532
+ throw new RangeError(`parseCosigSignaturePayload: sigSize must be a non-negative integer, got ${sigSize}`);
533
+ if (payload.length !== 8 + sigSize)
534
+ throw new MerkleCodecError('cosig-payload-length-mismatch', `payload length ${payload.length} != expected 8 + sigSize (${8 + sigSize})`);
535
+ const tsHi = ((payload[0] << 24) |
536
+ (payload[1] << 16) |
537
+ (payload[2] << 8) |
538
+ (payload[3])) >>> 0;
539
+ const tsLo = ((payload[4] << 24) |
540
+ (payload[5] << 16) |
541
+ (payload[6] << 8) |
542
+ (payload[7])) >>> 0;
543
+ // 0x200000 = 2^53 / 2^32; tsHi at or above this overflows
544
+ // Number safe-integer precision.
545
+ if (tsHi >= 0x200000)
546
+ throw new MerkleCodecError('timestamp-exceeds-safe-integer', `wire timestamp high32 ${tsHi} exceeds Number.MAX_SAFE_INTEGER / 2^32`);
547
+ const timestamp = tsHi * 0x100000000 + tsLo;
548
+ const signature = payload.subarray(8, 8 + sigSize);
549
+ return { timestamp, signature };
550
+ }
551
+ // ── ML-DSA-44 cosigned_message construction ─────────────────────────────────
552
+ // Fixed 12-byte label per c2sp.org/tlog-cosignature §"ML-DSA-44 signed
553
+ // message". The spec text reads `subtree/v1\n\0`; the literal bytes
554
+ // are the 10 ASCII characters of "subtree/v1", a 0x0A newline, and a
555
+ // 0x00 nul terminator (12 bytes total). The label appears verbatim
556
+ // for every cosignature regardless of whether the signed range is a
557
+ // full checkpoint (start=0) or a non-zero-start subtree.
558
+ const COSIGNED_MESSAGE_LABEL = new Uint8Array([
559
+ 0x73, 0x75, 0x62, 0x74, 0x72, 0x65, 0x65, 0x2f, // "subtree/"
560
+ 0x76, 0x31, // "v1"
561
+ 0x0a, // "\n"
562
+ 0x00, // "\0"
563
+ ]);
564
+ /**
565
+ * Build the bytes a cosigner signs when issuing an ML-DSA-44
566
+ * cosignature, per c2sp.org/tlog-cosignature §"ML-DSA-44 signed
567
+ * message". Layout (TLS-Presentation per RFC 8446 §3.3, lengths in
568
+ * big-endian network order):
569
+ *
570
+ * uint8 label[12] = "subtree/v1\n\0"
571
+ * opaque cosigner_name<1..2^8-1>
572
+ * uint64 timestamp
573
+ * opaque log_origin<1..2^8-1>
574
+ * uint64 start
575
+ * uint64 end
576
+ * uint8 hash[32]
577
+ *
578
+ * Total length is `70 + utf8(cosignerName).length + utf8(logOrigin).length`.
579
+ *
580
+ * Spec-correct for both checkpoint (start=0) and subtree (start>0)
581
+ * ML-DSA-44 cosignatures. Phase 7 uses only the checkpoint case;
582
+ * subtree cosignatures land with the witness-protocol work. The
583
+ * codec is agnostic so future TASKs do not re-cut the surface.
584
+ *
585
+ * Throws `MerkleCodecError`:
586
+ * 'timestamp-out-of-range' timestamp / start / end not safe non-negative
587
+ * 'cosigner-name-length' UTF-8 cosignerName empty or > 255 bytes
588
+ * 'log-origin-length' UTF-8 logOrigin empty or > 255 bytes
589
+ * 'cosigned-message-state' start > 0 and timestamp != 0 (spec MUST)
590
+ *
591
+ * Throws `RangeError` on a `hash` whose length is not 32 (the
592
+ * `cosigned_message.hash` field is fixed-length per the struct).
593
+ */
594
+ export function buildCosignedMessage(input) {
595
+ const { cosignerName, timestamp, logOrigin, start, end, hash } = input;
596
+ assertSafeTimestamp(timestamp);
597
+ // Reuse the same safe-integer guard for start / end; the spec
598
+ // allows up to 2^63 - 1 per §Format, the leviathan surface caps
599
+ // at 2^53 - 1 to keep Number-based math precise.
600
+ if (!Number.isInteger(start) || start < 0 || start > Number.MAX_SAFE_INTEGER)
601
+ throw new MerkleCodecError('timestamp-out-of-range', `cosigned_message.start ${start} must be a non-negative safe integer`);
602
+ if (!Number.isInteger(end) || end < 0 || end > Number.MAX_SAFE_INTEGER)
603
+ throw new MerkleCodecError('timestamp-out-of-range', `cosigned_message.end ${end} must be a non-negative safe integer`);
604
+ const cosignerBytes = utf8ToBytes(cosignerName);
605
+ if (cosignerBytes.length === 0 || cosignerBytes.length > 0xff)
606
+ throw new MerkleCodecError('cosigner-name-length', `cosigned_message.cosigner_name UTF-8 length ${cosignerBytes.length} must be in [1, 255]`);
607
+ const originBytes = utf8ToBytes(logOrigin);
608
+ if (originBytes.length === 0 || originBytes.length > 0xff)
609
+ throw new MerkleCodecError('log-origin-length', `cosigned_message.log_origin UTF-8 length ${originBytes.length} must be in [1, 255]`);
610
+ if (!(hash instanceof Uint8Array))
611
+ throw new TypeError('buildCosignedMessage: hash must be a Uint8Array');
612
+ if (hash.length !== 32)
613
+ throw new RangeError(`buildCosignedMessage: cosigned_message.hash must be 32 bytes, got ${hash.length}`);
614
+ // Per c2sp.org/tlog-cosignature §"ML-DSA-44 signed message": if
615
+ // start is non-zero the cosignature is for a subtree (not a
616
+ // checkpoint) and timestamp MUST be zero. The reverse case
617
+ // (start = 0 with timestamp = 0) is allowed: the cosigner makes
618
+ // no statement about observation time.
619
+ if (start !== 0 && timestamp !== 0)
620
+ throw new MerkleCodecError('cosigned-message-state', 'cosigned_message with start != 0 (subtree cosignature) requires timestamp == 0');
621
+ const totalLen = COSIGNED_MESSAGE_LABEL.length
622
+ + 1 + cosignerBytes.length
623
+ + 8
624
+ + 1 + originBytes.length
625
+ + 8 + 8
626
+ + hash.length;
627
+ const out = new Uint8Array(totalLen);
628
+ let off = 0;
629
+ out.set(COSIGNED_MESSAGE_LABEL, off);
630
+ off += COSIGNED_MESSAGE_LABEL.length;
631
+ // opaque cosigner_name<1..2^8-1>: 1-byte length prefix per RFC 8446
632
+ // §3.4 (variable-length vector with the smallest length encoding
633
+ // that holds 2^8 - 1).
634
+ out[off++] = cosignerBytes.length;
635
+ out.set(cosignerBytes, off);
636
+ off += cosignerBytes.length;
637
+ writeU64Be(out, off, timestamp);
638
+ off += 8;
639
+ out[off++] = originBytes.length;
640
+ out.set(originBytes, off);
641
+ off += originBytes.length;
642
+ writeU64Be(out, off, start);
643
+ off += 8;
644
+ writeU64Be(out, off, end);
645
+ off += 8;
646
+ out.set(hash, off);
647
+ return out;
648
+ }
@@ -0,0 +1,31 @@
1
+ import type { Checkpoint } from './checkpoint.js';
2
+ import type { SignatureLine } from './signed-note.js';
3
+ /**
4
+ * In-memory pairing of a parsed Checkpoint, the signature lines
5
+ * extracted from its signed-note envelope, and the primary log
6
+ * signature's POSIX-seconds timestamp.
7
+ *
8
+ * The wire format is the concatenation of
9
+ * `serializeCheckpointBody(checkpoint)` and the emitted signature
10
+ * lines per c2sp.org/signed-note §Format. Each signature line's
11
+ * opaque payload is a `timestamped_signature` struct per
12
+ * c2sp.org/tlog-cosignature §Format; the `timestamp` field surfaced
13
+ * here is the one extracted from the primary log signature (the
14
+ * signature line whose `name` matches the checkpoint origin). For
15
+ * checkpoints with additional witness cosignatures, each witness
16
+ * carries its own timestamp inside its own signature line's payload
17
+ * and is accessed by re-parsing that line via
18
+ * `parseCosigSignaturePayload`.
19
+ */
20
+ export interface SignedTreeHead {
21
+ readonly checkpoint: Checkpoint;
22
+ readonly signatures: readonly SignatureLine[];
23
+ /**
24
+ * POSIX-seconds timestamp the primary log cosignature was issued
25
+ * at, per the `timestamped_signature` struct in
26
+ * c2sp.org/tlog-cosignature §Format. Non-negative safe integer;
27
+ * see `parseCosigSignaturePayload` for the upper-bound semantics
28
+ * (Number-safe range, smaller than the spec's `2^63 - 1` ceiling).
29
+ */
30
+ readonly timestamp: number;
31
+ }