dstu_core 0.1.0-aarch64-linux → 0.5.0-aarch64-linux

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04231940a4a2760de82c62093446a0099ebc582476c77f27e1de9f1bc724d28d'
4
- data.tar.gz: a4c132c59df4aea211bc245999ba8d45ea4005c9271daf39ad2491df1a58ded7
3
+ metadata.gz: d25697754c4bacdf34799f7f30030d32452ebb98dad7b51be4936ce50fd49b75
4
+ data.tar.gz: d0d537156e83fb2e88a7fe01667de9a3ba35c54f1c70149f9b5f8c59b97b787d
5
5
  SHA512:
6
- metadata.gz: ea595bac5cca8e8339b6c3ad4a0f09b59121fba7947e428f55caf1eef2950cefe7fa8dc6b78d3e596b066f17298c974c84f539eec7585cb9917a1a277d02fbfe
7
- data.tar.gz: 4c862e95bb724478ae656f5decb35ee4a5f67456de9e80f055bff2c6989690fb13ed0185871a55be6f4f71595d9933704166b473a353ad194f89dbcafd19e7f4
6
+ metadata.gz: 7048b087b76cfbba8fc78c64f3e1680125671d59ba840fa6caafddaba1ba970a8f194a7ac11366bf485935ccc7e058d98663871d1ffe531792f583724bbfe340
7
+ data.tar.gz: 38d44e88e422e45e008b2281d16b7523b2cd0a4446e3c6c303ccf5ca32e69b5dda4fe47063a7bf2653a71a2ae0d5d7dd957975105b940b0906338e0bcfceb8d8
data/README.md CHANGED
@@ -1,11 +1,21 @@
1
1
  # dstu-core (Ruby bindings)
2
2
 
3
- **Provisional — not published to RubyGems, 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-160) —
6
- install from source as shown below.
3
+ **Pre-1.0. Not independently audited.** See the root project's `docs/SECURITY.md` and
4
+ `docs/DECISIONS.md` for the full threat model and per-construction status. This binding wraps the
5
+ full `dstu_core::crypto_*` surface (`docs/bindings-strategy.md` T-160).
7
6
 
8
- ## Installing (from source)
7
+ ## Installing
8
+
9
+ ```sh
10
+ gem install dstu_core
11
+ ```
12
+
13
+ ```ruby
14
+ require "dstu_core"
15
+ DstuCore.self_test
16
+ ```
17
+
18
+ ## Building from source (contributors)
9
19
 
