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 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
+ }