dstu-core 0.3.0 → 0.5.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/README.md CHANGED
@@ -55,12 +55,13 @@ console.log(dstu.secretboxOpen(key, sealed).toString()); // "a message worth pro
55
55
  | Module | Functions/classes | Notes |
56
56
  |---|---|---|
57
57
  | `crypto_secretbox` | `secretboxKeygen`, `secretboxSeal`, `secretboxOpen` | Single-message authenticated encryption. `examples/secretbox.js`. |
58
- | `crypto_box` | `boxKeygen`, `boxPublicKey`, `boxSeal`, `boxOpen` | Public-key encryption (hybrid via KDF over `hazmat::dstu9041`, `l(p)=256`, D-169). `boxSeal`/`boxOpen` are not memory-bounded — the whole message is held in memory. `examples/box.js`. |
59
- | `crypto_box512` | `box512Keygen`, `box512PublicKey`, `box512Seal`, `box512Open` | `l(p)=512`/E512/1 sibling of `crypto_box` (T-193/T-204). `examples/box512.js`. |
58
+ | `crypto_box` | `boxKeygen`, `boxPublicKey`, `boxSeal`, `boxOpen`, `BoxEncryptor`, `BoxDecryptor` | Public-key encryption (hybrid via KDF over `hazmat::dstu9041`, `l(p)=256`, D-169). `boxSeal`/`boxOpen` hold the whole message in memory; the `stream.Transform` `BoxEncryptor`/`BoxDecryptor` write and read the same bytes (format v3) chunk by chunk, as `uacrypt box-seal`/`box-open` do. `examples/box.js`. |
59
+ | `crypto_box384` | `box384Keygen`, `box384PublicKey`, `box384Seal`, `box384Open`, `Box384Encryptor`, `Box384Decryptor` | `l(p)=384`/E384/1 sibling of `crypto_box` (T-297). `examples/box384.js`. |
60
+ | `crypto_box512` | `box512Keygen`, `box512PublicKey`, `box512Seal`, `box512Open`, `Box512Encryptor`, `Box512Decryptor` | `l(p)=512`/E512/1 sibling of `crypto_box` (T-193/T-204). `examples/box512.js`. |
60
61
  | `crypto_secretstream` | `secretstreamKeygen`, `SecretStreamPushState`, `SecretStreamPullState`, `SecretStreamEncryptor`, `SecretStreamDecryptor` | Chunked streaming AEAD. The `stream.Transform` `SecretStreamEncryptor`/`SecretStreamDecryptor` wire format matches `uacrypt encrypt`/`decrypt` exactly (D-118). `examples/secretstream-file.js`. |
61
62
  | `crypto_sign` | `signKeygen`, `signVerifyingKey`, `signMessage`, `signVerify` | DSTU 4145 `m=163` digital signatures, deterministic nonce (no RNG dependency). `examples/sign.js`. |
62
63
  | `crypto_sign257` | `sign257Keygen`, `sign257VerifyingKey`, `sign257Message`, `sign257Verify` | `m=257` sibling of `crypto_sign` (T-199/T-204) — the curve real Diia-issued qualified signatures use. `examples/sign257.js`. |
63
- | `crypto_pwhash` | `pwhashHashPassword`, `pwhashVerifyPassword`, `PWHASH_INTERACTIVE`/`PWHASH_MODERATE`/`PWHASH_SENSITIVE` | Argon2id (the one deliberately non-DSTU component, D-49/D-50). `examples/password-hashing.js`. |
64
+ | `crypto_pwhash` | `pwhashHashPassword`, `pwhashVerifyPassword`, `pwhashDeriveKey`, `PWHASH_INTERACTIVE`/`PWHASH_MODERATE`/`PWHASH_SENSITIVE`, `PassphraseEncryptor`, `PassphraseDecryptor` | Argon2id (the one deliberately non-DSTU component, D-49/D-50). `pwhashDeriveKey` equals libsodium's `crypto_pwhash`. `PassphraseEncryptor`/`PassphraseDecryptor` write and read the files `uacrypt encrypt --passphrase` makes (D-219/D-240); each runs Argon2id at 256 MiB once, synchronously on the calling thread like `pwhashHashPassword`. `examples/password-hashing.js`. |
64
65
  | `crypto_auth` | `authKeygen`, `auth`, `authVerify` | Keyed message authentication (Kupyna-KMAC). `examples/misc.js`. |