10
20
  ```sh
11
21
  gem install bundler
@@ -64,12 +74,13 @@ raise unless DstuCore.secretbox_open(key, sealed) == "a message worth protecting
64
74
  | Module | Methods/classes | Notes |
65
75
  |---|---|---|
66
76
  | `crypto_secretbox` | `secretbox_keygen`, `secretbox_seal`, `secretbox_open` | Single-message authenticated encryption. `examples/secretbox.rb`. |
67
- | `crypto_box` | `box_keygen`, `box_public_key`, `box_seal`, `box_open` | Public-key encryption (hybrid via KDF over `hazmat::dstu9041`, `l(p)=256`, D-169). `box_seal`/`box_open` are not memory-bounded — the whole message is held in memory. `examples/box.rb`. |
68
- | `crypto_box512` | `box512_keygen`, `box512_public_key`, `box512_seal`, `box512_open` | `l(p)=512`/E512/1 sibling of `crypto_box` (T-193/T-204). `examples/box512.rb`. |
77
+ | `crypto_box` | `box_keygen`, `box_public_key`, `box_seal`, `box_open`, `BoxWriter`, `BoxReader` | Public-key encryption (hybrid via KDF over `hazmat::dstu9041`, `l(p)=256`, D-169). `box_seal`/`box_open` hold the whole message in memory; `BoxWriter`/`BoxReader` write and read the same bytes (format v3) chunk by chunk, as `uacrypt box-seal`/`box-open` do. `examples/box.rb`. |
78
+ | `crypto_box384` | `box384_keygen`, `box384_public_key`, `box384_seal`, `box384_open`, `Box384Writer`, `Box384Reader` | `l(p)=384`/E384/1 sibling of `crypto_box` (T-297). `examples/box384.rb`. |
79
+ | `crypto_box512` | `box512_keygen`, `box512_public_key`, `box512_seal`, `box512_open`, `Box512Writer`, `Box512Reader` | `l(p)=512`/E512/1 sibling of `crypto_box` (T-193/T-204). `examples/box512.rb`. |
69
80
  | `crypto_secretstream` | `secretstream_keygen`, `SecretStreamPushState`, `SecretStreamPullState`, `SecretStreamWriter`, `SecretStreamReader` | Chunked streaming AEAD. `SecretStreamWriter`/`Reader` (modeled on stdlib's own `Zlib::GzipWriter`/`GzipReader`) wire format matches `uacrypt encrypt`/`decrypt` exactly (D-118). `examples/secretstream_file.rb`. |
70
81
  | `crypto_sign` | `sign_keygen`, `sign_verifying_key`, `sign_message`, `sign_verify` | DSTU 4145 `m=163` digital signatures, deterministic nonce (no RNG dependency). `examples/sign.rb`. |
71
82
  | `crypto_sign257` | `sign257_keygen`, `sign257_verifying_key`, `sign257_message`, `sign257_verify` | `m=257` sibling of `crypto_sign` (T-199/T-204) — the curve real Diia-issued qualified signatures use. `examples/sign257.rb`. |
72
- | `crypto_pwhash` | `pwhash_hash_password`, `pwhash_verify_password`, `PWHASH_INTERACTIVE`/`PWHASH_MODERATE`/`PWHASH_SENSITIVE` | Argon2id (the one deliberately non-DSTU component, D-49/D-50). `examples/password_hashing.rb`. |
83
+ | `crypto_pwhash` | `pwhash_hash_password`, `pwhash_verify_password`, `pwhash_derive_key`, `PWHASH_INTERACTIVE`/`PWHASH_MODERATE`/`PWHASH_SENSITIVE`, `PassphraseWriter`, `PassphraseReader` | Argon2id (the one deliberately non-DSTU component, D-49/D-50). `pwhash_derive_key` equals libsodium's `crypto_pwhash`. `PassphraseWriter`/`PassphraseReader` write and read the files `uacrypt encrypt --passphrase` makes (D-219/D-240); each runs Argon2id at 256 MiB once. `examples/password_hashing.rb`. |
73
84
  | `crypto_auth` | `auth_keygen`, `auth`, `auth_verify` | Keyed message authentication (Kupyna-KMAC). `examples/misc.rb`. |
74
85
  | `crypto_kdf` | `kdf_keygen`, `kdf_derive_subkey` | Deterministic subkey derivation. `examples/misc.rb`. |
75
86
  | `crypto_generichash` | `kupyna256`, `kupyna512`, `Kupyna256Hasher`, `Kupyna512Hasher` | One-shot and streaming Kupyna hashing. `examples/misc.rb`. |
Binary file
Binary file
Binary file
Binary file
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # `crypto_secretstream` idiomatic wrapper (docs/DECISIONS.md D-118, docs/bindings-strategy.md
4
- # T-160 step 3) - hides chunk/tag/header bookkeeping behind `write`/`each`, built in pure Ruby on
5
- # top of the low-level SecretStreamPushState/SecretStreamPullState the compiled extension already
6
- # exposes (step 2), rather than new Rust glue. Modeled on stdlib's own
4
+ # T-160 step 3) - hides chunk/tag/header bookkeeping behind `write`/`each`. The framing itself
5
+ # (record layout, length rules, Final, trailing data) is dstu-core's stream-file codec, exposed as
6
+ # SecretStreamFileEncryptor/SecretStreamFileDecryptor (T-284); this file does only I/O. Modeled on
7
+ # stdlib's own
7
8
  # Zlib::GzipWriter/Zlib::GzipReader - the same "wraps an IO, transforms chunks transparently"
8
9
  # shape, the closest Ruby-native precedent for this problem (matching Python's file-like object,
9
10
  # Node's stream.Transform).
10
11
  #
11
12
  # **Wire format matches `uacrypt encrypt`/`decrypt` exactly**
12
13
  # (crates/uacrypt/src/lib.rs's `run_secretstream_encrypt`/`run_secretstream_decrypt`, D-68):
13
- # `header (32 bytes)` followed by one record per chunk, `tag_byte (1) || chunk_len_u32_le (4) ||
14
- # ciphertext (chunk_len) || auth_tag (16)`, chunks capped at 8 KiB (matching
15
- # `SECRETSTREAM_CHUNK_BYTES`, not an independent choice) - a file `SecretStreamWriter` writes is
16
- # decryptable by `uacrypt decrypt` and vice versa.
14
+ # `version (1) || header (32 bytes)` followed by one record per chunk, `tag_byte (1) ||
15
+ # chunk_len_u32_le (4) || ciphertext (chunk_len) || auth_tag (16)`. Every record but the Final one is
16
+ # exactly 8 KiB (`SECRETSTREAM_CHUNK_BYTES`, part of the format since D-208, not an independent
17
+ # choice) - a file `SecretStreamWriter` writes is decryptable by `uacrypt decrypt` and vice versa.
18
+ # The shared vectors in crates/dstu-core/tests/vectors/secretstream-file/ pin this.
17
19
  #
