pq_crypto-seal 0.1.1 → 0.1.2

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: aa301e0c50869cf3f9e1fec6924920573f895a4aa3ba9cf66cf63675a1ed0f60
4
- data.tar.gz: 73f8cb33894474e63326d0ad43cad5e4e230c4c3050f5e145a7e67e9966ca027
3
+ metadata.gz: 0e2f25bfc50a4e95892fb3c9e25992ff359bd16cc65535dacf3863d943c34e52
4
+ data.tar.gz: 40462d41fa188924ba4abbd5d76b4ad22c48377964d70a4544bae919ec4f5140
5
5
  SHA512:
6
- metadata.gz: 6749ce2e5b6e26d85bf33cd0aa93a4ce82b8fb710bbfc550404551567eab4006649e03f14088cc266786dbc9a6eb358367605eaf1145c6482857c0f6be1ba595
7
- data.tar.gz: 61fbbb576c26adb08279357ed800d5245e80d3e6b264992e4afcc7a30972b8fdeae9621fd30bc9a5fcac407d004d72a07925c26e0614cfaaa38e2d504e3d5bf6
6
+ metadata.gz: 8a3f087d4c922637fcbf5af86cae6a9e118b688215f9a218c6572e524555643e2d444f7452416575c2904f08a8c34742adc0e2cb4bfab6e33a0267015f03d177
7
+ data.tar.gz: e1746e895c1be25bd755fc358c4eac0291b21c6c638b7d87fb1603e08229783e942adcecbdc4c095e4ae1fde9ca00d49a7f472a89cc1ef183339a94f9546e025
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Security
6
+
7
+ - **Default padding enforcement on decrypt.** `decrypt` / `open` / `decrypt_io` /
8
+ `decrypt_file` now default to `required_padding: :from_header`. Padmé / none
9
+ get a full canonical size check from authenticated lengths. Fixed / buckets
10
+ are not self-describing on the wire, so `:from_header` only accepts the
11
+ authenticated policy id (envelope still opens); pass
12
+ `required_padding: { to: N }` / `{ buckets: [...] }` for full target
13
+ enforcement. Pass `required_padding: false` to opt out entirely.
14
+ - **RUP-safe IO staging.** `decrypt_io` / `decrypt_file` / DEK rotation stage
15
+ **ciphertext** on the temporary 0600 file and only materialise plaintext after
16
+ the AEGIS tag verifies (second pass). Unauthenticated plaintext is no longer
17
+ written to disk before authentication.
18
+ - **Honest recipient ACL API.** Removed `drop_recipient_stanza` and
19
+ `drop_recipient_stanza_file`. Those names suggested revocation while old copies
20
+ and the DEK remain valid. Use `rebuild_recipients` / `rebuild_recipients_file`
21
+ with the application's complete authoritative ACL for the current canonical
22
+ copy only; use `rotate_dek` when a new payload key is required.
23
+ - **Capacity / slot growth path.** `recipient_capacity` and `slot_size` remain
24
+ immutable in the payload header for a given envelope, but `rotate_dek` /
25
+ `rotate_dek_file` now accept optional `recipient_capacity:` and `slot_size:`
26
+ overrides because they re-encrypt the authenticated plaintext under a new DEK.
27
+ - **At-rest draft pinning documented.** SECURITY.md / FORMAT.md / VENDORING.md
28
+ state that suite 1 and content suite 1 are pinned to X-Wing draft-10 and
29
+ AEGIS-256 (IETF CFRG draft-18 vectors via libaegis 0.10.3), not to future RFC
30
+ text. External KATs are referenced; self-generated golden vectors pin Seal's
31
+ own wire layout only.
32
+ - **CI / release hygiene matched to claims.** Workflow Actions pinned by full
33
+ commit SHA, top-level `permissions: contents: read`, and jobs for fuzz,
34
+ gem-smoke, and `release_contract_check`. `.DS_Store` is gitignored; the
35
+ release-contract checker rejects tracked copies.
36
+
37
+ ### Breaking (0.1.x experimental)
38
+
39
+ - Default `required_padding` is `:from_header` (was effectively off / `nil`).
40
+ - `drop_recipient_stanza` / `drop_recipient_stanza_file` removed from the public API.
41
+
3
42
  ## 0.1.1
4
43
 
5
44
  - Release hygiene: CI Actions pinned by commit SHA, `permissions: contents: read`,
data/FORMAT.md CHANGED
@@ -38,9 +38,10 @@ The payload AEGIS associated data is exactly:
38
38
  SHA-256(exact immutable payload header bytes)
