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.
- package/CLAUDE.md +86 -443
- package/README.md +198 -65
- package/dist/aes/aes-cbc.d.ts +40 -0
- package/dist/aes/aes-cbc.js +158 -0
- package/dist/aes/aes-ctr.d.ts +50 -0
- package/dist/aes/aes-ctr.js +141 -0
- package/dist/aes/aes-gcm-siv.d.ts +67 -0
- package/dist/aes/aes-gcm-siv.js +217 -0
- package/dist/aes/aes-gcm.d.ts +61 -0
- package/dist/aes/aes-gcm.js +226 -0
- package/dist/aes/cipher-suite.d.ts +21 -0
- package/dist/aes/cipher-suite.js +179 -0
- package/dist/aes/embedded.d.ts +1 -0
- package/dist/aes/embedded.js +26 -0
- package/dist/aes/generator.d.ts +14 -0
- package/dist/aes/generator.js +103 -0
- package/dist/aes/index.d.ts +58 -0
- package/dist/aes/index.js +125 -0
- package/dist/aes/ops.d.ts +60 -0
- package/dist/aes/ops.js +164 -0
- package/dist/aes/pool-worker.d.ts +1 -0
- package/dist/aes/pool-worker.js +92 -0
- package/dist/aes/types.d.ts +1 -0
- package/dist/aes/types.js +23 -0
- package/dist/aes.wasm +0 -0
- package/dist/blake3/embedded.d.ts +1 -0
- package/dist/blake3/embedded.js +26 -0
- package/dist/blake3/index.d.ts +143 -0
- package/dist/blake3/index.js +620 -0
- package/dist/blake3/types.d.ts +102 -0
- package/dist/blake3/types.js +31 -0
- package/dist/blake3/validate.d.ts +29 -0
- package/dist/blake3/validate.js +80 -0
- package/dist/blake3.wasm +0 -0
- package/dist/chacha20/cipher-suite.js +47 -25
- package/dist/chacha20/generator.d.ts +2 -2
- package/dist/chacha20/generator.js +4 -4
- package/dist/chacha20/index.d.ts +16 -15
- package/dist/chacha20/index.js +52 -46
- package/dist/chacha20/ops.d.ts +7 -7
- package/dist/chacha20/ops.js +34 -34
- package/dist/chacha20/pool-worker.js +5 -3
- package/dist/cte-wasm.d.ts +1 -0
- package/dist/cte-wasm.js +3 -0
- package/dist/curve25519.wasm +0 -0
- package/dist/ecdsa/der.d.ts +23 -0
- package/dist/ecdsa/der.js +192 -0
- package/dist/ecdsa/ecprivatekey-der.d.ts +32 -0
- package/dist/ecdsa/ecprivatekey-der.js +230 -0
- package/dist/ecdsa/embedded.d.ts +1 -0
- package/dist/ecdsa/embedded.js +25 -0
- package/dist/ecdsa/index.d.ts +124 -0
- package/dist/ecdsa/index.js +366 -0
- package/dist/ecdsa/types.d.ts +31 -0
- package/dist/ecdsa/types.js +28 -0
- package/dist/ecdsa/validate.d.ts +18 -0
- package/dist/ecdsa/validate.js +92 -0
- package/dist/ed25519/embedded.d.ts +1 -0
- package/dist/ed25519/embedded.js +31 -0
- package/dist/ed25519/index.d.ts +70 -0
- package/dist/ed25519/index.js +308 -0
- package/dist/ed25519/types.d.ts +27 -0
- package/dist/ed25519/types.js +27 -0
- package/dist/ed25519/validate.d.ts +7 -0
- package/dist/ed25519/validate.js +77 -0
- package/dist/embedded/aes-pool-worker.d.ts +1 -0
- package/dist/embedded/aes-pool-worker.js +5 -0
- package/dist/embedded/aes.d.ts +1 -0
- package/dist/embedded/aes.js +3 -0
- package/dist/embedded/blake3.d.ts +1 -0
- package/dist/embedded/blake3.js +3 -0
- package/dist/embedded/chacha20-pool-worker.d.ts +1 -1
- package/dist/embedded/chacha20-pool-worker.js +2 -2
- package/dist/embedded/chacha20.d.ts +1 -1
- package/dist/embedded/chacha20.js +2 -2
- package/dist/embedded/curve25519.d.ts +1 -0
- package/dist/embedded/curve25519.js +3 -0
- package/dist/embedded/mldsa.d.ts +1 -0
- package/dist/embedded/mldsa.js +3 -0
- package/dist/embedded/mlkem.d.ts +1 -0
- package/dist/embedded/mlkem.js +3 -0
- package/dist/embedded/p256.d.ts +1 -0
- package/dist/embedded/p256.js +3 -0
- package/dist/embedded/serpent-pool-worker.d.ts +1 -1
- package/dist/embedded/serpent-pool-worker.js +2 -2
- package/dist/embedded/serpent.d.ts +1 -1
- package/dist/embedded/serpent.js +2 -2
- package/dist/embedded/sha2.d.ts +1 -1
- package/dist/embedded/sha2.js +2 -2
- package/dist/embedded/sha3.d.ts +1 -1
- package/dist/embedded/sha3.js +2 -2
- package/dist/embedded/slhdsa.d.ts +1 -0
- package/dist/embedded/slhdsa.js +3 -0
- package/dist/errors.d.ts +92 -1
- package/dist/errors.js +111 -1
- package/dist/fortuna.d.ts +5 -5
- package/dist/fortuna.js +37 -64
- package/dist/index.d.ts +38 -9
- package/dist/index.js +63 -19
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -25
- package/dist/keccak/embedded.js +1 -1
- package/dist/keccak/index.d.ts +2 -0
- package/dist/keccak/index.js +4 -2
- package/dist/loader.d.ts +1 -24
- package/dist/loader.js +13 -16
- package/dist/merkle/blake3-tree.d.ts +35 -0
- package/dist/merkle/blake3-tree.js +187 -0
- package/dist/merkle/checkpoint.d.ts +58 -0
- package/dist/merkle/checkpoint.js +217 -0
- package/dist/merkle/index.d.ts +19 -0
- package/dist/merkle/index.js +37 -0
- package/dist/merkle/merkle-log.d.ts +130 -0
- package/dist/merkle/merkle-log.js +207 -0
- package/dist/merkle/merkle-verifier.d.ts +126 -0
- package/dist/merkle/merkle-verifier.js +296 -0
- package/dist/merkle/proof.d.ts +70 -0
- package/dist/merkle/proof.js +300 -0
- package/dist/merkle/sha256-tree.d.ts +33 -0
- package/dist/merkle/sha256-tree.js +145 -0
- package/dist/merkle/signed-log.d.ts +156 -0
- package/dist/merkle/signed-log.js +356 -0
- package/dist/merkle/signed-note.d.ts +309 -0
- package/dist/merkle/signed-note.js +648 -0
- package/dist/merkle/sth.d.ts +31 -0
- package/dist/merkle/sth.js +31 -0
- package/dist/merkle/storage.d.ts +40 -0
- package/dist/merkle/storage.js +71 -0
- package/dist/merkle/tree.d.ts +68 -0
- package/dist/merkle/tree.js +94 -0
- package/dist/mldsa/embedded.d.ts +1 -0
- package/dist/{kyber → mldsa}/embedded.js +5 -5
- package/dist/mldsa/expand.d.ts +53 -0
- package/dist/mldsa/expand.js +188 -0
- package/dist/mldsa/format.d.ts +16 -0
- package/dist/mldsa/format.js +68 -0
- package/dist/mldsa/hashvariant.d.ts +32 -0
- package/dist/mldsa/hashvariant.js +248 -0
- package/dist/mldsa/index.d.ts +142 -0
- package/dist/mldsa/index.js +463 -0
- package/dist/mldsa/keygen.d.ts +16 -0
- package/dist/mldsa/keygen.js +232 -0
- package/dist/mldsa/params.d.ts +21 -0
- package/dist/mldsa/params.js +55 -0
- package/dist/mldsa/sha3-helpers.d.ts +30 -0
- package/dist/mldsa/sha3-helpers.js +124 -0
- package/dist/mldsa/sign.d.ts +36 -0
- package/dist/mldsa/sign.js +380 -0
- package/dist/mldsa/types.d.ts +91 -0
- package/dist/mldsa/types.js +25 -0
- package/dist/mldsa/validate.d.ts +55 -0
- package/dist/mldsa/validate.js +125 -0
- package/dist/mldsa/verify.d.ts +29 -0
- package/dist/mldsa/verify.js +269 -0
- package/dist/mldsa.wasm +0 -0
- package/dist/mlkem/embedded.d.ts +1 -0
- package/dist/mlkem/embedded.js +27 -0
- package/dist/mlkem/indcpa.d.ts +49 -0
- package/dist/{kyber → mlkem}/indcpa.js +44 -44
- package/dist/mlkem/index.d.ts +37 -0
- package/dist/{kyber → mlkem}/index.js +24 -34
- package/dist/mlkem/kem.d.ts +21 -0
- package/dist/{kyber → mlkem}/kem.js +44 -64
- package/dist/{kyber → mlkem}/params.d.ts +4 -4
- package/dist/{kyber → mlkem}/params.js +2 -2
- package/dist/mlkem/suite.d.ts +12 -0
- package/dist/{kyber → mlkem}/suite.js +17 -12
- package/dist/{kyber → mlkem}/types.d.ts +3 -3
- package/dist/{kyber → mlkem}/types.js +1 -1
- package/dist/{kyber → mlkem}/validate.d.ts +7 -7
- package/dist/{kyber → mlkem}/validate.js +7 -7
- package/dist/{kyber.wasm → mlkem.wasm} +0 -0
- package/dist/p256.wasm +0 -0
- package/dist/ratchet/index.d.ts +2 -0
- package/dist/ratchet/index.js +1 -0
- package/dist/ratchet/kdf-chain.js +3 -3
- package/dist/ratchet/ratchet-keypair.js +2 -2
- package/dist/ratchet/root-kdf.js +7 -7
- package/dist/ratchet/skipped-key-store.js +4 -4
- package/dist/ratchet/types.d.ts +1 -1
- package/dist/serpent/cipher-suite.js +20 -17
- package/dist/serpent/generator.d.ts +1 -1
- package/dist/serpent/generator.js +2 -2
- package/dist/serpent/index.d.ts +8 -7
- package/dist/serpent/index.js +18 -27
- package/dist/serpent/pool-worker.js +7 -5
- package/dist/serpent/serpent-cbc.d.ts +4 -4
- package/dist/serpent/serpent-cbc.js +11 -8
- package/dist/serpent/shared-ops.d.ts +3 -23
- package/dist/serpent/shared-ops.js +50 -85
- package/dist/serpent.wasm +0 -0
- package/dist/sha2/hkdf.js +5 -5
- package/dist/sha2/index.d.ts +21 -1
- package/dist/sha2/index.js +65 -10
- package/dist/sha2/types.d.ts +41 -2
- package/dist/sha2.wasm +0 -0
- package/dist/sha3/index.d.ts +72 -3
- package/dist/sha3/index.js +240 -14
- package/dist/sha3/kmac.d.ts +121 -0
- package/dist/sha3/kmac.js +800 -0
- package/dist/sha3.wasm +0 -0
- package/dist/shared/pkcs7.d.ts +22 -0
- package/dist/shared/pkcs7.js +84 -0
- package/dist/sign/ctx.d.ts +41 -0
- package/dist/sign/ctx.js +102 -0
- package/dist/sign/envelope.d.ts +45 -0
- package/dist/sign/envelope.js +152 -0
- package/dist/sign/hasher.d.ts +9 -0
- package/dist/sign/hasher.js +132 -0
- package/dist/sign/index.d.ts +11 -0
- package/dist/sign/index.js +34 -0
- package/dist/sign/sign-stream.d.ts +25 -0
- package/dist/sign/sign-stream.js +112 -0
- package/dist/sign/suites/ecdsa-p256.d.ts +2 -0
- package/dist/sign/suites/ecdsa-p256.js +120 -0
- package/dist/sign/suites/ed25519.d.ts +3 -0
- package/dist/sign/suites/ed25519.js +165 -0
- package/dist/sign/suites/hybrid-classical.d.ts +23 -0
- package/dist/sign/suites/hybrid-classical.js +526 -0
- package/dist/sign/suites/hybrid-pq.d.ts +4 -0
- package/dist/sign/suites/hybrid-pq.js +234 -0
- package/dist/sign/suites/mldsa.d.ts +7 -0
- package/dist/sign/suites/mldsa.js +161 -0
- package/dist/sign/suites/slhdsa.d.ts +7 -0
- package/dist/sign/suites/slhdsa.js +176 -0
- package/dist/sign/types.d.ts +106 -0
- package/dist/sign/types.js +28 -0
- package/dist/sign/verify-stream.d.ts +30 -0
- package/dist/sign/verify-stream.js +227 -0
- package/dist/slhdsa/embedded.d.ts +1 -0
- package/dist/slhdsa/embedded.js +26 -0
- package/dist/slhdsa/index.d.ts +149 -0
- package/dist/slhdsa/index.js +493 -0
- package/dist/slhdsa/params.d.ts +26 -0
- package/dist/slhdsa/params.js +70 -0
- package/dist/slhdsa/prehash.d.ts +68 -0
- package/dist/slhdsa/prehash.js +307 -0
- package/dist/slhdsa/sign.d.ts +39 -0
- package/dist/slhdsa/sign.js +116 -0
- package/dist/slhdsa/types.d.ts +129 -0
- package/dist/slhdsa/types.js +27 -0
- package/dist/slhdsa/validate.d.ts +60 -0
- package/dist/slhdsa/validate.js +127 -0
- package/dist/slhdsa/verify.d.ts +32 -0
- package/dist/slhdsa/verify.js +107 -0
- package/dist/slhdsa.wasm +0 -0
- package/dist/stream/header.js +3 -3
- package/dist/stream/index.d.ts +1 -0
- package/dist/stream/index.js +1 -0
- package/dist/stream/open-stream.js +31 -10
- package/dist/stream/seal-stream-pool.d.ts +1 -0
- package/dist/stream/seal-stream-pool.js +63 -26
- package/dist/stream/seal-stream.d.ts +1 -1
- package/dist/stream/seal-stream.js +20 -9
- package/dist/stream/seal.js +6 -6
- package/dist/stream/types.d.ts +3 -1
- package/dist/stream/types.js +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js +46 -54
- package/dist/wasm-source.d.ts +7 -7
- package/dist/wasm-source.js +1 -1
- package/dist/x25519/embedded.d.ts +1 -0
- package/dist/x25519/embedded.js +31 -0
- package/dist/x25519/index.d.ts +43 -0
- package/dist/x25519/index.js +159 -0
- package/dist/x25519/types.d.ts +25 -0
- package/dist/x25519/types.js +27 -0
- package/dist/x25519/validate.d.ts +2 -0
- package/dist/x25519/validate.js +39 -0
- package/package.json +70 -26
- package/SECURITY.md +0 -163
- package/dist/ct-wasm.d.ts +0 -1
- package/dist/ct-wasm.js +0 -3
- package/dist/docs/aead.md +0 -363
- package/dist/docs/architecture.md +0 -1011
- package/dist/docs/argon2id.md +0 -305
- package/dist/docs/chacha20.md +0 -781
- package/dist/docs/exports.md +0 -277
- package/dist/docs/fortuna.md +0 -530
- package/dist/docs/init.md +0 -301
- package/dist/docs/loader.md +0 -256
- package/dist/docs/serpent.md +0 -617
- package/dist/docs/sha2.md +0 -671
- package/dist/docs/sha3.md +0 -612
- package/dist/docs/types.md +0 -416
- package/dist/docs/utils.md +0 -457
- package/dist/embedded/kyber.d.ts +0 -1
- package/dist/embedded/kyber.js +0 -3
- package/dist/kyber/embedded.d.ts +0 -1
- package/dist/kyber/indcpa.d.ts +0 -49
- package/dist/kyber/index.d.ts +0 -38
- package/dist/kyber/kem.d.ts +0 -21
- package/dist/kyber/suite.d.ts +0 -12
- /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
|
+
}
|