18
20
  # **Every `String` this wrapper touches is binary (`ASCII-8BIT`/`BINARY`)**, both what it writes
19
21
  # to `out`/`inp` and what `#read_all`/`#each` yield - matching every underlying `crypto_*` wrapper
@@ -22,10 +24,12 @@
22
24
  # differing encodings and fails even when the bytes match (`"привіт".b == "привіт"` is `false` in
23
25
  # Ruby) - this is a caller-side encoding-compare gotcha, not a bug in this wrapper.
24
26
  module DstuCore
25
- # Matches crates/uacrypt/src/lib.rs's SECRETSTREAM_CHUNK_BYTES exactly - required for
26
- # wire-format interop with `uacrypt encrypt`/`decrypt`, not an independent choice.
27
+ # dstu-core's crypto_secretstream::CHUNK_BYTES and wire constants (D-208); the codec enforces
28
+ # them, these stay as public constants.
27
29
  SECRETSTREAM_CHUNK_BYTES = 8 * 1024
28
30
  SECRETSTREAM_AUTH_TAG_BYTES = 16
31
+ # Matches dstu-core's crypto_secretstream::FORMAT_VERSION (D-208); the shared vectors pin it.
32
+ SECRETSTREAM_FORMAT_VERSION = 2
29
33
 
30
34
  # Write-only wrapper: buffers input and pushes each full 8 KiB chunk to `out` as it fills, hiding
31
35
  # the header/tag/framing bookkeeping entirely.
@@ -35,12 +39,7 @@ module DstuCore
35
39
  # end
36
40
  class SecretStreamWriter
37
41
  def initialize(key, out)
38
- @out = out
39
- @out.binmode if @out.respond_to?(:binmode)
40
- @push = SecretStreamPushState.new(key)
41
- @out.write(@push.header)
42
- @buf = +""
43
- @closed = false
42
+ start(SecretStreamFileEncryptor.new(key), out)
44
43
  end
45
44
 
46
45
  # Block form: yields a writer, then closes it - **only on the success path**. The D-118
@@ -61,18 +60,21 @@ module DstuCore
61
60
  @closed
62
61
  end
63
62
 