39
39
  ```
40
40
 
41
- Content suite `1` fixes AEGIS-256, a 32-byte nonce, a 32-byte tag, and
42
- inner-frame-v1. The padding policy ID records how the final envelope length was
43
- chosen; it is authenticated as part of the header.
41
+ Content suite `1` fixes AEGIS-256 (libaegis 0.10.3; CFRG draft-18 KATs), a
42
+ 32-byte nonce, a 32-byte tag, and inner-frame-v1. The padding policy ID records
43
+ how the final envelope length was chosen; it is authenticated as part of the
44
+ header.
44
45
 
45
46
  ## Mutable recipient section
46
47
 
@@ -53,9 +54,10 @@ chosen; it is authenticated as part of the header.
53
54
  Every complete recipient-section rebuild creates a new random section ID and
54
55
  recreates all real and dummy slots. Mixed wrapping suites are forbidden.
55
56
 
56
- Wrap suite `1` fixes MLKEM768-X25519/X-Wing-compatible encapsulation
57
- (`pq_crypto` algorithm `:ml_kem_768_x25519_xwing`), HKDF-SHA256, AEGIS-256 DEK
58
- wrapping, and 32-byte wrap nonces/tags. Exact sizes:
57
+ Wrap suite `1` fixes MLKEM768-X25519/X-Wing **draft-10**-compatible encapsulation
58
+ (`pq_crypto = 0.6.4` algorithm `:ml_kem_768_x25519_xwing`), HKDF-SHA256, AEGIS-256
59
+ DEK wrapping, and 32-byte wrap nonces/tags. The suite id freezes that behaviour
60
+ for at-rest data even if a later RFC text diverges. Exact sizes:
59
61
 
60
62
  | Value | Bytes |
61
63
  |---|---:|
@@ -121,8 +123,9 @@ The padding amount is chosen so the size of the complete final envelope reaches
121
123
  the configured Padmé/fixed/bucket target.
122
124
 
123
125
  During incremental decryption, all inner bytes are untrusted until the final tag
124
- has been verified. Implementations stage the complete inner frame, authenticate
125
- it, then parse lengths and publish content.
126
+ has been verified. Implementations stage the complete **ciphertext** inner frame,
127
+ authenticate the tag, materialise plaintext only after verification, then parse
128
+ lengths and publish content.
126
129
 
127
130
  ## Defaults
128
131
 
@@ -139,10 +142,13 @@ stream consumers that intentionally embed one envelope inside a larger stream
139
142
  must use `encrypt_frame_io` / `decrypt_frame_io`.
140
143
 
141
144
 
142
- ## Receiver-side padding checks (0.1.1+)
145
+ ## Receiver-side padding checks (0.1.2+)
143
146
 
144
147
  `padding_policy_id` is authenticated but does not by itself encode fixed targets
145
- or bucket lists. Decryptors that need enforcement pass `required_padding:`. The
146
- receiver verifies the authenticated policy id and recomputes the canonical target
147
- from the authenticated inner lengths. Fixed and bucket policies require the
148
- expected target or bucket list from the application.
148
+ or bucket lists. Decrypt APIs default to `required_padding: :from_header`:
149
+
150
+ - Padmé / none full canonical size enforcement
151
+ - fixed / buckets accept authenticated policy id; full target checks need
152
+ `required_padding: { to: N }` or `{ buckets: [...] }`
153
+
154
+ Pass `required_padding: false` to skip enforcement.
data/GET_STARTED.md CHANGED
@@ -19,15 +19,21 @@ envelope = PQCrypto::Seal.encrypt("secret bytes", to: recipient.public_key)
19
19
  plaintext = PQCrypto::Seal.decrypt(envelope, with: recipient)
20
20
  ```
21
21
 
22
- For files, use `encrypt_file` and `decrypt_file`; decryption stages and verifies
23
- the complete inner frame before publishing plaintext. Use `recipient_capacity`
24
- and `slot_size` as long-lived application policy values because both are stored
25
- in the immutable payload header.
22
+ For files, use `encrypt_file` and `decrypt_file`; decryption stages ciphertext,
23
+ verifies the AEGIS tag, then materialises plaintext before publishing. Use
24
+ `recipient_capacity` and `slot_size` as long-lived application policy values:
25
+ both are stored in the immutable payload header. To grow them after encryption,
26
+ call `rotate_dek` / `rotate_dek_file` with new `recipient_capacity:` /
27
+ `slot_size:` (full re-encryption under a new DEK).
26
28
 
27
29
  Recipient-section rebuilds always require the application's complete,
28
- authoritative public-key ACL. They do not revoke access to plaintext or old
29
- envelope copies. Use `rotate_dek`/`rotate_dek_file` when a new payload key and
30
- full re-encryption are required.
30
+ authoritative public-key ACL. They do not revoke access to plaintext, the DEK,
31
+ or old envelope copies. Prefer `rebuild_recipients` over inventing “drop”
32
+ helpers. Use `rotate_dek` when a new payload key is required.
33
+
34
+ Decrypt defaults to `required_padding: :from_header`: Padmé/none get a full size
35
+ check; fixed/buckets open with a policy-id check (pass `{ to: }` / `{ buckets: }`
36
+ for full target enforcement).
31
37
 
32
38
  ## Large documents
33
39
 
data/README.md CHANGED
@@ -12,16 +12,17 @@ random DEK → AEGIS-256 → document
12
12
  `libaegis` 0.10.3 is vendored and compiled into the extension. The existing
