pq_crypto-seal 0.1.0 → 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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/FORMAT.md +24 -9
  4. data/GET_STARTED.md +23 -7
  5. data/README.md +40 -15
  6. data/RELEASING.md +9 -7
  7. data/SECURITY.md +78 -17
  8. data/VENDORING.md +13 -0
  9. data/lib/pq_crypto/seal/binary.rb +50 -41
  10. data/lib/pq_crypto/seal/core.rb +198 -335
  11. data/lib/pq_crypto/seal/envelope.rb +158 -0
  12. data/lib/pq_crypto/seal/files.rb +147 -0
  13. data/lib/pq_crypto/seal/format.rb +193 -165
  14. data/lib/pq_crypto/seal/io.rb +94 -300
  15. data/lib/pq_crypto/seal/io_helpers.rb +184 -0
  16. data/lib/pq_crypto/seal/models.rb +46 -0
  17. data/lib/pq_crypto/seal/padding.rb +199 -28
  18. data/lib/pq_crypto/seal/recipients.rb +245 -0
  19. data/lib/pq_crypto/seal/resource_limits.rb +56 -0
  20. data/lib/pq_crypto/seal/secrets.rb +21 -0
  21. data/lib/pq_crypto/seal/streaming.rb +270 -0
  22. data/lib/pq_crypto/seal/version.rb +2 -1
  23. data/lib/pq_crypto/seal.rb +18 -6
  24. data/pq_crypto-seal.gemspec +23 -8
  25. metadata +12 -95
  26. data/.github/workflows/ci.yml +0 -66
  27. data/Gemfile +0 -7
  28. data/Rakefile +0 -19
  29. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l.c +0 -246
  30. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_aesni.c +0 -73
  31. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_aesni.h +0 -9
  32. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_altivec.c +0 -70
  33. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_altivec.h +0 -9
  34. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_common.h +0 -691
  35. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_aes.c +0 -77
  36. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_aes.h +0 -9
  37. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_sha3.c +0 -86
  38. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_sha3.h +0 -9
  39. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_soft.c +0 -63
  40. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_soft.h +0 -9
  41. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/implementations.h +0 -40
  42. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2.c +0 -247
  43. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_aesni.c +0 -108
  44. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_aesni.h +0 -9
  45. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_altivec.c +0 -103
  46. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_altivec.h +0 -9
  47. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_avx2.c +0 -79
  48. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_avx2.h +0 -11
  49. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_common.h +0 -795
  50. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_neon_aes.c +0 -111
  51. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_neon_aes.h +0 -9
  52. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_soft.c +0 -96
  53. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_soft.h +0 -9
  54. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/implementations.h +0 -40
  55. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4.c +0 -252
  56. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_aesni.c +0 -117
  57. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_aesni.h +0 -9
  58. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_altivec.c +0 -111
  59. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_altivec.h +0 -9
  60. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx2.c +0 -112
  61. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx2.h +0 -11
  62. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx512.c +0 -85
  63. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx512.h +0 -11
  64. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_common.h +0 -808
  65. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_neon_aes.c +0 -119
  66. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_neon_aes.h +0 -9
  67. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_soft.c +0 -104
  68. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_soft.h +0 -9
  69. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/implementations.h +0 -41
  70. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2.c +0 -247
  71. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_aesni.c +0 -103
  72. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_aesni.h +0 -9
  73. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_altivec.c +0 -101
  74. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_altivec.h +0 -9
  75. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_avx2.c +0 -74
  76. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_avx2.h +0 -11
  77. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_common.h +0 -790
  78. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_neon_aes.c +0 -109
  79. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_neon_aes.h +0 -9
  80. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_soft.c +0 -91
  81. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_soft.h +0 -9
  82. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/implementations.h +0 -41
  83. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4.c +0 -252
  84. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_aesni.c +0 -112
  85. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_aesni.h +0 -9
  86. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_altivec.c +0 -109
  87. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_altivec.h +0 -9
  88. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx2.c +0 -107
  89. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx2.h +0 -11
  90. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx512.c +0 -79
  91. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx512.h +0 -11
  92. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_common.h +0 -805
  93. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_neon_aes.c +0 -117
  94. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_neon_aes.h +0 -9
  95. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_soft.c +0 -99
  96. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_soft.h +0 -9
  97. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/implementations.h +0 -42
  98. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf.c +0 -87
  99. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128l.c +0 -20
  100. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128x2.c +0 -20
  101. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128x4.c +0 -20
  102. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256.c +0 -20
  103. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256x2.c +0 -20
  104. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256x4.c +0 -20
  105. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_internal.h +0 -152
  106. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_merkle.c +0 -268
  107. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_merkle.h +0 -165
  108. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_variant.h +0 -1119
  109. data/fuzz/decrypt_fuzz.rb +0 -119
  110. data/script/vendor_libs.rb +0 -79
  111. data/test/aegis_vectors_test.rb +0 -54
  112. data/test/golden_vectors_test.rb +0 -183
  113. data/test/io_test.rb +0 -125
  114. data/test/native_test.rb +0 -36
  115. data/test/seal_test.rb +0 -238
  116. data/test/test_helper.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f0508e0149f076a9f8d6c9a829b516a41e2998dc5af916db9d0d4696349d36c