64
- # Buffers `data`, pushing any now-complete 8 KiB chunks immediately. The trailing partial (or
65
- # exactly-8-KiB) chunk is always held back until `close`, since only `close` knows no more
66
- # data is coming - the same one-chunk-ahead reasoning `uacrypt encrypt` itself uses to tag the
67
- # true last chunk `Final`, not an extra empty one after it.
63
+ # Buffers `data`, sealing every full 8 KiB chunk that is certain not to be the last one. The
64
+ # trailing partial (or exactly-8-KiB) chunk is held back until `close`, since only `close`
65
+ # knows no more data is coming - an exact multiple of 8 KiB must end in a full Final record
66
+ # (D-208). Full chunks are cut from `data` by offset, so one large write costs linear time
67
+ # (T-276).
68
68
  def write(data)
69
69
  raise IOError, "closed stream" if @closed
70
70
 
71
- @buf << data.b
72
- while @buf.bytesize > SECRETSTREAM_CHUNK_BYTES
73
- push_chunk(SECRETSTREAM_TAG_MESSAGE, @buf.byteslice(0, SECRETSTREAM_CHUNK_BYTES))
74
- @buf = @buf.byteslice(SECRETSTREAM_CHUNK_BYTES..) || +""
71
+ data = data.b
72
+ pos = top_up_buffer(data)
73
+ while data.bytesize - pos > SECRETSTREAM_CHUNK_BYTES
74
+ @out.write(@enc.seal_chunk(data.byteslice(pos, SECRETSTREAM_CHUNK_BYTES)))
75
+ pos += SECRETSTREAM_CHUNK_BYTES
75
76
  end
77
+ @buf << data.byteslice(pos..)
76
78
  data.bytesize
77
79
  end
78
80
 
@@ -83,19 +85,36 @@ module DstuCore
83
85
  def close
84
86
  return if @closed
85
87
 
86
- push_chunk(SECRETSTREAM_TAG_FINAL, @buf)
88
+ @out.write(@enc.seal_final(@buf))
87
89
  @buf = +""
88
90
  @closed = true
89
91
  end
90
92
 
91
93
  private
92
94
 
93
- def push_chunk(tag, data)
94
- ciphertext, auth_tag = @push.push(tag, data)
95
- @out.write([tag].pack("C"))
96
- @out.write([data.bytesize].pack("V"))
97
- @out.write(ciphertext)
98
- @out.write(auth_tag)
95
+ def start(enc, out)
96
+ @out = out
97
+ @out.binmode if @out.respond_to?(:binmode)
98
+ @enc = enc
99
+ @buf = +""
100
+ @closed = false
101
+ @out.write(@enc.prefix)
102
+ end
103
+
104
+ # Adds `data` to the held-back chunk if it all fits; otherwise tops that chunk up to a full one
105
+ # and seals it, since more data follows. Returns how many bytes of `data` it consumed.
106
+ def top_up_buffer(data)
107
+ return 0 if @buf.empty?
108
+
109
+ if @buf.bytesize + data.bytesize <= SECRETSTREAM_CHUNK_BYTES
110
+ @buf << data
111
+ return data.bytesize
112
+ end
113
+ taken = SECRETSTREAM_CHUNK_BYTES - @buf.bytesize
114
+ @buf << data.byteslice(0, taken)
115
+ @out.write(@enc.seal_chunk(@buf))
116
+ @buf = +""
117
+ taken
99
118
  end
100
119
  end
101
120
 
@@ -113,11 +132,8 @@ module DstuCore
113
132
  include Enumerable
114
133
 
115
134
  def initialize(key, inp)
116
- @inp = inp
117
- @inp.binmode if @inp.respond_to?(:binmode)
118
- header = read_exact(32, "header")
119
- @pull = SecretStreamPullState.new(key, header)
120
- @done = false
135
+ bind(inp)
136
+ start(SecretStreamFileDecryptor.new(key))
121
137
  end
122
138
 
123
139
  def self.open(key, inp)