13
13
  `pq_crypto` gem supplies the hybrid KEM and OpenSSL-backed primitives.
14
14
 
15
- > **Status:** 0.1.1 is experimental cryptographic software; format v1; not
15
+ > **Status:** 0.1.2 is experimental cryptographic software; format v1; not
16
16
  > independently audited. Read `SECURITY.md` before using it for irreplaceable data.
17
17
  >
18
- > `wrap_suite_id = 1` is pinned to `:ml_kem_768_x25519_xwing` and `pq_crypto = 0.6.4`.
18
+ > `wrap_suite_id = 1` is pinned to `:ml_kem_768_x25519_xwing` and `pq_crypto = 0.6.4`
19
+ > (X-Wing draft-10 wire sizes). Content suite 1 is AEGIS-256 via libaegis 0.10.3.
19
20
 
20
21
  ## Installation
21
22
 
22
23
  ```ruby
23
24
  gem "pq_crypto", "~> 0.6.4"
24
- gem "pq_crypto-seal", "~> 0.1.0"
25
+ gem "pq_crypto-seal", "~> 0.1.2"
25
26
  ```
26
27
 
27
28
  Ruby `>= 2.7.1` and OpenSSL `>= 3.0` development files are required for a
@@ -96,17 +97,19 @@ PQCrypto::Seal.decrypt_file(
96
97
  )
97
98
  ```
98
99
 
99
- `encrypt_io` accepts the exact plaintext `size:`. `decrypt_io` stages the entire
100
- unauthenticated inner frame to a mode-0600 temporary file and copies content to
101
- the caller's output only after the final AEGIS tag is valid.
100
+ `encrypt_io` accepts the exact plaintext `size:`. `decrypt_io` stages the
101
+ **ciphertext** inner frame to a mode-0600 temporary file, verifies the AEGIS tag,
102
+ then materialises plaintext and copies content to the caller's output. Decrypt
103
+ APIs default to `required_padding: :from_header`.
102
104
 
103
105
  ## Recipients and key lifecycle
104
106
 
105
107
  The envelope intentionally does not contain an authoritative list of recipient
106
- public keys. The application owns the ACL. Any complete recipient-section
107
- rewrite therefore receives the full list:
108
+ public keys. The application owns and must synchronise the ACL. Any recipient-section
109
+ rewrite therefore receives the **full** list:
108
110
 
109
111
  ```ruby