4
- data.tar.gz: ce0dd7ba92b94ae212f22ea73cc9754b2028da7037a46539c5408dd60bae961b
3
+ metadata.gz: 0e2f25bfc50a4e95892fb3c9e25992ff359bd16cc65535dacf3863d943c34e52
4
+ data.tar.gz: 40462d41fa188924ba4abbd5d76b4ad22c48377964d70a4544bae919ec4f5140
5
5
  SHA512:
6
- metadata.gz: 248f9187168dd55054827e08dbfcf771555101e00f748cdcef55dbaed31ba4c4fb98ce6f6017c05c00bc5f78de23cfcd4c457541eda93cc2537be8be749711e6
7
- data.tar.gz: b96cd381d599e6e9a52e0eba84de72500e6746e744183c5d383ed61d31305874393d640c8a1a79dbf288c7db1daeb1379ffc3c125443b5c0c4b2a39b14088402
6
+ metadata.gz: 8a3f087d4c922637fcbf5af86cae6a9e118b688215f9a218c6572e524555643e2d444f7452416575c2904f08a8c34742adc0e2cb4bfab6e33a0267015f03d177
7
+ data.tar.gz: e1746e895c1be25bd755fc358c4eac0291b21c6c638b7d87fb1603e08229783e942adcecbdc4c095e4ae1fde9ca00d49a7f472a89cc1ef183339a94f9546e025
data/CHANGELOG.md CHANGED
@@ -1,7 +1,76 @@
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
+
42
+ ## 0.1.1
43
+
44
+ - Release hygiene: CI Actions pinned by commit SHA, `permissions: contents: read`,
45
+ fuzz + gem-smoke + `release_contract_check` jobs; ignore `.DS_Store` / binaries.
46
+ - Aggressive internal refactor: parsing, recipient wrapping, one-shot operations,
47
+ streaming, atomic files, padding policies, and resource limits now live in
48
+ focused objects instead of large branching methods. Public API and wire v1
49
+ remain unchanged.
50
+ - Padding enforcement now validates both policy id and the canonical target;
51
+ parameterized fixed/bucket requirements cannot masquerade as one another.
52
+ - Added exact deterministic envelope/stream equivalence, official X-Wing
53
+ draft-10 decapsulation KATs, partial-read/write regression tests, and a
54
+ release-contract checker.
55
+ - Release gem contains production code plus only the AEGIS-256 sources needed
56
+ to compile; tests, CI, fuzz harnesses, RAF, and unused AEGIS families stay out.
57
+ - CI restores pinned Actions, sanitizer fuzz smoke, and isolated built-gem E2E.
58
+
59
+ - IO: all output paths use `write_all` (reject silent partial writes).
60
+ - Decrypt APIs accept `required_padding:` and can enforce Padmé / none / fixed /
61
+ buckets after successful AEAD (wire format unchanged).
62
+ - Runtime dependency pinned to `pq_crypto = 0.6.4`.
63
+ - Resource limits applied to `inspect_envelope`, `rebuild_recipients`,
64
+ `rotate_dek`, and `rebuild_recipients_file`.
65
+ - Default one-shot limits lowered to 64 MiB (use file/IO APIs for larger docs).
66
+ - Document that `rotate_dek` allocates a new `payload_id`.
67
+ - CI: Actions pinned by commit SHA; `permissions: contents: read`; fuzz and
68
+ gem-smoke jobs restored.
69
+ - Repository hygiene: ignore `.DS_Store` and built binaries.
70
+
3
71
  ## 0.1.0
4
72
 
73
+
5
74
  - AEGIS streaming state rejects `update`/`final` before `initialize` (`mode` guard).
6
75
  - Encrypt path wipes the internal plaintext copy in `ensure` (caller buffers unchanged).
7
76
  - CI: Actions pinned by commit SHA, `permissions: contents: read`, fuzz + gem-smoke jobs.
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
 
@@ -131,9 +134,21 @@ slot_size: 2048 bytes (configurable 2048..8192, multiple of 256)
131
134
  recipient_capacity: 4 (maximum 32; never derived automatically from ACL size)
132
135
  padding: Padmé over the complete final envelope size
133
136
  max_staging_bytes: ~1 GiB + private-metadata ceiling
