pq_crypto-seal 0.1.0

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 (143) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +66 -0
  3. data/CHANGELOG.md +44 -0
  4. data/FORMAT.md +139 -0
  5. data/GET_STARTED.md +30 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +215 -0
  9. data/RELEASING.md +34 -0
  10. data/Rakefile +19 -0
  11. data/SECURITY.md +85 -0
  12. data/VENDORING.md +14 -0
  13. data/ext/pq_crypto_seal/aegis_unused_stubs.c +20 -0
  14. data/ext/pq_crypto_seal/extconf.rb +296 -0
  15. data/ext/pq_crypto_seal/pq_crypto_seal.c +390 -0
  16. data/ext/pq_crypto_seal/vendor/libaegis/LICENSE +21 -0
  17. data/ext/pq_crypto_seal/vendor/libaegis/TREE_SHA256 +1 -0
  18. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l.c +246 -0
  19. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_aesni.c +73 -0
  20. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_aesni.h +9 -0
  21. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_altivec.c +70 -0
  22. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_altivec.h +9 -0
  23. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_common.h +691 -0
  24. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_aes.c +77 -0
  25. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_aes.h +9 -0
  26. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_sha3.c +86 -0
  27. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_neon_sha3.h +9 -0
  28. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_soft.c +63 -0
  29. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/aegis128l_soft.h +9 -0
  30. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128l/implementations.h +40 -0
  31. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2.c +247 -0
  32. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_aesni.c +108 -0
  33. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_aesni.h +9 -0
  34. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_altivec.c +103 -0
  35. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_altivec.h +9 -0
  36. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_avx2.c +79 -0
  37. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_avx2.h +11 -0
  38. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_common.h +795 -0
  39. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_neon_aes.c +111 -0
  40. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_neon_aes.h +9 -0
  41. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_soft.c +96 -0
  42. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/aegis128x2_soft.h +9 -0
  43. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x2/implementations.h +40 -0
  44. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4.c +252 -0
  45. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_aesni.c +117 -0
  46. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_aesni.h +9 -0
  47. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_altivec.c +111 -0
  48. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_altivec.h +9 -0
  49. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx2.c +112 -0
  50. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx2.h +11 -0
  51. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx512.c +85 -0
  52. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_avx512.h +11 -0
  53. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_common.h +808 -0
  54. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_neon_aes.c +119 -0
  55. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_neon_aes.h +9 -0
  56. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_soft.c +104 -0
  57. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/aegis128x4_soft.h +9 -0
  58. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis128x4/implementations.h +41 -0
  59. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256.c +240 -0
  60. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_aesni.c +68 -0
  61. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_aesni.h +9 -0
  62. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_altivec.c +68 -0
  63. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_altivec.h +9 -0
  64. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_common.h +673 -0
  65. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_neon_aes.c +75 -0
  66. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_neon_aes.h +9 -0
  67. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_soft.c +58 -0
  68. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/aegis256_soft.h +9 -0
  69. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256/implementations.h +39 -0
  70. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2.c +247 -0
  71. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_aesni.c +103 -0
  72. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_aesni.h +9 -0
  73. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_altivec.c +101 -0
  74. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_altivec.h +9 -0
  75. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_avx2.c +74 -0
  76. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_avx2.h +11 -0
  77. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_common.h +790 -0
  78. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_neon_aes.c +109 -0
  79. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_neon_aes.h +9 -0
  80. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_soft.c +91 -0
  81. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/aegis256x2_soft.h +9 -0
  82. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x2/implementations.h +41 -0
  83. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4.c +252 -0
  84. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_aesni.c +112 -0
  85. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_aesni.h +9 -0
  86. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_altivec.c +109 -0
  87. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_altivec.h +9 -0
  88. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx2.c +107 -0
  89. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx2.h +11 -0
  90. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx512.c +79 -0
  91. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_avx512.h +11 -0
  92. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_common.h +805 -0
  93. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_neon_aes.c +117 -0
  94. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_neon_aes.h +9 -0
  95. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_soft.c +99 -0
  96. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/aegis256x4_soft.h +9 -0
  97. data/ext/pq_crypto_seal/vendor/libaegis/src/aegis256x4/implementations.h +42 -0
  98. data/ext/pq_crypto_seal/vendor/libaegis/src/common/common.c +81 -0
  99. data/ext/pq_crypto_seal/vendor/libaegis/src/common/common.h +144 -0
  100. data/ext/pq_crypto_seal/vendor/libaegis/src/common/cpu.c +378 -0
  101. data/ext/pq_crypto_seal/vendor/libaegis/src/common/cpu.h +34 -0
  102. data/ext/pq_crypto_seal/vendor/libaegis/src/common/keccak.c +187 -0
  103. data/ext/pq_crypto_seal/vendor/libaegis/src/common/keccak.h +13 -0
  104. data/ext/pq_crypto_seal/vendor/libaegis/src/common/softaes.c +338 -0
  105. data/ext/pq_crypto_seal/vendor/libaegis/src/common/softaes.h +69 -0
  106. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis.h +77 -0
  107. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis128l.h +311 -0
  108. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis128x2.h +313 -0
  109. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis128x4.h +313 -0
  110. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis256.h +311 -0
  111. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis256x2.h +313 -0
  112. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis256x4.h +313 -0
  113. data/ext/pq_crypto_seal/vendor/libaegis/src/include/aegis_raf.h +580 -0
  114. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf.c +87 -0
  115. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128l.c +20 -0
  116. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128x2.c +20 -0
  117. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis128x4.c +20 -0
  118. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256.c +20 -0
  119. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256x2.c +20 -0
  120. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_aegis256x4.c +20 -0
  121. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_internal.h +152 -0
  122. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_merkle.c +268 -0
  123. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_merkle.h +165 -0
  124. data/ext/pq_crypto_seal/vendor/libaegis/src/raf/raf_variant.h +1119 -0
  125. data/fuzz/decrypt_fuzz.rb +119 -0
  126. data/lib/pq_crypto/seal/binary.rb +62 -0
  127. data/lib/pq_crypto/seal/core.rb +398 -0
  128. data/lib/pq_crypto/seal/errors.rb +15 -0
  129. data/lib/pq_crypto/seal/format.rb +275 -0
  130. data/lib/pq_crypto/seal/io.rb +336 -0
  131. data/lib/pq_crypto/seal/native.rb +13 -0
  132. data/lib/pq_crypto/seal/padding.rb +47 -0
  133. data/lib/pq_crypto/seal/version.rb +6 -0
  134. data/lib/pq_crypto/seal.rb +32 -0
  135. data/pq_crypto-seal.gemspec +37 -0
  136. data/script/vendor_libs.rb +79 -0
  137. data/test/aegis_vectors_test.rb +54 -0
  138. data/test/golden_vectors_test.rb +183 -0
  139. data/test/io_test.rb +125 -0
  140. data/test/native_test.rb +36 -0
  141. data/test/seal_test.rb +238 -0
  142. data/test/test_helper.rb +6 -0
  143. metadata +244 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9f0508e0149f076a9f8d6c9a829b516a41e2998dc5af916db9d0d4696349d36c