112
+ # Remove bob from the *current* copy only — not a cryptographic revocation.
110
113
  updated = PQCrypto::Seal.rebuild_recipients(
111
114
  sealed,
112
115
  with: alice,
@@ -115,9 +118,9 @@ updated = PQCrypto::Seal.rebuild_recipients(
115
118
  ```
116
119
 
117
120
  `add_recipient(..., recipient:, current_recipients:)` still requires the
118
- complete current ACL and rebuilds all slots.
119
- `drop_recipient_stanza(..., remaining_recipients:)` describes exactly what it
120
- does. It does **not** revoke plaintext, a DEK, or old copies already obtained.
121
+ complete current ACL and rebuilds all slots. There is no `drop_recipient_*`
122
+ helper: “drop” names falsely suggest revocation while old copies and the DEK
123
+ remain valid.
121
124
 
122
125
  ```ruby
123
126
  rotated = PQCrypto::Seal.rotate_dek(
@@ -134,6 +137,10 @@ still cannot erase knowledge from prior copies. Rotation preserves the current
134
137
  final envelope size by default (`padding: :preserve`); pass `:padme`, `:none`, or
135
138
  an explicit padding policy to recalculate it.
136
139
 
140
+ `recipient_capacity` and `slot_size` are immutable for a given payload header.
141
+ To grow either, re-encrypt via `rotate_dek` / `rotate_dek_file` with
142
+ `recipient_capacity:` / `slot_size:` overrides.
143
+
137
144
  ## Wire-format v1
138
145
 
139
146
  The exact bytes are implemented in `PQCrypto::Seal::Format`:
@@ -187,9 +194,10 @@ The payload-and-section-scoped recipient hint avoids a stable global recipient i
187
194
  Anyone who already knows a candidate public key can test for its presence. No
188
195
  formal recipient anonymity is claimed.
189
196
 
190
- The wrapping KEM follows the MLKEM768-X25519/X-Wing construction, targeting
191
- approximately 128-bit security. ML-KEM-768 supplies conservative margin; the
192
- full hybrid suite is not advertised as NIST category 3.
197
+ The wrapping KEM follows the MLKEM768-X25519/X-Wing construction (draft-10
198
+ wire sizes pinned by suite id 1), targeting approximately 128-bit security.
199
+ ML-KEM-768 supplies conservative margin; the full hybrid suite is not advertised
200
+ as NIST category 3. See `SECURITY.md` for at-rest draft-pinning risk.
193
201
 
194
202
  ## Envelope identity
195
203
 
@@ -217,15 +225,16 @@ limits.
217
225
 
218
226
  ## Padding enforcement on decrypt
219
227
 
220
- After AEAD verification you may require a padding policy:
228
+ After AEAD verification, decrypt APIs **default** to
229
+ `required_padding: :from_header`:
230
+
231
+ - Padmé / none — full canonical size check
232
+ - fixed / buckets — opens with policy-id check only; pass parameters for full
233
+ target enforcement
221
234
 
222
235
  ```ruby
236
+ PQCrypto::Seal.decrypt(envelope, with: credentials) # :from_header
223
237
  PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: :padme)
224
- PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: :from_header)
225
238
  PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: { to: 4096 })
239
+ PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: false) # opt out
226
240
  ```
227
-
228
- Omitting `required_padding` keeps padding enforcement optional. When supplied,
229
- the decryptor verifies both the authenticated policy id and the canonical target
230
- computed from the authenticated content and metadata lengths. Fixed and bucket
231
- policies require the expected target or bucket list from the application.
data/RELEASING.md CHANGED
@@ -3,13 +3,15 @@
3
3
  1. Run `bundle exec ruby script/vendor_libs.rb --check`.
4
4
  2. Confirm `pq_crypto` dependency remains `= 0.6.4` and `WRAP_KEM_ALGORITHM`
5
5
  is still `:ml_kem_768_x25519_xwing` with the documented sizes.
6
- 3. Run the full CI matrix and sanitizer job.
7
- 4. Build with `gem build pq_crypto-seal.gemspec`.
8
- 5. Install the exact built gem into an empty `GEM_HOME` together with the
6
+ 3. Run `bundle exec ruby script/release_contract_check.rb` (also enforced in CI).
7
+ 4. Run the full CI matrix: test, sanitize, fuzz, release_contract, gem-smoke.
8
+ 5. Build with `gem build pq_crypto-seal.gemspec`.
9
+ 6. Re-run the release-contract checker against the built gem path.
10
+ 7. Install the exact built gem into an empty `GEM_HOME` together with the
9
11
  supported `pq_crypto` gem and run String and File round trips.
10
- 6. Confirm golden envelope vectors and pinned X-Wing wire-contract checks in
11
- `test/golden_vectors_test.rb` pass, and that the CI fuzz job is green.
12
- 7. Publish only from a signed tag through RubyGems Trusted Publishing.
12
+ 8. Confirm golden envelope vectors, X-Wing draft-10 KATs, and AEGIS draft-18
13
+ KATs pass, and that the CI fuzz job is green.
14
+ 9. Publish only from a signed tag through RubyGems Trusted Publishing.
13
15
 
14
16
  Vendored libaegis sources are pinned by archive SHA-256 and a deterministic
15
17
  source-tree digest (`vendor/libaegis/TREE_SHA256`). The full upstream snapshot
data/SECURITY.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Security
2
2
 
3
3
  This project has not received an independent cryptographic or implementation
4
- audit. Do not treat version 0.1.1 as a substitute for a reviewed storage design.
4
+ audit. Do not treat version 0.1.2 as a substitute for a reviewed storage design.
5
5
 
6
6
  Experimental cryptographic software; format v1; not independently audited.
7
7
 
@@ -25,10 +25,34 @@ Successful opening does **not** authenticate:
25
25
  It does not claim recipient anonymity. Anyone who already knows a candidate
26
26
  public key can test for its presence via the scoped recipient hint.
27
27
 
28
- Removing a recipient stanza does not revoke plaintext, a DEK, or an older copy
29
- already obtained by that recipient. Rebuilding recipient stanzas protects the
30
- current copy only. Rotating the DEK creates a new protected version but cannot
31
- remove knowledge from older copies.
28
+ ### Recipients, ACL ownership, and non-revocation
29
+
30
+ The envelope is **not** an authoritative ACL. The application must store and
31
+ synchronise the complete recipient public-key list itself. Every recipient-section
32
+ rewrite (`rebuild_recipients` / `add_recipient` and the `*_file` variants)
33
+ requires that full list and only protects the **current canonical copy**.
34
+
35
+ Rebuilding the recipient section does **not** revoke:
36
+
37
+ - plaintext already opened by a removed party
38
+ - the DEK (unchanged across rebuilds)
39
+ - older envelope bytes the removed party already copied
40
+
41
+ `rotate_dek` allocates a new DEK and a new `payload_id`, re-encrypts the payload,
42
+ and still cannot erase knowledge from prior copies. Treat any former recipient
43
+ who retained an old envelope and their secret key as still able to open that old
44
+ copy.
45
+
46
+ There is intentionally **no** `drop_recipient_*` API: a “drop” name encourages
47
+ the false belief that access was revoked.
48
+
49
+ ### Capacity and slot size
50
+
51
+ `recipient_capacity` and `slot_size` are stored in the **immutable** payload
52
+ header and are associated-data for the payload AEAD. They cannot change for a
53
+ given encrypted payload without full re-encryption. Use `rotate_dek` /
54
+ `rotate_dek_file` with optional `recipient_capacity:` / `slot_size:` when the
55
+ application needs a larger slot table after authenticating the current payload.
32
56
 
33
57
  ## Side channels
34
58
 
@@ -47,18 +71,24 @@ responsibility after use.
47
71
 
48
72
  ## payload_id stability
49
73
 
50
- `payload_id` is stable across `rebuild_recipients` / add / drop operations.
74
+ `payload_id` is stable across `rebuild_recipients` / add operations.
51
75
  `rotate_dek` re-encrypts the payload with a fresh DEK and **allocates a new
52
76
  `payload_id`** — treat the result as a new document identity. Applications that
53
77
  need a stable external id across DEK rotation must track it outside the envelope.
54
78
 
55
79
  ## Padding policy enforcement
56
80
 
57
- The authenticated header carries `padding_policy_id` only. Receivers that require
58
- a specific length-hiding policy should pass `required_padding:` to decrypt/open
59
- (for example `required_padding: :padme` or `required_padding: :from_header`).
60
- The receiver then checks both the policy id and the canonical target computed from
61
- authenticated inner lengths. Without that argument the field is informational.
81
+ The authenticated header carries `padding_policy_id`. Decrypt/open APIs default
82
+ to `required_padding: :from_header`:
83
+
84
+ - Padmé / none full check of policy id **and** canonical size from
85
+ authenticated inner lengths
86
+ - fixed / buckets — policy id only (targets are not on the wire); pass
87
+ `required_padding: { to: N }` or `{ buckets: [...] }` for full target
88
+ enforcement
89
+
90
+ Pass `required_padding: false` only when the application deliberately skips
91
+ length-hiding checks.
62
92
 
63
93
  ## Envelope digests
64
94
 
@@ -81,21 +111,36 @@ unbounded staging or disk amplification before the final AEGIS tag fails.
81
111
 
82
112
  Applications may raise the limits explicitly when larger objects are required.
83
113
 
84
- ## Staging
114
+ ## Staging (RUP)
85
115
 
86
- Large decryptions stage unauthenticated plaintext in a mode-0600 temporary file.
87
- On Unix the file is unlinked immediately after opening. The staged bytes are
88
- never returned or published before the final AEGIS tag is verified. Platforms
89
- that cannot unlink an open file, filesystem snapshots, and storage-layer
90
- forensics may still retain remnants; use a suitably protected staging filesystem.
116
+ Large decryptions stage the **ciphertext** inner frame in a mode-0600 temporary
117
+ file. On Unix the file is unlinked immediately after opening. Intermediate AEGIS
118
+ streaming plaintext is wiped in memory and is not written to disk until the
119
+ final tag has verified; only then is verified plaintext materialised for
120
+ parsing and publication. Platforms that cannot unlink an open file, filesystem
121
+ snapshots, and storage-layer forensics may still retain remnants (including
122
+ ciphertext); use a suitably protected staging filesystem.
91
123
 
92
- ## Wire suite pinning
124
+ ## Wire suite pinning and draft risk
93
125
 
94
- `wrap_suite_id = 1` is permanently bound to MLKEM768-X25519 / X-Wing (draft-10
95
- compatible) with fixed public-key (1216), ciphertext (1120), and shared-secret
96
- (32) sizes, as implemented by `pq_crypto = 0.6.4`. Seal does not follow
97
- `HybridKEM::CANONICAL_ALGORITHM`; a future change of that constant must not
98
- alter suite 1 wire bytes. Always generate keys with
126
+ At-rest envelopes outlive draft documents. Suite identifiers freeze behaviour
127
+ independent of future RFC text:
128
+
129
+ | Component | Pin |
130
+ |---|---|
131
+ | `wrap_suite_id = 1` | MLKEM768-X25519 / X-Wing **draft-10** compatible via `pq_crypto = 0.6.4` algorithm `:ml_kem_768_x25519_xwing` (PK 1216, CT 1120, SS 32) |
132
+ | `content_suite_id = 1` | AEGIS-256, 32-byte nonce/tag, inner-frame-v1, via vendored **libaegis 0.10.3** |
133
+ | AEGIS KATs | CFRG AEGIS draft-18 positive/negative vectors in `test/aegis_vectors_test.rb` |
134
+ | X-Wing KATs | draft-10 decapsulation vectors in `test/fixtures/xwing-draft-10-vector-*.json` (see `test/xwing_kat_test.rb`) |
135
+
136
+ Seal does **not** follow `HybridKEM::CANONICAL_ALGORITHM`; a future change of
137
+ that constant must not alter suite 1 wire bytes. Always generate keys with
99
138
  `PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)`.
100
139
 
140
+ If a final RFC diverges from these pins, a new suite id (and migration) is
141
+ required; existing `PQCSEAL1` / suite-1 envelopes remain defined by this pin,
142
+ not by later drafts. Envelope golden vectors in `test/golden_vectors_test.rb`
143
+ are generated under a deterministic RNG to pin **Seal-controlled** layout and
144
+ labels; they are not a substitute for the external KEM/AEAD KATs above.
145
+
101
146
  Report vulnerabilities privately to the repository owner.
data/VENDORING.md CHANGED
@@ -8,6 +8,13 @@ libaegis 0.10.3.
8
8
  - Tag commit: `0bb1639`
9
9
  - Official tag archive SHA-256: `2f2682c1d08d9a5510caca1c82e3f8ea91f7085fef2ecbed0c398b2a921c79b1`
10
10
  - License: MIT
11
+ - KATs exercised by this gem: CFRG AEGIS draft-18 vectors in
12
+ `test/aegis_vectors_test.rb` (content suite 1). Wire behaviour is pinned to
13
+ this snapshot, not to a future RFC that may diverge.
14
+
15
+ X-Wing / MLKEM768-X25519 wrap suite 1 is supplied by runtime dependency
16
+ `pq_crypto = 0.6.4` (not vendored here). External draft-10 decapsulation KATs
17
+ live in `test/fixtures/xwing-draft-10-vector-*.json`.
11
18
 
12
19
  The complete source snapshot is retained, while the Ruby extension compiles only the AEGIS-256 and common implementation files it exposes. No system libaegis is
13
20
  loaded at runtime. Update the version, archive checksum, source snapshot, KATs,
@@ -117,12 +117,15 @@ module PQCrypto
117
117
  end
118
118
 
119
119
  class DekRotator
120
- def initialize(envelope, credentials:, recipients:, padding:, limits:)
120
+ def initialize(envelope, credentials:, recipients:, padding:, limits:,
121
+ recipient_capacity: nil, slot_size: nil)
121
122
  @envelope = Envelope.parse(envelope, limits: limits)
122
123
  @credentials = credentials
123
124
  @recipients = recipients
124
125
  @padding = padding == :preserve ? { to: @envelope.size } : padding
125
126
  @limits = limits
127
+ @recipient_capacity = recipient_capacity
128
+ @slot_size = slot_size
126
129
  end
127
130
 
128
131
  def call
@@ -135,14 +138,28 @@ module PQCrypto
135
138
  to: @recipients,
136
139
  metadata: inner.metadata,
137
140
  public_metadata: @envelope.header.public_metadata,
138
- recipient_capacity: @envelope.header.recipient_capacity,
139
- slot_size: @envelope.header.slot_size,
141
+ recipient_capacity: capacity_for_reencrypt,
142
+ slot_size: slot_size_for_reencrypt,
140
143
  padding: @padding
141
144
  ).call
142
145
  ensure
143
146
  Secrets.wipe_each!(dek, inner_bytes)
144
147
  Secrets.wipe_each!(inner.content, inner.metadata) if inner
145
148
  end
149
+
150
+ private
151
+
152
+ def capacity_for_reencrypt
153
+ return @envelope.header.recipient_capacity if @recipient_capacity.nil?
154
+
155
+ Format.validate_capacity!(@recipient_capacity, KeyMaterial.public_keys(@recipients).length)
156
+ end
157
+
158
+ def slot_size_for_reencrypt
159
+ return @envelope.header.slot_size if @slot_size.nil?
160
+
161
+ Format.validate_slot_size!(@slot_size)
162
+ end
146
163
  end
147
164
 
148
165
  module_function
@@ -165,11 +182,11 @@ module PQCrypto
165
182
  ).call
166
183
  end
167
184
 
168
- def decrypt(envelope, with:, required_padding: nil, **limit_options)
185
+ def decrypt(envelope, with:, required_padding: :from_header, **limit_options)
169
186
  open(envelope, with: with, required_padding: required_padding, **limit_options).data
170
187
  end
171
188
 
172
- def open(envelope, with:, required_padding: nil, **limit_options)
189
+ def open(envelope, with:, required_padding: :from_header, **limit_options)
173
190
  OneShotOpener.new(
174
191
  envelope,
175
192
  credentials: with,
@@ -204,16 +221,15 @@ module PQCrypto
204
221
  )
205
222
  end
206
223
 
207
- def drop_recipient_stanza(envelope, with:, remaining_recipients:, **limit_options)
208
- rebuild_recipients(envelope, with: with, recipients: remaining_recipients, **limit_options)
209
- end
210
-
211
- def rotate_dek(envelope, with:, recipients:, padding: :preserve, **limit_options)
224
+ def rotate_dek(envelope, with:, recipients:, padding: :preserve,
225
+ recipient_capacity: nil, slot_size: nil, **limit_options)
212
226
  DekRotator.new(
213
227
  envelope,
214
228
  credentials: with,
215
229
  recipients: recipients,
216
230
  padding: padding,
231
+ recipient_capacity: recipient_capacity,
232
+ slot_size: slot_size,
217
233
  limits: ResourceLimits.resolve(limit_options)
218
234
  ).call
219
235
  end
@@ -237,7 +253,7 @@ module PQCrypto
237
253
 
238
254
  PUBLIC_API = %i[
239
255
  credentials encrypt decrypt open inspect_envelope digest
240
- rebuild_recipients add_recipient drop_recipient_stanza rotate_dek
256
+ rebuild_recipients add_recipient rotate_dek
241
257
  ].freeze
242
258
 
243
259
  private_class_method(*(singleton_methods(false).map(&:to_sym) - PUBLIC_API))
@@ -53,7 +53,8 @@ module PQCrypto
53
53
 
54
54
  class FileDekRotator
55
55
  def initialize(source, destination, credentials:, recipients:, padding:,
56
- staging_directory:, chunk_size:, limits:)
56
+ staging_directory:, chunk_size:, limits:,
57
+ recipient_capacity: nil, slot_size: nil)
57
58
  @source = source