65
66
  | `crypto_kdf` | `kdfKeygen`, `kdfDeriveSubkey` | Deterministic subkey derivation. `subkeyId` is a plain non-negative `number`, not `BigInt`. `examples/misc.js`. |
66
67
  | `crypto_generichash` | `kupyna256`, `kupyna512`, `Kupyna256Hasher`, `Kupyna512Hasher` | One-shot and streaming Kupyna hashing. `examples/misc.js`. |
package/js/index.js CHANGED
@@ -15,10 +15,9 @@
15
15
  */
16
16
 
17
17
  const native = require('../native/index.js');
18
- const { SecretStreamEncryptor, SecretStreamDecryptor } = require('./secretstream.js');
18
+ const streams = require('./secretstream.js');
19
19
 
20
20
  module.exports = {
21
21
  ...native,
22
- SecretStreamEncryptor,
23
- SecretStreamDecryptor,
22
+ ...streams,
24
23
  };
@@ -2,9 +2,10 @@
2
2
 
3
3
  /**
4
4
  * Idiomatic `crypto_secretstream` pipeline (docs/DECISIONS.md D-118, docs/bindings-strategy.md
5
- * T-50 step 3) - hides chunk/tag/length-prefix bookkeeping behind a `stream.Transform` pair, built
6
- * in pure JS on top of the low-level SecretStreamPushState/SecretStreamPullState the native addon
7
- * already exposes (step 2), rather than new Rust glue. Mirrors
5
+ * T-50 step 3) - hides chunk/tag/length-prefix bookkeeping behind a `stream.Transform` pair. The
6
+ * framing itself (record layout, length rules, Final, trailing data) is dstu-core's stream-file
7
+ * codec, exposed as SecretStreamFileEncryptor/SecretStreamFileDecryptor (T-284); this module only
8
+ * buffers. Mirrors
8
9
  * bindings/python/python/dstu_core/secretstream.py's own design exactly (see D-118's two
9
10
  * standing pitfalls this port re-checks below).
10
11
  *
@@ -19,12 +20,9 @@
19
20
  const { Transform } = require('node:stream');
20
21
  const native = require('../native/index.js');
21
22
 
22
- // Matches crates/uacrypt/src/lib.rs's SECRETSTREAM_CHUNK_BYTES exactly - required for wire-format
23
- // interop with `uacrypt encrypt`/`decrypt`, not an independent choice.
23
+ // dstu-core's crypto_secretstream::CHUNK_BYTES (D-208): the codec seals and checks it; this module
24
+ // only needs it to cut input into chunks.
24
25
  const CHUNK_BYTES = 8 * 1024;
25
- const AUTH_TAG_BYTES = 16;
26
- // Matches dstu-core's crypto_secretstream::FORMAT_VERSION (D-208); the shared vectors pin it.
27
- const FORMAT_VERSION = 2;
28
26
 
29
27
  /**
30
28
  * Write-only Transform: buffers input and pushes each full 8 KiB chunk downstream as it fills,
@@ -44,27 +42,28 @@ const FORMAT_VERSION = 2;
44
42
  class SecretStreamEncryptor extends Transform {
45
43
  constructor(key, options) {
46
44
  super(options);
47
- this._pushState = new native.SecretStreamPushState(key);
45
+ this._enc = this._startCodec(key);
48
46
  this._buf = Buffer.alloc(0);
49
- this.push(Buffer.concat([Buffer.from([FORMAT_VERSION]), this._pushState.header]));
47
+ this.push(this._enc.prefix);
50
48
  }
51
49
 
52
- _pushChunk(tag, data) {
53
- const { ciphertext, authTag } = this._pushState.push(tag, data);
54
- const lenBuf = Buffer.alloc(4);
55
- lenBuf.writeUInt32LE(data.length, 0);
56
- this.push(Buffer.concat([Buffer.from([tag]), lenBuf, ciphertext, authTag]));
50
+ /** The native codec for `key`; the box/passphrase subclasses start it their own way. */
51
+ _startCodec(key) {
52
+ return new native.SecretStreamFileEncryptor(key);
57
53
  }
58
54
 
