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.
- package/js/secretstream.js +30 -82
- package/package.json +5 -5
package/js/secretstream.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
//
|
|
23
|
-
//
|
|
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.
|
|
45
|
+
this._enc = new native.SecretStreamFileEncryptor(key);
|
|
48
46
|
this._buf = Buffer.alloc(0);
|
|
49
|
-
this.push(
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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:
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
+
"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.
|
|
51
|
-
"dstu-core-darwin-arm64": "0.
|
|
52
|
-
"dstu-core-win32-x64-msvc": "0.
|
|
53
|
-
"dstu-core-linux-arm64-gnu": "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
|
}
|