leviathan-crypto 2.0.0 → 2.1.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 +171 -7
- package/LICENSE +4 -0
- package/README.md +109 -54
- package/SECURITY.md +125 -233
- package/dist/chacha20/cipher-suite.d.ts +10 -0
- package/dist/chacha20/cipher-suite.js +66 -2
- package/dist/chacha20/generator.d.ts +12 -0
- package/dist/chacha20/generator.js +91 -0
- package/dist/chacha20/index.d.ts +97 -1
- package/dist/chacha20/index.js +139 -11
- package/dist/chacha20/ops.d.ts +57 -6
- package/dist/chacha20/ops.js +93 -13
- package/dist/chacha20/pool-worker.js +12 -0
- package/dist/chacha20/types.d.ts +1 -32
- package/dist/ct-wasm.js +1 -1
- package/dist/ct.wasm +0 -0
- package/dist/docs/aead.md +69 -26
- package/dist/docs/architecture.md +600 -520
- package/dist/docs/argon2id.md +17 -14
- package/dist/docs/chacha20.md +146 -39
- package/dist/docs/exports.md +46 -10
- package/dist/docs/fortuna.md +339 -122
- package/dist/docs/init.md +24 -25
- package/dist/docs/loader.md +142 -47
- package/dist/docs/serpent.md +139 -41
- package/dist/docs/sha2.md +77 -19
- package/dist/docs/sha3.md +81 -15
- package/dist/docs/types.md +156 -15
- package/dist/docs/utils.md +171 -81
- package/dist/embedded/chacha20-pool-worker.d.ts +1 -0
- package/dist/embedded/chacha20-pool-worker.js +5 -0
- package/dist/embedded/kyber.d.ts +1 -1
- package/dist/embedded/kyber.js +1 -1
- package/dist/embedded/serpent-pool-worker.d.ts +1 -0
- package/dist/embedded/serpent-pool-worker.js +5 -0
- package/dist/embedded/serpent.d.ts +1 -1
- package/dist/embedded/serpent.js +1 -1
- package/dist/fortuna.d.ts +14 -8
- package/dist/fortuna.js +144 -50
- package/dist/index.d.ts +8 -6
- package/dist/index.js +6 -5
- package/dist/init.d.ts +0 -2
- package/dist/init.js +83 -3
- package/dist/kyber/indcpa.js +4 -4
- package/dist/kyber/index.js +25 -5
- package/dist/kyber/kem.js +56 -1
- package/dist/kyber/suite.d.ts +1 -2
- package/dist/kyber/suite.js +1 -0
- package/dist/kyber/types.d.ts +1 -0
- package/dist/kyber/validate.d.ts +8 -4
- package/dist/kyber/validate.js +18 -14
- package/dist/kyber.wasm +0 -0
- package/dist/loader.d.ts +7 -2
- package/dist/loader.js +25 -28
- package/dist/ratchet/index.d.ts +6 -0
- package/dist/ratchet/index.js +37 -0
- package/dist/ratchet/kdf-chain.d.ts +13 -0
- package/dist/ratchet/kdf-chain.js +85 -0
- package/dist/ratchet/ratchet-keypair.d.ts +9 -0
- package/dist/ratchet/ratchet-keypair.js +61 -0
- package/dist/ratchet/root-kdf.d.ts +4 -0
- package/dist/ratchet/root-kdf.js +124 -0
- package/dist/ratchet/skipped-key-store.d.ts +14 -0
- package/dist/ratchet/skipped-key-store.js +154 -0
- package/dist/ratchet/types.d.ts +36 -0
- package/dist/ratchet/types.js +26 -0
- package/dist/serpent/cipher-suite.d.ts +10 -0
- package/dist/serpent/cipher-suite.js +136 -50
- package/dist/serpent/generator.d.ts +12 -0
- package/dist/serpent/generator.js +97 -0
- package/dist/serpent/index.d.ts +61 -1
- package/dist/serpent/index.js +92 -7
- package/dist/serpent/pool-worker.js +25 -95
- package/dist/serpent/serpent-cbc.d.ts +14 -4
- package/dist/serpent/serpent-cbc.js +58 -34
- package/dist/serpent/shared-ops.d.ts +83 -0
- package/dist/serpent/shared-ops.js +213 -0
- package/dist/serpent/types.d.ts +1 -5
- package/dist/serpent.wasm +0 -0
- package/dist/sha2/hash.d.ts +2 -0
- package/dist/sha2/hash.js +53 -0
- package/dist/sha2/index.d.ts +1 -0
- package/dist/sha2/index.js +15 -1
- package/dist/sha3/hash.d.ts +2 -0
- package/dist/sha3/hash.js +53 -0
- package/dist/sha3/index.d.ts +17 -2
- package/dist/sha3/index.js +79 -7
- package/dist/stream/header.js +5 -5
- package/dist/stream/open-stream.js +36 -14
- package/dist/stream/seal-stream-pool.d.ts +1 -0
- package/dist/stream/seal-stream-pool.js +47 -8
- package/dist/stream/seal-stream.js +29 -11
- package/dist/stream/types.d.ts +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/utils.d.ts +7 -8
- package/dist/utils.js +73 -40
- package/dist/wasm-source.d.ts +9 -8
- package/package.json +79 -64
|
@@ -0,0 +1,124 @@
|
|
|
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/ratchet/root-kdf.ts
|
|
23
|
+
//
|
|
24
|
+
// Root KDF functions for the Sparse Post-Quantum Ratchet (spec §7.2).
|
|
25
|
+
// Implements KDF_SCKA_INIT (ratchetInit) and KDF_SCKA_RK (kemRatchetEncap/Decap).
|
|
26
|
+
import { HKDF_SHA256 } from '../sha2/index.js';
|
|
27
|
+
import { isInitialized } from '../init.js';
|
|
28
|
+
import { wipe, concat, utf8ToBytes } from '../utils.js';
|
|
29
|
+
// Signal Double Ratchet §7.2 — info strings
|
|
30
|
+
const INFO_INIT = utf8ToBytes('leviathan-ratchet-v1 Chain Start');
|
|
31
|
+
const INFO_ROOT = utf8ToBytes('leviathan-ratchet-v1 Chain Add Epoch');
|
|
32
|
+
// INFO_CHAIN ('leviathan-ratchet-v1 Chain Step') is used in kdf-chain.ts
|
|
33
|
+
const ZERO_SALT = new Uint8Array(32);
|
|
34
|
+
// HKDF-SHA-256 root step (KDF_SCKA_INIT and KDF_SCKA_RK share this shape).
|
|
35
|
+
// ikm = 32-byte secret (shared secret or sk)
|
|
36
|
+
// salt = 32-byte salt (zero bytes for init, rk for KEM ratchet)
|
|
37
|
+
// info = protocol info bytes
|
|
38
|
+
// length = 96
|
|
39
|
+
// → { nextRootKey: [0:32], sendChainKey: [32:64], recvChainKey: [64:96] }
|
|
40
|
+
// Wipes okm after slicing.
|
|
41
|
+
function kdfRoot(secret, salt, info) {
|
|
42
|
+
const h = new HKDF_SHA256();
|
|
43
|
+
try {
|
|
44
|
+
const okm = h.derive(secret, salt, info, 96);
|
|
45
|
+
const nextRootKey = okm.slice(0, 32);
|
|
46
|
+
const sendChainKey = okm.slice(32, 64);
|
|
47
|
+
const recvChainKey = okm.slice(64, 96);
|
|
48
|
+
wipe(okm);
|
|
49
|
+
return { nextRootKey, sendChainKey, recvChainKey };
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
h.dispose();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// u32 big-endian length prefix — same convention as serpent/cipher-suite.ts AAD encoding.
|
|
56
|
+
function u32be(n) {
|
|
57
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xFFFFFFFF)
|
|
58
|
+
throw new RangeError(`u32be: n must be an integer in [0, 0xFFFFFFFF] (got ${n})`);
|
|
59
|
+
const b = new Uint8Array(4);
|
|
60
|
+
new DataView(b.buffer).setUint32(0, n, false);
|
|
61
|
+
return b;
|
|
62
|
+
}
|
|
63
|
+
// KEM ratchet info — binds peerEk, kemCt, and context with u32be length prefixes.
|
|
64
|
+
// Defense-in-depth: the HKDF output is tied to the exact (peerEk, kemCt, context)
|
|
65
|
+
// tuple used, not just the KEM shared secret. An attacker who substitutes any of
|
|
66
|
+
// these inputs derives a different chain key trio, regardless of the KEM transcript.
|
|
67
|
+
function buildRootInfo(peerEk, kemCt, context) {
|
|
68
|
+
const ctxBytes = context ?? new Uint8Array(0);
|
|
69
|
+
return concat(INFO_ROOT, u32be(peerEk.length), peerEk, u32be(kemCt.length), kemCt, u32be(ctxBytes.length), ctxBytes);
|
|
70
|
+
}
|
|
71
|
+
// KDF_SCKA_INIT — spec §7.2
|
|
72
|
+
// Derives initial root key, send chain key, and receive chain key from a
|
|
73
|
+
// shared secret sk. Optional context bytes are appended to the info string.
|
|
74
|
+
export function ratchetInit(sk, context) {
|
|
75
|
+
if (!isInitialized('sha2'))
|
|
76
|
+
throw new Error('leviathan-crypto: call init({ sha2: ... }) before using ratchetInit');
|
|
77
|
+
if (sk.length !== 32)
|
|
78
|
+
throw new RangeError('ratchetInit: sk must be 32 bytes');
|
|
79
|
+
const info = context != null && context.length > 0 ? concat(INFO_INIT, context) : INFO_INIT;
|
|
80
|
+
const { nextRootKey, sendChainKey, recvChainKey } = kdfRoot(sk, ZERO_SALT, info);
|
|
81
|
+
return { nextRootKey, sendChainKey, recvChainKey };
|
|
82
|
+
}
|
|
83
|
+
// KDF_SCKA_RK — encapsulation side (spec §7.2)
|
|
84
|
+
// Generates a fresh KEM ciphertext, derives next epoch keys from the shared secret.
|
|
85
|
+
// `peerEk` and `kemCt` are bound into the HKDF info string (defense-in-depth).
|
|
86
|
+
export function kemRatchetEncap(kem, rk, peerEk, context) {
|
|
87
|
+
if (!isInitialized('sha2'))
|
|
88
|
+
throw new Error('leviathan-crypto: call init({ sha2: ... }) before using kemRatchetEncap');
|
|
89
|
+
if (rk.length !== 32)
|
|
90
|
+
throw new RangeError('kemRatchetEncap: rk must be 32 bytes');
|
|
91
|
+
const { ciphertext: kemCt, sharedSecret } = kem.encapsulate(peerEk);
|
|
92
|
+
const info = buildRootInfo(peerEk, kemCt, context);
|
|
93
|
+
try {
|
|
94
|
+
const { nextRootKey, sendChainKey, recvChainKey } = kdfRoot(sharedSecret, rk, info);
|
|
95
|
+
return { nextRootKey, sendChainKey, recvChainKey, kemCt };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
wipe(sharedSecret);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// KDF_SCKA_RK — decapsulation side (spec §7.2)
|
|
102
|
+
// Recovers the shared secret from the KEM ciphertext, derives next epoch keys.
|
|
103
|
+
// The chain key slots are swapped relative to the encap side: what the KDF
|
|
104
|
+
// labels 'sendChainKey' (A→B direction) becomes the decap side's recvChainKey,
|
|
105
|
+
// and vice versa — Alice's send IS Bob's receive.
|
|
106
|
+
//
|
|
107
|
+
// `ownEk` is the local party's encapsulation key (the same public key the
|
|
108
|
+
// encap side targeted as `peerEk`). It must be passed explicitly so both
|
|
109
|
+
// sides bind the identical (peerEk, kemCt) pair into the HKDF info string.
|
|
110
|
+
export function kemRatchetDecap(kem, rk, dk, kemCt, ownEk, context) {
|
|
111
|
+
if (!isInitialized('sha2'))
|
|
112
|
+
throw new Error('leviathan-crypto: call init({ sha2: ... }) before using kemRatchetDecap');
|
|
113
|
+
if (rk.length !== 32)
|
|
114
|
+
throw new RangeError('kemRatchetDecap: rk must be 32 bytes');
|
|
115
|
+
const sharedSecret = kem.decapsulate(dk, kemCt);
|
|
116
|
+
const info = buildRootInfo(ownEk, kemCt, context);
|
|
117
|
+
try {
|
|
118
|
+
const { nextRootKey, sendChainKey: recvChainKey, recvChainKey: sendChainKey } = kdfRoot(sharedSecret, rk, info);
|
|
119
|
+
return { nextRootKey, sendChainKey, recvChainKey };
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
wipe(sharedSecret);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { KDFChain } from './kdf-chain.js';
|
|
2
|
+
import type { ResolveHandle, SkippedKeyStoreOpts } from './types.js';
|
|
3
|
+
export declare class SkippedKeyStore {
|
|
4
|
+
private _store;
|
|
5
|
+
private _maxCacheSize;
|
|
6
|
+
private _maxSkipPerResolve;
|
|
7
|
+
constructor(opts?: SkippedKeyStoreOpts);
|
|
8
|
+
private _evictOldest;
|
|
9
|
+
resolve(chain: KDFChain, counter: number): ResolveHandle;
|
|
10
|
+
private _makeHandle;
|
|
11
|
+
advanceToBoundary(chain: KDFChain, pn: number): void;
|
|
12
|
+
get size(): number;
|
|
13
|
+
wipeAll(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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/ratchet/skipped-key-store.ts
|
|
23
|
+
//
|
|
24
|
+
// SkippedKeyStore — MKSKIPPED cache for a single KDFChain (DR spec §3.2/§3.5).
|
|
25
|
+
// Manages out-of-order and skipped message key storage with transactional
|
|
26
|
+
// resolve via ResolveHandle. Split budgets: maxCacheSize bounds memory;
|
|
27
|
+
// maxSkipPerResolve bounds per-message HKDF work (DoS mitigation).
|
|
28
|
+
import { wipe } from '../utils.js';
|
|
29
|
+
// Best-effort wipe if a handle is GC'd without settling. GC is non-deterministic
|
|
30
|
+
// so this is a safety net only; the contract remains that callers MUST settle.
|
|
31
|
+
const finalizer = new FinalizationRegistry(key => wipe(key));
|
|
32
|
+
export class SkippedKeyStore {
|
|
33
|
+
_store;
|
|
34
|
+
_maxCacheSize;
|
|
35
|
+
_maxSkipPerResolve;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
const cache = opts?.maxCacheSize ?? opts?.ceiling ?? 100;
|
|
38
|
+
const skip = opts?.maxSkipPerResolve ?? opts?.ceiling ?? 50;
|
|
39
|
+
if (!Number.isSafeInteger(cache) || cache < 1)
|
|
40
|
+
throw new RangeError('SkippedKeyStore: maxCacheSize must be a safe integer >= 1');
|
|
41
|
+
if (!Number.isSafeInteger(skip) || skip < 1)
|
|
42
|
+
throw new RangeError('SkippedKeyStore: maxSkipPerResolve must be a safe integer >= 1');
|
|
43
|
+
if (skip > cache)
|
|
44
|
+
throw new RangeError(`SkippedKeyStore: maxSkipPerResolve (${skip}) must not exceed maxCacheSize (${cache})`);
|
|
45
|
+
this._store = new Map();
|
|
46
|
+
this._maxCacheSize = cache;
|
|
47
|
+
this._maxSkipPerResolve = skip;
|
|
48
|
+
}
|
|
49
|
+
// O(1) eviction — Map iteration is insertion order, and keys are inserted
|
|
50
|
+
// in strictly increasing counter order, so the first key yielded IS the
|
|
51
|
+
// oldest (lowest counter).
|
|
52
|
+
_evictOldest() {
|
|
53
|
+
const oldest = this._store.keys().next().value;
|
|
54
|
+
if (oldest === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const val = this._store.get(oldest);
|
|
57
|
+
if (val !== undefined)
|
|
58
|
+
wipe(val);
|
|
59
|
+
this._store.delete(oldest);
|
|
60
|
+
}
|
|
61
|
+
// Resolve a message key for the given counter. Returns a ResolveHandle the
|
|
62
|
+
// caller settles via commit() (success — key wiped) or rollback()
|
|
63
|
+
// (failure — key returned to the store so a later legitimate message at
|
|
64
|
+
// the same counter can still decrypt).
|
|
65
|
+
//
|
|
66
|
+
// Three paths based on counter vs chain.n:
|
|
67
|
+
// in-order (=== n+1): step chain, wrap final key
|
|
68
|
+
// skip-ahead (> n+1): step chain storing intermediates, wrap final
|
|
69
|
+
// past (<= n): look up in map and delete; throw if absent
|
|
70
|
+
resolve(chain, counter) {
|
|
71
|
+
if (!Number.isSafeInteger(counter) || counter < 1)
|
|
72
|
+
throw new RangeError(`SkippedKeyStore: invalid counter ${counter}`);
|
|
73
|
+
let key;
|
|
74
|
+
if (counter === chain.n + 1) {
|
|
75
|
+
key = chain.step();
|
|
76
|
+
}
|
|
77
|
+
else if (counter > chain.n + 1) {
|
|
78
|
+
const skipNeeded = counter - chain.n - 1;
|
|
79
|
+
if (skipNeeded > this._maxSkipPerResolve)
|
|
80
|
+
throw new RangeError(`SkippedKeyStore: counter ${counter} requires ${skipNeeded} skip derivations, `
|
|
81
|
+
+ `exceeds maxSkipPerResolve=${this._maxSkipPerResolve}`);
|
|
82
|
+
while (chain.n < counter - 1) {
|
|
83
|
+
const k = chain.step();
|
|
84
|
+
if (this._store.size >= this._maxCacheSize)
|
|
85
|
+
this._evictOldest();
|
|
86
|
+
this._store.set(chain.n, k);
|
|
87
|
+
}
|
|
88
|
+
key = chain.step();
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const stored = this._store.get(counter);
|
|
92
|
+
if (stored === undefined)
|
|
93
|
+
throw new Error(`SkippedKeyStore: unrecoverable. key for counter ${counter} not found`);
|
|
94
|
+
this._store.delete(counter);
|
|
95
|
+
key = stored;
|
|
96
|
+
}
|
|
97
|
+
return this._makeHandle(key, counter);
|
|
98
|
+
}
|
|
99
|
+
_makeHandle(key, counter) {
|
|
100
|
+
let settled = false;
|
|
101
|
+
const handle = {
|
|
102
|
+
get key() {
|
|
103
|
+
if (settled)
|
|
104
|
+
throw new Error('SkippedKeyStore: handle already settled');
|
|
105
|
+
return key;
|
|
106
|
+
},
|
|
107
|
+
commit: () => {
|
|
108
|
+
if (settled)
|
|
109
|
+
throw new Error('SkippedKeyStore: handle already settled');
|
|
110
|
+
settled = true;
|
|
111
|
+
finalizer.unregister(handle);
|
|
112
|
+
wipe(key);
|
|
113
|
+
},
|
|
114
|
+
rollback: () => {
|
|
115
|
+
if (settled)
|
|
116
|
+
throw new Error('SkippedKeyStore: handle already settled');
|
|
117
|
+
settled = true;
|
|
118
|
+
finalizer.unregister(handle);
|
|
119
|
+
if (this._store.size >= this._maxCacheSize)
|
|
120
|
+
this._evictOldest();
|
|
121
|
+
this._store.set(counter, key);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
finalizer.register(handle, key, handle);
|
|
125
|
+
return handle;
|
|
126
|
+
}
|
|
127
|
+
// Step chain from its current position up to and including pn, storing each
|
|
128
|
+
// key. Used at epoch transitions so late-arriving old-epoch messages can
|
|
129
|
+
// still be decrypted. No-op when pn <= chain.n. Enforces maxSkipPerResolve
|
|
130
|
+
// so a malicious header can't force unbounded HKDF work.
|
|
131
|
+
advanceToBoundary(chain, pn) {
|
|
132
|
+
if (!Number.isSafeInteger(pn) || pn < 0)
|
|
133
|
+
throw new RangeError(`SkippedKeyStore: invalid pn ${pn}`);
|
|
134
|
+
const skipNeeded = pn - chain.n;
|
|
135
|
+
if (skipNeeded > this._maxSkipPerResolve)
|
|
136
|
+
throw new RangeError(`SkippedKeyStore: pn=${pn} requires ${skipNeeded} skip derivations, `
|
|
137
|
+
+ `exceeds maxSkipPerResolve=${this._maxSkipPerResolve}`);
|
|
138
|
+
while (chain.n < pn) {
|
|
139
|
+
const key = chain.step();
|
|
140
|
+
if (this._store.size >= this._maxCacheSize)
|
|
141
|
+
this._evictOldest();
|
|
142
|
+
this._store.set(chain.n, key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
get size() {
|
|
146
|
+
return this._store.size;
|
|
147
|
+
}
|
|
148
|
+
// Wipe all stored key buffers and clear the map. Idempotent.
|
|
149
|
+
wipeAll() {
|
|
150
|
+
for (const v of this._store.values())
|
|
151
|
+
wipe(v);
|
|
152
|
+
this._store.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type { MlKemLike } from '../kyber/suite.js';
|
|
2
|
+
export interface RatchetInitResult {
|
|
3
|
+
readonly nextRootKey: Uint8Array;
|
|
4
|
+
readonly sendChainKey: Uint8Array;
|
|
5
|
+
readonly recvChainKey: Uint8Array;
|
|
6
|
+
}
|
|
7
|
+
export interface KemEncapResult {
|
|
8
|
+
readonly nextRootKey: Uint8Array;
|
|
9
|
+
readonly sendChainKey: Uint8Array;
|
|
10
|
+
readonly recvChainKey: Uint8Array;
|
|
11
|
+
readonly kemCt: Uint8Array;
|
|
12
|
+
}
|
|
13
|
+
export interface KemDecapResult {
|
|
14
|
+
readonly nextRootKey: Uint8Array;
|
|
15
|
+
readonly sendChainKey: Uint8Array;
|
|
16
|
+
readonly recvChainKey: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
export interface RatchetMessageHeader {
|
|
19
|
+
readonly epoch: number;
|
|
20
|
+
readonly counter: number;
|
|
21
|
+
readonly pn?: number;
|
|
22
|
+
readonly kemCt?: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
export interface ResolveHandle {
|
|
25
|
+
readonly key: Uint8Array;
|
|
26
|
+
commit(): void;
|
|
27
|
+
rollback(): void;
|
|
28
|
+
}
|
|
29
|
+
export interface SkippedKeyStoreOpts {
|
|
30
|
+
/** Max keys held in cache. Default 100. Must be >= maxSkipPerResolve. */
|
|
31
|
+
maxCacheSize?: number;
|
|
32
|
+
/** Max skip-ahead derivations per resolve() call. Default 50. */
|
|
33
|
+
maxSkipPerResolve?: number;
|
|
34
|
+
/** @deprecated use maxCacheSize + maxSkipPerResolve. If provided, sets both. */
|
|
35
|
+
ceiling?: number;
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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/ratchet/types.ts
|
|
23
|
+
//
|
|
24
|
+
// Shared types for the ratchet KDF construction.
|
|
25
|
+
// Re-exports MlKemLike so consumers import from one place.
|
|
26
|
+
export {};
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { CipherSuite } from '../stream/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `CipherSuite` implementation for the stream construction using Serpent-256.
|
|
4
|
+
*
|
|
5
|
+
* Each chunk is encrypted with Serpent-CBC (PKCS7) and authenticated with
|
|
6
|
+
* HMAC-SHA-256. Keys are derived via 3-way HKDF-SHA-256 (enc / mac / iv keys).
|
|
7
|
+
* Verify-then-decrypt ordering prevents padding oracle attacks (Vaudenay 2002).
|
|
8
|
+
*
|
|
9
|
+
* Pass to `SealStream` / `OpenStream` / `SealStreamPool` instead of constructing
|
|
10
|
+
* this object directly. Use `SerpentCipher.keygen()` to generate a 32-byte key.
|
|
11
|
+
*/
|
|
2
12
|
export declare const SerpentCipher: CipherSuite & {
|
|
3
13
|
keygen(): Uint8Array;
|
|
4
14
|
};
|
|
@@ -24,12 +24,23 @@
|
|
|
24
24
|
// SerpentCipher — CipherSuite implementation for the STREAM construction.
|
|
25
25
|
// 3-key HKDF derivation, HMAC-derived CBC IV, Serpent-CBC + HMAC-SHA-256.
|
|
26
26
|
// Verify-then-decrypt ordering prevents padding oracle attacks (Vaudenay 2002).
|
|
27
|
-
import {
|
|
28
|
-
import { HKDF_SHA256, HMAC_SHA256 } from '../sha2/index.js';
|
|
27
|
+
import { HKDF_SHA256 } from '../sha2/index.js';
|
|
29
28
|
import { constantTimeEqual, wipe, concat, randomBytes } from '../utils.js';
|
|
30
29
|
import { AuthenticationError } from '../errors.js';
|
|
31
|
-
import { getInstance } from '../init.js';
|
|
30
|
+
import { getInstance, _assertNotOwned } from '../init.js';
|
|
31
|
+
import { hmacSha256, cbcEncryptChunk, cbcDecryptChunk, } from './shared-ops.js';
|
|
32
|
+
import { WORKER_SOURCE } from '../embedded/serpent-pool-worker.js';
|
|
32
33
|
const INFO = new TextEncoder().encode('serpent-sealstream-v2');
|
|
34
|
+
/**
|
|
35
|
+
* `CipherSuite` implementation for the stream construction using Serpent-256.
|
|
36
|
+
*
|
|
37
|
+
* Each chunk is encrypted with Serpent-CBC (PKCS7) and authenticated with
|
|
38
|
+
* HMAC-SHA-256. Keys are derived via 3-way HKDF-SHA-256 (enc / mac / iv keys).
|
|
39
|
+
* Verify-then-decrypt ordering prevents padding oracle attacks (Vaudenay 2002).
|
|
40
|
+
*
|
|
41
|
+
* Pass to `SealStream` / `OpenStream` / `SealStreamPool` instead of constructing
|
|
42
|
+
* this object directly. Use `SerpentCipher.keygen()` to generate a 32-byte key.
|
|
43
|
+
*/
|
|
33
44
|
export const SerpentCipher = {
|
|
34
45
|
formatEnum: 0x02,
|
|
35
46
|
formatName: 'serpent',
|
|
@@ -38,10 +49,19 @@ export const SerpentCipher = {
|
|
|
38
49
|
kemCtSize: 0,
|
|
39
50
|
tagSize: 32,
|
|
40
51
|
padded: true,
|
|
52
|
+
wasmChunkSize: 65552, // src/asm/serpent/buffers.ts CHUNK_SIZE (65536 + 16 PKCS7 max overhead)
|
|
41
53
|
wasmModules: ['serpent', 'sha2'],
|
|
54
|
+
/** Generate a random 32-byte master key suitable for use with `SerpentCipher`. @returns 32 cryptographically random bytes */
|
|
42
55
|
keygen() {
|
|
43
56
|
return randomBytes(32);
|
|
44
57
|
},
|
|
58
|
+
/**
|
|
59
|
+
* Derive 96 bytes of keying material from `masterKey` and `nonce` via HKDF-SHA-256.
|
|
60
|
+
* Layout: bytes[0:32]=enc_key, bytes[32:64]=mac_key, bytes[64:96]=iv_key.
|
|
61
|
+
* @param masterKey 32-byte master key
|
|
62
|
+
* @param nonce Stream nonce (16 bytes minimum)
|
|
63
|
+
* @returns `DerivedKeys` holding the 96-byte material
|
|
64
|
+
*/
|
|
45
65
|
deriveKeys(masterKey, nonce, _kemCt) {
|
|
46
66
|
const hkdf = new HKDF_SHA256();
|
|
47
67
|
const derived = hkdf.derive(masterKey, nonce, INFO, 96);
|
|
@@ -49,73 +69,139 @@ export const SerpentCipher = {
|
|
|
49
69
|
// bytes[0:32]=enc_key, bytes[32:64]=mac_key, bytes[64:96]=iv_key
|
|
50
70
|
return { bytes: derived };
|
|
51
71
|
},
|
|
72
|
+
/**
|
|
73
|
+
* Encrypt and authenticate one stream chunk.
|
|
74
|
+
* IV is derived from `counterNonce` via HMAC-SHA-256 with the iv_key.
|
|
75
|
+
* Output: ciphertext (PKCS7-padded) || 32-byte HMAC tag.
|
|
76
|
+
* @param keys Derived keys from `deriveKeys`
|
|
77
|
+
* @param counterNonce Per-chunk nonce (unique per chunk in the stream)
|
|
78
|
+
* @param chunk Plaintext chunk
|
|
79
|
+
* @param aad Optional additional authenticated data
|
|
80
|
+
* @returns Authenticated ciphertext
|
|
81
|
+
*/
|
|
52
82
|
sealChunk(keys, counterNonce, chunk, aad) {
|
|
83
|
+
// shared-ops functions operate directly on the module exports without
|
|
84
|
+
// going through `_acquireModule`. Assert no stateful instance owns
|
|
85
|
+
// either module before touching WASM memory.
|
|
86
|
+
_assertNotOwned('serpent');
|
|
87
|
+
_assertNotOwned('sha2');
|
|
88
|
+
const sx = getInstance('sha2').exports;
|
|
89
|
+
const kx = getInstance('serpent').exports;
|
|
53
90
|
const encKey = keys.bytes.subarray(0, 32);
|
|
54
91
|
const macKey = keys.bytes.subarray(32, 64);
|
|
55
92
|
const ivKey = keys.bytes.subarray(64, 96);
|
|
56
93
|
const aadBytes = aad ?? new Uint8Array(0);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
let iv;
|
|
95
|
+
let tagInput;
|
|
96
|
+
try {
|
|
97
|
+
// Derive IV from counter nonce
|
|
98
|
+
const ivFull = hmacSha256(sx, ivKey, counterNonce);
|
|
99
|
+
iv = ivFull.slice(0, 16);
|
|
100
|
+
wipe(ivFull);
|
|
101
|
+
// Encrypt: Serpent-CBC with PKCS7 padding
|
|
102
|
+
const ct = cbcEncryptChunk(kx, encKey, iv, chunk);
|
|
103
|
+
// Compute HMAC tag: HMAC-SHA-256(mac_key, counterNonce || u32be(aad_len) || aad || ct)
|
|
104
|
+
const aadLenBuf = new Uint8Array(4);
|
|
105
|
+
new DataView(aadLenBuf.buffer).setUint32(0, aadBytes.length, false);
|
|
106
|
+
tagInput = concat(counterNonce, aadLenBuf, aadBytes, ct);
|
|
107
|
+
const tag = hmacSha256(sx, macKey, tagInput);
|
|
108
|
+
// Output: ct || tag (IV is NOT included)
|
|
109
|
+
return concat(ct, tag);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (iv)
|
|
113
|
+
wipe(iv);
|
|
114
|
+
if (tagInput)
|
|
115
|
+
wipe(tagInput);
|
|
116
|
+
// No hmac/cbc instance to dispose — shared-ops functions are instance-free.
|
|
117
|
+
}
|
|
76
118
|
},
|
|
119
|
+
/**
|
|
120
|
+
* Verify and decrypt one stream chunk. HMAC is verified before decryption
|
|
121
|
+
* to prevent padding oracle attacks (Vaudenay 2002). Throws
|
|
122
|
+
* `AuthenticationError` on tag mismatch.
|
|
123
|
+
* @param keys Derived keys from `deriveKeys`
|
|
124
|
+
* @param counterNonce Per-chunk nonce — must match the value used by `sealChunk`
|
|
125
|
+
* @param chunk Ciphertext || 32-byte HMAC tag
|
|
126
|
+
* @param aad Optional additional authenticated data
|
|
127
|
+
* @returns Plaintext with PKCS7 padding removed
|
|
128
|
+
*/
|
|
77
129
|
openChunk(keys, counterNonce, chunk, aad) {
|
|
78
130
|
if (chunk.length < 32)
|
|
79
131
|
throw new RangeError(`chunk too short for 32-byte tag (got ${chunk.length})`);
|
|
132
|
+
_assertNotOwned('serpent');
|
|
133
|
+
_assertNotOwned('sha2');
|
|
134
|
+
const sx = getInstance('sha2').exports;
|
|
135
|
+
const kx = getInstance('serpent').exports;
|
|
80
136
|
const encKey = keys.bytes.subarray(0, 32);
|
|
81
137
|
const macKey = keys.bytes.subarray(32, 64);
|
|
82
138
|
const ivKey = keys.bytes.subarray(64, 96);
|
|
83
139
|
const aadBytes = aad ?? new Uint8Array(0);
|
|
84
140
|
const ct = chunk.subarray(0, chunk.length - 32);
|
|
85
141
|
const receivedTag = chunk.subarray(chunk.length - 32);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
let iv;
|
|
143
|
+
let tagInput;
|
|
144
|
+
let expectedTag;
|
|
145
|
+
try {
|
|
146
|
+
// Derive IV from counter nonce
|
|
147
|
+
const ivFull = hmacSha256(sx, ivKey, counterNonce);
|
|
148
|
+
iv = ivFull.slice(0, 16);
|
|
149
|
+
wipe(ivFull);
|
|
150
|
+
// Compute expected tag: HMAC-SHA-256(mac_key, counterNonce || u32be(aad_len) || aad || ct)
|
|
151
|
+
const aadLenBuf = new Uint8Array(4);
|
|
152
|
+
new DataView(aadLenBuf.buffer).setUint32(0, aadBytes.length, false);
|
|
153
|
+
tagInput = concat(counterNonce, aadLenBuf, aadBytes, ct);
|
|
154
|
+
expectedTag = hmacSha256(sx, macKey, tagInput);
|
|
155
|
+
// CRITICAL: Verify HMAC BEFORE decrypting.
|
|
156
|
+
// Evaluating PKCS7 padding on unauthenticated data is a padding oracle (Vaudenay 2002).
|
|
157
|
+
// Belt-and-suspenders: explicit wipes here cover the auth-fail path before
|
|
158
|
+
// throwing; the finally block below covers every other path.
|
|
159
|
+
if (!constantTimeEqual(expectedTag, receivedTag)) {
|
|
160
|
+
wipe(iv);
|
|
161
|
+
wipe(tagInput);
|
|
162
|
+
wipe(expectedTag);
|
|
163
|
+
getInstance('serpent').exports.wipeBuffers();
|
|
164
|
+
throw new AuthenticationError('serpent');
|
|
165
|
+
}
|
|
166
|
+
// ONLY decrypt after authentication succeeds
|
|
167
|
+
return cbcDecryptChunk(kx, encKey, iv, ct);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (iv)
|
|
171
|
+
wipe(iv);
|
|
172
|
+
if (tagInput)
|
|
173
|
+
wipe(tagInput);
|
|
174
|
+
if (expectedTag)
|
|
175
|
+
wipe(expectedTag);
|
|
105
176
|
}
|
|
106
|
-
wipe(tagInput);
|
|
107
|
-
wipe(expectedTag);
|
|
108
|
-
// ONLY decrypt after authentication succeeds
|
|
109
|
-
const cbc = new SerpentCbc({ dangerUnauthenticated: true });
|
|
110
|
-
const plaintext = cbc.decrypt(encKey, iv, ct);
|
|
111
|
-
cbc.dispose();
|
|
112
|
-
wipe(iv);
|
|
113
|
-
return plaintext;
|
|
114
177
|
},
|
|
178
|
+
/**
|
|
179
|
+
* Zero all derived key material in `keys`. Called by the stream layer on
|
|
180
|
+
* teardown and after auth failure.
|
|
181
|
+
* @param keys Derived keys to wipe
|
|
182
|
+
*/
|
|
115
183
|
wipeKeys(keys) {
|
|
116
184
|
wipe(keys.bytes);
|
|
117
185
|
},
|
|
186
|
+
/**
|
|
187
|
+
* Spawn a Serpent pool worker from the embedded IIFE bundle.
|
|
188
|
+
* The worker holds its own serpent + sha2 WASM instances.
|
|
189
|
+
* @returns Newly constructed `Worker` instance
|
|
190
|
+
*/
|
|
118
191
|
createPoolWorker() {
|
|
119
|
-
|
|
192
|
+
// IIFE source is bundled at lib build time (scripts/embed-workers.ts).
|
|
193
|
+
// Avoids the syntactic `new Worker(new URL(..., import.meta.url))`
|
|
194
|
+
// pattern that triggers eager worker-chunk emission in Vite's
|
|
195
|
+
// transform hook (issue.md). Classic worker via blob URL —
|
|
196
|
+
// module workers fail on file:// in Chromium (issue2.md).
|
|
197
|
+
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' });
|
|
198
|
+
const url = URL.createObjectURL(blob);
|
|
199
|
+
const w = new Worker(url);
|
|
200
|
+
// Worker spec fetches the URL synchronously at construction. Revoke
|
|
201
|
+
// in a macrotask so the spawn completes first; releases the Blob
|
|
202
|
+
// (~5 KB per spawn × N workers) instead of leaking it for the
|
|
203
|
+
// document's lifetime.
|
|
204
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
205
|
+
return w;
|
|
120
206
|
},
|
|
121
207
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Generator } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Serpent-256 ECB counter-mode PRF for Fortuna's generator slot.
|
|
4
|
+
*
|
|
5
|
+
* Each 16-byte counter value is encrypted as a plaintext block to produce
|
|
6
|
+
* one block of pseudorandom output. Practical Cryptography (Ferguson &
|
|
7
|
+
* Schneier, 2003) §9.4.
|
|
8
|
+
*
|
|
9
|
+
* Pass to `Fortuna.create({ generator: SerpentGenerator, ... })` — do not
|
|
10
|
+
* call `generate()` directly outside of Fortuna.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SerpentGenerator: Generator;
|