134
- max_plaintext_bytes:~1 GiB
137
+ max_plaintext_bytes:64 MiB by default
135
138
  ```
136
139
 
137
140
  Parsers reject trailing bytes on the one-shot and default IO paths. Framed
138
141
  stream consumers that intentionally embed one envelope inside a larger stream
139
142
  must use `encrypt_frame_io` / `decrypt_frame_io`.
143
+
144
+
145
+ ## Receiver-side padding checks (0.1.2+)
146
+
147
+ `padding_policy_id` is authenticated but does not by itself encode fixed targets
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,12 +19,28 @@ 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).
37
+
38
+ ## Large documents
39
+
40
+ Default one-shot limits are 64 MiB (`max_plaintext_bytes` / related staging and
41
+ envelope ceilings). For larger objects use `encrypt_file` / `decrypt_file` or
42
+ `encrypt_io` / `decrypt_io` and raise the limits explicitly, for example:
43
+
44
+ ```ruby
45
+ PQCrypto::Seal.decrypt_file(src, dst, with: credentials, max_plaintext_bytes: 512 * 1024 * 1024)
46
+ ```
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.0 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(
@@ -129,11 +132,15 @@ rotated = PQCrypto::Seal.rotate_dek(
129
132
 
130
133
  `rebuild_recipients` preserves the DEK and encrypted payload. It is an
131
134
  operational migration of the current canonical copy, not protection against old
132
- saved envelopes. `rotate_dek` creates a new DEK and re-encrypts the payload; it
135
+ saved envelopes. `rotate_dek` creates a new DEK, a **new payload_id**, and re-encrypts the payload; it
133
136
  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
 
@@ -213,3 +221,20 @@ Set `PQC_SEAL_SANITIZE=1` when compiling under ASan/UBSan. The suite covers
213
221
  AEGIS one-shot/incremental equivalence, tampering, multi-recipient opening,
214
222
  full-envelope padding, recipient rebuilds, staged file publication, and parser
215
223
  limits.
224
+
225
+
226
+ ## Padding enforcement on decrypt
227
+
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
234
+
235
+ ```ruby
236
+ PQCrypto::Seal.decrypt(envelope, with: credentials) # :from_header
237
+ PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: :padme)
238
+ PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: { to: 4096 })
239
+ PQCrypto::Seal.decrypt(envelope, with: credentials, required_padding: false) # opt out
240
+ ```
data/RELEASING.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # Releasing
2
2
 
3
3
  1. Run `bundle exec ruby script/vendor_libs.rb --check`.
4
- 2. Confirm `pq_crypto` dependency remains `~> 0.6.4` and `WRAP_KEM_ALGORITHM`
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.0 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
 
@@ -44,6 +68,28 @@ callers that need residual-data hygiene must clear their own buffers.
44
68
  `Opened#data` / `Opened#metadata` returned from `open` are the caller's
45
69
  responsibility after use.
46
70
 
71
+
72
+ ## payload_id stability
73
+
74
+ `payload_id` is stable across `rebuild_recipients` / add operations.
75
+ `rotate_dek` re-encrypts the payload with a fresh DEK and **allocates a new
76
+ `payload_id`** — treat the result as a new document identity. Applications that
77
+ need a stable external id across DEK rotation must track it outside the envelope.
78
+
79
+ ## Padding policy enforcement
80
+
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.
92
+
47
93
  ## Envelope digests
48
94
 
49
95
  `Seal.digest` hashes the complete envelope, including the mutable recipient
@@ -65,21 +111,36 @@ unbounded staging or disk amplification before the final AEGIS tag fails.
65
111
 
66
112
  Applications may raise the limits explicitly when larger objects are required.
67
113
 
68
- ## Staging
114
+ ## Staging (RUP)
115
+
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.
69
123
 
70
- Large decryptions stage unauthenticated plaintext in a mode-0600 temporary file.
71
- On Unix the file is unlinked immediately after opening. The staged bytes are
72
- never returned or published before the final AEGIS tag is verified. Platforms
73
- that cannot unlink an open file, filesystem snapshots, and storage-layer
74
- forensics may still retain remnants; use a suitably protected staging filesystem.
124
+ ## Wire suite pinning and draft risk
75
125
 
76
- ## Wire suite pinning
126
+ At-rest envelopes outlive draft documents. Suite identifiers freeze behaviour
127
+ independent of future RFC text:
77
128
 
78
- `wrap_suite_id = 1` is permanently bound to MLKEM768-X25519 / X-Wing (draft-10
79
- compatible) with fixed public-key (1216), ciphertext (1120), and shared-secret
80
- (32) sizes, as implemented by `pq_crypto ~> 0.6.4`. Seal does not follow
81
- `HybridKEM::CANONICAL_ALGORITHM`; a future change of that constant must not
82
- alter suite 1 wire bytes. Always generate keys with
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
83
138
  `PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)`.
84
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
+
85
146
  Report vulnerabilities privately to the repository owner.