58
59
  @destination = destination
59
60
  @credentials = credentials
@@ -62,6 +63,8 @@ module PQCrypto
62
63
  @staging_directory = staging_directory
63
64
  @chunk_size = IOHelpers.validate_chunk_size(chunk_size)
64
65
  @limits = limits
66
+ @recipient_capacity = recipient_capacity
67
+ @slot_size = slot_size
65
68
  end
66
69
 
67
70
  def call
@@ -88,6 +91,8 @@ module PQCrypto
88
91
  def rotate(verified)
89
92
  envelope = verified.stream_envelope
90
93
  padding = @padding == :preserve ? { to: envelope.envelope_bytes } : @padding
94
+ capacity = @recipient_capacity.nil? ? envelope.header.recipient_capacity : @recipient_capacity
95
+ slot = @slot_size.nil? ? envelope.header.slot_size : @slot_size
91
96
  AtomicDestination.new(@destination).write do |output|
92
97
  StreamingEncryptor.new(
93
98
  verified.content_io,
@@ -96,8 +101,8 @@ module PQCrypto
96
101
  to: @recipients,
97
102
  metadata: verified.inner.metadata,
98
103
  public_metadata: envelope.header.public_metadata,
99
- recipient_capacity: envelope.header.recipient_capacity,
100
- slot_size: envelope.header.slot_size,
104
+ recipient_capacity: capacity,
105
+ slot_size: slot,
101
106
  padding: padding,
102
107
  chunk_size: @chunk_size,
103
108
  strict_size: false
@@ -28,7 +28,7 @@ module PQCrypto
28
28
 
29
29
  def decrypt_io(input, output, with:, staging_directory: nil,
30
30
  chunk_size: DEFAULT_CHUNK_SIZE, strict_eof: true,
31
- required_padding: nil, **limit_options)
31
+ required_padding: :from_header, **limit_options)
32
32
  StreamingDecryptor.new(
33
33
  input,
34
34
  output,
@@ -55,7 +55,7 @@ module PQCrypto
55
55
  end
56
56
 
57
57
  def decrypt_file(source, destination, with:, staging_directory: nil,
58
- chunk_size: DEFAULT_CHUNK_SIZE, required_padding: nil,
58
+ chunk_size: DEFAULT_CHUNK_SIZE, required_padding: :from_header,
59
59
  **limit_options)
60
60
  FileOperations.decrypt(
61
61
  source,
@@ -90,13 +90,10 @@ module PQCrypto
90
90
  )
91
91
  end
92
92
 
93
- def drop_recipient_stanza_file(source, destination, with:, remaining_recipients:, **options)
94
- rebuild_recipients_file(source, destination, with: with, recipients: remaining_recipients, **options)
95
- end
96
-
97
93
  def rotate_dek_file(source, destination, with:, recipients:,
98
94
  padding: :preserve, staging_directory: nil,
99
- chunk_size: DEFAULT_CHUNK_SIZE, **limit_options)
95
+ chunk_size: DEFAULT_CHUNK_SIZE,
96
+ recipient_capacity: nil, slot_size: nil, **limit_options)
100
97
  FileDekRotator.new(
101
98
  source,
102
99
  destination,
@@ -105,6 +102,8 @@ module PQCrypto
105
102
  padding: padding,
106
103
  staging_directory: staging_directory,
107
104
  chunk_size: chunk_size,
105
+ recipient_capacity: recipient_capacity,
106
+ slot_size: slot_size,
108
107
  limits: ResourceLimits.resolve(limit_options)
109
108
  ).call
