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 +4 -4
- data/CHANGELOG.md +39 -0
- data/FORMAT.md +19 -13
- data/GET_STARTED.md +13 -7
- data/README.md +30 -21
- data/RELEASING.md +8 -6
- data/SECURITY.md +68 -23
- data/VENDORING.md +7 -0
- data/lib/pq_crypto/seal/core.rb +27 -11
- data/lib/pq_crypto/seal/files.rb +8 -3
- data/lib/pq_crypto/seal/io.rb +6 -7
- data/lib/pq_crypto/seal/padding.rb +19 -3
- data/lib/pq_crypto/seal/streaming.rb +35 -9
- data/lib/pq_crypto/seal/version.rb +1 -1
- data/lib/pq_crypto/seal.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e2f25bfc50a4e95892fb3c9e25992ff359bd16cc65535dacf3863d943c34e52
|
|
4
|
+
data.tar.gz: 40462d41fa188924ba4abbd5d76b4ad22c48377964d70a4544bae919ec4f5140
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
42
|
-
inner-frame-v1. The padding policy ID records
|
|
43
|
-
chosen; it is authenticated as part of the
|
|
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
|
|
58
|
-
wrapping, and 32-byte wrap nonces/tags.
|
|
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,
|
|
125
|
-
|
|
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.
|
|
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.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
23
|
-
the
|
|
24
|
-
and `slot_size` as long-lived application policy values
|
|
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
|
|
29
|
-
envelope copies.
|
|
30
|
-
|
|
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.
|
|
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.
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
191
|
-
approximately 128-bit security.
|
|
192
|
-
full hybrid suite is not advertised
|
|
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
|
|
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
|
|
7
|
-
4.
|
|
8
|
-
5.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
authenticated inner lengths
|
|
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
|
|
87
|
-
On Unix the file is unlinked immediately after opening.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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,
|
data/lib/pq_crypto/seal/core.rb
CHANGED
|
@@ -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:
|
|
139
|
-
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:
|
|
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:
|
|
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
|
|
208
|
-
|
|
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
|
|
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))
|
data/lib/pq_crypto/seal/files.rb
CHANGED
|
@@ -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:
|
|
100
|
-
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
|
data/lib/pq_crypto/seal/io.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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(
|
|
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
|
|
208
|
-
envelope.stream_verified_payload(@input, dek, @chunk_size, strict_eof: @strict_eof) do |
|
|
209
|
-
IOHelpers.write_all(staging,
|
|
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
|
|
data/lib/pq_crypto/seal.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-07-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pq_crypto
|