-
-
Notifications
You must be signed in to change notification settings - Fork 1
signing
Scheme-agnostic digital signatures for any scale. One-shot with Sign, chunked with SignStream and VerifyStream. All three share an envelope wire format and accept any SignatureSuite.
Digital signatures in leviathan-crypto center on three classes: Sign, SignStream, and VerifyStream. All are scheme-agnostic. Pass a SignatureSuite object at the call site and they handle context binding, the M' construction, and authentication automatically.
These three form a natural progression by use case. Use Sign for messages that fit in memory. Use SignStream and VerifyStream for messages arriving in chunks or too large to buffer. Sign and SignStream share the same attached envelope, so a VerifyStream can verify a Sign.sign blob and vice versa.
leviathan-crypto ships 22 signature suites grouped into six families:
| Family | Members | Modules |
|---|---|---|
| ML-DSA (FIPS 204) |
MlDsa{44,65,87}Suite plus MlDsa{44,65,87}PreHashSuite
|
mldsa, sha3
|
| SLH-DSA (FIPS 205) |
SlhDsa{128f,192f,256f}Suite plus SlhDsa{128f,192f,256f}PreHashSuite
|
slhdsa (+ sha3 for prehash) |
| Ed25519 (RFC 8032) |
Ed25519Suite, Ed25519PreHashSuite
|
curve25519 (+ sha2 for prehash) |
| ECDSA-P256 (FIPS 186-5) | EcdsaP256Suite |
p256, sha2
|
| Classical+PQ hybrid composite | MlDsa{44,65}{Ed25519,EcdsaP256}Suite |
mldsa, sha3, (curve25519 or p256), sha2
|
| PQ-only hybrid composite | MlDsa{44,65,87}SlhDsa{128f,192f,256f}Suite |
mldsa, sha3, slhdsa
|
See signaturesuite.md for the full catalog, per-suite tables, hybrid composite wire formats, and the SignatureSuite interface.
Every signature binds the message bytes to a caller-supplied ctx. The envelope carries ctx on the wire and the verifier compares it against the receiver-supplied ctx in constant time before any cryptographic work runs. A mismatch fails fast with SigningError('sig-ctx-mismatch').
Important
SignStream and VerifyStream are single-use. After finalize() returns, the stream is finalized and further update() calls throw SigningError('sig-stream-finalized'). After dispose() returns, any operation throws SigningError('sig-stream-disposed'). Construct a new stream for each message.
SignStream and VerifyStream require a StreamableSignatureSuite. Pure-mode suites (Ed25519Suite, MlDsa{44,65,87}Suite, SlhDsa{128f,192f,256f}Suite) are rejected at the type level. Streaming requires a prehash so the suite can drive the digest incrementally; pure-mode suites bind the full message bytes and cannot stream. Use the matching *PreHashSuite variant for chunked input.
ctx is required on every call. Pass an empty Uint8Array if you have no context, never undefined and never a missing positional argument. The wire ctx slot is Uint8Array(0) in that case. Some suites reject non-empty ctx outright (Ed25519Suite and EcdsaP256Suite); see signaturesuite.md for the per-suite contract.
Hybrid suites run both sub-verifies on every call. Classical+PQ hybrids (0x20-0x23) and PQ-only hybrids (0x30-0x32) AND-reduce the two boolean outcomes after both verifies have completed. A timing observer cannot distinguish which half failed. See signaturesuite.md for the rationale.
ML-DSA's NTT and rejection sampling, SLH-DSA's hash-tree authentication, Ed25519's scalar multiplication, and ECDSA-P256's scalar multiplication are written for constant-time execution at the algorithm level. See architecture.md §Where defense ends for the canonical WASM side-channel posture and threat-model boundaries.
Sign.sign and SignStream emit the same byte sequence. The layout is one suite byte, one ctx length byte, the user ctx bytes, a four-byte payload-length header, the payload, and finally the signature.
byte 0 : suite_byte (u8, suite.formatEnum)
byte 1 : ctx_len (u8, 0..255)
bytes 2 .. 2+ctx_len : ctx (raw user_ctx, no domain prefix)
bytes 2+ctx_len .. 2+ctx_len+4 : payload_len (u32 big-endian, 0..2^32 - 1)
bytes 2+ctx_len+4 .. payload_end : payload (exactly payload_len bytes)
bytes payload_end .. N : sig (variable, <= suite.sigMaxSize bytes)
Total size is 2 + ctx_len + 4 + payload_len + sig.length. The explicit payload_len field lets the sig slot float, which is required for variable-length signature schemes (composite ECDSA, whose Ecdsa-Sig-Value DER encoding per RFC 3279 §2.2.3 varies with leading-zero stripping). For fixed-length suites the trailing sig fills exactly suite.sigMaxSize bytes; the suite's verify path enforces the exact length. The 4-byte overhead is rounding error on PQ signature sizes (under 0.2% on a ~2500-byte ML-DSA-44 sig) and irrelevant on multi-megabyte signed blobs.
Note
The wire carries the raw user_ctx, not the effective_ctx the suite builds internally. The receiver passes its own ctx to Sign.verify or VerifyStream, the envelope layer compares it against the wire ctx in constant time, and the suite reconstructs effective_ctx for the underlying primitive. The wire bytes do not encode the suite's ctxDomain.
- Validate
blob.length >= 6. The minimum legal blob carries the fixed 1+1+4-byte header even with empty ctx and empty payload. Fail withsig-blob-too-short. - Read
suite_byte. Compare againstsuite.formatEnum. Fail withsig-suite-mismatch. - Read
ctx_len. - Validate
blob.length >= 2 + ctx_len + 4so thepayload_lenu32 fits. Fail withsig-blob-too-short. - Read
payload_lenas a u32 big-endian at offset2 + ctx_len. - Validate
2 + ctx_len + 4 + payload_len <= blob.length. Fail withsig-blob-too-short. - Validate that the trailing sig length fits the suite's catalog upper bound,
blob.length - (2 + ctx_len + 4 + payload_len) <= suite.sigMaxSize. Fail withsig-blob-too-short. - Slice
ctx,payload, andsigfrom the known offsets. - Compare caller
ctxagainst wirectxin constant time. Fail withsig-ctx-mismatch. - Call
suite.verify(pk, payload, sig, wire_ctx). Afalsereturn becomesverify-failed. For fixed-length suites this is where the exact sig-length check happens; for variable-length suites the suite's verify path handles parsing the sig. - Return
payloadon success.
All wire-shape overflows fold into sig-blob-too-short so the discriminator count stays stable across the wire upgrade. The error message names the specific overflow (short header, ctx past blob end, payload past blob end, trailing sig over sigMaxSize); callers that want a sharper diagnostic read the thrown SigningError's .message. sig-suite-unknown is reserved for a future routing API that resolves the suite from the wire byte; callers always pass the suite explicitly today, so the discriminator never fires here.
Sign.signDetached returns raw signature bytes (length at most suite.sigMaxSize; for fixed-length suites the length is exactly the catalog value). No header, no metadata. The caller manages the (suite, pk, msg, sig, ctx) tuple out of band. Use detached signatures when the message is transported separately, or when the wire format must match an external standard (CMS, COSE, JWS) that frames the signature itself.
Sign is a static class, never instantiated. It handles one-shot signing and verification in both attached-envelope and detached forms, plus a peek helper for envelope inspection without verification. A Sign.sign blob is structurally identical to a single-update() SignStream output for the same suite, key, and inputs.
import { init, Sign, MlDsa65Suite } from 'leviathan-crypto'
import { mldsaWasm } from 'leviathan-crypto/mldsa/embedded'
import { sha3Wasm } from 'leviathan-crypto/sha3/embedded'
await init({ mldsa: mldsaWasm, sha3: sha3Wasm })
const { pk, sk } = MlDsa65Suite.keygen()
const msg = new TextEncoder().encode('hello world')
const ctx = new TextEncoder().encode('myapp/v1')
const blob = Sign.sign(MlDsa65Suite, sk, msg, ctx)
const payload = Sign.verify(MlDsa65Suite, pk, blob, ctx) // recovers msg bytes| Method | Returns | Description |
|---|---|---|
Sign.sign(suite, sk, msg, ctx) |
Uint8Array |
One-shot sign. Returns the attached envelope. |
Sign.verify(suite, pk, blob, ctx) |
Uint8Array |
One-shot verify. Returns the recovered payload. Throws SigningError on suite mismatch, ctx mismatch, malformed envelope, or invalid signature. |
Sign.signDetached(suite, sk, msg, ctx) |
Uint8Array |
Detached sign. Returns raw signature bytes (no envelope). |
Sign.verifyDetached(suite, pk, msg, sig, ctx) |
boolean |
Detached verify. Returns true on valid signature, false otherwise. Throws SigningError only on contract violations (wrong-length key, ctx too long). |
Sign.peek(blob, suite) |
PeekMeta |
Inspect envelope structure without verifying. Returns { suiteByte, ctx, payloadOffset, payloadLength, sigOffset }. Use for routing or logging; always follow up with Sign.verify before trusting the payload. |
ctx. Required Uint8Array carrying authenticated context. Pass new Uint8Array(0) if you have no context. Authenticated but not encrypted; bound into the signature via the suite's effective_ctx construction. Pass the same value on sign and verify, or verify rejects with sig-ctx-mismatch.
const sig = Sign.signDetached(MlDsa65Suite, sk, msg, ctx)
const ok = Sign.verifyDetached(MlDsa65Suite, pk, msg, sig, ctx)
// ok === true; for fixed-length suites sig is exactly MlDsa65Suite.sigMaxSize bytes// blob is an attached envelope produced by Sign.sign or SignStream.
// peek validates structural shape only; it does NOT verify the signature
// and does NOT compare ctx.
const meta = Sign.peek(blob, MlDsa65Suite)
// meta.suiteByte : number, the wire suite byte
// meta.ctx : Uint8Array, the wire ctx
// meta.payloadOffset : number, byte offset of the payload start
// meta.payloadLength : number, payload length in bytes
// meta.sigOffset : number, byte offset of the signature startNote
All stream classes require a StreamableSignatureSuite, which means a prehash variant. Pure-mode suites are a compile-time error.
import { init, SignStream, MlDsa65PreHashSuite } from 'leviathan-crypto'
import { mldsaWasm } from 'leviathan-crypto/mldsa/embedded'
import { sha3Wasm } from 'leviathan-crypto/sha3/embedded'
await init({ mldsa: mldsaWasm, sha3: sha3Wasm })
const { pk, sk } = MlDsa65PreHashSuite.keygen()
const ctx = new TextEncoder().encode('myapp/v1')
const signer = new SignStream(MlDsa65PreHashSuite, sk, ctx)
signer.update(chunk1)
signer.update(chunk2)
const sig = signer.finalize()
const payloadLen = chunk1.length + chunk2.length
const preamble = signer.buildPreamble(payloadLen)
// wire output is preamble || chunk1 || chunk2 || sig
signer.dispose()Constructor: new SignStream(suite, sk, ctx)
| Parameter | Type | Description |
|---|---|---|
suite |
StreamableSignatureSuite |
Any prehash-mode or hybrid suite. Pure-mode suites are rejected at the type level. |
sk |
Uint8Array |
Secret key. Must be suite.skSize bytes. |
ctx |
Uint8Array |
Authenticated context. Required; pass new Uint8Array(0) if you have no context. Copied into a lib-owned buffer. |
| Method | Returns | Description |
|---|---|---|
update(chunk) |
void |
Drive the running prehash with one chunk. Accepts arbitrarily-sized Uint8Array. |
finalize() |
Uint8Array |
Finalize the prehash, sign the digest, and return the signature bytes. Wipes the running prehash. |
buildPreamble(payloadLength) |
Uint8Array |
Build the envelope preamble (suite_byte, ctx_len, ctx, payload_len) for the caller-known payload length. Safe to call any time before dispose(). |
dispose() |
void |
Wipe the ctx copy. Idempotent. Call once the envelope blob is assembled. |
The canonical assembly pattern is finalize() first, then buildPreamble() with the payload length, then concatenate preamble || payload || sig. The ctx copy survives finalize() deliberately so buildPreamble() can read it; dispose() wipes the copy.
import { init, VerifyStream, MlDsa65PreHashSuite } from 'leviathan-crypto'
import { mldsaWasm } from 'leviathan-crypto/mldsa/embedded'
import { sha3Wasm } from 'leviathan-crypto/sha3/embedded'
await init({ mldsa: mldsaWasm, sha3: sha3Wasm })
// pk and ctx must match the SignStream side
const verifier = new VerifyStream(MlDsa65PreHashSuite, pk, ctx)
verifier.update(preamble)
verifier.update(chunk1)
verifier.update(chunk2)
verifier.update(sig)
const payload = verifier.finalize() // throws SigningError on bad sig
verifier.dispose()Constructor: new VerifyStream(suite, pk, ctx)
Throws if the wire suite_byte does not match suite.formatEnum, or if the wire ctx does not match the caller-supplied ctx.
| Parameter | Type | Description |
|---|---|---|
suite |
StreamableSignatureSuite |
Must match the suite that produced the wire bytes. |
pk |
Uint8Array |
Public key. Must be suite.pkSize bytes. |
ctx |
Uint8Array |
Expected context. Compared in constant time against the wire ctx. |
| Method | Returns | Description |
|---|---|---|
update(chunk) |
void |
Feed wire bytes in. Accepts arbitrarily-sized chunks; the stream parses the 6-byte header (suite_byte, ctx_len, ctx, payload_len) byte-by-byte, then consumes exactly payload_len bytes of payload, then buffers the trailing bytes as the sig. |
finalize() |
Uint8Array |
Verify the signature against the buffered payload and return the payload bytes. Throws SigningError on sig-suite-mismatch, sig-ctx-mismatch, sig-blob-too-short, or verify-failed. Wipes the internal payload, sig, and header buffers on both success and failure. |
dispose() |
void |
Wipe internal buffers. Idempotent. |
A receiver that does not yet know which suite produced the wire bytes can call Sign.peek against the leading bytes before constructing VerifyStream.
The signing layer holds two copies of secret-adjacent state for streams. The library wipes its copies on well-defined boundaries; caller-owned buffers (sk, pk, msg, sig, the user ctx) are never touched.
SignStream. new SignStream(suite, sk, ctx) copies ctx into a lib-owned Uint8Array. sk is held by reference and never wiped. The running prehash is disposed in both finalize() and dispose(). The ctx copy survives finalize() so buildPreamble() can still read it; call dispose() once the blob is assembled.
VerifyStream. update(chunk) copies every payload byte into an internally-owned chunk so a caller-side mutation cannot retroactively change the buffered payload. pk and the expected ctx are held by reference and never wiped. finalize() wipes payloadChunks, sigBuf, and headerBuf on every code path; the returned payload is a fresh concat(...) allocation, so wiping the internal chunks does not corrupt the result. dispose() performs the same wipe and is idempotent.
See signaturesuite.md for the suite-layer wipe discipline (effective_ctx, one-shot prehash digests, ECDSA-P256 hedging entropy).
Sign.sign, Sign.verify, Sign.signDetached, Sign.verifyDetached, Sign.peek, SignStream, and VerifyStream all throw SigningError(discriminator, message?) on contract violations and verification failures. The discriminator is the stable, machine-readable identifier; the message carries human-readable context.
import { SigningError } from 'leviathan-crypto'
try {
const payload = Sign.verify(MlDsa65Suite, pk, tampered, ctx)
} catch (e) {
if (e instanceof SigningError) {
// e.discriminator: stable identifier (see table below)
// e.message: human-readable context
}
}| Discriminator | Layer | Trigger |
|---|---|---|
sig-key-size |
suite | Wrong-length sk or pk for the suite. |
sig-ctx-too-long |
suite |
user_ctx exceeds USER_CTX_MAX (255 bytes per FIPS 204 §3.6.1), or the combined effective_ctx exceeds the same cap. |
sig-ctx-unsupported |
suite | Non-empty user_ctx passed to a suite with no native context parameter (Ed25519Suite, EcdsaP256Suite). Context-bound signing must use a prehash or hybrid suite. |
sig-malformed-input |
suite | Primitive validation failure, for example a wrong-length digest in signPrehashed or verifyPrehashed. |
sig-blob-too-short |
envelope | Wire-shape rejection. Fires on a blob shorter than the 6-byte envelope header, on ctx_len pushing past the blob end, on payload_len pushing the payload past the blob end, or on a trailing sig larger than suite.sigMaxSize. The thrown .message names the specific overflow. |
sig-suite-unknown |
envelope | Wire suite_byte is not in the catalog. Reserved; callers pass the suite explicitly today, so this discriminator does not fire. |
sig-suite-mismatch |
envelope, stream | Wire suite_byte does not equal the caller's suite.formatEnum. |
sig-ctx-overflow |
envelope | Reserved for future routing APIs; the v3 envelope folds the ctx-past-blob case into sig-blob-too-short. |
sig-ctx-mismatch |
envelope, stream | Caller ctx does not equal wire ctx. Constant-time compared. |
verify-failed |
envelope |
suite.verify returned false during envelope verify. |
sig-stream-finalized |
stream |
update() called after finalize(). |
sig-stream-disposed |
stream | Any operation on a disposed stream. |
VerifyStream.finalize also throws verify-failed and sig-blob-too-short (the latter when finalize fires before enough bytes have arrived for a full signature).
Never attempt to recover the payload after a SigningError. VerifyStream.finalize wipes its internal buffers before throwing.
| Document | Description |
|---|---|
| index | Project Documentation index |
| lexicon | Glossary of cryptographic terms |
| architecture | Repository structure, build and CI, WASM modules, public API, test suite, and security posture |
| signaturesuite |
SignatureSuite interface, full suite catalog, hybrid composite wire formats, ctx-domain construction, format-byte allocation |
| mldsa | ML-DSA (FIPS 204) raw primitives |
| slhdsa | SLH-DSA (FIPS 205) raw primitives |
| ed25519 | Ed25519 (RFC 8032) raw primitives |
| ecdsa-p256 | ECDSA-P256 (FIPS 186-5) raw primitives |
| aead |
Seal, SealStream, OpenStream, SealStreamPool (authenticated encryption counterpart) |
| exports | complete export reference |
| init | WASM loading and WasmSource
|
- Sign Tools
-
SignatureSuite
- format-byte catalog, hybrid composite encodings, custom suite contract
- Serpent-256 TypeScript | WASM
-
Serpent,SerpentCtr,SerpentCbc,SerpentGenerator
-
- ChaCha20 TypeScript | WASM
-
ChaCha20,Poly1305,ChaCha20Poly1305,XChaCha20Poly1305,ChaCha20Generator
-
- AES TypeScript | WASM
-
AES,AESCbc,AESCtr,AESGCM,AESGCMSIV,AESGenerator
-
- ML-DSA TypeScript | WASM
- pure (FIPS 204):
MlDsa44,MlDsa65,MlDsa87 - pure-mode suites:
MlDsa44Suite,MlDsa65Suite,MlDsa87Suite - prehash suites:
MlDsa44PreHashSuite,MlDsa65PreHashSuite,MlDsa87PreHashSuite
- pure (FIPS 204):
- SLH-DSA TypeScript | WASM
- pure (FIPS 205):
SlhDsa128f,SlhDsa192f,SlhDsa256f - pure-mode suites:
SlhDsa128fSuite,SlhDsa192fSuite,SlhDsa256fSuite - prehash suites:
SlhDsa128fPreHashSuite,SlhDsa192fPreHashSuite,SlhDsa256fPreHashSuite
- pure (FIPS 205):
- Ed25519 TypeScript | WASM
-
Ed25519(pure + Ed25519ph),Ed25519Suite,Ed25519PreHashSuite
-
- ECDSA-P256 TypeScript | WASM
-
EcdsaP256(hedged + RFC 6979),EcdsaP256Suite - DER codec:
ecdsaSignatureToDer,ecdsaSignatureFromDer,encodeEcPrivateKey,decodeEcPrivateKey,pointDecompress
-
- Hybrid composites PQ-only | Classical+PQ
- PQ-only:
MlDsa44SlhDsa128fSuite,MlDsa65SlhDsa192fSuite,MlDsa87SlhDsa256fSuite - Classical+PQ:
MlDsa44Ed25519Suite,MlDsa65Ed25519Suite,MlDsa44EcdsaP256Suite,MlDsa65EcdsaP256Suite
- PQ-only:
- X25519 TypeScript | WASM
-
X25519,KeyAgreementError(RFC 7748)
-
- ML-KEM TypeScript | WASM
-
MlKem512,MlKem768,MlKem1024
-
-
Ratchet (SPQR)
-
KDFChain,ratchetInit,kemRatchetEncap,kemRatchetDecap,RatchetKeypair,SkippedKeyStore
-
- Hashing overview
- SHA-2 TypeScript | WASM
-
SHA256,SHA384,SHA512,SHA224,SHA512_224,SHA512_256 -
HMAC_SHA256,HMAC_SHA384,HMAC_SHA512,HKDF_SHA256,HKDF_SHA512
-
- SHA-3 TypeScript | WASM
-
SHA3_224,SHA3_256,SHA3_384,SHA3_512,SHAKE128,SHAKE256
-
- BLAKE3 TypeScript | WASM
-
BLAKE3,BLAKE3Stream,BLAKE3KeyedHash,BLAKE3KeyedHashStream -
BLAKE3DeriveKey,BLAKE3DeriveKeyStream,BLAKE3OutputReader,BLAKE3Hash
-
-
KMAC
-
CSHAKE128,CSHAKE256,KMAC128,KMAC256,KMACXOF128,KMACXOF256
-
-
Merkle
-
MerkleVerifier,MerkleLog -
SignedLog,Sha256Tree,Blake3Tree,MemoryStorage
-
-
Fortuna CSPRNG
-
Fortuna,SerpentGenerator,ChaCha20Generator,AESGenerator,SHA256Hash,SHA3_256Hash,BLAKE3Hash
-
- Utils TypeScript | WASM
-
constantTimeEqual,randomBytes,wipe, encoding helpers
-
-
TypeScript interfaces
-
Hash,KeyedHash,Blockcipher,Streamcipher,AEAD,Generator,HashFn
-