data/VENDORING.md CHANGED
@@ -8,7 +8,20 @@ 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,
14
21
  and cross-backend tests together.
22
+
23
+ The git tree keeps the full upstream snapshot so `script/vendor_libs.rb --check`
24
+ can prove byte-identity against the audited archive. The published gem package
25
+ ships only the AEGIS-256 family plus shared `common`/`include` sources required
26
+ to compile the extension (see `pq_crypto-seal.gemspec`); unused families and RAF
27
+ remain in the repository for provenance and are not loaded at runtime.
@@ -3,59 +3,68 @@
3
3
  module PQCrypto
4
4
  module Seal
5
5
  module Binary
6
- module_function
6
+ INTEGER_TYPES = {
7
+ u8: [1, "C", 0xff],
8
+ u16: [2, "n", 0xffff],
9
+ u32: [4, "N", 0xffff_ffff],
10
+ u64: [8, "Q>", 0xffff_ffff_ffff_ffff]
11
+ }.freeze
7
12
 
8
- def u8(value)
9
- [integer!(value, 0xff)].pack("C")
10
- end
13
+ class Reader
14
+ attr_reader :offset
11
15
 
12
- def u16(value)
13
- [integer!(value, 0xffff)].pack("n")
14
- end
16
+ def initialize(bytes, offset = 0)
17
+ @bytes = String(bytes).b
18
+ @offset = Integer(offset)
19
+ end
15
20
 
16
- def u32(value)
17
- [integer!(value, 0xffff_ffff)].pack("N")
18
- end
21
+ INTEGER_TYPES.each do |name, (length, directive, _max)|
22
+ define_method(name) { bytes(length).unpack1(directive) }
23
+ end
19
24
 
20
- def u64(value)
21
- [integer!(value, 0xffff_ffff_ffff_ffff)].pack("Q>")
22
- end
25
+ def bytes(length)
26
+ length = Integer(length)
27
+ Binary.ensure_available!(@bytes, offset, length)
28
+ value = @bytes.byteslice(offset, length).b
29
+ @offset += length
30
+ value
31
+ end
23
32
 
24
- def read_u8(bytes, offset)
25
- ensure_available!(bytes, offset, 1)
26
- [bytes.getbyte(offset), offset + 1]
33
+ def finished?
34
+ offset == @bytes.bytesize
35
+ end
27
36
  end
28
37
 
29
- def read_u16(bytes, offset)
30
- ensure_available!(bytes, offset, 2)
31
- [bytes.byteslice(offset, 2).unpack1("n"), offset + 2]
32
- end
38
+ class << self
39
+ INTEGER_TYPES.each do |name, (_length, directive, max)|
40
+ define_method(name) { |value| [integer!(value, max)].pack(directive) }
41
+ define_method("read_#{name}") { |bytes, offset| read_with(bytes, offset, &name) }
42
+ end
33
43
 
34
- def read_u32(bytes, offset)
35
- ensure_available!(bytes, offset, 4)
36
- [bytes.byteslice(offset, 4).unpack1("N"), offset + 4]
37
- end
44
+ def read_bytes(bytes, offset, length)
45
+ read_with(bytes, offset) { |reader| reader.bytes(length) }
46
+ end
38
47
 
39
- def read_u64(bytes, offset)
40
- ensure_available!(bytes, offset, 8)
41
- [bytes.byteslice(offset, 8).unpack1("Q>"), offset + 8]
42
- end
48
+ def ensure_available!(bytes, offset, length)
49
+ offset, length = Integer(offset), Integer(length)
43
50
 
44
- def read_bytes(bytes, offset, length)
45
- raise FormatError, "negative length" if length.negative?
46
- ensure_available!(bytes, offset, length)
47
- [bytes.byteslice(offset, length).b, offset + length]
48
- end
51
+ raise FormatError, "invalid offset" if offset.negative? || length.negative?
52
+ raise FormatError, "truncated envelope" if offset > bytes.bytesize || length > bytes.bytesize - offset
53
+ end
49
54
 
50
- def ensure_available!(bytes, offset, length)
51
- raise FormatError, "invalid offset" if offset.negative? || length.negative?
52
- raise FormatError, "truncated envelope" if offset > bytes.bytesize || length > bytes.bytesize - offset
53
- end
55
+ def integer!(value, max)
56
+ number = Integer(value)
57
+ raise RangeError, "integer is out of range" if number.negative? || number > max
58
+
59
+ number
60
+ end
61
+
62
+ private
54
63
 
55
- def integer!(value, max)
56
- number = Integer(value)
57
- raise RangeError, "integer is out of range" if number.negative? || number > max
58
- number
64
+ def read_with(bytes, offset)
65
+ reader = Reader.new(bytes, offset)
66
+ [yield(reader), reader.offset]
67
+ end
59
68
  end
60
69
  end
61
70
  end