dstu-core 0.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/README.md +80 -0
- package/js/index.js +25 -0
- package/js/secretstream.js +175 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# dstu-core (Node.js bindings)
|
|
2
|
+
|
|
3
|
+
**Provisional — not published to npm, not independently audited.** See the root project's
|
|
4
|
+
`docs/SECURITY.md` and `docs/DECISIONS.md` for the full threat model and per-construction status.
|
|
5
|
+
This binding wraps the full `dstu_core::crypto_*` surface (`docs/bindings-strategy.md` T-50) —
|
|
6
|
+
install from source as shown below.
|
|
7
|
+
|
|
8
|
+
## Installing (from source)
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install
|
|
12
|
+
npm run build
|
|
13
|
+
node -e "require('./js/index.js').selfTest()"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`npm run build` (`napi build native --platform --release`) compiles the Rust addon and generates
|
|
17
|
+
`native/index.js`/`native/index.d.ts`/the platform-specific `*.node` binary — all gitignored,
|
|
18
|
+
regenerated fresh every build (never hand-edited or committed). `js/index.js` is the hand-written
|
|
19
|
+
public entry point: every native export re-exported as-is, plus the idiomatic
|
|
20
|
+
`stream.Transform`-based `SecretStreamEncryptor`/`SecretStreamDecryptor` (D-118) built in pure JS on
|
|
21
|
+
top of them.
|
|
22
|
+
|
|
23
|
+
`selfTest()` re-runs `dstu_core::selftest::run()` (`docs/TASKS.md` T-161) against the exact
|
|
24
|
+
compiled build and throws if anything official-vector-level is wrong — the first thing to run
|
|
25
|
+
after any build to confirm it actually works, not just compiled.
|
|
26
|
+
|
|
27
|
+
This crate is its own Cargo workspace, separate from the repo root (`docs/DECISIONS.md` D-119) —
|
|
28
|
+
build/test it from inside this directory, not from the repo root. On Windows, `napi-build`'s
|
|
29
|
+
`gnu`-host path needs a real `libnode.dll` that no prebuilt Node.js distribution ships — if your
|
|
30
|
+
own Rust toolchain defaults to a `-gnu` host (uncommon, but possible), switch to an MSVC toolchain
|
|
31
|
+
for this directory specifically: `rustup override set stable-x86_64-pc-windows-msvc` (see
|
|
32
|
+
`docs/DECISIONS.md` D-125/D-130 for why this is a machine-local fix, not something committed here).
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Every function/class below is a named export of `js/index.js` (`require('dstu-core')` once
|
|
37
|
+
published, `require('../js/index.js')` from inside this repo). See `examples/` for complete,
|
|
38
|
+
runnable scripts, and `test/` for the full correctness/rejection/misuse suite each one is verified
|
|
39
|
+
against (D-64/D-65).
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const dstu = require('./js/index.js');
|
|
43
|
+
|
|
44
|
+
const key = dstu.secretboxKeygen();
|
|
45
|
+
const sealed = dstu.secretboxSeal(key, Buffer.from('a message worth protecting'));
|
|
46
|
+
console.log(dstu.secretboxOpen(key, sealed).toString()); // "a message worth protecting"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Module | Functions/classes | Notes |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `crypto_secretbox` | `secretboxKeygen`, `secretboxSeal`, `secretboxOpen` | Single-message authenticated encryption. `examples/secretbox.js`. |
|
|
52
|
+
| `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`. |
|
|
53
|
+
| `crypto_box512` | `box512Keygen`, `box512PublicKey`, `box512Seal`, `box512Open` | `l(p)=512`/E512/1 sibling of `crypto_box` (T-193/T-204). `examples/box512.js`. |
|
|
54
|
+
| `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`. |
|
|
55
|
+
| `crypto_sign` | `signKeygen`, `signVerifyingKey`, `signMessage`, `signVerify` | DSTU 4145 `m=163` digital signatures, deterministic nonce (no RNG dependency). `examples/sign.js`. |
|
|
56
|
+
| `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`. |
|
|
57
|
+
| `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`. |
|
|
58
|
+
| `crypto_auth` | `authKeygen`, `auth`, `authVerify` | Keyed message authentication (Kupyna-KMAC). `examples/misc.js`. |
|
|
59
|
+
| `crypto_kdf` | `kdfKeygen`, `kdfDeriveSubkey` | Deterministic subkey derivation. `subkeyId` is a plain non-negative `number`, not `BigInt`. `examples/misc.js`. |
|
|
60
|
+
| `crypto_generichash` | `kupyna256`, `kupyna512`, `Kupyna256Hasher`, `Kupyna512Hasher` | One-shot and streaming Kupyna hashing. `examples/misc.js`. |
|
|
61
|
+
| `crypto_stream` | `streamKeygen`, `streamEncrypt`, `streamDecrypt` | Strumok-256 keystream — **unauthenticated**, `streamDecrypt` never fails on tampered input. `examples/misc.js`. |
|
|
62
|
+
| `randombytes` | `randombytesBuf` | CSPRNG-backed random bytes. `examples/misc.js`. |
|
|
63
|
+
| — | `selfTest` | Runtime KAT self-check (T-161). Every crypto-operation failure throws a plain `Error` carrying the underlying cause's message. |
|
|
64
|
+
|
|
65
|
+
## Testing
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
node --test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`cargo build -p uacrypt --release` (from the repo root) first if you want
|
|
72
|
+
`test/secretstream.test.js`'s live `uacrypt` CLI interop test to actually run instead of skipping.
|
|
73
|
+
`cargo xtask nodejs` (from the repo root) runs this whole sequence, including that build step, in
|
|
74
|
+
one command.
|
|
75
|
+
|
|
76
|
+
## Static analysis
|
|
77
|
+
|
|
78
|
+
`npm run lint` (ESLint, `@eslint/js` recommended rules - `eslint.config.js`) over `js/`/`test/`/
|
|
79
|
+
`examples/`, not `native/` (napi-rs-generated, never hand-edited). A real required CI step
|
|
80
|
+
(`bindings-nodejs.yml`), and part of `cargo xtask nodejs` (T-208).
|
package/js/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Node.js bindings for dstu-core (Ukrainian DSTU cryptographic standards).
|
|
5
|
+
*
|
|
6
|
+
* Provisional - not yet published to npm, not independently audited. See the root project's
|
|
7
|
+
* docs/SECURITY.md and docs/DECISIONS.md for the full threat model and per-construction status
|
|
8
|
+
* (docs/bindings-strategy.md T-50).
|
|
9
|
+
*
|
|
10
|
+
* `../native/index.js` is the napi-rs-generated low-level binding (regenerated by `npm run
|
|
11
|
+
* build`, never hand-edited or committed - see .gitignore). This file is the public package
|
|
12
|
+
* entry point: every native export re-exported as-is, plus the idiomatic
|
|
13
|
+
* `stream.Transform`-based `SecretStreamEncryptor`/`SecretStreamDecryptor` (step 3) layered on
|
|
14
|
+
* top of the native `SecretStreamPushState`/`SecretStreamPullState` (step 2) in pure JS, no new
|
|
15
|
+
* Rust glue - mirrors bindings/python/python/dstu_core/__init__.py's own split exactly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const native = require('../native/index.js');
|
|
19
|
+
const { SecretStreamEncryptor, SecretStreamDecryptor } = require('./secretstream.js');
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
...native,
|
|
23
|
+
SecretStreamEncryptor,
|
|
24
|
+
SecretStreamDecryptor,
|
|
25
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
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
|
|
8
|
+
* bindings/python/python/dstu_core/secretstream.py's own design exactly (see D-118's two
|
|
9
|
+
* standing pitfalls this port re-checks below).
|
|
10
|
+
*
|
|
11
|
+
* Wire format matches `uacrypt encrypt`/`decrypt` exactly (crates/uacrypt/src/lib.rs's
|
|
12
|
+
* run_secretstream_encrypt/run_secretstream_decrypt, D-68): `header (32 bytes)` followed by one
|
|
13
|
+
* record per chunk, `tagByte (1) || chunkLenU32LE (4) || ciphertext (chunkLen) || authTag (16)`,
|
|
14
|
+
* chunks capped at 8 KiB (matching SECRETSTREAM_CHUNK_BYTES, not an independent choice).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { Transform } = require('node:stream');
|
|
18
|
+
const native = require('../native/index.js');
|
|
19
|
+
|
|
20
|
+
// Matches crates/uacrypt/src/lib.rs's SECRETSTREAM_CHUNK_BYTES exactly - required for wire-format
|
|
21
|
+
// interop with `uacrypt encrypt`/`decrypt`, not an independent choice.
|
|
22
|
+
const CHUNK_BYTES = 8 * 1024;
|
|
23
|
+
const AUTH_TAG_BYTES = 16;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write-only Transform: buffers input and pushes each full 8 KiB chunk downstream as it fills,
|
|
27
|
+
* hiding the header/tag/framing bookkeeping entirely.
|
|
28
|
+
*
|
|
29
|
+
* Usage: `fs.createReadStream(inPath).pipe(new SecretStreamEncryptor(key)).pipe(fs.createWriteStream(outPath))`
|
|
30
|
+
*
|
|
31
|
+
* D-118 pitfall check #1: this class deliberately overrides only `_flush`, not `_destroy`, to
|
|
32
|
+
* emit the Final chunk. Node's stream machinery calls `_flush` only when the writable side ends
|
|
33
|
+
* gracefully (`.end()`) - never on `destroy()`/an upstream error, which instead skips straight to
|
|
34
|
+
* `_destroy`. So a pipeline that errors partway leaves the output stream without a Final chunk,
|
|
35
|
+
* and `SecretStreamDecryptor` reading that truncated output fails closed in `_flush` ("stream
|
|
36
|
+
* ended before a Final chunk") rather than silently accepting a complete-looking but truncated
|
|
37
|
+
* file - matching this project's standing "no partial output treated as valid on failure" rule
|
|
38
|
+
* (D-65), the same property Python's own `__exit__` had to be fixed to provide (T-49 step 3).
|
|
39
|
+
*/
|
|
40
|
+
class SecretStreamEncryptor extends Transform {
|
|
41
|
+
constructor(key, options) {
|
|
42
|
+
super(options);
|
|
43
|
+
this._pushState = new native.SecretStreamPushState(key);
|
|
44
|
+
this._buf = Buffer.alloc(0);
|
|
45
|
+
this.push(this._pushState.header);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_pushChunk(tag, data) {
|
|
49
|
+
const { ciphertext, authTag } = this._pushState.push(tag, data);
|
|
50
|
+
const lenBuf = Buffer.alloc(4);
|
|
51
|
+
lenBuf.writeUInt32LE(data.length, 0);
|
|
52
|
+
this.push(Buffer.concat([Buffer.from([tag]), lenBuf, ciphertext, authTag]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_transform(chunk, _encoding, callback) {
|
|
56
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
57
|
+
let err;
|
|
58
|
+
try {
|
|
59
|
+
while (this._buf.length > CHUNK_BYTES) {
|
|
60
|
+
const piece = this._buf.subarray(0, CHUNK_BYTES);
|
|
61
|
+
this._buf = this._buf.subarray(CHUNK_BYTES);
|
|
62
|
+
this._pushChunk(native.SECRETSTREAM_TAG_MESSAGE, piece);
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
err = e;
|
|
66
|
+
}
|
|
67
|
+
// Deferred via process.nextTick, not called synchronously: Node's own stream docs warn that
|
|
68
|
+
// invoking _transform's callback synchronously (this method never actually awaits any I/O)
|
|
69
|
+
// makes an error passed to it throw synchronously out of the triggering write() call instead
|
|
70
|
+
// of emitting 'error' asynchronously the documented way - confirmed the hard way, a real
|
|
71
|
+
// deadlock/uncaught-throw found running this suite under node:test, not a defensive guess.
|
|
72
|
+
process.nextTick(callback, err);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_flush(callback) {
|
|
76
|
+
let err;
|
|
77
|
+
try {
|
|
78
|
+
this._pushChunk(native.SECRETSTREAM_TAG_FINAL, this._buf);
|
|
79
|
+
this._buf = Buffer.alloc(0);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
err = e;
|
|
82
|
+
}
|
|
83
|
+
process.nextTick(callback, err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read-only Transform: parses and decrypts one chunk at a time from arbitrarily-chunked input,
|
|
89
|
+
* emitting plaintext downstream. Errors (via the stream's `'error'` event) on authentication
|
|
90
|
+
* failure or truncation - a dropped/tampered/reordered chunk, or a stream that ends before a
|
|
91
|
+
* Final chunk, both fail closed rather than yielding wrong plaintext.
|
|
92
|
+
*
|
|
93
|
+
* D-118 pitfall check #2: `chunkLen` is untrusted wire input, read before any tag verification -
|
|
94
|
+
* rejected here the moment it is parsed (before ever buffering up to its declared length), the
|
|
95
|
+
* same `CliError::SecretstreamChunkTooLarge` bound `uacrypt decrypt` enforces
|
|
96
|
+
* (crates/uacrypt/src/lib.rs) and Python's own reader re-checked (T-49 step 3). Bytes remaining
|
|
97
|
+
* after the Final chunk are rejected too (`CliError::SecretstreamTrailingData`'s equivalent),
|
|
98
|
+
* checked both as more input arrives (`_transform`) and at end-of-stream (`_flush`) so trailing
|
|
99
|
+
* data appended in the same write as the Final chunk, or arriving in a later write, is caught
|
|
100
|
+
* either way - not just the case naive testing would think to check first.
|
|
101
|
+
*/
|
|
102
|
+
class SecretStreamDecryptor extends Transform {
|
|
103
|
+
constructor(key, options) {
|
|
104
|
+
super(options);
|
|
105
|
+
this._key = key;
|
|
106
|
+
this._pullState = null;
|
|
107
|
+
this._buf = Buffer.alloc(0);
|
|
108
|
+
this._done = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_transform(chunk, _encoding, callback) {
|
|
112
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
113
|
+
let err;
|
|
114
|
+
try {
|
|
115
|
+
this._drain();
|
|
116
|
+
} catch (e) {
|
|
117
|
+
err = e;
|
|
118
|
+
}
|
|
119
|
+
// See SecretStreamEncryptor._transform's comment - same synchronous-callback pitfall applies.
|
|
120
|
+
process.nextTick(callback, err);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_drain() {
|
|
124
|
+
if (!this._pullState) {
|
|
125
|
+
if (this._buf.length < 32) return;
|
|
126
|
+
const header = this._buf.subarray(0, 32);
|
|
127
|
+
this._buf = this._buf.subarray(32);
|
|
128
|
+
this._pullState = new native.SecretStreamPullState(this._key, header);
|
|
129
|
+
}
|
|
130
|
+
for (;;) {
|
|
131
|
+
if (this._done) {
|
|
132
|
+
if (this._buf.length > 0) {
|
|
133
|
+
throw new Error('trailing data after the secretstream Final chunk');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this._buf.length < 5) return;
|
|
138
|
+
const tagByte = this._buf[0];
|
|
139
|
+
const chunkLen = this._buf.readUInt32LE(1);
|
|
140
|
+
if (chunkLen > CHUNK_BYTES) {
|
|
141
|
+
throw new Error(`secretstream chunk too large: declared ${chunkLen} bytes, max ${CHUNK_BYTES}`);
|
|
142
|
+
}
|
|
143
|
+
const recordLen = 5 + chunkLen + AUTH_TAG_BYTES;
|
|
144
|
+
if (this._buf.length < recordLen) return;
|
|
145
|
+
const ciphertext = this._buf.subarray(5, 5 + chunkLen);
|
|
146
|
+
const authTag = this._buf.subarray(5 + chunkLen, recordLen);
|
|
147
|
+
this._buf = this._buf.subarray(recordLen);
|
|
148
|
+
const { tag, plaintext } = this._pullState.pull(tagByte, ciphertext, authTag);
|
|
149
|
+
this.push(plaintext);
|
|
150
|
+
if (tag === native.SECRETSTREAM_TAG_FINAL) {
|
|
151
|
+
this._done = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_flush(callback) {
|
|
157
|
+
let err;
|
|
158
|
+
try {
|
|
159
|
+
if (!this._pullState) {
|
|
160
|
+
throw new Error('truncated secretstream: missing header');
|
|
161
|
+
}
|
|
162
|
+
if (!this._done) {
|
|
163
|
+
throw new Error('truncated secretstream: stream ended before a Final chunk');
|
|
164
|
+
}
|
|
165
|
+
if (this._buf.length > 0) {
|
|
166
|
+
throw new Error('trailing data after the secretstream Final chunk');
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
err = e;
|
|
170
|
+
}
|
|
171
|
+
process.nextTick(callback, err);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { SecretStreamEncryptor, SecretStreamDecryptor };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dstu-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Node.js bindings for dstu-core (Ukrainian DSTU cryptographic standards) - provisional, not yet published to npm",
|
|
6
|
+
"main": "js/index.js",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/user137/uacrypt"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"js/",
|
|
16
|
+
"native/index.js",
|
|
17
|
+
"native/index.d.ts",
|
|
18
|
+
"native/*.node"
|
|
19
|
+
],
|
|
20
|
+
"napi": {
|
|
21
|
+
"name": "dstu-core",
|
|
22
|
+
"triples": {
|
|
23
|
+
"defaults": false,
|
|
24
|
+
"additional": [
|
|
25
|
+
"x86_64-unknown-linux-gnu",
|
|
26
|
+
"aarch64-apple-darwin",
|
|
27
|
+
"x86_64-pc-windows-msvc",
|
|
28
|
+
"aarch64-unknown-linux-gnu"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT OR Apache-2.0",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">= 18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "^10",
|
|
38
|
+
"@napi-rs/cli": "^2",
|
|
39
|
+
"eslint": "^10",
|
|
40
|
+
"globals": "^17"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "napi build native --platform --release",
|
|
44
|
+
"build:debug": "napi build native --platform",
|
|
45
|
+
"test": "node --test",
|
|
46
|
+
"lint": "eslint .",
|
|
47
|
+
"prepublishOnly": "napi prepublish -t npm --skip-gh-release"
|
|
48
|
+
},
|
|
49
|
+
"optionalDependencies": {
|
|
50
|
+
"dstu-core-linux-x64-gnu": "0.1.0",
|
|
51
|
+
"dstu-core-darwin-arm64": "0.1.0",
|
|
52
|
+
"dstu-core-win32-x64-msvc": "0.1.0",
|
|
53
|
+
"dstu-core-linux-arm64-gnu": "0.1.0"
|
|
54
|
+
}
|
|
55
|
+
}
|