Language bindings strategy (Phase 3)
Requested 2026-08-02 by the project owner: an analysis of which languages actually benefit from a
dstu-core binding, what to bind and how, what engineers need to consume it, a project-structure
placement, and a phased roadmap built with advisor() input, executed in small committed steps.
This document is the durable record of that analysis — docs/TASKS.md tracks the same work at the
task-checklist level (Phase 3 section, T-158 onward); this file is the reasoning behind it, not a
duplicate of the checklist.
docs/dstu-crypto-project.md’s “Second priority” section already named the five core languages
(Python, JavaScript, Java, .NET, C++) before this document existed — this is the plan for how,
not a re-litigation of whether.
Popularity analysis — why this order, not TIOBE rank alone
TIOBE (July 2026): Python #1 (18.9%), C #2, C++ #3, Java #4, C# #5, JavaScript #6, Rust newly #10.
Raw rank is a weak signal for this specific library’s audience, though — a DSTU crypto library’s
real consumers skew toward PKI/enterprise/security tooling, not general web/app development. Two
pieces of direct evidence from this project’s own oracle map (docs/ORACLES.md) outweigh TIOBE rank
for ordering Java/.NET: UAPKI (a real Ukrainian PKI stack, state-expertise-certified once) already
ships Java/Kotlin bindings, and Bouncy Castle .NET is already a verification oracle used in this
repo’s own test harnesses. That is direct evidence of where real DSTU-consuming demand already
sits, not a rank-based guess.
Net ordering, and why each sits where it does:
- Python — not chosen for TIOBE rank #1 alone, but because PyO3 + maturin is the most mature direct-Rust-binding toolchain that exists today, so it validates the whole pipeline (workspace member → build → package → local test → examples → CI) with the least incidental FFI complexity. Every later binding reuses this template rather than re-deriving it.
- A C ABI crate — not a language binding itself, but the shared foundation C++, .NET, and
(pending a spike, see below) Java need. This is
dstu-crypto-project.md’s “On the horizon” C-ABI idea finally becoming real, but scoped strictly as “serves our own bindings” — the speculative “UAPKI itself could adopt this instead of its own C implementation” idea stays exactly as speculative and unscheduled as that section already states; this document does not schedule it. - .NET — P/Invoke over the C ABI crate. No new Rust-side glue beyond the C ABI itself.
- Java — real Ukrainian-PKI demand evidence (UAPKI), but needs one implementation-choice spike first (below) before locking an approach.
- JavaScript (Node) — napi-rs, a direct-Rust binding shaped like Python’s, deliberately not
built second despite the shape match, because Node’s actual audience (web/app dev) overlaps
least with this project’s demonstrated demand (PKI/enterprise). Scope explicitly Node-only,
confirmed with the project owner 2026-08-02, see D-118: a browser-usable target (Web Crypto
API-style in-browser TLS/signing was the concrete comparison raised) needs a genuinely different
toolchain (WASM via
wasm-bindgen, not napi-rs — napi-rs binaries don’t run in a browser at all) and is deliberately not scheduled now, not silently assumed either way. - C++ — consumes the same C ABI crate/header directly; no separate Rust glue needed.
Two additional languages the project owner asked to include, deliberately placed after the original five and not interleaved with them (no equivalent Ukrainian-PKI demand evidence exists for either):
- PHP (Phase 8) — TIOBE ~#8, large web-backend footprint (Laravel/WordPress), essentially no
presence in crypto/PKI tooling. Rides the already-built C ABI (
ext-php-rsas a real extension, or a plainerFFI-extension path over the same header) rather than justifying its own Rust-side binding. - Ruby (Phase 9) — smaller than PHP by TIOBE rank (~#12-15), but a somewhat stronger
security/ops-tooling footprint (Metasploit, DevSecOps scripting) than PHP has. Binds the Rust
crate directly, like Python/Node, via
magnus/rb-sys— the current standard for production Rust-backed gems, not through the C ABI.
Build order revised 2026-08-02 (D-121/D-122) — the analysis above stays, the ordering it drove doesn’t
The popularity analysis above is kept verbatim, not rewritten — it was correct evidence, just aimed
at the wrong question. It asked “where does real DSTU demand already exist,” and answered
Java/.NET via UAPKI/Bouncy Castle. The better question for this project’s own ordering is “where
does a gap exist that only this project’s zero-config crypto_* surface fills” — and Bouncy
Castle/UAPKI already serving Java/.NET means this project’s marginal contribution there is real but
smaller than in a language with no DSTU library at all (Node, Ruby, PHP, and now Go — none of which
have an incumbent the way Java/.NET do).
Revised order: T-49 (Python, done) → T-50 (Node) → T-160 (Ruby) → T-159 (PHP, committed to
ext-php-rs specifically so it’s a direct binding like Node/Ruby, not gated on the C ABI crate
below) → T-158 (C ABI crate, built once actually needed by the group below) → T-52 (.NET) → T-51
(Java) → T-163 (Go, new - see its own section below; needs the C ABI too, since no Go binding
toolchain matches PyO3/napi-rs/magnus’s maturity) → T-53 (C++, reordered again same day - D-123 -
to build after Go specifically, the owner’s explicit preference) → T-162 (docs, last).
Dart, raised in the same conversation, is explicitly deferred, not silently assumed either way (D-122) — same reasoning as Node’s own browser/WASM scoping (D-118): Dart’s primary audience (Flutter mobile/web) overlaps least with this project’s demonstrated PKI/enterprise/security- tooling demand, so it doesn’t earn a place ahead of the languages that do.
What to bind and how — the three forks, resolved
Fork 1 — C ABI vs. native FFI, resolved by tooling maturity, not preference
Python (PyO3) and Node (napi-rs) bind the dstu-core Rust crate directly — routing either through a
C ABI would double-marshal data for no benefit and lose idiomatic types (Python bytes, JS
Uint8Array) for nothing. C++ and .NET consume the C ABI crate directly instead: C++ via the
generated header + link, .NET via P/Invoke. Java gets an explicit spike step (Phase 4, step 1)
comparing the jni crate (write the JNI layer directly in Rust, no hand-written C shim) against
JNI-over-the-C-ABI, before committing to either — record the outcome in docs/DECISIONS.md when
that spike runs. Ruby follows Python/Node’s direct-binding shape (magnus); PHP was planned to
follow C++/.NET’s C-ABI-consuming shape (ext-php-rs or the FFI extension) here, but the
actual T-159 implementation binds dstu-core directly via ext-php-rs (confirmed by its own
Cargo.toml dependency, not this plan) - flat dstu_core_*-prefixed globals matching ext-sodium’s
own naming (D-142), same direct-binding group as Python/Node/Ruby in practice. This paragraph is
left uncorrected below as the historical record of what was planned; treat the per-binding
Cargo.toml as the source of truth for what a binding actually links against, not this section
(found stale doing T-181’s own PHP work, 2026-08-06 - the T-181 phase entry below reflects reality).
Fork 2 — crypto_sign (DSTU 4145) exposure: uniform across every binding
dstu-crypto-project.md’s original “Second priority” text says: “Do not separately reimplement DSTU
4145 in the native core — for Java/.NET, integrate/wrap Bouncy Castle… for Rust, port it while
relying on Bouncy Castle as a second verification oracle.” That guidance predates
hazmat::dstu4145/dstu_core::crypto_sign actually existing — they’re now fully implemented and
verified in Rust (per-Annex-B.1 worked example, dual-oracle cross-checked against real Bouncy
Castle Java/.NET, docs/DECISIONS.md D-25/D-46). Bouncy Castle’s role today is verification oracle
only, already used that way in tests/oracle-harness/. There is no remaining reason for a Java or
.NET binding to route signing through Bouncy Castle instead of this project’s own audited
crypto_sign — a binding that silently omits crypto_sign, or reimplements it against a different
library per language, is strictly worse than one that calls the same Rust implementation every other
binding calls. Resolution: every binding exposes the same crypto_* surface, crypto_sign
included, uniformly. See docs/DECISIONS.md D-115 for the citation.
Fork 3 — package naming: uacrypt / dstu-core everywhere
Confirmed with the project owner this session: match the existing CLI binary (uacrypt, D-36) and
crate (dstu-core) names on every registry, using each registry’s own idiomatic spelling
(underscore vs. hyphen) rather than inventing a new brand or an artificial dstu-ua- prefix.
Checked exact-name availability directly against each registry’s own API (not a search engine, which
under-indexes empty results) on 2026-08-02:
| Registry | Name checked | Result |
|---|---|---|
| PyPI | uacrypt | 404 — free |
| PyPI | dstu-core | 404 — free |
| PyPI | dstu_core | 404 — free |
| npm | uacrypt | 404 — free |
| npm | dstu-core | 404 — free |
| NuGet | uacrypt | 404 — free |
| NuGet | dstu-core | 404 — free |
| Maven Central | artifactId uacrypt | numFound: 0 — free |
| Maven Central | artifactId dstu-core | numFound: 0 — free |
No collision with li0ard (D-07, excluded as an untrusted supply-chain source): their TypeScript
packages live under the npm scope @li0ard/kalyna, @li0ard/kupyna, @li0ard/strumok — a
different namespace entirely from the unscoped dstu-core/uacrypt names this project would use.
A future consumer typing the unscoped name has no path to land on @li0ard/* by mistake.
What engineers need — the per-binding checklist
Every binding, regardless of language, ships all of the following before it’s considered done — this is the template every phase below instantiates:
- The same
crypto_*API surface (secretbox,secretstream,auth,kdf,generichash,stream,sign,pwhashwhere the feature is enabled,randombytes) — not a subset, per Fork 2. - Idiomatic to the target language, per
docs/cross-language-style-guide.md(casing, error shape, resource cleanup, doc-comment format) — that document is the style authority; this document doesn’t re-derive its conventions. - “Install and forget” — zero-config API, no knobs to misconfigure. Same libsodium-style hard-
defaults philosophy the core already applies (
crypto_secretbox/crypto_secretstream’s internally-generated nonce, D-47’s “delete the knob”). A binding’s public surface takes a key and a message and returns a result — no mode/nonce/IV/padding parameter for the consumer to get wrong, no setup step beyondimport/require/using+ one key-generation call. This is a functional requirement, not just documentation quality — if a binding needs a config object or an init call beyond constructing a key, that’s a design defect to fix before the binding ships, not something to explain away in a README. - Prebuilt binaries — never “clone and build it yourself” for the binding’s own consumer. Same
bar
uacryptitself already clears (T-18/T-119: GitHub Release binaries for Windows/Linux/macOS, “no Rust toolchain required on their side” per D-12’s own scope note) — a consumer of the binding installs a package and never invokescargo buildthemselves. Per language: Python — manylinux/ macOS/Windows wheels viamaturin; Node — prebuilt.nodebinaries per platform via napi-rs’s cross-compile; Java — a native library bundled per OS/arch classifier (or one fat JAR); .NET — a package withruntimes/{rid}/native/per platform; C++ — prebuilt static/dynamic libs alongside the header, or a one-line CMakeFetchContent; PHP/Ruby — a prebuilt extension binary where the ecosystem supports it, source build only as a fallback. This is about the packaging mechanism and applies to local/CI-artifact installs immediately — it is independent of, and does not wait on, the separate registry-publish authorization gate below. crypto_secretstreamgets an idiomatic stream/pipe wrapper per language, not a raw push/pull loop the consumer manages themselves. See D-118: the same “.NETCryptoStream/GZipStream, Nodestream.Transform, Python file-like object, JavaInputStream/OutputStream, C++istream/ostream” shape every one of those ecosystems already has for exactly this kind of transform-a-stream operation. A consumer wires a source stream to a destination stream (orFile.Encrypt(inPath, outPath, key)-style helper for the common case) and chunking, tag framing, and rekeying stay entirely invisible — this extends the “install and forget” requirement above to the mechanics of streaming, not just to the absence of crypto knobs. This adds no new configuration surface — D-47’s “delete the knob” still holds; the “wider” instinct that prompted this is satisfied by which primitive to call (secretboxfor one message,secretstreamfor a file/stream,signfor a signature — already all in scope), not by new tunables inside any one of them.- Three test categories (already this project’s standing rule, D-64/D-65): (1) correctness against the same vectors/oracles the Rust core already uses, (2) rejection — tampered ciphertext/tag/nonce, wrong key, (3) misuse — bad lengths/paths, empty input, no partial output on failure. “Round-trip works” alone is category 1 only, not sufficient coverage.
- Category 1 specifically must run the actual official vectors, not just round-trip against
itself. Each binding’s local test suite loads and runs the same
crates/dstu-core/tests/vectors/{kalyna,kupyna,strumok,dstu4145}/*.jsonfiles the Rust tests already use, through the binding’s own public API — one source of truth, no hand-copied duplicate vector data per language to drift out of sync (the same “test-vector fix needs a citation, not just matching numbers” disciplineCLAUDE.mdalready applies to the Rust tests themselves). Where a language’s ecosystem makes reading JSON test fixtures awkward, generate that language’s fixture format from the JSON at test-build time — never hand-transcribe the numbers. - A runtime self-test function the binding’s own consumer can call, not just a dev-time test
suite. See D-117:
dstu_coregains one sharedselftestmodule that re-runs the official KAT vectors against the live compiled code and reports pass/fail (which primitive failed, if any). Every binding exposes a thin, idiomatically-named wrapper around that single implementation (dstu_core.selftest()in Python,selfTest()in Node/Java/.NET,dstu_selftest()in the C ABI) — built once at the core, not reimplemented per language. This lets a consumer verify their exact installed binary is producing correct outputs on their exact platform before trusting it with real data, the same “don’t just trust it compiled” instinct this project already applies to itself via dual-oracle verification. - A local test suite in that language’s native framework (pytest, xUnit, JUnit,
node:test, a small C/C++ harness, PHPUnit, RSpec/Minitest) — runnable without any other binding installed. - Test-first and cross-language, for every binding language, not just Python (T-49) where it
happened to land that way — see D-124. Test-first: the failing test for a given wrapper/
surface is written before that wrapper’s code, same as this project’s root “test-first, always”
rule already requires for the Rust core — T-161’s own step 1 is the pattern every later binding’s
step 6 follows, not a one-off. Cross-language: every binding’s category-1 correctness tests load
the same shared vector files under
crates/dstu-core/tests/vectors/(already stated above) — two languages passing against one shared vector file is what makes them comparable, not a separate suite that runs one language’s output against another’s. - Accessible examples for a working programmer, not API reference restated: real recipes
(“encrypt a file,” “hash a string,” “sign/verify a message”), comment-light, in an
examples/directory. - The same provisional-status banner the root README/crate docs already carry (T-112) — a binding that omits “Kalyna modes not primary-text-confirmed / Strumok vectors UAPKI-attributed” would be less honest than the Rust crate it wraps.
- A
cargo xtasksubcommand, not a one-off shell/PowerShell script (D-12 —xtaskis the single cross-platform QA entry point) — wired into CI the same way every other target already is. - Build/test only, never publish, until publishing that specific registry is separately, explicitly requested — the same gating T-17 already applies to crates.io (still not requested as of this document). PyPI/npm/Maven Central/NuGet/RubyGems/Packagist are five more instances of the same class of decision, not a bundle to authorize once.
Project structure
New top-level bindings/ directory, sibling to crates/:
bindings/
python/ # PyO3 crate + maturin config, pytest suite, examples/
capi/ # C ABI crate (cdylib+staticlib), generated header, C smoke test, examples/
dotnet/ # P/Invoke wrapper over capi, xUnit suite, examples/
java/ # JNI (jni crate or over capi, per Phase 4 spike), JUnit suite, examples/
nodejs/ # napi-rs crate, node:test suite, examples/
cpp/ # thin C++ header-only RAII wrapper over capi, a small test, examples/
php/ # ext-php-rs extension (or FFI-extension over capi), PHPUnit suite, examples/
ruby/ # magnus/rb-sys crate, RSpec/Minitest suite, examples/
Each binding directory carries its own README.md (provisional-status banner + quickstart), its
own test suite in the language’s native layout, and its own examples/ directory — see the
checklist above for what each of those must contain.
Phased roadmap
Full phase-by-phase task breakdown, with commit points, lives in docs/TASKS.md’s “Phase 3 —
Language bindings” section (T-158 onward) — this document states the reasoning and order; that
document tracks live status so it doesn’t drift from this analysis. Summary:
| Phase | Deliverable | Depends on |
|---|---|---|
| 0 | This document + tracking + naming check (done this session) | — |
| 1 | Python binding (the template) | Phase 0 |
| 2 | C ABI crate | Phase 1’s pipeline lessons |
| 3 | .NET binding | Phase 2 |
| 4 | Java binding (spike first) | Phase 2 |
| 5 | Node.js binding | Phase 1’s pipeline lessons |
| 6 | C++ binding | Phase 2 |
| 7 | Publishing to each registry — owner-gated, one explicit ask per registry | Phases 1-6 |
| 8 | PHP binding | Phase 2 |
| 9 | Ruby binding | Phase 1’s pipeline lessons |
| 10 | GitHub-facing docs + gh-pages site refresh | Phases 1-9 |
| 11 | Go binding (T-163, added 2026-08-02) | Phase 2 (C ABI - no direct-Rust-binding toolchain for Go has PyO3/napi-rs/magnus’s maturity) |
Phase numbers above are dependency labels, not the current build sequence — D-121/D-122/D-123
reordered the actual sequence (Node/Ruby/PHP before the C ABI group; Go added, needing the C ABI,
built ahead of C++ specifically; Dart deferred). docs/TASKS.md’s “Build order revised 2026-08-02”
line is the current
authoritative sequence; this table stays as originally written since the dependency relationships
it states (what needs what) are still accurate, only the order changed.
Cross-session execution plan
Requested 2026-08-02: a granular, checkable, per-task step list that survives a memory clear or a new session — this section is the one to update as work lands, and the one to read first when resuming. Update the resume line below every time a step is checked off; a stale resume line is worse than no resume line, since it actively misdirects the next session.
*Resume point: T-161 done (2026-08-02). T-49 (Python) done in full 2026-08-02 - see D-120. T-50
(Node.js) done in full 2026-08-02 - see D-125 through D-132 (step 6 done before step 5, a
tooling-forced reorder, D-129 explains why; D-130 corrects D-125’s toolchain-pin approach). T-160
(Ruby) done in full 2026-08-02 - see D-133 (own Ruby+MSYS2-clang toolchain install, several real
rb_sys/bindgen gotchas), D-134 (full crypto_ surface), D-135 (SecretStreamWriter/Reader,
Zlib::GzipWriter/Reader-modeled), D-136 (advisor-review fixes to steps 2-3, then step 4’s
precompiled native gem - a source gem cannot install standalone at all, the path-dependency
finding), D-137 (cargo xtask ruby + bindings-ruby.yml, rubocop wired in), D-138 (58-example RSpec
suite, cross-language vector loading, real uacrypt interop), D-139 (examples/ + README.md), D-140/
D-141 (three real CI round-trips to get bindings-ruby.yml actually green - ridk not on the hosted
runner’s PATH, Gemfile.lock missing non-Windows platforms, and the root rust-toolchain.toml
silently overriding rustup default on Windows - confirmed green on real CI, run id
30759971107, all four jobs success). T-159 (PHP) done in full 2026-08-02 too - see D-142
through D-147 (flat dstu_core_* naming modeled on ext-sodium, a plain PHP Writer/Reader
over stream_filter_register rejected for step 3, a real xtask-level RUSTUP_TOOLCHAIN
inheritance bug found and fixed (D-146), and D-147’s own two CI round-trips - a macOS
-undefined dynamic_lookup linker gotcha, a cross-OS cargo-deny license-allow-list gap, and a
Windows pwsh-vs-bash POSIX-path mismatch - confirmed green on real CI, run id
30765006443, all four jobs success). T-158 (C ABI crate) done in full 2026-08-03 - see D-148
(pre-implementation design forks: symbol prefix, cbindgen-via-xtask, output-buffer convention,
unconditional std dependency, unsafe-boundary hygiene, rlib crate-type) and D-149 (the
implementation: cbindgen.toml, crates/dstu-core-capi’s full crypto_* wrap, the C test
harness, examples, README, cargo xtask capi plus a new capi job in rust.yml - not yet
confirmed on real CI, only verified locally on this Windows-GNU dev machine, same caveat every
prior binding’s own first-pass session carried). T-52 (.NET) done in full 2026-08-03 - see D-152
(P/Invoke [LibraryImport] bool-marshalling finding, SafeHandle handles,
SecretStreamEncryptStream/DecryptStream’s Complete()-not-Dispose() finalization split,
NuGet packaging + fresh-install check, then the Pi ARM64 re-check - step 10, all green first try,
no bug found) - T-52 is now done in full, all ten standard steps. T-51 (Java) done in full
2026-08-03, all ten standard steps - see D-153 (step-0 spike chose the jni crate direct-Rust
binding over JNI-over-capi; full crypto_* surface, SecretStreamEncryptor/Decryptor, 56 JUnit
tests including real uacrypt interop, cargo xtask java + CI, examples/README; JDK build/test
baseline 17, published bytecode target 8; step 10’s Pi re-check found one real bug - Debian’s
apt-packaged Maven defaults to an old maven-compiler-plugin that silently ignores
maven.compiler.release, fixed by pinning the plugin version explicitly). T-163 (Go) done in full
2026-08-03, all ten standard steps - see D-155 (step-0: hand-written cgo over c-for-go, decided
on inspection rather than a full spike, since T-158’s own C ABI surface is already stable; a real
selftest-only link spike found two genuine static-linking gaps on Windows-GNU - -ldstu_core_capi
alone links dynamically unless -Wl,-Bstatic/-Bdynamic bracket it, and the Rust staticlib
transitively needs -lws2_32 -luserenv -lntdll even though dstu-core-capi itself never touches
networking; full crypto_* surface, CryptoError/ArgumentError/InternalError split,
SecretStreamEncryptWriter/DecryptReader (io.Writer/io.Reader-shaped, Complete()-not-
Close() finalization split same as .NET’s), cargo xtask go + bindings-go.yml CI (Windows leg
forces the GNU-hosted Rust toolchain since cgo can’t link MSVC output - unconfirmed on real CI as
of this writing), examples/README; step 10’s Pi re-check found the Windows-only LDFLAGS didn’t
work unmodified on Linux - fixed with cgo’s own per-GOOS #cgo pragma syntax, all tests then
green on real aarch64). T-53 (C++) done in full 2026-08-03, all ten standard steps - see D-158
(four step-0 forks: Finish()-not-destructor Final emission, std::ostream&/std::istream&,
prebuilt-lib CMake packaging with no FetchContent for the Rust side, hand-rolled CHECK-macro
test harness mirroring c-tests/test_capi.c); header-only C++17 RAII wrapper (unique_ptr-backed
move-only handles) over crates/dstu-core-capi’s cdylib (not the staticlib Go links - matches the
C test harness’s own existing choice), full crypto_* surface, exception-based errors, real
bidirectional uacrypt.exe interop in the test suite, cargo xtask cpp + bindings-cpp.yml CI (no
Windows GNU-forcing needed, branches on target_env the same way capi() already does), five
examples + README; step 10’s Pi re-check found no bug this time (unlike D-151’s c_char/i8
finding in the C ABI crate) - libdstu_core_capi.so linked correctly, Kupyna-256 digest
byte-identical to the x86-64 dev machine. Pushed and confirmed green on real CI, run id
30839873166, all three bindings-cpp.yml jobs (ubuntu-latest/GCC, macos-latest/Clang,
windows-latest/MSVC) success — MSVC/Clang were never exercised locally on this dev machine
(no cl.exe on PATH, no local macOS box), so this CI run is their only confirmation, checked via
gh run view per CLAUDE.md’s own rule, not assumed from the push alone. Every planned binding
(T-49/T-50/T-160/T-159/T-158/T-52/T-51/T-163/T-53) is now done in full. Next: T-162 (docs, last).
The standard binding steps
Every binding task (T-49/T-50/T-51/T-52/T-53/T-158/T-159/T-160) follows this same ten-step template unless its own entry below says otherwise — written once here rather than repeated ten times, per this project’s own “three similar lines beat a premature abstraction, but don’t duplicate a real invariant” instinct. (Step 10 was added 2026-08-03 — T-49/T-50/T-158/T-159/T-160 predate it and weren’t retroactively re-run for it at the time; D-151’s own pass covered all of them retroactively the same day it was added, see that entry.)
- Scaffold the binding crate/project, wired into the Cargo workspace where applicable.
- Wrap the full
crypto_*surface, zero-config (D-116), including aselftest()wrapper around T-161. - Wrap
crypto_secretstreamin the language’s idiomatic stream/pipe primitive (D-118). Two pitfalls found by advisor review while building T-49’s own wrapper — check both again for every later binding, not just Python (see T-49 step 3’s own entry below for the concrete Python bugs and fixes): - The language’s own “always runs, even on error” resource-cleanup hook must NOT finalize (emit theFinalchunk) on the error/exception path. Python’s__exit__(exc_type, ...)was the concrete case (T-49) — it originally calledclose()unconditionally, so a write loop that raised partway still produced a stream with aFinalchunk, and a reader saw a complete-looking file instead of failing closed (violates D-65’s “no partial output treated as valid on failure”, the same propertyuacrypt encrypt’s own temp-file-then-rename gets for free). Every language’s equivalent hook has the same shape and needs the same check: C#’susing/IDisposable.Dispose(), Node’sstream.Transform_flush/'error'vs.'end'event, Java’s try-with-resourcesclose(), C++ RAII destructors (which can’t even see whether unwinding is due to an exception without extra machinery — decide the mechanism deliberately, don’t assume the default is correct). - The wire-format reader must validate untrusted length-prefixed fields itself, not just copy the encoder’s happy path. Python’s decoder read the wirechunk_lenfield (attacker-controlled, read before any tag verification) and used it directly to size a read, with no upper bound — for a file this just hits EOF, but the language’s own file-like abstraction may also have to accept a socket/pipe, where an oversized declared length means accumulating gigabytes before ever failing. Also missed: rejecting trailing bytes after theFinalchunk (silently ignored instead of erroring). Both are checksuacrypt decryptalready has (CliError::SecretstreamChunkTooLarge/CliError::SecretstreamTrailingData,crates/uacrypt/src/lib.rs) — port them explicitly into every language’s own reader, they don’t come for free from the wire format matching. - Prebuilt-artifact packaging for the target platform(s) (D-116) — build/local-install only, no registry publish.
-
cargo xtasksubcommand + CI wiring (D-12). - Local test suite: official vectors through the binding’s own API (category 1), rejection (category 2), misuse (category 3) — D-64/D-65 plus this session’s official-vectors requirement.
-
examples/+README.mdwith the provisional-status banner (T-112). - Doc-map sweep (
README.md/dstu-crypto-project.md/release-readiness.md/user-journey-gaps.md/cross-language-style-guide.md) + mark the task done indocs/TASKS.md. - Commit — each numbered step above is its own commit, not one large drop.
- Added 2026-08-03, D-151 — cross-arch smoke check on the Raspberry Pi rig (real aarch64
Linux, access/re-sync details in
.claude.local.md, not here):cargo xtask <binding>run there after installing whatever this binding’s own toolchain needs (see D-151/docs/TASKS.mdT-35’s entry for the concrete per-language install commands already worked out — Node/Ruby/ PHP/Python/cbindgen). Same “no CPU-family lock-in” reasoningdocs/TASKS.mdT-35 already applies to the core crate, extended to cover a binding’s own FFI-boundary code too — D-151 found a real bug this way (a hardcodedi8test buffer that should have beenc_char, compiling fine on every x86-64 platform but not on ARM Linux’s unsigned-by-defaultchar). Run bindings sequentially on the Pi, not concurrently — two at once race on~/.rustup’s shared component-download cache (D-151’s own process-lesson note).
T-161 — dstu_core::selftest (first; nothing below can start without it)
No binding exists yet at this point, so the standard template above doesn’t apply — this is real
Rust-core work, confirmed as a genuine gap (see docs/TASKS.md T-161’s own note).
Done 2026-08-02.
- Test-first: write the test asserting
selftest::run()reports success, before the module exists. - New Cargo feature (
selftest), off by default in the bare crate. - Embed the official vectors (build-time include from
crates/dstu-core/tests/vectors/*.json, not hand-copied). - Implement
run()— scope note: one vector per primitive (Kalyna, Kupyna, Strumok, DSTU 4145), not one per everyhazmatmode/crypto_*wrapper — a fast spot check of the underlying algorithm each of those builds on, not a re-run of the fulltests/vectors/corpus (the module’s own doc comment says this explicitly, so a future reader doesn’t assume broader coverage than exists). The report names which primitive(s) failed, if any. - Verify:
cargo test --features selftest(workspace-default run unaffected),cargo clippy --features selftest --all-targets -- -D warningsclean for the new files,cargo fmt --checkclean,no_std/no_std+alloc/default builds all still succeed with the feature absent. - Mark T-161 done in
docs/TASKS.md, note in D-117 that it landed. - Commit.
T-49 — Python (the template every later task assumes)
Standard steps above, with:
- Step 1:
bindings/python/, PyO3 + maturin. Corrected 2026-08-02, see D-119: its own[workspace]table, a path dependency ondstu-core, not added to the rootCargo.toml’smembers- two existing CI jobs (cargo +nightly miri test --workspace, the MSRV-pinned--workspacebuild) would otherwise silently start covering a PyO3cdylibneither job is equipped for. Same shape applies to T-50/T-160 (Node/Ruby, also direct Rust bindings) - T-158 (C ABI) is unaffected and stays a real workspace member, see D-119 for why the two cases differ. Done 2026-08-02:dstu_core_pycrate (cdylib,pyo3 = "0.26",extension-modulefeature), mixed maturin layout (python/dstu_core/__init__.pypure-Python package wrapping the compiled_dstu_coreextension). Wraps onlyselftest()so far, as this scaffold’s own pipeline proof - the fullcrypto_*surface is step 2, not yet done. Verified end-to-end, not just “compiles”:cargo build/clippy --all-targets -- -D warnings/fmt --checkall clean;maturin develop(in a.venv, real Python 3.12.10 resolved viaPYO3_PYTHON- see.claude.local.md,python/python3on PATH are broken Store stubs on this machine) builds and installs the wheel;python -c "import dstu_core; dstu_core.selftest()"runs the real Rust self-check and returns cleanly. Confirmed the root workspace is unaffected:cargo build --workspacefrom the repo root still only seescrates/dstu-core/crates/uacrypt(this is the concrete case D-119 was written to prevent -cargo initinsidebindings/pythonhad in fact auto-added itself to the rootCargo.toml’smembersbefore this was caught and reverted). Follow-up fix same day:pyo3bumped"0.26"→"0.29"(the version actually resolved, caught in self-review) plus aPYO3_PYTHONbuild-prerequisite note added tobindings/python/README.md. - Step 2: Done 2026-08-02. One Rust module per
dstu_core::crypto_*module -secretbox/secretstream/auth/kdf/generichash/stream/sign/pwhash/randombytes- pluspwhashturned on inbindings/python/Cargo.toml(it’sstd-gated only, no reason to withhold it from a binding whose whole point is a full-surface wheel). Keys/ciphertexts/tags cross the FFI boundary as plain Pythonbytes, not an opaque handle type -SecretKey’sZeroize-on-drop guarantee can’t reach abytesobject regardless of wrapper shape, so an opaque type would buy nothing here (PyNaCl’s own libsodium bindings make the same call). A singleDstuErrorexception class covers every crypto-operation failure (tag mismatch, truncation, CSPRNG failure); the stdlibValueErrorcovers caller-input mistakes a fixed-size Rust array forecloses (wrong-length key/context/etc.) - two different failure classes, not one exception type doing both jobs.crypto_secretstream’sPushState/PullStateare wrapped as thin#[pyclass]es mirroring the Rust API 1:1 (tag as a plainint,SECRETSTREAM_TAG_*module constants) - the idiomatic file-like wrapper is deliberately step 3, not built here.crypto_generichash’s streamingKupyna{256,512}Hasherare#[pyclass]es holdingOption<Hasher>,.take()n onfinalize()since the wrapped Rustfinalize(self)consumes ownership - a secondfinalize()call raisesValueErrorrather than panicking. Verified end-to-end viamaturin develop+ a real Python smoke script exercising every wrapped function, including tamper rejection (secretbox/auth/secretstream), wrong-message/wrong-key signature rejection, and a wrong-length-keyValueError- not just “it compiles.”cargo build/clippy --all-targets -- -D warnings/fmt --checkall clean; rootcargo build --workspacereconfirmed unaffected. - Step 3: Done 2026-08-02.
SecretStreamEncryptor/SecretStreamDecryptor(bindings/python/python/dstu_core/secretstream.py) - pure Python, built on step 2’sSecretStreamPushState/PullStaterather than new Rust glue (native-language idiom is exactly what D-118 asks for, and file I/O against arbitrary Python file-like objects is more natural to write directly in Python than via PyO3 callbacks).write()/iterate hide chunk/tag/header bookkeeping entirely. Wire format matchesuacrypt encrypt/decryptexactly (8 KiB chunks,tag || len_u32_le || ciphertext || auth_tagrecords after a 32-byte header) - a deliberate choice, not required by D-118 itself, verified with a real interop test in both directions against the builtuacryptbinary (not just self-consistency): a fileSecretStreamEncryptorwrote round-tripped throughuacrypt decrypt, and a fileuacrypt encryptwrote round-tripped throughSecretStreamDecryptor. Also verified: exact-chunk-boundary plaintext sizes (e.g. exactly 2×8192 bytes) produce the identical byte layout to the Rust CLI’s own one-chunk-ahead buffering - the last full chunk is taggedFinaldirectly, not followed by a spurious emptyFinalrecord (a real bug caught and fixed during this step, not assumed correct); tamper and truncation both raiseDstuError.ruff check --fix/ruff format --checkclean (installed into the.venvfor this check - not yet wired intoxtask/CI, that’s step 5). - Step 4: Windows wheel done locally 2026-08-02 (this machine is Windows-only - manylinux/
macOS builds genuinely need CI, not a local shortfall; deferred to step 5, reusing
.github/workflows/release.yml’s existingmatrix.os: [ubuntu-latest, macos-latest, windows-latest]/tag-trigger/artifact-upload conventions rather than inventing a parallel scheme).maturin build --release --out distproducesdstu_core-0.1.0-cp39-abi3-win_amd64.whl(one wheel for all supported CPython versions - see step 1’sabi3-py39note); installed into a fresh venv viapip install(not the editable.venvevery other check in this file used) and re-run against the full smoke suite (selftest,secretbox, thesecretstreamfile-like pipeline,sign) - a materially different check thanmaturin develop, since it provessecretstream.py(added in step 3, after the previous packaging check) actually ships inside the wheel rather than only ever having been exercised through the source tree. manylinux/macOS wheels are folded into step 5 below, not a separate step - they need CI, not a local shortfall. - Step 5: Done 2026-08-02, see D-120. Two distinct CI pieces, not one (advisor review): (1)
.github/workflows/bindings-python.yml, own job (D-119) -test(matrix ubuntu/macos/windows: fmt-check ubuntu-only per the autocrlf false-positive rust.yml’s own fmt job already avoids the same way, clippy, builduacryptfirst from the repo root so the pytest interop test can’t silently skip,maturin build+pip install --find-linksrather thanmaturin developsincedevelopneeds a virtualenv a bareactions/setup-pythoninterpreter isn’t, then pytest with an explicit grep-for-SKIPPEDfailure gate, then ruff),wheel-preview(the realPyO3/maturin-action@v1/manylinux: autorecipe, run on every push so a broken recipe is caught immediately - confirmed on real CI producingdstu_core-0.1.0-cp39-abi3-manylinux_2_17_x86_64. manylinux2014_x86_64.whl, the tag actually verified, not assumed), andsupply-chain(cargo deny check/cargo auditagainst this workspace). (2)release.yml’sbuild-python-wheelsjob, same matrix/maturin-action recipe, added topublish-release’sneeds(wheel-build failure blocks the release, a deliberate choice).cargo xtask pythonadded (best-effort, D-12 posture: builduacrypt, fmt/clippy,maturin develop, pytest - verified locally, all 57 tests passing with the interop test actually running). Also closed in this pass: D-119’s own recorded consequence that rootcargo deny/auditdidn’t reachbindings/python’s dependency tree - turned out cargo-deny already walks up and finds the rootdeny.tomlwith no second file needed, and running it for the first time caught a real wildcard-dependency bug (missingversion =on thedstu-corepath dependency, T-75/D-11’s exact failure mode), fixed in the same pass. - Step 6: Done 2026-08-02, out of order (before step 5, advisor review) - a CI job wired to an
empty test directory passes vacuously, so writing the suite first gives step 5 something real to
fail on. 57 tests across every module, D-64/D-65’s three categories - see the T-49 section above
for the concrete shape (a real Kupyna-256 vector, live
uacryptCLI interop, the two rejection gaps an earlier advisor pass caught).[project.optional-dependencies]devgroup pinsmaturin/pytest/ruffto the versions verified this session. - Step 7: Done 2026-08-02.
examples/(secretbox.py,secretstream_file.py,sign.py,password_hashing.py,misc.pyfor auth/kdf/generichash/stream/randombytes) - each run against the real built extension before committing, not just written from the API surface.README.mdrewritten from its step-1 “scaffold only” state to document the full surface with a module-by-example table; provisional-status banner kept, reworded to match. Wiring ruff into a real gate for the first time (step 5) surfaced two realPYI034findings insecretstream.py’s__enter__methods, fixed with an inlinenoqa(this binding’srequires-pythonfloor is 3.9,typing.Selfneeds 3.11+, notyping_extensionsdependency wanted for a pre-1.0 zero-dependency binding). - Step 8: Done 2026-08-02, this entry. Doc-map sweep:
README.md(root repo-tree line was still “planned, not yet built”),docs/dstu-crypto-project.md,docs/release-readiness.mdupdated;docs/user-journey-gaps.md/docs/cross-language-style-guide.mdchecked, no T-49 references existed to update. T-49 marked done indocs/TASKS.md, D-120 added. - Step 9: each step above landed as its own commit (see
git logfor the exact sequence) - no large single drop.
T-158 — C ABI crate (foundation for C++/.NET, maybe Java)
Not a language binding itself — no idiomatic-language step 2/3 the way the others have; steps 1/4/5/6/7/8/9 of the standard template, renumbered for what this crate actually needs:
- Scaffold
crates/dstu-core-capi(cdylib+staticlib+rlib) — opaque handles, explicit error codes,catch_unwindat every boundary call, zeroize-on-free. Verified the existing 8-combination feature matrix still passes with this new workspace member present (D-148/ D-149). -
cbindgen-generated header (include/dstu_core.h), including adstu_selftest()export (T-161).usize_is_size_t = trueincbindgen.tomlso generated signatures readsize_t, matching the spec’s own C convention, rather than cbindgen’s defaultuintptr_t. -
xtask/CI wiring —cargo xtask capi(header regen+diff, C harness, examples) and a newcapijob inrust.yml(matrix ubuntu/macos/windows; not yet confirmed on real CI, only verified locally on this Windows-GNU dev machine, D-149). - Prebuilt dynamic/static libs per platform (D-116) — local build only so far (this Windows-GNU
machine’s own
target/release/{dstu_core_capi.dll,libdstu_core_capi.dll.a, libdstu_core_capi.a}); cross-OSrelease.ymlpackaging deferred, see D-149. - A small C test harness (
c-tests/test_capi.c): correctness, rejection, misuse per D-64/D-65, run against the just-built cdylib viacargo xtask capi. -
examples/(secretbox.c,secretstream_file.c,sign.c,misc.c) +README.mdprovisional-status banner — each example actually run against the real built library, not just written from the API surface. - Doc-map sweep + mark T-158 done — this entry, D-149.
- Commit per step (see
git logfor the exact sequence).
T-52 — .NET
Done in full 2026-08-03 — see D-152. No Cargo workspace of its own at all (unique among the
bindings so far) - bindings/dotnet/DstuCore is pure C#, P/Invoking T-158’s already-built C ABI.
- Step 1: Done.
bindings/dotnet/DstuCore(net8.0 class library) +Directory.Build.props(copies whichever platform’sdstu_core_capi.{dll,so,dylib}exists under the repo’starget/release/into every project’s own build output, sodotnet build/test/runneed no manual copy step).Native/NativeMethods.csuses[LibraryImport](source-generated interop), not classicDllImport— its marshaller requires an explicit[MarshalAs(UnmanagedType.U1)]on everybool-returning export or the build fails to compile, catching at compile time what would otherwise be C#’s silently-wrong default 4-byteBOOLmarshalling against Rust’s 1-bytebool(dstu_verify/dstu_verify_digest/dstu_pwhash_verify_password/dstu_secretstream_{push,pull}_is_finalized— a wrongtrueout ofdstu_verifyspecifically would be a silent signature-verification bypass, the .NET analogue of D-151’s ARMc_char/i8finding, found by advisor review before implementation rather than after a failing test). Every opaquedstu_*handle is aSafeHandlesubclass (Native/NativeHandles.cs), not a bareIntPtr— deterministic release + protection against premature finalization during a call, this project’sIDisposable/usingidiom applied to a native handle. - Step 2: Done. Full
crypto_*surface wrapped —AuthKey,KdfMasterKey,GenericHash/Kupyna256Hasher/Kupyna512Hasher,SecretboxKey,SigningKey/VerifyingKey,StreamCipherKey(named to avoid colliding withSystem.IO.Stream),Pwhash,RandomBytes,Selftest.DstuException(crypto-operation/data-integrity failure) vs.ArgumentException(caller-input mistake) mirrorsbindings/python’s ownDstuError/ValueErrorsplit (Native/NativeStatus.cscentralizes the mapping). - Step 3: Done.
SecretStreamEncryptStream/SecretStreamDecryptStream(Stream-derived, matchingCryptoStream/GZipStream’s own shape per this document’s own template text). Both D-118 pitfalls apply, with one deliberate deviation fromCryptoStream’s own close-flushes convention:Dispose()never emits aFinalchunk at all — C#’sDispose()has no parameter telling it whether it’s unwinding from an exception (unlike Python’s__exit__(exc_type, ...)), so finalization is an explicit, always-requiredComplete()call on the success path instead of a conditional one. The reader bounds the untrusted wirechunkLenfield againstDstuConstants.SecretstreamChunkBytesand rejects trailing bytes afterFinal, same as every other binding. - Step 4: Done.
dotnet packproducesruntimes/{rid}/native/for the build machine’s own RID (win-x64 here; cross-OS RIDs deferred to arelease.ymljob, same split T-158’s own step 4 took). Verified with a real fresh-install check: packed, installed from a local NuGet feed into an unrelated temp console project,Selftest.Run()+ aSecretboxKeyround trip both ran against the installed package. - Step 5: Done.
cargo xtask dotnet(dotnet format --verify-no-changes+dotnet test—build()/test()/clippy()/fmt()already cover this binding’s one Rust-side dependency,dstu-core-capi, for free since it’s a real workspace member) +bindings-dotnet.yml(ubuntu/macos/windows matrix). - Step 6: Done. 56 xUnit tests (
DstuCore.Tests/) mirroringbindings/python/testsfile-for- file — Kupyna-256 correctness against the real shared JSON vector, DSTU 4145 correctness viaSelftest.Run()(matchingbindings/python/tests/test_sign.py’s own precedent — the Annex B.1 vector is exercised there, not re-derived per binding), real bidirectionaluacryptinterop for secretstream, D-64/D-65’s three categories throughout. - Step 7: Done.
examples/(one console project,dotnet run -- <name>dispatch —secretbox/secretstream-file/sign/password-hashing/misc, mirroringbindings/python/examplesfile-for-file) +README.mdwith the provisional-status banner. - Step 10: Done 2026-08-03. Real aarch64 Linux (the Raspberry Pi rig) had no .NET SDK installed
at all before this -
dotnet-install.sh --channel 8.0(Microsoft’s official install script; Debian isn’t one of the OSespackages.microsoft.com’s apt feed officially supports, unlike Ubuntu) got a real linux-arm64 SDK working there for the first time. All 56 tests passed on the first run, no ARM-portability bug found this time (unlike D-151’sc_char/i8finding in the C ABI crate’s own test) - genuine evidence the[LibraryImport]/SafeHandle/nuintmarshalling choices in D-152 are actually architecture-portable, not just working by x86-64 coincidence.
T-51 — Java
Standard steps, plus an upfront spike before step 1 (see docs/bindings-strategy.md Fork 1):
- Step 0: done 2026-08-03, see
docs/DECISIONS.mdD-153. Built two real, runnable prototypes (not reasoned from memory) - Spike A (jni = "0.21"crate, direct Rust binding againstdstu_core, no C ABI involved) vs. Spike B (hand-written C JNI shim over T-158’s already-builtdstu-core-capi). Both worked on the first run; chosen: Spike A - Java joins Python/Node/ Ruby/PHP’s direct-binding group, not .NET/C++/Go’s C-ABI group. Spike B would have added a third language (C) to the binding and doubled the packaged native surface per platform; Spike A avoids the C ABI’s caller-allocated-out-buffer protocol the same way Python/Node/Ruby already do. Panama (JEP 454) named and rejected (JDK 22+ baseline too new for this audience), not left unmentioned.jnipinned to0.21, not0.22(a real breakingJNIEnv/EnvUnownedAPI change, confirmed by actually trying the bump, not assumed). JDK baseline: build/test on 17 (matches the Pi’s Debian 12 default), but the published artifact’s bytecode target is<maven.compiler.release>8</maven.compiler.release>- Java 8 still has real enterprise/PKI- adjacent footprint (owner-requested correction), verified empirically by cross-compiling Spike A with--release 8from the JDK 17 install and running the resulting class on a real local JDK 8 JVM, all three test paths (selftest, seal/open round trip, wrong-key exception) unchanged. CI must matrix JDK 8 and 17 for the test suite (step 5), not just build once on 17. - Step 1: direct-Rust binding via the
jnicrate (own[workspace], D-119), per the spike above - not JNI-over-capi. - Step 3: an
InputStream/OutputStreampair; D-118’s Java pitfall carries over from T-52’s own resolution unchanged (try-with-resourcesclose()can’t see whether the block threw, same structural limitation as C#’sDispose()- explicitcomplete(), not auto-finalize-on-close). - Step 4: a native library bundled per OS/arch classifier (or one fat JAR).
- Step 5:
cargo xtask java+ CI, matrix at least JDK 8 and 17 (per step 0’s finding above). - Step 6: JUnit, run under both JDK 8 and 17 in CI.
T-50 — Node.js
Standard steps:
- Step 1: Done 2026-08-02, see D-125/D-130.
bindings/nodejs/, napi-rs, own[workspace]table per D-119 (not a root workspace member). Wraps onlyselfTest()so far, matching T-49 step 1’s own split.napi-build = 2.0.0pinned inCargo.lock(a real MSRV constraint, D-125). The MSVC toolchain this machine’s build needs is a machine-localrustup override, not a committed file (D-130 corrects D-125’s original committed-rust-toolchain.tomlapproach, which would have broken Linux/macOS CI runners) - see.claude.local.mdfor the exact command. - Step 2: Done 2026-08-02, see D-126. Full
crypto_*surface wrapped -secretbox,sign,pwhash,generichash(one-shot + incrementalKupyna{256,512}Hasherclasses),auth,kdf,stream,randombytes, pluscrypto_secretstream’s rawpush/pull(idiomaticstream.Transformstill deferred to step 3, matching Python’s own step 2/3 split). Every byte parameter/return usesnapi::bindgen_prelude::Buffer(maps to a real JSBuffer), notVec<u8>(which napi-rs maps to a plain JS number array, wrong for binary data - confirmed by reading napi’s ownVec<T>/BufferToNapiValue/FromNapiValueimpls, not assumed). Every function has an explicitjs_namefor camelCase (napi-derive does not auto-convert casing from the Rust identifier, unlike PyO3’s implicitsnake_casepassthrough that Python’s ownsnake_case-native convention didn’t need to override). Multi-value returns (secretstream’spush/pull) use a#[napi(object)]struct with named, camelCase fields (SecretStreamPushResult/SecretStreamPullResult) rather than a tuple - napi-rs has no tupleToNapiValueimpl at all, and a named-field result object is the more idiomatic JS shape anyway (matches this project’s cross-language style guide principle 2, name communicates intent). - Step 3: Done 2026-08-02, see D-127.
SecretStreamEncryptor/SecretStreamDecryptor, astream.Transformpair in pure hand-written JS (bindings/nodejs/js/secretstream.js) on top of step 2’s rawSecretStreamPushState/PullState, mirroringbindings/python/python/dstu_core/secretstream.py’s design and wire format exactly (same 8 KiBSECRETSTREAM_CHUNK_BYTES, sametag(1) || len_u32_le(4) || ciphertext || authTag(16)framing, interoperable withuacrypt encrypt/decryptin both directions - verified against the realuacryptbinary, not just self-consistently). Generated napi output relocated tobindings/nodejs/native/(vianapi build native) so the hand-writtenjs/index.jsentry point can live at the package root without colliding with the regenerated files. Both D-118 pitfalls re-checked for this port specifically (D-127 has the detail):_flush(not_destroy) emits the Final chunk, so an upstream error never produces a complete-looking truncated file;chunkLenis bounds-checked the moment it is parsed, and trailing bytes afterFinalare rejected both mid-stream and at_flush. - Step 4: Windows prebuilt artifact done locally 2026-08-02, see D-128 (this machine is
Windows-only, same constraint Python’s own step 4 hit - Linux/macOS cross-builds genuinely need
CI, deferred to step 5, not a local shortfall).
package.json’sfilesfield (js/,native/index.js,native/index.d.ts,native/*.node) makesnpm packbundle thenative/build output despite it being gitignored from source control -filesoverrides the.gitignore-based default for packing specifically, a real gotcha found and fixed here, not assumed to just work. Verified with a genuine fresh-install round trip (Python’s own step-4 bar):npm packinto a tarball,npm install <tarball>in an unrelated temp directory as a real dependency, thenrequire('dstu-core')(not the source tree) and re-run the full smoke suite (selfTest,secretbox, thesecretstreamstream.Transformpair) against the installed package - proves the packaged artifact actually contains everything needed, not just the dev source tree. - Step 5: Done 2026-08-02, see D-131.
cargo xtask nodejs(mirrorspython()exactly) +.github/workflows/bindings-nodejs.yml(mirrorsbindings-python.yml’s shape:testmatrix ubuntu/macos/windows,supply-chaindeny/audit). No MSVC-specific CI step needed anywhere -windows-latestis MSVC-host by default (D-130). Real gotcha hit and fixed: a bareCommand::new("npm")fails to resolve on Windows the same waymvnalready needed a.cmdspecial-case -command_for()extended accordingly. - Step 6: Done 2026-08-02, see D-129 — done before step 5 for this binding specifically, a
tooling-forced reorder (
node --test test/errors on a nonexistent directory, unlike pytest’s vacuous-pass-on-empty-collection behavior Python’s own step 5-before-6 order relied on), not a preference change to the standard template.node:test, one file percrypto_*module, mirroringbindings/python/tests/*.pyfile-for-file. Found and fixed a realnode:test-runner hang:SecretStreamEncryptor/Decryptor’s_transform/_flushcallbacks were invoked synchronously, which Node’s own docs warn can make an error throw synchronously out of the triggering.write()instead of emitting'error'the documented async way - fixed by deferring throughprocess.nextTick, confirmed stable across three repeated full-suite runs. - Step 7: Done 2026-08-02, see D-132.
examples/{secretbox,secretstream-file,sign, password-hashing,misc}.js(one-for-one with Python’s own five example files) and a fully rewrittenREADME.md(T-50 step 1 never created one - a gap Python’s step 1 didn’t have). - Step 8: Done 2026-08-02. Swept
README.md/dstu-crypto-project.md/release-readiness.md(stale “T-50 onward haven’t started” framing);user-journey-gaps.md/cross-language-style- guide.mdchecked, no T-50 references existed to update (same finding T-49’s own step 8 had). T-50 is now done in full - all nine standard steps - seedocs/TASKS.md. - Node-only (D-118) — browser/WASM is explicitly deferred; don’t reinterpret this task as covering it.
T-53 — C++ (reordered 2026-08-02, D-123: now builds after T-163/Go)
Done in full 2026-08-03, all ten standard steps — see D-158. Standard steps, consuming T-158’s header:
- Step 1: Done, see D-158.
bindings/cpp/include/dstu/*.hpp, C++17, header-only. Move-only RAII wrapper classes over every opaquecrates/dstu-core-capihandle viastd::unique_ptr<T, void(*)(T*)>(a custom-deleterunique_ptrgives move semantics almost for free, avoided writing ~8 near-identical move-ctor/move-assign/destructor bodies by hand). Fullcrypto_*surface. Errors are exceptions (dstu::CryptoError/ArgumentError/InternalError, cross-language-style-guide.md principle 4), matching Python’s own choice from that table’s “exception or return code” row. - Step 3: Done, see D-158.
std::ostream&/std::istream&(D-158 point 2) — never opened or closed by this wrapper (unlike Go/.NET’s ownleaveOpen-flag closer-forwarding, unnecessary here since a C++ reference is never owning).SecretStreamEncryptor/Decryptor. The finalization pitfall (D-118) resolved by porting theComplete()-not-Dispose()/Close()split D-152 (.NET)/D-155 (Go) already chose: a destructor cannot reliably tell exception-unwind from normal scope exit withoutstd::uncaught_exceptions()bookkeeping (fragile under nested exceptions besides), so the destructor only frees the native push state; emitting theFinalchunk is a separate explicitFinish()call on the success path only. Reader hardening (chunk length bound, trailing-data rejection) ported fromcrates/uacrypt/src/lib.rs’sCliError::SecretstreamChunkTooLarge/SecretstreamTrailingData, cross-checked byte-for-byte againstbindings/go/dstu/secretstream.go’s wire framing. - Step 4: Done, see D-158. Prebuilt lib alongside the header, no CMake
FetchContentfor the Rust side (no tooling equivalent ofcorrosionis already a project dependency).bindings/cpp/ CMakeLists.txt: anINTERFACEheader-only target plus aSHARED IMPORTEDtarget pointing atcrates/dstu-core-capi’s already-built cdylib (DSTU_CORE_CAPI_DIR/DSTU_CORE_TARGET_DIRvariables, defaulting to the sibling crate/target/release) — matchesc-tests/test_capi.c’s own existing choice of linking the cdylib, not the staticlibbindings/golinks (D-155’s-Wl,-Bstatic/-Bdynamicbracketing and transitive-lws2_32 -luserenv -lntdllneeds don’t apply here, since the cdylib itself resolves those at its own link time). - Step 5: Done, see D-158. GCC verified locally on both this project’s own Windows-GNU
dev-machine posture (MinGW Makefiles) and the aarch64 Pi (step 10);
cl.exeisn’t on this dev machine’s PATH (confirmed by trying, not assumed) and no local macOS/Clang machine exists, so MSVC and Clang were confirmed the other way — pushed and checked viagh run view(CLAUDE.md’s own “never assume from a green badge” rule), run30839873166, all threebindings-cpp.ymllegs (ubuntu-latest/GCC,macos-latest/Clang,windows-latest/MSVC) green, MSVC’s leg 2m48s vs. ~40s for the other two (cl.exe’s own known slower cold-start, not a problem).cargo xtask cppbuildsdstu-core-capi+uacrypt, thencmakeconfigure+build+ctest— branches ontarget_envthe same wayxtask’s owncapi()/capi_compile_msvcalready do for the plain-C harness, so no Windows GNU-forcing is needed the waybindings-go.yml’s cgo requirement needed one (D-155); the MSVC branch (dstu_core_capi.dll.libimport lib) is confirmed by this same CI run, not just reasoned fromcapi’s own precedent. - Step 6: Done, see D-158.
tests/test_dstu.cpp, a hand-rolledCHECKmacro mirroringc-tests/test_capi.c’s own structure exactly (no Catch2/doctest/GoogleTest — C++ has no stdlib JSON either, so the single official Kupyna-256 vector is hand-transcribed the same way the C harness already does it, matching cross-language-style-guide.md’s “standard library over a third-party one” KISS principle). D-64/D-65’s three categories throughout, plus a real bidirectionaluacrypt.exeinterop test (std::system, with the documented Windowscmd.exeouter-quote-wrapping workaround for its “first token is quoted” parsing quirk) and an explicit property test for the D-118 no-finalize-on-error property (destroying an encryptor without callingFinish()leaves a stream a decryptor must fail closed on). - Step 7: Done.
examples/{secretbox,secretstream_file,sign,password_hashing,misc}.cpp(one-for-one with the other bindings’ own five example files) +README.mdwith the provisional-status banner and a module-by-example table. - Step 8: Done, this entry. Doc-map sweep:
docs/dstu-crypto-project.md/docs/release-readiness.md/README.md’s own repo-tree listing updated;docs/user-journey- gaps.mdchecked, no T-53 references existed to update (same finding every earlier binding’s own step 8 had). T-53 marked done indocs/TASKS.md. - Step 9: each step above landed as its own commit, not one large drop.
- Step 10: Done. Raspberry Pi ARM64 re-check -
cargo xtask cppgreen end-to-end on real aarch64 (cmake 3.25.1/g++ 12.2.0, both already present, no new install needed unlike Node/Ruby/ PHP/.NET’s own first Pi runs), including the realuacrypt↔C++ interop test over a plain POSIXsh(not Windowscmd.exe-RunCommand’s outer-quote wrapping is a no-op there, D-158’s own test file comment). Linkslibdstu_core_capi.so(confirmed viafile, not assumed) - the non-Windows CMakeLists branch exercised for the first time on real hardware. Kupyna-256(“hello world”) verified byte-identical to the x86-64 dev machine’s own digest. No ARM-portability bug found this time (unlike D-151’sc_char/i8finding in the C ABI crate itself), matching T-52/.NET’s own clean first pass rather than T-51/Java’s or T-163/Go’s own Pi-specific findings.
cargo xtask cpp passes end-to-end on both the x86-64 Windows dev machine (GCC/MinGW, all tests +
all five examples green, real uacrypt.exe interop confirmed both directions) and the aarch64 Pi.
T-159 — PHP (reordered 2026-08-02, D-121: builds right after T-49/T-50/T-160, not deferred)
No longer consumes T-158. Original plan left ext-php-rs vs. FFI-over-the-C-ABI open;
D-121 commits to ext-php-rs specifically so this binding is a direct Rust binding like
Python/Node/Ruby and doesn’t wait on the C ABI crate at all.
Done in full 2026-08-02. Standard steps:
- Step 1: Done, see D-142.
bindings/php/,ext-php-rs, own[workspace]table (a direct Rust binding, same shape as Python/Node/Ruby - noext/split needed,ext-php-rshas norb_sys-stylecargo metadataquirk). PHP 8.3.33 installed by hand (winget’s own packages 404’d on a stale manifest patch version). Windows needs nightly Rust (abi_vectorcall) + the MSVC host (PHP’s own Windows builds are MSVC) +rust-lld(avoids an MSVC-linker-version mismatch) - a machine-localrustup override, matching Node’s D-130 pattern.ext-php-rs’s own Windows build script downloads a matching devel pack fromwindows.php.netautomatically - no manual devel-pack management needed. - Step 2: Done, see D-142. Full
crypto_*surface wrapped - flatdstu_core_*-prefixed global functions + a singleDstuCoreExceptionclass, modeled directly on PHP’s own bundledext-sodiumextension (sodium_crypto_secretbox,SodiumException) rather than a namespace or static-method class.Binary<u8>(notString/Vec<u8>) for every crypto byte parameter/return - confirmed by readingext-php-rs’s own source that PHP strings are raw byte buffers, not UTF-8-validated. Three real build-error findings:wrap_function!()needs its argument in the same module as the#[php_function]it names (fixed via a per-moduleregister(ModuleBuilder)function);u8doesn’t implementIntoConst(PHP has no unsigned int type);#[php_function]’s default rename splits a letter-to-digit boundary (kupyna256->kupyna_256), fixed with an explicit#[php(name = ...)]. - Step 3: Done, see D-143. PHP’s own
stream_filter_register/php_user_filtermechanism was investigated and rejected (no clean hook for a one-time header write before filtered bytes, and PHP’s own internal stream buffer doesn’t align with the fixed 8 KiB chunk boundary) - a plain PHPDstuCoreSecretStreamWriter/Reader(lib/DstuCoreSecretStream.php, implementingIterator) over aresourceinstead, matching Python’s/Ruby’s own choice. Found a realext-php-rsgap: a Rust-registered exception class with no#[php_impl]constructor cannot benew-ed from pure PHP - fixed with adstu_core_throw_error()escape hatch reusing the same Rust-sidePhpException::from_classconstruction path. Verified both directions against the real builtuacrypt.exe, plus six rejection/misuse cases including the D-118 no-finalize-on-error property. - Step 4: Done, see D-144. No PECL/Composer publish attempted (Composer never manages native
extensions at all; PECL needs its own account/manifest/review pipeline) - the honest deliverable
is a release-profile compiled binary plus a documented
php.ini extension=line, verified with a fresh-install-style check (only the compiled.dllcopied to an unrelated directory, loaded via a full path). - Step 5: Done, see D-145/D-146.
cargo xtask php+bindings-php.yml(shivammathur/ setup-php, re-deriving the Windows nightly+MSVC axis rather than copyingbindings-ruby.yml’s GNU-vs-MSVC conditional). PHPUnit ships as a standalone PHAR, no Composer dependency added. Found and fixed a realxtask-level bug (D-146, not PHP-specific):run()’s child cargo invocations inheritedRUSTUP_TOOLCHAINfrom the outercargo xtaskprocess, silently overriding any binding’s own directory-scopedrustup override- almost certainly affectscargo xtask nodejsidentically, not yet re-verified there. - Step 6: Done, see D-145. 58 PHPUnit tests across all 10
crypto_*modules, mirroringbindings/ruby/spec/*.rb/bindings/nodejs/test/*.test.jsfile-for-file - the real official Kupyna-256 vector (D-124), real bidirectionaluacrypt.exeinterop, D-64/D-65’s three categories throughout. - Step 7: Done.
examples/{secretbox,secretstream-file,sign,password-hashing,misc}.php(one-for-one with the other bindings’ own five example files) +README.mdwith a module-by-example table and the honest packaging story. - Step 8: Done, this entry. Doc-map sweep:
docs/dstu-crypto-project.md/docs/release-readiness.mdupdated (stale “T-159 onward haven’t started” framing);docs/user-journey-gaps.md/docs/cross-language-style-guide.mdchecked, no T-159 references existed to update (same finding every earlier binding’s own step 8 had). T-159 marked done indocs/TASKS.md. - Step 9: each step above landed as its own commit - no large single drop.
Not yet confirmed on real CI - bindings-php.yml has not been pushed to origin/master yet
(push needs separate explicit approval, same posture T-160’s own push had). cargo xtask php
passes end-to-end on this dev machine (58/58 tests, fmt/clippy clean).
T-160 — Ruby (reordered 2026-08-02, D-121: builds right after T-50, no longer last)
Standard steps:
- Step 1: Done 2026-08-02, see D-133.
bindings/ruby/,magnus/rb_sys, own[workspace]split across two files (bindings/ruby/Cargo.tomlas the workspace root withmembers = ["ext/dstu_core_rb"], the actual crate insideext/dstu_core_rb/with no[workspace]of its own) —rb_sys’sCargo::Metadatashells out to a plaincargo metadatafrom the gem root, so a Cargo.toml has to exist there or Cargo walks up and finds the repo-root workspace instead (D-133’s concrete failure mode). Hand-authored, not generated viabundle gem --ext=rust— that generator hung indefinitely in this non-interactive shell even with every documented flag, root cause not fully isolated (likely a Windows-Ruby console-handle quirk), not worth debugging further given Python/Node were both hand-authored too. Ruby itself had to be installed on this machine first (DevKit variant, bundles a matching MSYS2/mingw-w64-ucrt toolchain) — see.claude.local.md. Three more real toolchain gotchas found and fixed (D-133 has full detail):rb-sys-envpinned to"0.1"to match the installedrb_sysgem’s Makefile convention;rb-sysadded as an explicit direct dependency (not just transitive viamagnus) so Cargo’sDEP_RUBY_*build-script propagation reaches this crate’s ownbuild.rs; the MSYS2 ucrt64clangpackage installed andLIBCLANG_PATHpointed at it, since this machine’s pre-existing standalone Windows LLVM parses Ruby’s mingw-targeted headers incorrectly. Wraps onlyself_testso far (Ruby’s nativesnake_caseneeds no per-function casing override, unlike Node’sjs_namerequirement — D-126). Verified via a full clean rebuild (not incremental) plus a realruby -Ilib -e "require 'dstu_core'; DstuCore.self_test"smoke call against the live compiled build;cargo fmt --all -- --check/cargo clippy --all-targets -- -D warningsboth clean. - Step 2: Done 2026-08-02, see D-134. Full
crypto_*surface wrapped, flatDstuCore.secretbox_seal-style naming (idiomatic restructuring deferred to step 3, same posture as Python/Node).RString::to_bytes()needsmagnus’s"bytes"feature enabled - the alternative,as_slice(), isunsafe; enabling the feature keeps this binding’s wrapper code free ofunsafeentirely. No tupleIntoValue(same gap as Node’s napi-rs, D-126) - Ruby’s own idiom is a positionally-destructuredArray, sosecretstream’spush/pullbuild a two-elementRArrayrather than reaching for a named-struct workaround.method!’s trait bounds needFn(&Ruby, RbSelf, Args...)order for a Ruby-taking instance method, incompatible with&selfsugar - every instance method keeps plain&selfand callsRuby::get()internally instead, matching step 1’sself_test()pattern; onlyfunction!-registered constructors/ module functions takeruby: &Rubyas a literal first parameter. Verified via a 15-check smoke script against the live compiled.so(round-trip, tamper-rejection, wrong-length-key rejection, hasher double-finalize rejection, secretstream push/pull);cargo clippy --all-targets -- -D warningsclean. - Step 3: Done 2026-08-02, see D-135.
SecretStreamWriter/SecretStreamReader(bindings/ruby/lib/dstu_core/secretstream.rb), pure Ruby on top of step 2’s rawSecretStreamPushState/PullState. Idiom researched, not assumed: modeled on stdlib’s ownZlib::GzipWriter/Zlib::GzipReader(same “wraps an arbitrary IO, transforms chunks transparently” shape).SecretStreamReaderincludesEnumerable. Both D-118 pitfalls re-checked:SecretStreamWriter.opendeliberately avoids Ruby’s ownensure-based cleanup idiom (would finalize even on the error path) in favor of a plain last-statementcloseon the block’s normal-return path only; the reader boundschunk_lenand rejects trailing data afterFinal. Verified against the realuacrypt.exebidirectionally, plus exact chunk-boundary sizing and theensure-avoidance pitfall itself, all against the live compiled.so.rubocopdeferred to step 5 (matching where Python’s ownrufflanded), not introduced here. - Step 4: Done 2026-08-02, see D-136.
rake native gem(this machine’s Windows/x64-mingw-ucrtplatform only - Linux/macOS cross-compiled native gems needrake-compiler-dock/Docker, deferred to CI, same precedent Python/Node’s own step 4 set). Real finding: a source gem cannot install standalone at all - confirmed by installing into a freshGEM_HOMEand watchingcargofail to resolve theext/dstu_core_rb/Cargo.tomlpath dependency oncrates/dstu-core, which only exists inside this repo’s own tree. A precompiled, platform-tagged native gem (whichrake-compiler/rb_sysalready build via an auto-definednativetask chain) ships the compiled.sodirectly instead, sidestepping the path dependency entirely. Verified via the same fresh-GEM_HOMEinstall bar Python/Node’s own step 4 used:require "dstu_core",self_test, and a fullSecretStreamWriter/Readerround-trip all pass against the installed gem. Same advisor pass also caught and fixed five real correctness gaps in steps 2/3 before they could ship (gemspecfilesglob, missingbinmode, the binary-string encoding contract,is_finalized→finalized?,ArgumentError→IOErrorfor write-after-close) - see D-136 for the full list. - Step 5: Done 2026-08-02, see D-137.
cargo xtask ruby(mirrorspython()/nodejs()) +.github/workflows/bindings-ruby.yml(mirrors the same shape:testmatrix +supply-chain).rubocop(deferred from step 3) wired in here, matching where Python’s ownrufflanded - 63 offenses on the first pass, settled in.rubocop.yml(double-quoted strings matching this project’s other languages,Layout/EndOfLinedisabled for the Windows autocrlf false positive,Metrics/MethodLengthraised slightly for the wire-format parsing methods) rather than reflowing to defaults.command_for()extended a third time (bundle→bundle.baton Windows, same gotcha asmvn/npm). CI’s Windows leg needs one binding-specific step no other language does: install a matching MSYS2clangviaridk exec pacmanand pointLIBCLANG_PATHat it (D-133’s fix, codified for CI).cargo deny/cargo auditverified locally against this workspace’s real dependency tree. - Step 6: Done 2026-08-02, see D-138. 10 spec files, file-for-file mirroring Python/Node’s own
test suites - 58 examples, D-64/D-65’s three categories. Confirmed empty
bundle exec rspecpasses vacuously (unlike Node’snode --test, D-129) - no tooling-forced reorder needed, unlike Node’s own step 6.generichash_spec.rbloads the same shared Kupyna-256 vector JSON the Rust tests use (the actual cross-language mechanism, D-124).secretstream_spec.rb’s realuacryptinterop usesif:metadata to run only when the binary exists, confirmed by counting examples in--format documentationoutput, not assumed; the uacrypt-missing case usesskip(visible in RSpec’s summary) rather than silently vanishing -cargo xtask ruby/CI always builduacryptfirst, so this never actually triggers there.rubocopneeded one spec-specific config addition (Metrics/BlockLengthexcluded forspec/**/*.rb, the standard shape for RSpec test files). - Step 7: Done 2026-08-02, see D-139.
examples/{secretbox,secretstream_file,sign, password_hashing,misc}.rb, one-for-one with Python/Node, each run against the real compiled.so.README.mdwritten from scratch (no README existed after step 1, same gap Node’s own step 1 had). One real fix found:require_relative "../lib/dstu_core"alone doesn’t reachlib/dstu_core.rb’s own internal non-relativerequire "dstu_core/dstu_core_rb"- every example addslib/to$LOAD_PATHexplicitly first, matching how an installed gem’s ownrequire "dstu_core"would resolve.
T-163 — Go (added 2026-08-02, D-122; builds alongside T-52/T-51, needs the C ABI)
Done in full 2026-08-03, steps 0-9 — see D-155. No incumbent DSTU library exists for Go, and
it has a real DevSecOps/cloud-infra audience (same class of reasoning as Ruby’s own security/ops-
tooling footprint) — but unlike Node/Ruby/PHP, no Go binding toolchain matches PyO3/napi-rs/magnus’s
maturity, so this one goes through the C ABI crate (cgo over bindings/capi’s cbindgen-
generated header) same as .NET/Java/C++. Builds after T-158 lands, alongside that group, not ahead
of it. Reordered again 2026-08-02 (D-123): built ahead of T-53 (C++) specifically — the owner’s
explicit preference, no further rationale recorded beyond that.
Standard steps, consuming T-158’s header:
- Step 0: Done. Hand-written
cgo, notc-for-go— decided on inspection, not a full spike (T-158’s ~50-function opaque-handle surface is already stable, and a generator would still need a hand-written idiomatic layer on top for the secretstreamio.Writer/io.Readerwrapper anyway). A real selftest-only link spike (advisor-recommended vertical slice) found genuine static-linking gaps on Windows-GNU before the full surface was wrapped:-ldstu_core_capialone links dynamically (GNUldprefers the import lib over the static one when both exist) unless-Wl,-Bstatic/-Bdynamicbracket it, and even then three more system libraries (-lws2_32 -luserenv -lntdll) are needed for symbols the Rust standard library pulls in transitively (std::net, temp-dir/child-process-pipe code) despitedstu-core-capiitself never touching networking or process spawning. - Step 1: Done.
bindings/go/dstu(packagedstu—goalone is a reserved word and can’t be a package identifier), wrapping every opaque handle with an explicitClose()— noruntime.SetFinalizerbackstop, a deliberate correction after advisor review found one would be a premature-free race, not aSafeHandle-equivalent: a bare Go finalizer can fire (and free the native key) while aC.dstu_*call using that same pointer is still in flight, since the last Go-side reference becomes the call argument itself, not the wrapper struct -SafeHandleavoids this because P/Invoke marshalling itself roots the handle for the call’s duration, which a plain finalizer does not replicate. See D-155 for the full mechanism and why it was invisible to every test in this binding’s own suite (each one holds its key reachable viadeferacross the whole test function). Every[]byte-taking wrapper guards the empty-slice case (unsafe.Pointer(&b[0])panics onlen(b)==0, and the header documents zero-length input as legal throughout) via a sharedcBytes()helper.CryptoError/ArgumentError/InternalErrormirror .NET’sDstuException/ArgumentExceptionsplit (cross-language style guide principle 4). - Step 3: Done.
SecretStreamEncryptWriter/SecretStreamDecryptReader(io.Writer/io.Reader-shaped) forcrypto_secretstream— the idiomatic fit here, same reasoning as C++’sistream/ostream. D-118’s shape:Close()never emits aFinalchunk (Go’sdeferhas no exception-type parameter, same reasoning as .NET’sDispose()/Complete()split); the reader bounds the untrusted chunk-length prefix againstSecretstreamChunkBytesand rejects trailing bytes afterFinal. - Step 4: Done, local/repo-relative only. No true prebuilt-artifact/registry story exists for
this binding yet — unlike every other binding,
dstu/dstu.go’s own#cgo LDFLAGSuses${SRCDIR}-relative paths intotarget/release, sobindings/goonly builds from inside a checkout of this repo withdstu-core-capialready built there, not as a standalonego get-able module. Flagged explicitly in the binding’s own README rather than silently glossed over. - Step 5: Done.
cargo xtask go(builddstu-core-capi,gofmt -lvia a dedicated output- capturing check sincegofmtitself always exits 0,go vet,go test) +bindings-go.ymlCI (own job, D-119 reasoning). The Windows CI leg forces the GNU-hosted Rust toolchain as the default (not just an additional cross target) and installs MinGW-w64 viachoco, sincecgocannot link againstdtolnay/rust-toolchain@stable’s default MSVC-hosted output onwindows-latest— unconfirmed on real CI as of this writing, flagged in the workflow’s own header comment (same “confirm on real CI, not just locally” posture as D-147/D-149). - Step 6: Done. Go’s own
testingpackage, three categories (D-64/D-65) — official Kupyna-256 vector via the shared JSON, real byte-for-byteuacryptinterop for secretstream, tamper/wrong- key rejection across secretbox/auth/sign/secretstream, misuse (wrong-length keys/tags/context, truncated/oversized/trailing-data secretstream input, double-finalize, write-after-Complete). - Step 7: Done.
examples/(five runnable programs mirroringbindings/python/examples/bindings/dotnet/examplesfile-for-file, each actually run against the real built library) +README.mdwith the provisional-status banner, including the step-4 repo-relative caveat. - Step 8/9: Done — this entry, plus
docs/DECISIONS.mdD-155,docs/TASKS.md,README.md,docs/dstu-crypto-project.md,docs/release-readiness.md. - Step 10: Done. Real aarch64 Linux (the Raspberry Pi rig) had no Go toolchain at all before
this — installed the official
linux-arm641.26.5 tarball (Debian’s own apt package is a stale 1.19). Found one real gap, not an ARM-portability bug: the cgoLDFLAGSwritten on the Windows dev machine (-lws2_32 -luserenv -lntdll) are Windows-only and failed to link at all on Linux — fixed with cgo’s own per-GOOS#cgopragma syntax (#cgo windows LDFLAGS: .../#cgo linux LDFLAGS: .../#cgo darwin LDFLAGS: ...), each platform getting its own full flag set rather than a shared base plus a negated exclusion. All tests passed after the fix, including the realuacryptinterop test and all 5 examples (output byte-identical to the Windows run where comparable) — see D-155 for the full account.
Dart — raised in the same conversation, explicitly deferred (D-122), not scheduled. Same reasoning as Node’s own browser/WASM scoping (D-118): Dart’s primary audience (Flutter mobile/web) overlaps least with this project’s demonstrated PKI/enterprise/security-tooling demand. Revisit if real demand evidence appears, same as any other out-of-scope language would need.
T-181 — crypto_box across all eight bindings (added 2026-08-06)
Incremental, not a from-scratch binding phase — every one of the eight bindings below already
exists (T-49 through T-163 above), each with its own scaffold, packaging, xtask/CI wiring, and
doc-map entries already in place. This phase adds exactly one new module’s surface
(dstu_core::crypto_box — SecretKey/PublicKey/seal/open, D-169) to each, so most of “The
standard binding steps” above collapse: no new step 1 (scaffold), step 4 (packaging), or step 5
(xtask/CI wiring) per language — only steps 2 (wrap the surface), 6 (tests), 7 (examples/README),
8 (doc-map sweep), 9 (commit per step) apply, plus step 10 (Pi smoke check) once per language still
worth running since it caught a real bug before (D-151). Step 3 (secretstream wrapping) does not
apply — crypto_box::seal/open are one-shot, not a stream.
Prerequisite closed first, not trailing behind: advisor() flagged that three of the eight
languages below (.NET, Go, C++ — the real C-ABI-consuming group, per each binding’s own Cargo.toml/
build config, not Fork 1’s original planning text above which incorrectly also names PHP) cannot
wrap crypto_box at all until dstu-core-capi has it. T-178c (crates/dstu-core-capi/src/ crypto_box.rs, D-171) landed first this session specifically to unblock this phase, not as a
trailing footnote the way T-178’s own original plan had it. PHP turned out not to need it at
all - confirmed only once its own crypto_box.rs was actually being written (2026-08-06): its
Cargo.toml depends on dstu-core directly, the same direct-ext-php-rs-binding shape as Python/
Node/Ruby, contradicting Fork 1’s original “PHP follows C++/.NET” text above (now corrected there
too). Re-check a binding’s actual dependency before assuming Fork 1’s planning-time text still
describes it — it was written before any binding existed.
Order (grouped by what a language actually links, confirmed per binding, not assumed from Fork 1’s original planning text):
- Python/Node.js/Ruby/PHP — direct FFI (PyO3/napi-rs/magnus/ext-php-rs), no C ABI
involved. Done 2026-08-06 (all four, PHP included once its real dependency shape was
confirmed). Python first as the template every other language’s
crypto_boxwrapper checked itself against, matching T-49’s own original role. - .NET/Go/C++ — consume
dstu-core-capi’s now-completecrypto_boxC ABI (T-178c) directly: P/Invoke (.NET), cgo (Go), the generated header + link (C++). Done 2026-08-06. - Java — last, per Fork 1’s own note that Java gets an explicit spike (
jnicrate direct vs. JNI-over-the-C-ABI) before committing to a shape; do that spike once, forcrypto_boxspecifically if the original Fork 1 spike (recorded when it runs,docs/DECISIONS.md) didn’t already settle it for every future module this binding adds.
What “wrap the surface” means per language, concretely: a keypair type (generate + from/to
bytes, mirroring crypto_sign’s own SigningKey/VerifyingKey idiom each binding already has), a
seal(message, public_key) -> bytes and open(sealed, secret_key) -> bytes pair (or the language’s
own idiomatic error-return shape — exception, Result, (value, err) — matching how that binding
already surfaces crypto_secretbox’s open failure). No new streaming primitive — seal/open
already documented as not memory-bounded at the Rust/C-ABI layer (D-169/D-171), the binding inherits
that limitation, document it in the same place crypto_secretbox’s own binding wrapper already
notes its own non-streaming nature.
Test-vector note: no DSTU vector oracle exists for this composite construction (D-169’s own
“Provenance” section — same as crypto_secretstream, D-68) — every binding’s local test suite
verifies round-trip/rejection/misuse only, not a shared fixed-vector JSON the way Kalyna/Kupyna/DSTU
4145 bindings’ tests do. A cross-language round-trip check (seal in one binding’s test process,
open via the Rust core directly, or vice versa) is worth adding once at least two bindings exist, to
catch a wire-format assumption divergence early — not required before the first binding lands.
After all eight land: T-180’s remaining gh-pages scope (mentioning DSTU 9041/crypto_box on
the public site) happens here, not before — per the owner’s own 2026-08-06 instruction (“update
gh-pages after all the tasks”). This mirrors T-162’s own precedent exactly (site refresh only after
every binding it covers actually exists) — expect a smaller version of T-162’s own checklist above,
not a full re-run, since the rest of the site’s binding-facing content doesn’t change.
T-204 — crypto_box512/crypto_sign257 across all eight bindings + capi (closed 2026-08-09/10)
Same incremental shape as T-181 — two new modules’ surfaces (dstu_core::crypto_box512,
T-193/D-182; dstu_core::crypto_sign257, T-199/D-185/D-186), each a direct sibling of an existing
wrapped module (crypto_box, crypto_sign) at a different curve size, added to every binding that
already exists. No new step 1/4/5 per language, same as T-181’s own reasoning.
Order, three phases: (1) dstu-core-capi first (crates/dstu-core-capi/src/box512.rs,
sign257.rs) — unblocks the C-ABI-consuming group, same dependency reason T-181 cites for
crypto_box. (2) .NET/Go/C++ — consume the now-complete C ABI directly. (3) Python/Node.js/Ruby/
Java/PHP — direct-Rust wrappers, each against its own macro system (PyO3/napi-rs/magnus/jni/
ext-php-rs). All three phases done same session.
One binding-specific bug found: ext-php-rs’s #[php_function] macro’s default
RenameRule::Snake splits a letter/digit boundary (box512 → box_512), silently mis-registering
all 8 new PHP function names — fixed with an explicit #[php(name = "...")] override per function
(memory: php_ext_php_rs_digit_rename_pitfall). No equivalent issue in any other binding’s own
macro system.
No new curve-tag dispatch anywhere — crypto_sign257/crypto_box512 are distinct function/
type names in every binding, same as the C ABI and core itself; tag-byte dispatch stays a
uacrypt-CLI-only concern (D-118’s “don’t duplicate wire-format logic into every binding” lesson
generalized).
Publishing (all registries) — separate, owner-gated, not scheduled
One explicit ask per registry (PyPI/npm/Maven Central/NuGet/RubyGems/Packagist), the same class of decision T-17 already applies to crates.io. Not started, not broken into steps above — tracked only once actually requested.
T-162 — GitHub-facing docs + gh-pages site refresh (last, after every binding lands)
Done in full 2026-08-03. Requested 2026-08-02: once all bindings above exist, the project’s
public-facing surfaces — README.md, the doc set under docs/, and the separate gh-pages branch
site (the landing page docs/PERFORMANCE.md/docs/TASKS.md already reference, e.g. its
orientation table naming AES/Whirlpool/ChaCha20 as role-analogs) — need a pass to actually mention
the bindings, not just the Rust crate/CLI. This is a documentation-only task, no primitive/binding
code changes.
- Done. Re-read the
gh-pagesbranch’s current content via the existing local worktree (git fetch origin gh-pages+ diff againstorigin/gh-pagesto confirm it was in sync) - not assumed from memory. Found the site is a single bilingual page (index.html/uk/index.html, identical body content, differing only in<head>metadata and the language-switch link - confirmed by diffing the two files before editing) with zero mention of any language binding anywhere, Rust/CLI-only throughout. - Done.
README.md: new “Language bindings” section (table, all eight, approach + README link each, honest “not published to any registry yet” status, C ABI cross-reference) added right after “Usinguacrypt”. The repo tree already listed all eight bindings (landed incidentally as part of T-53’s own step 8 doc-map sweep, before this task started). - Done.
docs/dstu-crypto-project.md’s “Second priority” section was already current (same T-53 step 8 sweep - “every planned binding is now built”).docs/release- readiness.md’s “Phase 3” line had one stale leftover phrase from Python/Node’s own landing day (“First two bindings done”) - fixed to the accurate all-nine count. - Done.
gh-pagesupdated - real new content existed (see step 1’s finding). Added a bilingual “Eight languages, one C ABI” section between the existing “Try it” and “Status” sections in bothindex.htmlanduk/index.html: acheck-gridof eight cards (one per language, approach + a link to that binding’s ownREADME.mdon GitHub, reusing the site’s own existing CSS component rather than inventing a new one) plus acallout.neutralnoting the C ABI itself is usable from any C-FFI-capable language, not just the three (.NET/Go/ C++) that consume it directly. Since browser automation wasn’t available this session, the edited file was sent directly to the owner for a real visual check before pushing (not assumed correct from reading the markup alone) - confirmed, pushed, commit43e8022. - Done. Doc-map sweep:
docs/user-journey-gaps.md/docs/cross-language-style-guide.mdchecked, nothing stale found (same result every earlier binding’s own step 8 had). T-162 marked done indocs/TASKS.md. - Done. Each step above landed as its own commit on
master; thegh-pageschange is its own commit on that separate branch (a different branch’s commit history, notmaster’s one-step-per-commit sequence, but the same discipline - one change, one commit, not a mixed drop).
Doc-map sweep discipline
Landing any phase above touches more than docs/TASKS.md — grep that phase’s task ID across
README.md (repo tree), docs/dstu-crypto-project.md (“Second priority” line),
docs/release-readiness.md (“Phase 3” line), docs/user-journey-gaps.md (new persona per binding),
and docs/cross-language-style-guide.md’s “applies today to” line, before calling that phase done —
CLAUDE.md’s own agent-discipline notes record this exact failure mode happening once already for
crypto_secretstream (D-68) and warn against repeating it here at five-times the scale.