4
+ data.tar.gz: ce0dd7ba92b94ae212f22ea73cc9754b2028da7037a46539c5408dd60bae961b
5
+ SHA512:
6
+ metadata.gz: 248f9187168dd55054827e08dbfcf771555101e00f748cdcef55dbaed31ba4c4fb98ce6f6017c05c00bc5f78de23cfcd4c457541eda93cc2537be8be749711e6
7
+ data.tar.gz: b96cd381d599e6e9a52e0eba84de72500e6746e744183c5d383ed61d31305874393d640c8a1a79dbf288c7db1daeb1379ffc3c125443b5c0c4b2a39b14088402
@@ -0,0 +1,66 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ strategy:
10
+ fail-fast: false
11
+ matrix:
12
+ os: [ubuntu-24.04, macos-14]
13
+ ruby: ["2.7.1", "3.1", "3.3", "3.4", "4.0"]
14
+ exclude:
15
+ - os: macos-14
16
+ ruby: "2.7.1"
17
+ runs-on: ${{ matrix.os }}
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - if: runner.os == 'Linux'
21
+ run: sudo apt-get update && sudo apt-get install -y libssl-dev
22
+ - if: runner.os == 'macOS'
23
+ run: brew install openssl@3 || true
24
+ - uses: ruby/setup-ruby@v1
25
+ with:
26
+ ruby-version: ${{ matrix.ruby }}
27
+ bundler-cache: true
28
+ env:
29
+ PKG_CONFIG_PATH: /opt/homebrew/opt/openssl@3/lib/pkgconfig:/usr/local/opt/openssl@3/lib/pkgconfig
30
+ - run: bundle exec rake
31
+ - name: Verify OpenSSL 3 linkage on Linux Ruby 2.7.1
32
+ if: matrix.os == 'ubuntu-24.04' && matrix.ruby == '2.7.1'
33
+ shell: bash
34
+ run: |
35
+ set -euxo pipefail
36
+ extension="lib/pq_crypto/seal/pq_crypto_seal.so"
37
+ test -f "${extension}"
38
+ ldd "${extension}" | tee linked-libraries.txt
39
+ grep -E 'libcrypto\.so\.3([[:space:]]|$)' linked-libraries.txt
40
+ - name: Show mkmf logs on failure
41
+ if: failure()
42
+ run: find tmp -name mkmf.log -print -exec cat {} \; || true
43
+ - run: bundle exec ruby script/vendor_libs.rb --check
44
+
45
+ sanitize:
46
+ runs-on: ubuntu-24.04
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - run: sudo apt-get update && sudo apt-get install -y libssl-dev
50
+ - uses: ruby/setup-ruby@v1
51
+ with:
52
+ ruby-version: "3.4"
53
+ bundler-cache: true
54
+ - run: bundle exec rake clean
55
+ - run: PQC_SEAL_SANITIZE=1 bundle exec rake compile
56
+ - name: Show mkmf logs on compile failure
57
+ if: failure()
58
+ run: find tmp -name mkmf.log -print -exec cat {} \; || true
59
+ - name: Run sanitizer tests
60
+ env:
61
+ N: "0"
62
+ run: |
63
+ export LD_PRELOAD="$(gcc -print-file-name=libasan.so)"
64
+ export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1"
65
+ export UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1"
66
+ bundle exec rake test
data/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - AEGIS streaming state rejects `update`/`final` before `initialize` (`mode` guard).
6
+ - Encrypt path wipes the internal plaintext copy in `ensure` (caller buffers unchanged).
7
+ - CI: Actions pinned by commit SHA, `permissions: contents: read`, fuzz + gem-smoke jobs.
8
+ - Repository hygiene: `.DS_Store` / build artifacts ignored; no tracked binaries.
9
+
10
+ - Initial versioned `PQCSEAL1` envelope format.
11
+ - Multi-recipient DEK wrapping through `PQCrypto::HybridKEM` algorithm
12
+ `:ml_kem_768_x25519_xwing` (pinned; not `CANONICAL_ALGORITHM`).
13
+ - Runtime dependency pinned to `pq_crypto ~> 0.6.4` so suite ID 1 cannot drift
14
+ with a future 0.7+ line.
15
+ - Exact X-Wing size checks: public key 1216, ciphertext 1120, shared secret 32.
16
+ - HKDF-SHA256 key separation and AEGIS-256 payload/DEK encryption.
17
+ - Authenticated `padding_policy_id` in the immutable header (`none` / `padme` /
18
+ fixed / buckets).
19
+ - String, IO, and atomic file APIs with strict EOF/size defaults; framed helpers
20
+ `encrypt_frame_io` / `decrypt_frame_io` for embedded envelopes.
21
+ - Resource limits: `max_staging_bytes`, `max_plaintext_bytes`, `max_envelope_bytes`
22
+ with safe defaults against payload amplification.
23
+ - Full-envelope Padmé padding.
24
+ - Recipient-section rebuild and DEK rotation operations.
25
+ - Public API hygiene: internal helpers (`unwrap_dek`, `derive_kek`, …) are
26
+ private class methods.
27
+ - Malformed wire fields raise `FormatError` (not `InvalidConfigurationError`).
28
+ - `ResourceLimitError` for ceiling violations.
29
+ - Golden-vector suite pinning the X-Wing wire contract, the immutable-header
30
+ byte layout, the payload AD derivation, and byte-identical one-shot vs
31
+ incremental output under a deterministic RNG (`test/golden_vectors_test.rb`).
32
+ - Self-driving decrypt/parser fuzz job in CI (mutated and random envelopes),
33
+ failing on any error outside the documented classes.
34
+ - CI GitHub Actions pinned by commit SHA; workflow `permissions` reduced to
35
+ `contents: read`.
36
+ - Robust OpenSSL 3 selection for Homebrew Intel/Apple Silicon, `pkg-config`,
37
+ environment roots, and explicit extconf paths.
38
+ - Remove OpenSSL 1.1 include paths inherited from Ruby 2.7 and link the selected
39
+ OpenSSL 3 `libcrypto` by absolute path.
40
+ - Avoid unreliable OpenSSL link probes in legacy Ruby 2.7 `mkmf`; verify headers
41
+ at configure time and the linked runtime in the extension initializer.
42
+ - Disable Minitest's parallel executor only in the ASan job (`N=0`) to avoid a
43
+ known teardown conflict caused by preloading ASan into an otherwise unsanitized
44
+ Ruby executable.
data/FORMAT.md ADDED
@@ -0,0 +1,139 @@
1
+ # PQCSEAL1 wire format
2
+
3
+ All integers are unsigned big-endian. Parsers reject trailing bytes, unknown
4
+ flags, unsupported suites, noncanonical lengths, and arithmetic overflow before
5
+ allocating from untrusted lengths.
6
+
7
+ ## Immutable payload header
8
+
9
+ | Field | Size |
10
+ |---|---:|
11
+ | magic (`PQCSEAL1`) | 8 |
12
+ | format version (`1`) | 1 |
13
+ | header length | 4 |
14
+ | content suite ID (`1`) | 2 |
15
+ | lookup mode (`1`, scoped hint) | 1 |
16
+ | flags (`0`) | 2 |
17
+ | padding policy ID | 1 |
18
+ | payload ID | 32 |
19
+ | payload nonce | 32 |
20
+ | recipient capacity | 2 |
21
+ | slot size | 2 |
22
+ | padded inner length | 8 |
23
+ | public metadata length | 4 |
24
+ | public metadata | variable |
25
+
26
+ Padding policy IDs:
27
+
28
+ | ID | Meaning |
29
+ |---:|---|
30
+ | 0 | none |
31
+ | 1 | Padmé over the complete final envelope size |
32
+ | 2 | fixed target (`padding: { to: N }`) |
33
+ | 3 | application buckets (`padding: { buckets: [...] }`) |
34
+
35
+ The payload AEGIS associated data is exactly:
36
+
37
+ ```text
38
+ SHA-256(exact immutable payload header bytes)
39
+ ```
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.
44
+
45
+ ## Mutable recipient section
46
+
47
+ | Field | Size |
48
+ |---|---:|
49
+ | wrap suite ID (`1`) | 2 |
50
+ | random section ID | 32 |
51
+ | fixed slots | `recipient_capacity * slot_size` |
52
+
53
+ Every complete recipient-section rebuild creates a new random section ID and
54
+ recreates all real and dummy slots. Mixed wrapping suites are forbidden.
55
+
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:
59
+
60
+ | Value | Bytes |
61
+ |---|---:|
62
+ | public key | 1216 |
63
+ | KEM ciphertext | 1120 |
64
+ | shared secret | 32 |
65
+
66
+ ## Slot layout for wrap suite 1
67
+
68
+ | Field | Size |
69
+ |---|---:|
70
+ | recipient hint | 32 |
71
+ | KEM ciphertext | 1120 |
72
+ | wrap nonce | 32 |
73
+ | encrypted DEK | 32 |
74
+ | wrap tag | 32 |
75
+ | authenticated random slot padding | `slot_size - 1248` |
76
+
77
+ The hint is:
78
+
79
+ ```text
80
+ SHA-256(
81
+ "PQC-SEAL-V1-RECIPIENT-HINT\0" ||
82
+ payload_id || section_id || uint16(wrap_suite_id) || canonical_public_key
83
+ )
84
+ ```
85
+
86
+ The 32-byte KEK is derived with RFC 5869 HKDF-SHA256. Salt is the RFC 5869
87
+ default of 32 zero bytes (HashLen zeros). `info` is:
88
+
89
+ ```text
90
+ "PQC-SEAL-V1-WRAP-KEY\0" ||
91
+ payload_id || section_id || uint16(wrap_suite_id) || uint16(slot_index)
92
+ ```
93
+
94
+ The AEGIS AD for wrapping is:
95
+
96
+ ```text
97
+ SHA-256(
98
+ "PQC-SEAL-V1-WRAP-AD\0" ||
99
+ payload_header_hash || section_id || uint16(slot_index) ||
100
+ uint16(wrap_suite_id) || recipient_hint || slot_padding
101
+ )
102
+ ```
103
+
104
+ Dummy slots execute the same full KEM/HKDF/AEGIS pipeline against disposable
105
+ keypairs and random dummy DEKs. No recipient count is serialized.
106
+
107
+ ## Encrypted inner frame
108
+
109
+ | Field | Size |
110
+ |---|---:|
111
+ | inner version (`1`) | 1 |
112
+ | inner flags (`0`) | 1 |
113
+ | real content length | 8 |
114
+ | private metadata length | 4 |
115
+ | private metadata | variable |
116
+ | content | variable |
117
+ | random encrypted padding | variable |
118
+
119
+ The final field following the encrypted inner frame is the 32-byte payload tag.
120
+ The padding amount is chosen so the size of the complete final envelope reaches
121
+ the configured Padmé/fixed/bucket target.
122
+
123
+ 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
+
127
+ ## Defaults
128
+
129
+ ```text
130
+ slot_size: 2048 bytes (configurable 2048..8192, multiple of 256)
131
+ recipient_capacity: 4 (maximum 32; never derived automatically from ACL size)
132
+ padding: Padmé over the complete final envelope size
133
+ max_staging_bytes: ~1 GiB + private-metadata ceiling
134
+ max_plaintext_bytes:~1 GiB
135
+ ```
136
+
137
+ Parsers reject trailing bytes on the one-shot and default IO paths. Framed
138
+ stream consumers that intentionally embed one envelope inside a larger stream
139
+ must use `encrypt_frame_io` / `decrypt_frame_io`.
data/GET_STARTED.md ADDED
@@ -0,0 +1,30 @@
1
+ # Getting started
2
+
3
+ ## Build prerequisites
4
+
5
+ Ruby 2.7.1+ and OpenSSL 3.0+ development files are required. On macOS the
6
+ build automatically searches Homebrew `openssl@3`. For a custom installation:
7
+
8
+ ```bash
9
+ bundle config set --local build.pq_crypto-seal \
10
+ "--with-openssl-dir=$(brew --prefix openssl@3)"
11
+ bundle exec rake clean compile
12
+ ```
13
+
14
+ ```ruby
15
+ require "pq_crypto/seal"
16
+
17
+ recipient = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
18
+ envelope = PQCrypto::Seal.encrypt("secret bytes", to: recipient.public_key)
19
+ plaintext = PQCrypto::Seal.decrypt(envelope, with: recipient)
20
+ ```
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.
26
+
27
+ 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.
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ if ENV["PQ_CRYPTO_PATH"]
4
+ gem "pq_crypto", path: ENV.fetch("PQ_CRYPTO_PATH")
5
+ end
6
+
7
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Haydarov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # pq_crypto-seal
2
+
3
+ Post-quantum envelope encryption for Ruby 2.7.1+.
4
+
5
+ The gem turns `pq_crypto`'s hybrid KEM into practical document encryption:
6
+
7
+ ```text
8
+ MLKEM768-X25519 shared secret → HKDF-SHA256 → KEK → wrap random DEK
9
+ random DEK → AEGIS-256 → document
10
+ ```
11
+
12
+ `libaegis` 0.10.3 is vendored and compiled into the extension. The existing
13
+ `pq_crypto` gem supplies the hybrid KEM and OpenSSL-backed primitives.
14
+
15
+ > **Status:** 0.1.0 is experimental cryptographic software; format v1; not
16
+ > independently audited. Read `SECURITY.md` before using it for irreplaceable data.
17
+ >
18
+ > `wrap_suite_id = 1` is pinned to `:ml_kem_768_x25519_xwing` and `pq_crypto ~> 0.6.4`.
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ gem "pq_crypto", "~> 0.6.4"
24
+ gem "pq_crypto-seal", "~> 0.1.0"
25
+ ```
26
+
27
+ Ruby `>= 2.7.1` and OpenSSL `>= 3.0` development files are required for a
28
+ source build.
29
+
30
+ On macOS, `extconf.rb` automatically searches Homebrew `openssl@3` on both
31
+ Apple Silicon and Intel installations. If OpenSSL 3 is installed in a custom
32
+ location, configure it explicitly:
33
+
34
+ ```bash
35
+ brew install openssl@3
36
+ bundle config set --local build.pq_crypto-seal \
37
+ "--with-openssl-dir=$(brew --prefix openssl@3)"
38
+ bundle exec rake clean compile
39
+ ```
40
+
41
+ `OPENSSL_ROOT_DIR` and `OPENSSL_DIR` are also supported. An explicit
42
+ `--with-openssl-dir`, `--with-openssl-include`, or `--with-openssl-lib` always
43
+ takes precedence over automatic discovery.
44
+
45
+ ## String API
46
+
47
+ ```ruby
48
+ require "pq_crypto/seal"
49
+
50
+ alice = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
51
+ bob = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
52
+
53
+ sealed = PQCrypto::Seal.encrypt(
54
+ image_bytes,
55
+ to: [alice.public_key, bob.public_key],
56
+ metadata: { mime: "image/png" }.to_json,
57
+ public_metadata: "tenant-7",
58
+ recipient_capacity: 4,
59
+ slot_size: 2048,
60
+ padding: :padme
61
+ )
62
+
63
+ opened = PQCrypto::Seal.open(sealed, with: alice) # keypair supplies secret + public hint material
64
+ opened.data
65
+ opened.metadata
66
+ opened.public_metadata
67
+
68
+ PQCrypto::Seal.decrypt(sealed, with: bob)
69
+ ```
70
+
71
+ All random keys and nonces are generated internally. There is no public API for
72
+ supplying a DEK or nonce. Because scoped hint lookup needs the matching public
73
+ key, `with:` accepts a `HybridKEM::Keypair`. When keys are stored separately:
74
+
75
+ ```ruby
76
+ credentials = PQCrypto::Seal.credentials(
77
+ secret_key: loaded_secret_key,
78
+ public_key: loaded_public_key
79
+ )
80
+ PQCrypto::Seal.decrypt(sealed, with: credentials)
81
+ ```
82
+
83
+ ## Files and IO
84
+
85
+ ```ruby
86
+ PQCrypto::Seal.encrypt_file(
87
+ "scan.tiff",
88
+ "scan.tiff.pqcseal",
89
+ to: alice.public_key
90
+ )
91
+
92
+ PQCrypto::Seal.decrypt_file(
93
+ "scan.tiff.pqcseal",
94
+ "restored.tiff",
95
+ with: alice
96
+ )
97
+ ```
98
+
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.
102
+
103
+ ## Recipients and key lifecycle
104
+
105
+ 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
+
109
+ ```ruby
110
+ updated = PQCrypto::Seal.rebuild_recipients(
111
+ sealed,
112
+ with: alice,
113
+ recipients: [alice.public_key, carol.public_key]
114
+ )
115
+ ```
116
+
117
+ `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
+
122
+ ```ruby
123
+ rotated = PQCrypto::Seal.rotate_dek(
124
+ sealed,
125
+ with: alice,
126
+ recipients: [alice.public_key, carol.public_key]
127
+ ) # preserves the existing envelope size by default
128
+ ```
129
+
130
+ `rebuild_recipients` preserves the DEK and encrypted payload. It is an
131
+ 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
133
+ still cannot erase knowledge from prior copies. Rotation preserves the current
134
+ final envelope size by default (`padding: :preserve`); pass `:padme`, `:none`, or
135
+ an explicit padding policy to recalculate it.
136
+
137
+ ## Wire-format v1
138
+
139
+ The exact bytes are implemented in `PQCrypto::Seal::Format`:
140
+
141
+ ```text
142
+ immutable payload header
143
+ magic "PQCSEAL1"
144
+ version
145
+ content_suite_id = AEGIS-256 payload profile
146
+ lookup_mode = payload-scoped recipient hint
147
+ payload_id, payload_nonce
148
+ recipient_capacity, slot_size
149
+ padded_inner_length
150
+ public metadata
151
+
152
+ mutable recipient section
153
+ wrap_suite_id = MLKEM768-X25519 + HKDF-SHA256 + AEGIS-256 wrap
154
+ random section_id (changes on every complete rebuild)
155
+ fixed-capacity, fixed-size slots
156
+
157
+ slot
158
+ recipient_hint
159
+ 1120-byte hybrid KEM ciphertext
160
+ wrap nonce
161
+ wrapped 32-byte DEK
162
+ 32-byte tag
163
+ authenticated random slot padding
164
+
165
+ AEGIS-encrypted inner frame
166
+ authenticated content length and private metadata length
167
+ private metadata
168
+ content
169
+ encrypted Padmé padding
170
+
171
+ 32-byte payload tag
172
+ ```
173
+
174
+ `content_suite_id` and `wrap_suite_id` are deliberately independent. A recipient
175
+ section can be rebuilt without changing payload AD as long as the new wrapping
176
+ stanza fits the immutable `slot_size`.
177
+
178
+ Defaults:
179
+
180
+ ```text
181
+ slot_size: 2048 bytes (configurable 2048..8192, multiple of 256)
182
+ recipient_capacity: 4 (maximum 32; never derived automatically from ACL size)
183
+ padding: Padmé over the complete final envelope size
184
+ ```
185
+
186
+ The payload-and-section-scoped recipient hint avoids a stable global recipient identifier and prevents stable hints from revealing real slots across recipient-section rebuilds.
187
+ Anyone who already knows a candidate public key can test for its presence. No
188
+ formal recipient anonymity is claimed.
189
+
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.
193
+
194
+ ## Envelope identity
195
+
196
+ `PQCrypto::Seal.digest(envelope)` is SHA-256 over the **complete** envelope
197
+ bytes. The recipient section is intentionally mutable, so
198
+ `rebuild_recipients` / `rotate_dek` change the digest even when the encrypted
199
+ payload is unchanged. Use `opened.payload_id` (or `inspect_envelope`) for a
200
+ stable document identifier.
201
+
202
+ High `recipient_capacity` makes encryption slower: every empty slot still runs
203
+ a full X-Wing encapsulation against a disposable keypair.
204
+
205
+ ## Tests
206
+
207
+ ```bash
208
+ bundle install
209
+ bundle exec rake
210
+ ```
211
+
212
+ Set `PQC_SEAL_SANITIZE=1` when compiling under ASan/UBSan. The suite covers
213
+ AEGIS one-shot/incremental equivalence, tampering, multi-recipient opening,
214
+ full-envelope padding, recipient rebuilds, staged file publication, and parser
215
+ limits.
data/RELEASING.md ADDED
@@ -0,0 +1,34 @@
1
+ # Releasing
2
+
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`
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
9
+ 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.
13
+
14
+ Vendored libaegis sources are pinned by archive SHA-256 and a deterministic
15
+ source-tree digest (`vendor/libaegis/TREE_SHA256`). The full upstream snapshot
16
+ ships intact — including families and the RAF sources that this gem does not
17
+ compile — so `script/vendor_libs.rb --check` can prove the vendored tree is
18
+ byte-identical to the audited archive. Never delete vendored files to slim the
19
+ gem; that would break the provenance guarantee. Never update the snapshot
20
+ without rerunning official KATs, cross-backend equivalence checks, envelope
21
+ golden vectors, and fuzz tests.
22
+
23
+ The golden vectors fix every value this gem controls (recipient keys, DEK,
24
+ nonces, section id, padding bytes, metadata, plaintext) and assert the derived
25
+ header bytes, associated data, and one-shot/incremental equivalence. A fully
26
+ frozen "plaintext to exact bytes" vector is intentionally not asserted for the
27
+ KEM ciphertext region, because X-Wing encapsulation randomness originates inside
28
+ `pq_crypto` and is not injectable here; the wire-contract check pins its exact
29
+ sizes and round-trip instead.
30
+
31
+ The ASan job sets `detect_leaks=0` because ASan is preloaded into an otherwise
32
+ unsanitized Ruby interpreter; host-process allocator noise would otherwise fail
33
+ the job. Memory-safety errors still abort via `halt_on_error`.
34
+
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+ require "rake/extensiontask"
5
+ require_relative "lib/pq_crypto/seal/version"
6
+
7
+ Rake::ExtensionTask.new("pq_crypto_seal", Gem::Specification.load("pq_crypto-seal.gemspec")) do |ext|
8
+ ext.lib_dir = "lib/pq_crypto/seal"
9
+ end
10
+
11
+ Rake::TestTask.new do |task|
12
+ task.libs << "lib"
13
+ task.libs << "test"
14
+ task.pattern = "test/**/*_test.rb"
15
+ task.warning = false
16
+ end
17
+
18
+ task test: :compile
19
+ task default: :test
data/SECURITY.md ADDED
@@ -0,0 +1,85 @@
1
+ # Security
2
+
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.
5
+
6
+ Experimental cryptographic software; format v1; not independently audited.
7
+
8
+ ## Threat model
9
+
10
+ The v1 format protects confidentiality and integrity of the **payload** when an
11
+ attacker obtains a passive copy of envelopes but not a recipient secret key.
12
+
13
+ Successful opening authenticates:
14
+
15
+ - the immutable payload header
16
+ - the selected recipient stanza that yielded the DEK
17
+ - the encrypted inner frame (private metadata + content + encrypted padding)
18
+
19
+ Successful opening does **not** authenticate:
20
+
21
+ - the integrity of other recipients' stanzas
22
+ - the completeness or correctness of the application ACL
23
+ - absence of rollback against a replaceable canonical envelope
24
+
25
+ It does not claim recipient anonymity. Anyone who already knows a candidate
26
+ public key can test for its presence via the scoped recipient hint.
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.
32
+
33
+ ## Side channels
34
+
35
+ Recipient lookup short-circuits slots whose hint does not match. The number of
36
+ full KEM decapsulations therefore depends on how many slots share the expected
37
+ hint. Against a purely passive adversary holding only the envelope bytes this is
38
+ out of scope. Local timing, cache, or power side channels on a machine that
39
+ already holds a recipient secret key are not mitigated. `AmbiguousRecipientStanzas`
40
+ is raised when more than one slot opens successfully.
41
+
42
+ Caller-owned plaintext and metadata buffers are not wiped by the encrypt path;
43
+ callers that need residual-data hygiene must clear their own buffers.
44
+ `Opened#data` / `Opened#metadata` returned from `open` are the caller's
45
+ responsibility after use.
46
+
47
+ ## Envelope digests
48
+
49
+ `Seal.digest` hashes the complete envelope, including the mutable recipient
50
+ section. Prefer `payload_id` (exposed on `Opened` and `Inspection`) when a
51
+ stable document identifier is required.
52
+
53
+ ## Resource limits
54
+
55
+ Untrusted envelopes declare `padded_inner_length` as a uint64. Decrypt APIs
56
+ enforce default ceilings (`max_staging_bytes`, `max_plaintext_bytes`,
57
+ `max_envelope_bytes`) so a sender who knows a recipient public key cannot force
58
+ unbounded staging or disk amplification before the final AEGIS tag fails.
59
+
60
+ - `max_staging_bytes` / `max_envelope_bytes` are checked **before** AEAD
61
+ verification (pre-auth).
62
+ - `max_plaintext_bytes` is enforced **after** the payload tag verifies
63
+ (post-auth), because the real content length is authenticated inside the
64
+ inner frame.
65
+
66
+ Applications may raise the limits explicitly when larger objects are required.
67
+
68
+ ## Staging
69
+
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.
75
+
76
+ ## Wire suite pinning
77
+
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
83
+ `PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)`.
84
+
85
+ Report vulnerabilities privately to the repository owner.