dstu-core 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/js/secretstream.js +30 -82
  2. package/package.json +5 -5
@@ -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,23 @@ 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 = new native.SecretStreamFileEncryptor(key);
48
46
  this._buf = Buffer.alloc(0);
49
- this.push(Buffer.concat([Buffer.from([FORMAT_VERSION]), this._pushState.header]));
50
- }
51
-
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]));
47
+ this.push(this._enc.prefix);
57
48
  }
58
49
 
59
50
  _transform(chunk, _encoding, callback) {
60
51
  this._buf = Buffer.concat([this._buf, chunk]);
61
52
  let err;
62
53
  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);
54
+ // Hold back the last chunk (even a full one) until _flush: only then is it known to be the
55
+ // Final one (an exact multiple of 8 KiB ends in a full Final record, D-208).
56
+ let pos = 0;
57
+ while (this._buf.length - pos > CHUNK_BYTES) {
58
+ this.push(this._enc.sealChunk(this._buf.subarray(pos, pos + CHUNK_BYTES)));
59
+ pos += CHUNK_BYTES;
67
60
  }
61
+ this._buf = this._buf.subarray(pos);
68
62
  } catch (e) {
69
63
  err = e;
70
64
  }
@@ -79,7 +73,7 @@ class SecretStreamEncryptor extends Transform {
79
73
  _flush(callback) {
80
74
  let err;
81
75
  try {
82
- this._pushChunk(native.SECRETSTREAM_TAG_FINAL, this._buf);
76
+ this.push(this._enc.sealFinal(this._buf));
83
77
  this._buf = Buffer.alloc(0);
84
78
  } catch (e) {
85
79
  err = e;
@@ -94,29 +88,29 @@ class SecretStreamEncryptor extends Transform {
94
88
  * failure or truncation - a dropped/tampered/reordered chunk, or a stream that ends before a
95
89
  * Final chunk, both fail closed rather than yielding wrong plaintext.
96
90
  *
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.
91
+ * D-118 pitfall check #2: the untrusted length field, the 8 KiB rule, trailing data after Final
92
+ * and truncation are all the codec's checks (T-284); this class only collects `nextLen()` bytes
93
+ * at a time. A Final chunk's plaintext is pushed only in `_flush`, once the input is known to end
94
+ * right after it.
105
95
  */
106
96
  class SecretStreamDecryptor extends Transform {
107
97
  constructor(key, options) {
108
98
  super(options);
109
- this._key = key;
110
- this._pullState = null;
99
+ this._dec = new native.SecretStreamFileDecryptor(key);
111
100
  this._buf = Buffer.alloc(0);
112
- this._done = false;
113
101
  }
114
102
 
115
103
  _transform(chunk, _encoding, callback) {
116
104
  this._buf = Buffer.concat([this._buf, chunk]);
117
105
  let err;
118
106
  try {
119
- this._drain();
107
+ let pos = 0;
108
+ for (let want = this._dec.nextLen(); this._buf.length - pos >= want; want = this._dec.nextLen()) {
109
+ const plaintext = this._dec.feed(this._buf.subarray(pos, pos + want));
110
+ pos += want;
111
+ if (plaintext.length > 0) this.push(plaintext);
112
+ }
113
+ this._buf = this._buf.subarray(pos);
120
114
  } catch (e) {
121
115
  err = e;
122
116
  }
@@ -124,57 +118,11 @@ class SecretStreamDecryptor extends Transform {
124
118
  process.nextTick(callback, err);
125
119
  }
126
120
 
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
121
  _flush(callback) {
167
122
  let err;
168
123
  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
- }
124
+ const plaintext = this._dec.finish();
125
+ if (plaintext.length > 0) this.push(plaintext);
178
126
  } catch (e) {
179
127
  err = e;
180
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dstu-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.4.0",
51
+ "dstu-core-darwin-arm64": "0.4.0",
52
+ "dstu-core-win32-x64-msvc": "0.4.0",
53
+ "dstu-core-linux-arm64-gnu": "0.4.0"
54
54
  }
55
55
  }