59
55
  _transform(chunk, _encoding, callback) {
60
56
  this._buf = Buffer.concat([this._buf, chunk]);
61
57
  let err;
62
58
  try {
63
- while (this._buf.length > CHUNK_BYTES) {
64
- const piece = this._buf.subarray(0, CHUNK_BYTES);
65
- this._buf = this._buf.subarray(CHUNK_BYTES);
66
- this._pushChunk(native.SECRETSTREAM_TAG_MESSAGE, piece);
59
+ // Hold back the last chunk (even a full one) until _flush: only then is it known to be the
60
+ // Final one (an exact multiple of 8 KiB ends in a full Final record, D-208).
61
+ let pos = 0;
62
+ while (this._buf.length - pos > CHUNK_BYTES) {
63
+ this.push(this._enc.sealChunk(this._buf.subarray(pos, pos + CHUNK_BYTES)));
64
+ pos += CHUNK_BYTES;
67
65
  }
66
+ this._buf = this._buf.subarray(pos);
68
67
  } catch (e) {
69
68
  err = e;
70
69
  }
@@ -79,7 +78,7 @@ class SecretStreamEncryptor extends Transform {
79
78
  _flush(callback) {
80
79
  let err;
81
80
  try {
82
- this._pushChunk(native.SECRETSTREAM_TAG_FINAL, this._buf);
81
+ this.push(this._enc.sealFinal(this._buf));
83
82
  this._buf = Buffer.alloc(0);
84
83
  } catch (e) {
85
84
  err = e;
@@ -94,29 +93,42 @@ class SecretStreamEncryptor extends Transform {
94
93
  * failure or truncation - a dropped/tampered/reordered chunk, or a stream that ends before a
95
94
  * Final chunk, both fail closed rather than yielding wrong plaintext.
96
95
  *
97
- * D-118 pitfall check #2: `chunkLen` is untrusted wire input, read before any tag verification -
98
- * rejected here the moment it is parsed (before ever buffering up to its declared length), the
99
- * same `CliError::SecretstreamChunkTooLarge` bound `uacrypt decrypt` enforces
100
- * (crates/uacrypt/src/lib.rs) and Python's own reader re-checked (T-49 step 3). Bytes remaining
101
- * after the Final chunk are rejected too (`CliError::SecretstreamTrailingData`'s equivalent),
102
- * checked both as more input arrives (`_transform`) and at end-of-stream (`_flush`) so trailing
103
- * data appended in the same write as the Final chunk, or arriving in a later write, is caught
104
- * either way - not just the case naive testing would think to check first.
96
+ * D-118 pitfall check #2: the untrusted length field, the 8 KiB rule, trailing data after Final
97
+ * and truncation are all the codec's checks (T-284); this class only collects `nextLen()` bytes
98
+ * at a time. A Final chunk's plaintext is pushed only in `_flush`, once the input is known to end
99
+ * right after it.
105
100
  */
106
101
  class SecretStreamDecryptor extends Transform {
107
102
  constructor(key, options) {
108
103
  super(options);
109
- this._key = key;
110
- this._pullState = null;
104
+ this._dec = this._startCodec(key);
111
105
  this._buf = Buffer.alloc(0);
112
- this._done = false;
106
+ }
107
+
108
+ /** The native codec for `key`, or null while a subclass still collects its prefix. */
109
+ _startCodec(key) {
110
+ return new native.SecretStreamFileDecryptor(key);
113
111
  }
114
112
 
115
113
  _transform(chunk, _encoding, callback) {
116
114
  this._buf = Buffer.concat([this._buf, chunk]);
117
115
  let err;
118
116
  try {
119
- this._drain();
117
+ if (this._dec === null) {
118
+ if (this._buf.length < this._prefixBytes) {
119
+ process.nextTick(callback);
120
+ return;
121
+ }
122
+ this._dec = this._openPrefixed(this._buf.subarray(0, this._prefixBytes));
123
+ this._buf = this._buf.subarray(this._prefixBytes);
124
+ }
125
+ let pos = 0;
126
+ for (let want = this._dec.nextLen(); this._buf.length - pos >= want; want = this._dec.nextLen()) {
127
+ const plaintext = this._dec.feed(this._buf.subarray(pos, pos + want));
128
+ pos += want;
129
+ if (plaintext.length > 0) this.push(plaintext);
130
+ }
131
+ this._buf = this._buf.subarray(pos);
120
132
  } catch (e) {
121
133
  err = e;
122
134
  }
@@ -124,57 +136,12 @@ class SecretStreamDecryptor extends Transform {
124
136
  process.nextTick(callback, err);
125
137
  }
126
138
 
127
- _drain() {
128
- if (!this._pullState) {
129
- if (this._buf.length >= 1 && this._buf[0] !== FORMAT_VERSION) {
130
- throw new Error(`unsupported stream format version ${this._buf[0]} (this build reads ${FORMAT_VERSION})`);
131
- }
132
- if (this._buf.length < 33) return;
133
- const header = this._buf.subarray(1, 33);
134
- this._buf = this._buf.subarray(33);
135
- this._pullState = new native.SecretStreamPullState(this._key, header);
136
- }
137
- for (;;) {
138
- if (this._done) {
139
- if (this._buf.length > 0) {
140
- throw new Error('trailing data after the secretstream Final chunk');
141
- }
142
- return;
143
- }
144
- if (this._buf.length < 5) return;
145
- const tagByte = this._buf[0];
146
- const chunkLen = this._buf.readUInt32LE(1);
147
- if (chunkLen > CHUNK_BYTES) {
148
- throw new Error(`secretstream chunk too large: declared ${chunkLen} bytes, max ${CHUNK_BYTES}`);
149
- }
150
- if (tagByte !== native.SECRETSTREAM_TAG_FINAL && chunkLen !== CHUNK_BYTES) {
151
- throw new Error(`non-final chunk of ${chunkLen} bytes, must be exactly ${CHUNK_BYTES} (D-208)`);
152
- }
153
- const recordLen = 5 + chunkLen + AUTH_TAG_BYTES;
154
- if (this._buf.length < recordLen) return;
155
- const ciphertext = this._buf.subarray(5, 5 + chunkLen);
156
- const authTag = this._buf.subarray(5 + chunkLen, recordLen);
157
- this._buf = this._buf.subarray(recordLen);
158
- const { tag, plaintext } = this._pullState.pull(tagByte, ciphertext, authTag);
159
- this.push(plaintext);
160
- if (tag === native.SECRETSTREAM_TAG_FINAL) {
161
- this._done = true;
162
- }
163
- }
164
- }
165
-
166
139
  _flush(callback) {
167
140
  let err;
168
141
  try {
169
- if (!this._pullState) {
170
- throw new Error('truncated secretstream: missing header');
171
- }
172
- if (!this._done) {
173
- throw new Error('truncated secretstream: stream ended before a Final chunk');
174
- }
175
- if (this._buf.length > 0) {
176
- throw new Error('trailing data after the secretstream Final chunk');
177
- }
142
+ if (this._dec === null) throw new Error(`input too short to contain a ${this._prefixName}`);
143
+ const plaintext = this._dec.finish();
144
+ if (plaintext.length > 0) this.push(plaintext);
178
145
  } catch (e) {
179
146
  err = e;
180
147
  }
@@ -182,4 +149,130 @@ class SecretStreamDecryptor extends Transform {
182
149
  }
183
150
  }
184
151
 
185
- module.exports = { SecretStreamEncryptor, SecretStreamDecryptor };
152
+ function checkLength(buf, bytes, name) {
153
+ if (!Buffer.isBuffer(buf) || buf.length !== bytes) {
154
+ throw new TypeError(`${name} must be a ${bytes}-byte Buffer`);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * `crypto_box` sealed to `publicKey` as a stream (format v3, T-265/T-269): the same bytes
160
+ * `boxSeal` returns, written chunk by chunk; `boxOpen`/`BoxDecryptor` read them.
161
+ */
162
+ class BoxEncryptor extends SecretStreamEncryptor {
163
+ _startCodec(publicKey) {
164
+ return native.SecretStreamFileEncryptor.boxSeal(publicKey);
165
+ }
166
+ }
167
+
168
+ /** `crypto_box384` counterpart of `BoxEncryptor`. */
169
+ class Box384Encryptor extends SecretStreamEncryptor {
170
+ _startCodec(publicKey) {
171
+ return native.SecretStreamFileEncryptor.box384Seal(publicKey);
172
+ }
173
+ }
174
+
175
+ /** `crypto_box512` counterpart of `BoxEncryptor`. */
176
+ class Box512Encryptor extends SecretStreamEncryptor {
177
+ _startCodec(publicKey) {
178
+ return native.SecretStreamFileEncryptor.box512Seal(publicKey);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * A passphrase file (D-219/D-240) - the bytes `uacrypt encrypt --passphrase` writes. The
184
+ * constructor runs Argon2id (256 MiB, t=3, about a second) on the calling thread.
185
+ */
186
+ class PassphraseEncryptor extends SecretStreamEncryptor {
187
+ _startCodec(passphrase) {
188
+ return native.SecretStreamFileEncryptor.passphraseSeal(passphrase);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Collects the bytes in front of the stream file, then opens the codec with them. The secret is
194
+ * held only until then.
195
+ */
196
+ class PrefixedDecryptor extends SecretStreamDecryptor {
197
+ _startCodec(secret) {
198
+ this._secret = secret;
199
+ return null;
200
+ }
201
+
202
+ _openPrefixed(prefix) {
203
+ const dec = this._openCodec(this._secret, prefix);
204
+ this._secret = null;
205
+ return dec;
206
+ }
207
+ }
208
+
209
+ /** Reads a `crypto_box` stream (or a `boxSeal` blob) with bounded memory. */
210
+ class BoxDecryptor extends PrefixedDecryptor {
211
+ constructor(secretKey, options) {
212
+ checkLength(secretKey, 32, 'secretKey');
213
+ super(secretKey, options);
214
+ this._prefixBytes = native.BOX_OPEN_STREAM_PREFIX_BYTES;
215
+ this._prefixName = 'sealed message';
216
+ }
217
+
218
+ _openCodec(secretKey, prefix) {
219
+ return native.SecretStreamFileDecryptor.boxOpen(secretKey, prefix);
220
+ }
221
+ }
222
+
223
+ /** `crypto_box384` counterpart of `BoxDecryptor`. */
224
+ class Box384Decryptor extends PrefixedDecryptor {
225
+ constructor(secretKey, options) {
226
+ checkLength(secretKey, 48, 'secretKey');
227
+ super(secretKey, options);
228
+ this._prefixBytes = native.BOX384_OPEN_STREAM_PREFIX_BYTES;
229
+ this._prefixName = 'sealed message';
230
+ }
231
+
232
+ _openCodec(secretKey, prefix) {
233
+ return native.SecretStreamFileDecryptor.box384Open(secretKey, prefix);
234
+ }
235
+ }
236
+
237
+ /** `crypto_box512` counterpart of `BoxDecryptor`. */
238
+ class Box512Decryptor extends PrefixedDecryptor {
239
+ constructor(secretKey, options) {
240
+ checkLength(secretKey, 64, 'secretKey');
241
+ super(secretKey, options);
242
+ this._prefixBytes = native.BOX512_OPEN_STREAM_PREFIX_BYTES;
243
+ this._prefixName = 'sealed message';
244
+ }
245
+
246
+ _openCodec(secretKey, prefix) {
247
+ return native.SecretStreamFileDecryptor.box512Open(secretKey, prefix);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Reads a passphrase file. The header is checked before Argon2 runs (on the calling thread, once
253
+ * the header has arrived); a wrong passphrase errors at the first record.
254
+ */
255
+ class PassphraseDecryptor extends PrefixedDecryptor {
256
+ constructor(passphrase, options) {
257
+ super(passphrase, options);
258
+ this._prefixBytes = native.PASSPHRASE_FILE_HEADER_BYTES;
259
+ this._prefixName = 'passphrase file header';
260
+ }
261
+
262
+ _openCodec(passphrase, header) {
263
+ return native.SecretStreamFileDecryptor.passphraseOpen(header, passphrase);
264
+ }
265
+ }
266
+
267
+ module.exports = {
268
+ SecretStreamEncryptor,
269
+ SecretStreamDecryptor,
270
+ BoxEncryptor,
271
+ BoxDecryptor,
272
+ Box384Encryptor,
273
+ Box384Decryptor,
274
+ Box512Encryptor,
275
+ Box512Decryptor,
276
+ PassphraseEncryptor,
277
+ PassphraseDecryptor,
278
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dstu-core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Node.js bindings for dstu-core (Ukrainian DSTU cryptographic standards)",
6
6
  "main": "js/index.js",
@@ -47,9 +47,9 @@
47
47
  "prepublishOnly": "napi prepublish -t npm --skip-gh-release"
48
48
  },
49
49
  "optionalDependencies": {
50
- "dstu-core-linux-x64-gnu": "0.3.0",
51
- "dstu-core-darwin-arm64": "0.3.0",
52
- "dstu-core-win32-x64-msvc": "0.3.0",
53
- "dstu-core-linux-arm64-gnu": "0.3.0"
50
+ "dstu-core-linux-x64-gnu": "0.5.0",
51
+ "dstu-core-darwin-arm64": "0.5.0",
52
+ "dstu-core-win32-x64-msvc": "0.5.0",
53
+ "dstu-core-linux-arm64-gnu": "0.5.0"
54
54
  }
55
55
  }