@@ -128,7 +144,10 @@ module DstuCore
128
144
  def each
129
145
  return enum_for(:each) unless block_given?
130
146
 
131
- yield next_chunk until @done
147
+ until @done
148
+ plaintext = step
149
+ yield plaintext unless plaintext.empty?
150
+ end
132
151
  end
133
152
 
134
153
  def read_all
@@ -140,34 +159,41 @@ module DstuCore
140
159
 
141
160
  private
142
161
 
143
- def next_chunk
144
- tag_byte = read_exact(1, "chunk tag").unpack1("C")
145
- chunk_len = read_exact(4, "chunk length").unpack1("V")
146
- # `chunk_len` is untrusted wire input, read before any tag verification - reject an
147
- # oversized declared length before acting on it (`read_exact` would otherwise try to
148
- # accumulate up to `chunk_len` bytes from `inp`, which for a socket/pipe could mean
149
- # gigabytes before ever failing). Matches `uacrypt decrypt`'s own
150
- # `CliError::SecretstreamChunkTooLarge` bound (`crates/uacrypt/src/lib.rs`).
151
- if chunk_len > SECRETSTREAM_CHUNK_BYTES
152
- raise DstuCore::Error,
153
- "secretstream chunk too large: declared #{chunk_len} bytes, max #{SECRETSTREAM_CHUNK_BYTES}"
154
- end
155
- ciphertext = read_exact(chunk_len, "chunk ciphertext")
156
- auth_tag = read_exact(SECRETSTREAM_AUTH_TAG_BYTES, "chunk auth tag")
157
- tag, plaintext = @pull.pull(tag_byte, ciphertext, auth_tag)
158
- if tag == SECRETSTREAM_TAG_FINAL
159
- # Matches `uacrypt decrypt`'s own `CliError::SecretstreamTrailingData` check - reject
160
- # bytes remaining after `Final` rather than silently ignoring them. Checked before
161
- # `plaintext` is returned, so a trailing-data stream never yields this last chunk either
162
- # via `each` or `read_all`.
163
- raise DstuCore::Error, "trailing data after the secretstream's Final chunk" if @inp.read(1)
162
+ def bind(inp)
163
+ @inp = inp
164
+ @inp.binmode if @inp.respond_to?(:binmode)
165
+ end
166
+
167
+ def start(dec)
168
+ @dec = dec
169
+ @done = false
170
+ # The version byte and header, read now so a wrong version fails at construction.
171
+ 2.times { step }
172
+ end
173
+
174
+ # The bytes in front of the stream file (a box prefix, a passphrase-file header); a shorter
175
+ # input cannot be opened at all.
176
+ def read_prefix(size, what)
177
+ prefix = read_up_to(size)
178
+ raise Error, "input too short to contain a #{what}" if prefix.bytesize < size
164
179
 
180
+ prefix
181
+ end
182
+
183
+ # Feeds the codec one `next_len` read: the plaintext that read released (possibly empty), or
184
+ # at end of input the Final chunk's (after which the stream is done).
185
+ def step
186
+ want = @dec.next_len
187
+ data = read_up_to(want)
188
+ if data.bytesize < want
165
189
  @done = true
190
+ return @dec.finish
166
191
  end
167
- plaintext
192
+ @dec.feed(data)
168
193
  end
169
194
 
170
- def read_exact(size, what)
195
+ # Reads until `size` bytes arrived or `inp` ended; a short result means end of input.
196
+ def read_up_to(size)
171
197
  pieces = []
172
198
  remaining = size
173
199
  while remaining.positive?
@@ -177,12 +203,71 @@ module DstuCore
177
203
  pieces << piece
178
204
  remaining -= piece.bytesize
179
205
  end