110
109
  end
@@ -114,6 +114,21 @@ module PQCrypto
114
114
  end
115
115
  end
116
116
 
117
+ class PolicyIdRequirement
118
+ def initialize(policy_id)
119
+ @policy_id = Integer(policy_id)
120
+ freeze
121
+ end
122
+
123
+ def verify!(context)
124
+ actual = context.header.padding_policy_id
125
+ return true if actual == @policy_id
126
+
127
+ raise FormatError,
128
+ "header padding_policy_id=#{actual} does not match required policy #{@policy_id}"
129
+ end
130
+ end
131
+
117
132
  NONE = NonePolicy.new
118
133
  PADME = PadmePolicy.new
119
134
  NO_REQUIREMENT = NoRequirement.new.freeze
@@ -127,7 +142,9 @@ module PQCrypto
127
142
 
128
143
  HEADER_POLICIES = {
129
144
  Format::PADDING_NONE => NONE,
130
- Format::PADDING_PADME => PADME
145
+ Format::PADDING_PADME => PADME,
146
+ Format::PADDING_FIXED => PolicyIdRequirement.new(Format::PADDING_FIXED),
147
+ Format::PADDING_BUCKETS => PolicyIdRequirement.new(Format::PADDING_BUCKETS)
131
148
  }.freeze
