leviathan-crypto 1.1.0 → 1.3.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.
@@ -5,11 +5,19 @@ export declare class SerpentStreamSealer {
5
5
  private readonly _cbc;
6
6
  private readonly _hmac;
7
7
  private readonly _hkdf;
8
+ private readonly _framed;
8
9
  private readonly _ivs;
9
10
  private _ivIdx;
10
11
  private _index;
11
12
  private _state;
12
- constructor(key: Uint8Array, chunkSize?: number, _nonce?: Uint8Array, _ivs?: Uint8Array[]);
13
+ /** Public: consumers use this 3-param form. */
14
+ constructor(key: Uint8Array, chunkSize?: number, opts?: {
15
+ framed?: boolean;
16
+ });
17
+ /** @internal Test-only overload to inject fixed nonce/IVs for deterministic KAT vectors. */
18
+ constructor(key: Uint8Array, chunkSize: number | undefined, opts: {
19
+ framed?: boolean;
20
+ } | undefined, _nonce: Uint8Array, _ivs?: Uint8Array[]);
13
21
  header(): Uint8Array;
14
22
  seal(plaintext: Uint8Array): Uint8Array;
15
23
  final(plaintext: Uint8Array): Uint8Array;
@@ -24,11 +32,19 @@ export declare class SerpentStreamOpener {
24
32
  private readonly _cbc;
25
33
  private readonly _hmac;
26
34
  private readonly _hkdf;
35
+ private readonly _framed;
36
+ private readonly _buf;
37
+ private readonly _maxFrame;
38
+ private _bufLen;
27
39
  private _index;
28
40
  private _dead;
29
- constructor(key: Uint8Array, header: Uint8Array);
41
+ constructor(key: Uint8Array, header: Uint8Array, opts?: {
42
+ framed?: boolean;
43
+ });
30
44
  get closed(): boolean;
31
45
  open(chunk: Uint8Array): Uint8Array;
46
+ private _openRaw;
47
+ feed(bytes: Uint8Array): Uint8Array[];
32
48
  private _wipe;
33
49
  dispose(): void;
34
50
  }
@@ -63,12 +63,12 @@ export class SerpentStreamSealer {
63
63
  _cbc;
64
64
  _hmac;
65
65
  _hkdf;
66
+ _framed;
66
67
  _ivs; // test seam: fixed IVs
67
68
  _ivIdx;
68
69
  _index;
69
70
  _state;
70
- // _nonce, _ivs: test seams — inject fixed nonce/IVs for deterministic KAT vectors
71
- constructor(key, chunkSize, _nonce, _ivs) {
71
+ constructor(key, chunkSize, opts, _nonce, _ivs) {
72
72
  if (!_serpentReady())
73
73
  throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamSealer');
74
74
  if (!_sha2Ready())
@@ -80,6 +80,7 @@ export class SerpentStreamSealer {
80
80
  throw new RangeError(`SerpentStreamSealer chunkSize must be ${CHUNK_MIN}..${CHUNK_MAX} (got ${cs})`);
81
81
  this._key = key.slice();
82
82
  this._cs = cs;
83
+ this._framed = opts?.framed ?? false;
83
84
  this._nonce = new Uint8Array(16);
84
85
  if (_nonce && _nonce.length === 16) {
85
86
  this._nonce.set(_nonce);
@@ -138,7 +139,13 @@ export class SerpentStreamSealer {
138
139
  const ciphertext = this._cbc.encrypt(encKey, iv, plaintext);
139
140
  const tag = this._hmac.hash(macKey, concat(iv, ciphertext));
140
141
  this._index++;
141
- return concat(concat(iv, ciphertext), tag);
142
+ const sealed = concat(concat(iv, ciphertext), tag);
143
+ if (!this._framed)
144
+ return sealed;
145
+ const out = new Uint8Array(4 + sealed.length);
146
+ out.set(u32be(sealed.length), 0);
147
+ out.set(sealed, 4);
148
+ return out;
142
149
  }
143
150
  _wipe() {
144
151
  wipe(this._key);
@@ -160,9 +167,13 @@ export class SerpentStreamOpener {
160
167
  _cbc;
161
168
  _hmac;
162
169
  _hkdf;
170
+ _framed;
171
+ _buf;
172
+ _maxFrame;
173
+ _bufLen;
163
174
  _index;
164
175
  _dead;
165
- constructor(key, header) {
176
+ constructor(key, header, opts) {
166
177
  if (!_serpentReady())
167
178
  throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamOpener');
168
179
  if (!_sha2Ready())
@@ -174,11 +185,21 @@ export class SerpentStreamOpener {
174
185
  this._key = key.slice();
175
186
  this._nonce = header.slice(0, 16);
176
187
  this._cs = (header[16] << 24 | header[17] << 16 | header[18] << 8 | header[19]) >>> 0;
188
+ if (this._cs < CHUNK_MIN || this._cs > CHUNK_MAX)
189
+ throw new RangeError(`SerpentStreamOpener: header contains invalid chunkSize ${this._cs} (expected ${CHUNK_MIN}..${CHUNK_MAX})`);
190
+ this._framed = opts?.framed ?? false;
177
191
  this._cbc = new SerpentCbc({ dangerUnauthenticated: true });
178
192
  this._hmac = new HMAC_SHA256();
179
193
  this._hkdf = new HKDF_SHA256();
180
194
  this._index = 0;
181
195
  this._dead = false;
196
+ this._bufLen = 0;
197
+ if (this._framed) {
198
+ const cs = this._cs;
199
+ const maxSealed = 16 + (cs + (16 - (cs % 16))) + 32;
200
+ this._maxFrame = 4 + maxSealed;
201
+ this._buf = new Uint8Array(this._maxFrame);
202
+ }
182
203
  }
183
204
  get closed() {
184
205
  return this._dead;
@@ -186,6 +207,11 @@ export class SerpentStreamOpener {
186
207
  open(chunk) {
187
208
  if (this._dead)
188
209
  throw new Error('SerpentStreamOpener: stream is closed');
210
+ if (this._framed)
211
+ throw new Error('SerpentStreamOpener: call feed() on framed openers — open() expects raw sealed chunks without length prefix');
212
+ return this._openRaw(chunk);
213
+ }
214
+ _openRaw(chunk) {
189
215
  // Try isLast = true first, then false.
190
216
  // Whichever passes auth is the correct interpretation.
191
217
  for (const isLast of [true, false]) {
@@ -208,12 +234,104 @@ export class SerpentStreamOpener {
208
234
  }
209
235
  throw new Error('SerpentStreamOpener: authentication failed');
210
236
  }
237
+ feed(bytes) {
238
+ if (!this._framed)
239
+ throw new Error('SerpentStreamOpener: feed() requires { framed: true }');
240
+ if (this._dead)
241
+ throw new Error('SerpentStreamOpener: stream is closed');
242
+ const buf = this._buf;
243
+ const maxFrame = this._maxFrame;
244
+ const results = [];
245
+ let consumed = 0;
246
+ // ── Phase 1: drain carry-over ─────────────────────────────────────
247
+ if (this._bufLen > 0) {
248
+ // Sub-case A: partial prefix — we have < 4 bytes buffered
249
+ if (this._bufLen < 4) {
250
+ const need = 4 - this._bufLen;
251
+ const take = Math.min(need, bytes.length - consumed);
252
+ buf.set(bytes.subarray(consumed, consumed + take), this._bufLen);
253
+ this._bufLen += take;
254
+ consumed += take;
255
+ if (this._bufLen < 4)
256
+ return results;
257
+ }
258
+ const sealedLen = (buf[0] << 24 | buf[1] << 16 | buf[2] << 8 | buf[3]) >>> 0;
259
+ if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
260
+ this._wipe();
261
+ throw new Error('SerpentStreamOpener: invalid sealed chunk length');
262
+ }
263
+ const frameLen = 4 + sealedLen;
264
+ // Sub-case B: partial frame body
265
+ const haveInBuf = this._bufLen;
266
+ const needMore = frameLen - haveInBuf;
267
+ if (needMore > 0) {
268
+ const take = Math.min(needMore, bytes.length - consumed);
269
+ buf.set(bytes.subarray(consumed, consumed + take), haveInBuf);
270
+ this._bufLen += take;
271
+ consumed += take;
272
+ if (this._bufLen < frameLen)
273
+ return results;
274
+ }
275
+ const plaintext = this._openRaw(buf.subarray(4, frameLen));
276
+ results.push(plaintext);
277
+ this._bufLen = 0;
278
+ if (this._dead) {
279
+ if (consumed < bytes.length) {
280
+ this._wipe();
281
+ throw new Error('SerpentStreamOpener: unexpected bytes after final chunk');
282
+ }
283
+ return results;
284
+ }
285
+ }
286
+ // ── Phase 2: parse complete frames directly from bytes ────────────
287
+ let pos = consumed;
288
+ while (true) {
289
+ if (bytes.length - pos < 4)
290
+ break;
291
+ const sealedLen = (bytes[pos] << 24 | bytes[pos + 1] << 16 | bytes[pos + 2] << 8 | bytes[pos + 3]) >>> 0;
292
+ if (sealedLen === 0 || sealedLen > (maxFrame - 4)) {
293
+ this._wipe();
294
+ throw new Error('SerpentStreamOpener: invalid sealed chunk length');
295
+ }
296
+ const frameLen = 4 + sealedLen;
297
+ if (bytes.length - pos < frameLen)
298
+ break;
299
+ const plaintext = this._openRaw(bytes.subarray(pos + 4, pos + frameLen));
300
+ results.push(plaintext);
301
+ if (this._dead) {
302
+ const remaining = bytes.length - pos - frameLen;
303
+ if (remaining > 0) {
304
+ this._wipe();
305
+ throw new Error('SerpentStreamOpener: unexpected bytes after final chunk');
306
+ }
307
+ return results;
308
+ }
309
+ pos += frameLen;
310
+ }
311
+ // ── Carry over any incomplete trailing bytes into _buf ────────────
312
+ const leftover = bytes.length - pos;
313
+ if (leftover > 0) {
314
+ if (leftover > maxFrame) {
315
+ this._wipe();
316
+ throw new Error('SerpentStreamOpener: input exceeds maximum frame size');
317
+ }
318
+ buf.set(bytes.subarray(pos), 0);
319
+ this._bufLen = leftover;
320
+ }
321
+ return results;
322
+ }
211
323
  _wipe() {
324
+ if (this._dead)
325
+ return;
212
326
  wipe(this._key);
213
327
  wipe(this._nonce);
214
328
  this._cbc.dispose();
215
329
  this._hmac.dispose();
216
330
  this._hkdf.dispose();
331
+ if (this._framed) {
332
+ wipe(this._buf);
333
+ this._bufLen = 0;
334
+ }
217
335
  this._dead = true;
218
336
  }
219
337
  dispose() {
package/dist/serpent.wasm CHANGED
Binary file
package/dist/sha2.wasm CHANGED
Binary file
package/dist/sha3.wasm CHANGED
Binary file
package/dist/utils.d.ts CHANGED
@@ -24,3 +24,8 @@ export declare const xor: (a: Uint8Array, b: Uint8Array) => Uint8Array;
24
24
  export declare const concat: (a: Uint8Array, b: Uint8Array) => Uint8Array;
25
25
  /** Cryptographically secure random bytes via Web Crypto API. */
26
26
  export declare const randomBytes: (n: number) => Uint8Array;
27
+ /**
28
+ * Detects WASM SIMD support once and caches the result.
29
+ * Gates CTR/CBC-decrypt dispatch in Serpent and encryptChunk dispatch in ChaCha20.
30
+ */
31
+ export declare function hasSIMD(): boolean;
package/dist/utils.js CHANGED
@@ -167,3 +167,28 @@ export const randomBytes = (n) => {
167
167
  crypto.getRandomValues(buf);
168
168
  return buf;
169
169
  };
170
+ // ── SIMD detection ───────────────────────────────────────────────────────────
171
+ let _simd = null;
172
+ /**
173
+ * Detects WASM SIMD support once and caches the result.
174
+ * Gates CTR/CBC-decrypt dispatch in Serpent and encryptChunk dispatch in ChaCha20.
175
+ */
176
+ export function hasSIMD() {
177
+ if (_simd !== null)
178
+ return _simd;
179
+ if (typeof WebAssembly === 'undefined' || typeof WebAssembly.validate !== 'function') {
180
+ _simd = false;
181
+ return _simd;
182
+ }
183
+ // Minimal WASM module using v128 — validates iff SIMD is supported
184
+ try {
185
+ _simd = WebAssembly.validate(new Uint8Array([
186
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123,
187
+ 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11,
188
+ ]));
189
+ }
190
+ catch {
191
+ _simd = false;
192
+ }
193
+ return _simd;
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leviathan-crypto",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "author": "xero (https://x-e.ro)",
5
5
  "license": "MIT",
6
6
  "description": "Zero-dependency WebAssembly cryptography library for TypeScript: Serpent-256, XChaCha20-Poly1305, SHA-2/3, HMAC, HKDF, and Fortuna CSPRNG, with a strictly typed API built on vector-verified primitives.",
@@ -1,20 +0,0 @@
1
- export declare class SerpentStreamEncoder {
2
- private readonly _sealer;
3
- private _state;
4
- constructor(key: Uint8Array, chunkSize?: number, _nonce?: Uint8Array, _ivs?: Uint8Array[]);
5
- header(): Uint8Array;
6
- encode(plaintext: Uint8Array): Uint8Array;
7
- encodeFinal(plaintext: Uint8Array): Uint8Array;
8
- dispose(): void;
9
- }
10
- export declare class SerpentStreamDecoder {
11
- private readonly _opener;
12
- private readonly _buf;
13
- private readonly _maxFrame;
14
- private _bufLen;
15
- private _dead;
16
- constructor(key: Uint8Array, header: Uint8Array);
17
- feed(bytes: Uint8Array): Uint8Array[];
18
- private _wipe;
19
- dispose(): void;
20
- }
@@ -1,167 +0,0 @@
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/serpent/stream-encoder.ts
23
- //
24
- // Tier 2 pure-TS composition: SerpentStreamSealer / SerpentStreamOpener
25
- // with u32be length-prefixed framing.
26
- //
27
- // SerpentStreamEncoder: wraps SerpentStreamSealer, prepends u32be(sealedLen)
28
- // SerpentStreamDecoder: wraps SerpentStreamOpener, buffers input and assembles
29
- // complete frames before dispatching to opener.open()
30
- //
31
- // Wire format per chunk:
32
- // u32be(sealedLen) || IV(16) || CBC_ciphertext(padded) || HMAC(32)
33
- //
34
- // Use these classes when chunks will be concatenated into a flat byte array
35
- // (files, buffered TCP, etc). Use SerpentStreamSealer/Opener directly when
36
- // the transport already frames messages (WebSocket, IPC, etc).
37
- import { SerpentStreamSealer, SerpentStreamOpener } from './stream-sealer.js';
38
- import { _serpentReady } from './index.js';
39
- import { _sha2Ready } from '../sha2/index.js';
40
- import { wipe } from '../utils.js';
41
- import { u32be } from './stream.js';
42
- export class SerpentStreamEncoder {
43
- _sealer;
44
- _state;
45
- // _nonce, _ivs: test seams — passed through to SerpentStreamSealer
46
- constructor(key, chunkSize, _nonce, _ivs) {
47
- if (!_serpentReady())
48
- throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamEncoder');
49
- if (!_sha2Ready())
50
- throw new Error('leviathan-crypto: call init([\'sha2\']) before using SerpentStreamEncoder');
51
- this._sealer = new SerpentStreamSealer(key, chunkSize, _nonce, _ivs);
52
- this._state = 'fresh';
53
- }
54
- header() {
55
- if (this._state === 'encoding')
56
- throw new Error('SerpentStreamEncoder: header() already called');
57
- if (this._state === 'dead')
58
- throw new Error('SerpentStreamEncoder: stream is closed');
59
- this._state = 'encoding';
60
- return this._sealer.header();
61
- }
62
- encode(plaintext) {
63
- if (this._state === 'fresh')
64
- throw new Error('SerpentStreamEncoder: call header() first');
65
- if (this._state === 'dead')
66
- throw new Error('SerpentStreamEncoder: stream is closed');
67
- const sealed = this._sealer.seal(plaintext);
68
- return _prependLen(sealed);
69
- }
70
- encodeFinal(plaintext) {
71
- if (this._state === 'fresh')
72
- throw new Error('SerpentStreamEncoder: call header() first');
73
- if (this._state === 'dead')
74
- throw new Error('SerpentStreamEncoder: stream is closed');
75
- const sealed = this._sealer.final(plaintext);
76
- this._state = 'dead';
77
- return _prependLen(sealed);
78
- }
79
- dispose() {
80
- if (this._state !== 'dead') {
81
- this._sealer.dispose();
82
- this._state = 'dead';
83
- }
84
- }
85
- }
86
- // ── SerpentStreamDecoder ─────────────────────────────────────────────────────
87
- export class SerpentStreamDecoder {
88
- _opener;
89
- _buf; // fixed-size accumulation buffer
90
- _maxFrame; // 4 + max sealed chunk size
91
- _bufLen; // valid bytes currently in _buf
92
- _dead;
93
- constructor(key, header) {
94
- if (!_serpentReady())
95
- throw new Error('leviathan-crypto: call init([\'serpent\']) before using SerpentStreamDecoder');
96
- if (!_sha2Ready())
97
- throw new Error('leviathan-crypto: call init([\'sha2\']) before using SerpentStreamDecoder');
98
- this._opener = new SerpentStreamOpener(key, header);
99
- // Parse chunkSize from stream header (bytes 16..20, u32be)
100
- const cs = (header[16] << 24 | header[17] << 16 | header[18] << 8 | header[19]) >>> 0;
101
- // Max sealed chunk size: IV(16) + PKCS7-padded ciphertext + HMAC(32)
102
- // PKCS7: plaintext always padded to next multiple of 16 (minimum 1 pad byte)
103
- const maxSealed = 16 + (cs + (16 - (cs % 16))) + 32;
104
- this._maxFrame = 4 + maxSealed;
105
- this._buf = new Uint8Array(this._maxFrame);
106
- this._bufLen = 0;
107
- this._dead = false;
108
- }
109
- feed(bytes) {
110
- if (this._dead)
111
- throw new Error('SerpentStreamDecoder: stream is closed');
112
- // Append incoming bytes to accumulation buffer
113
- if (this._bufLen + bytes.length > this._maxFrame) {
114
- throw new Error('SerpentStreamDecoder: input exceeds maximum frame size');
115
- }
116
- this._buf.set(bytes, this._bufLen);
117
- this._bufLen += bytes.length;
118
- const results = [];
119
- while (true) {
120
- // Need at least 4 bytes for the length prefix
121
- if (this._bufLen < 4)
122
- break;
123
- const sealedLen = ((this._buf[0] << 24 | this._buf[1] << 16 |
124
- this._buf[2] << 8 | this._buf[3]) >>> 0);
125
- const frameLen = 4 + sealedLen;
126
- // Need the full frame
127
- if (this._bufLen < frameLen)
128
- break;
129
- // Complete frame — dispatch to opener
130
- const sealedChunk = this._buf.subarray(4, frameLen);
131
- const plaintext = this._opener.open(sealedChunk);
132
- results.push(plaintext);
133
- if (this._opener.closed) {
134
- // After final chunk: any leftover bytes are a protocol error
135
- const remaining = this._bufLen - frameLen;
136
- if (remaining > 0) {
137
- this._wipe();
138
- throw new Error('SerpentStreamDecoder: unexpected bytes after final chunk');
139
- }
140
- this._wipe();
141
- return results;
142
- }
143
- // Shift remaining bytes to front of buffer — no allocation
144
- const remaining = this._bufLen - frameLen;
145
- this._buf.copyWithin(0, frameLen, frameLen + remaining);
146
- this._bufLen = remaining;
147
- }
148
- return results;
149
- }
150
- _wipe() {
151
- wipe(this._buf);
152
- this._bufLen = 0;
153
- this._opener.dispose();
154
- this._dead = true;
155
- }
156
- dispose() {
157
- if (!this._dead)
158
- this._wipe();
159
- }
160
- }
161
- // ── helpers ──────────────────────────────────────────────────────────────────
162
- function _prependLen(chunk) {
163
- const out = new Uint8Array(4 + chunk.length);
164
- out.set(u32be(chunk.length), 0);
165
- out.set(chunk, 4);
166
- return out;
167
- }