180
- data = pieces.join
181
- if data.bytesize != size
182
- raise DstuCore::Error, "truncated secretstream: expected #{size} bytes for #{what}, got #{data.bytesize}"
183
- end
206
+ pieces.join.b
207
+ end
208
+ end
209
+
210
+ # crypto_box sealed to `public_key` as a stream (format v3, T-265/T-269): the same bytes
211
+ # `box_seal` returns, written chunk by chunk; `box_open`/BoxReader read them.
212
+ class BoxWriter < SecretStreamWriter
213
+ def initialize(public_key, out) # rubocop:disable Lint/MissingSuper
214
+ start(SecretStreamFileEncryptor.box_seal(public_key), out)
215
+ end
216
+ end
217
+
218
+ # Reads a crypto_box stream (or a `box_seal` blob) with bounded memory.
219
+ class BoxReader < SecretStreamReader
220
+ def initialize(secret_key, inp) # rubocop:disable Lint/MissingSuper
221
+ bind(inp)
222
+ start(SecretStreamFileDecryptor.box_open(secret_key, read_prefix(BOX_OPEN_STREAM_PREFIX_BYTES, "sealed message")))
223
+ end
224
+ end
225
+
226
+ # crypto_box384 counterpart of BoxWriter.
227
+ class Box384Writer < SecretStreamWriter
228
+ def initialize(public_key, out) # rubocop:disable Lint/MissingSuper
229
+ start(SecretStreamFileEncryptor.box384_seal(public_key), out)
230
+ end
231
+ end
232
+
233
+ # crypto_box384 counterpart of BoxReader.
234
+ class Box384Reader < SecretStreamReader
235
+ def initialize(secret_key, inp) # rubocop:disable Lint/MissingSuper
236
+ bind(inp)
237
+ start(SecretStreamFileDecryptor.box384_open(secret_key, read_prefix(BOX384_OPEN_STREAM_PREFIX_BYTES, "sealed message")))
238
+ end
239
+ end
240
+
241
+ # crypto_box512 counterpart of BoxWriter.
242
+ class Box512Writer < SecretStreamWriter
243
+ def initialize(public_key, out) # rubocop:disable Lint/MissingSuper
244
+ start(SecretStreamFileEncryptor.box512_seal(public_key), out)
245
+ end
246
+ end
247
+
248
+ # crypto_box512 counterpart of BoxReader.
249
+ class Box512Reader < SecretStreamReader
250
+ def initialize(secret_key, inp) # rubocop:disable Lint/MissingSuper
251
+ bind(inp)
252
+ start(SecretStreamFileDecryptor.box512_open(secret_key, read_prefix(BOX512_OPEN_STREAM_PREFIX_BYTES, "sealed message")))
253
+ end
254
+ end
255
+
256
+ # A passphrase file (D-219/D-240) - the bytes `uacrypt encrypt --passphrase` writes. `new` runs
257
+ # Argon2id (256 MiB, t=3, about a second) before writing anything.
258
+ class PassphraseWriter < SecretStreamWriter
259
+ def initialize(passphrase, out) # rubocop:disable Lint/MissingSuper
260
+ start(SecretStreamFileEncryptor.passphrase_seal(passphrase), out)
261
+ end
262
+ end
184
263
 
185
- data
264
+ # Reads a passphrase file. The header is checked before Argon2 runs; a wrong passphrase raises
265
+ # DstuCore::Error at the first record.
266
+ class PassphraseReader < SecretStreamReader
267
+ def initialize(passphrase, inp) # rubocop:disable Lint/MissingSuper
268
+ bind(inp)
269
+ header = read_prefix(PASSPHRASE_FILE_HEADER_BYTES, "passphrase file header")
270
+ start(SecretStreamFileDecryptor.passphrase_open(header, passphrase))
186
271
  end
187
272
  end
188
273
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DstuCore
4
- VERSION = "0.1.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dstu_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.5.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - dstu-core contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby bindings for dstu-core (Ukrainian DSTU cryptographic standards)
14
- - provisional, not yet published to RubyGems.
14
+ - not independently audited.
15
15
  email:
16
16
  executables: []
17
17
  extensions: []