132
149
 
133
150
  module_function
@@ -191,8 +208,7 @@ module PQCrypto
191
208
 
192
209
  def policy_from_header(policy_id)
193
210
  HEADER_POLICIES.fetch(policy_id) do
194
- raise InvalidConfigurationError,
195
- "required_padding: :from_header cannot verify fixed/buckets without parameters"
211
+ raise FormatError, "unknown padding_policy_id in header: #{policy_id}"
196
212
  end
197
213
  end
198
214
 
@@ -186,17 +186,20 @@ module PQCrypto
186
186
  end
187
187
 
188
188
  def call
189
- staging = IOHelpers.staging_file("pqcrypto-seal-inner", @staging_directory)
189
+ ciphertext_staging = IOHelpers.staging_file("pqcrypto-seal-ct", @staging_directory)
190
190
  envelope = StreamEnvelope.read(@input, @limits)
191
191
  dek = envelope.unwrap_dek(@credentials)
192
- decrypt_payload(staging, envelope, dek)
193
- staging.flush
194
- inner = StagedInner.parse(staging, envelope.header.padded_inner_length)
192
+ tag = stage_and_authenticate_ciphertext(ciphertext_staging, envelope, dek)
193
+ plaintext_staging = materialize_verified_plaintext(ciphertext_staging, envelope, dek, tag)
194
+ IOHelpers.close_tempfile(ciphertext_staging)
195
+ ciphertext_staging = nil
196
+ inner = StagedInner.parse(plaintext_staging, envelope.header.padded_inner_length)
195
197
  @limits.check_plaintext!(inner.content_length)
196
- VerifiedStream.new(staging, envelope, inner, @chunk_size)
198
+ VerifiedStream.new(plaintext_staging, envelope, inner, @chunk_size)
197
199
  rescue StandardError
198
200
  Secrets.wipe!(inner.metadata) if inner
199
- IOHelpers.close_tempfile(staging) if staging
201
+ IOHelpers.close_tempfile(plaintext_staging) if plaintext_staging
202
+ IOHelpers.close_tempfile(ciphertext_staging) if ciphertext_staging
200
203
  raise
201
204
  ensure
202
205
  Secrets.wipe!(dek)
@@ -204,10 +207,33 @@ module PQCrypto
204
207
 
205
208
  private
206
209
 
207
- def decrypt_payload(staging, envelope, dek)
208
- envelope.stream_verified_payload(@input, dek, @chunk_size, strict_eof: @strict_eof) do |_ciphertext, plaintext|
209
- IOHelpers.write_all(staging, plaintext)
210
+ def stage_and_authenticate_ciphertext(staging, envelope, dek)
211
+ envelope.stream_verified_payload(@input, dek, @chunk_size, strict_eof: @strict_eof) do |ciphertext, _plaintext|
212
+ IOHelpers.write_all(staging, ciphertext)
213
+ end
214
+ end
215
+
216
+ def materialize_verified_plaintext(ciphertext_staging, envelope, dek, tag)
217
+ staging = IOHelpers.staging_file("pqcrypto-seal-inner", @staging_directory)
218
+ decryptor = Native::Decryptor.new(dek, envelope.header.payload_nonce, envelope.header_hash)
219
+ ciphertext_staging.rewind
220
+ message = { message: "staged ciphertext is truncated" }
221
+ IOHelpers.each_exact_chunk(
222
+ ciphertext_staging, envelope.header.padded_inner_length, @chunk_size, **message
223
+ ) do |ciphertext|
224
+ plaintext = decryptor.update(ciphertext)
225
+ begin
226
+ IOHelpers.write_all(staging, plaintext)
227
+ ensure
228
+ Secrets.wipe!(plaintext)
229
+ end
210
230
  end
231
+ decryptor.final(tag)
232
+ staging.flush
233
+ staging
234
+ rescue StandardError
235
+ IOHelpers.close_tempfile(staging) if staging
236
+ raise
211
237
  end
212
238
  end
213
239
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PQCrypto
4
4
  module Seal
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
@@ -23,7 +23,7 @@ module PQCrypto
23
23
  IO_DELEGATES = %i[
24
24
  encrypt_io decrypt_io encrypt_frame_io decrypt_frame_io
25
25
  encrypt_file decrypt_file rebuild_recipients_file
26
- add_recipient_file drop_recipient_stanza_file rotate_dek_file
26
+ add_recipient_file rotate_dek_file
27
27
  inspect_file
28
28
  ].freeze
29
29
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pq_crypto-seal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Khaidarov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-20 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pq_crypto