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
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decrypt/parse fuzzer for pq_crypto-seal.
4
+ #
5
+ # Two modes:
6
+ # * AFL harness : reads one input from stdin (when AFL is available).
7
+ # * CI self-fuzz : `--ci --iterations N` generates valid envelopes and feeds
8
+ # random mutations of them, plus fully random inputs, through
9
+ # the public decrypt path. Any exception outside the
10
+ # documented error classes fails the run.
11
+
12
+ require "pq_crypto/seal"
13
+ require "stringio"
14
+
15
+ EXPECTED = [
16
+ PQCrypto::Seal::FormatError,
17
+ PQCrypto::Seal::AuthenticationError,
18
+ PQCrypto::Seal::UnsupportedSuiteError,
19
+ PQCrypto::Seal::RecipientNotFoundError,
20
+ PQCrypto::Seal::AmbiguousRecipientStanzas,
21
+ PQCrypto::Seal::InvalidConfigurationError,
22
+ PQCrypto::Seal::ResourceLimitError,
23
+ PQCrypto::Seal::RecipientCapacityExceeded,
24
+ PQCrypto::Error,
25
+ ArgumentError,
26
+ EOFError,
27
+ RangeError
28
+ ].freeze
29
+
30
+ KEYPAIR = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
31
+ STRANGER = PQCrypto::HybridKEM.generate(PQCrypto::Seal::WRAP_KEM_ALGORITHM)
32
+
33
+ def decrypt_attempt(input, keypair)
34
+ PQCrypto::Seal.decrypt(input.b, with: keypair)
35
+ rescue *EXPECTED
36
+ nil
37
+ end
38
+
39
+ def strict_decrypt(input, keypair)
40
+ PQCrypto::Seal.decrypt(input.b, with: keypair)
41
+ :opened
42
+ rescue *EXPECTED
43
+ :rejected
44
+ rescue StandardError, SystemStackError => e
45
+ warn "UNEXPECTED #{e.class}: #{e.message}"
46
+ warn e.backtrace.first(8).join("\n")
47
+ raise
48
+ end
49
+
50
+ def sample_valid_envelope
51
+ data = Random.bytes(rand(0..4096))
52
+ meta = Random.bytes(rand(0..64))
53
+ PQCrypto::Seal.encrypt(
54
+ data, to: KEYPAIR.public_key, metadata: meta,
55
+ padding: [:none, :padme].sample
56
+ )
57
+ end
58
+
59
+ def mutate(bytes)
60
+ copy = bytes.dup
61
+ return copy if copy.empty?
62
+
63
+ case rand(6)
64
+ when 0
65
+ rand(1..8).times { copy.setbyte(rand(copy.bytesize), rand(256)) }
66
+ when 1
67
+ copy = copy.byteslice(0, rand(0...copy.bytesize)).to_s.b
68
+ when 2
69
+ copy << Random.bytes(rand(1..64))
70
+ when 3
71
+ start = rand(copy.bytesize)
72
+ len = rand(1..[32, copy.bytesize - start].min)
73
+ len.times { |i| copy.setbyte(start + i, 0) }
74
+ when 4
75
+ start = rand(copy.bytesize)
76
+ len = rand(1..[64, copy.bytesize - start].min)
77
+ copy << copy.byteslice(start, len)
78
+ else
79
+ i = rand(copy.bytesize)
80
+ copy.setbyte(i, (copy.getbyte(i) + 1) & 0xff)
81
+ end
82
+ copy.b
83
+ end
84
+
85
+ def run_ci(iterations)
86
+ opened = 0
87
+ rejected = 0
88
+ base = sample_valid_envelope
89
+
90
+ iterations.times do |i|
91
+ base = sample_valid_envelope if (i % 500).zero?
92
+
93
+ candidate =
94
+ case rand(3)
95
+ when 0 then Random.bytes(rand(0..4096))
96
+ when 1 then mutate(base)
97
+ else base.dup
98
+ end
99
+
100
+ result = strict_decrypt(candidate, KEYPAIR)
101
+ strict_decrypt(candidate, STRANGER)
102
+
103
+ result == :opened ? opened += 1 : rejected += 1
104
+ end
105
+
106
+ raise "fuzzer never opened a valid envelope" if opened.zero?
107
+
108
+ puts "fuzz complete: #{iterations} iterations, #{opened} opened, #{rejected} rejected"
109
+ end
110
+
111
+ if ARGV.include?("--ci")
112
+ idx = ARGV.index("--iterations")
113
+ iterations = idx ? Integer(ARGV[idx + 1]) : 10_000
114
+ run_ci(iterations)
115
+ elsif defined?(AFL)
116
+ AFL.loop { decrypt_attempt($stdin.read, KEYPAIR) }
117
+ else
118
+ decrypt_attempt($stdin.read, KEYPAIR)
119
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PQCrypto
4
+ module Seal
5
+ module Binary
6
+ module_function
7
+
8
+ def u8(value)
9
+ [integer!(value, 0xff)].pack("C")
10
+ end
11
+
12
+ def u16(value)
13
+ [integer!(value, 0xffff)].pack("n")
14
+ end
15
+
16
+ def u32(value)
17
+ [integer!(value, 0xffff_ffff)].pack("N")
18
+ end
19
+
20
+ def u64(value)
21
+ [integer!(value, 0xffff_ffff_ffff_ffff)].pack("Q>")
22
+ end
23
+
24
+ def read_u8(bytes, offset)
25
+ ensure_available!(bytes, offset, 1)
26
+ [bytes.getbyte(offset), offset + 1]
27
+ end
28
+
29
+ def read_u16(bytes, offset)
30
+ ensure_available!(bytes, offset, 2)
31
+ [bytes.byteslice(offset, 2).unpack1("n"), offset + 2]
32
+ end
33
+
34
+ def read_u32(bytes, offset)
35
+ ensure_available!(bytes, offset, 4)
36
+ [bytes.byteslice(offset, 4).unpack1("N"), offset + 4]
37
+ end
38
+
39
+ def read_u64(bytes, offset)
40
+ ensure_available!(bytes, offset, 8)
41
+ [bytes.byteslice(offset, 8).unpack1("Q>"), offset + 8]
42
+ end
43
+
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
49
+
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
54
+
55
+ def integer!(value, max)
56
+ number = Integer(value)
57
+ raise RangeError, "integer is out of range" if number.negative? || number > max
58
+ number
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PQCrypto
4
+ module Seal
5
+ Opened = Struct.new(:data, :metadata, :public_metadata, :payload_id, :content_suite_id, :wrap_suite_id,
6
+ :padding_policy_id, keyword_init: true) do
7
+ def self.from_header(header, section, data:, metadata:)
8
+ new(
9
+ data: data, metadata: metadata, public_metadata: header.public_metadata,
10
+ payload_id: header.payload_id, content_suite_id: header.content_suite_id,
11
+ wrap_suite_id: section.wrap_suite_id, padding_policy_id: header.padding_policy_id
12
+ )
13
+ end
14
+ end
15
+
16
+ Inspection = Struct.new(:payload_id, :public_metadata, :recipient_capacity, :slot_size,
17
+ :padded_inner_length, :content_suite_id, :wrap_suite_id,
18
+ :padding_policy_id, :envelope_bytes, keyword_init: true) do
19
+ def self.from_header(header, section, envelope_bytes:)
20
+ new(
21
+ payload_id: header.payload_id, public_metadata: header.public_metadata,
22
+ recipient_capacity: header.recipient_capacity, slot_size: header.slot_size,
23
+ padded_inner_length: header.padded_inner_length,
24
+ content_suite_id: header.content_suite_id, wrap_suite_id: section.wrap_suite_id,
25
+ padding_policy_id: header.padding_policy_id, envelope_bytes: envelope_bytes
26
+ )
27
+ end
28
+ end
29
+ Credentials = Struct.new(:secret_key, :public_key, keyword_init: true)
30
+
31
+ WRAP_KEM_ALGORITHM = :ml_kem_768_x25519_xwing
32
+ HINT_DOMAIN = "PQC-SEAL-V1-RECIPIENT-HINT\0".b
33
+ WRAP_KEY_DOMAIN = "PQC-SEAL-V1-WRAP-KEY\0".b
34
+ WRAP_AD_DOMAIN = "PQC-SEAL-V1-WRAP-AD\0".b
35
+
36
+ module_function
37
+
38
+ def credentials(secret_key:, public_key:)
39
+ validate_secret_key!(secret_key)
40
+ normalize_public_keys(public_key)
41
+ Credentials.new(secret_key: secret_key, public_key: public_key).freeze
42
+ end
43
+
44
+ def encrypt(data, to:, metadata: "".b, public_metadata: "".b,
45
+ recipient_capacity: Format::DEFAULT_RECIPIENT_CAPACITY,
46
+ slot_size: Format::DEFAULT_SLOT_SIZE, padding: :padme)
47
+ data = String(data).b
48
+ metadata = String(metadata).b
49
+ validate_private_metadata!(metadata)
50
+ recipients = normalize_public_keys(to)
51
+ capacity = Format.validate_capacity!(recipient_capacity, recipients.length)
52
+ slot_size = Format.validate_slot_size!(slot_size)
53
+
54
+ parts = nil
55
+ pad_bytes = nil
56
+ inner = nil
57
+ parts = materialize_crypto_parts(
58
+ recipients: recipients, capacity: capacity, slot_size: slot_size,
59
+ padding: padding, public_metadata: public_metadata,
60
+ content_size: data.bytesize, metadata_size: metadata.bytesize
61
+ )
62
+ pad_bytes = Native.random_bytes(parts[:padded_inner_length] - parts[:raw_inner_length])
63
+ inner = parts[:inner_prefix] + metadata + data + pad_bytes
64
+ ciphertext, tag = Native.aegis256_encrypt(parts[:dek], parts[:payload_nonce], parts[:header_hash], inner)
65
+ parts[:header] + parts[:section] + ciphertext + tag
66
+ ensure
67
+ wipe_string!(parts[:dek]) if parts
68
+ wipe_string!(inner)
69
+ wipe_string!(pad_bytes)
70
+ wipe_string!(metadata) if metadata.is_a?(String) && !metadata.frozen?
71
+ wipe_string!(data) if data.is_a?(String) && !data.frozen?
72
+ end
73
+
74
+ def decrypt(envelope, with:, **limits)
75
+ open(envelope, with: with, **limits).data
76
+ end
77
+
78
+ def open(envelope, with:, **limits)
79
+ limits = Format::LIMIT_DEFAULTS.merge(limits)
80
+ bytes = String(envelope).b
81
+ Format.check_resource_limits!(
82
+ padded_inner_length: 0, envelope_bytes: bytes.bytesize, **Format.pre_auth_limits(limits)
83
+ )
84
+ header, section, ciphertext, tag = parse_envelope(bytes)
85
+ Format.check_resource_limits!(
86
+ padded_inner_length: header.padded_inner_length,
87
+ envelope_bytes: bytes.bytesize, **Format.pre_auth_limits(limits)
88
+ )
89
+ dek = unwrap_dek(header, section, with)
90
+ header_hash = Native.sha256(header.raw)
91
+ inner = Native.aegis256_decrypt(dek, header.payload_nonce, header_hash, ciphertext, tag)
92
+ content, metadata, = Format.parse_verified_inner(inner)
93
+ enforce_plaintext_limit!(content, metadata, limits[:max_plaintext_bytes])
94
+ Opened.from_header(header, section, data: content, metadata: metadata)
95
+ ensure
96
+ wipe_string!(dek) if defined?(dek)
97
+ wipe_string!(inner) if defined?(inner)
98
+ end
99
+
100
+ def inspect_envelope(envelope)
101
+ bytes = String(envelope).b
102
+ header, section, = parse_envelope(bytes)
103
+ Inspection.from_header(header, section, envelope_bytes: bytes.bytesize)
104
+ end
105
+
106
+ def digest(envelope)
107
+ Native.sha256(String(envelope).b)
108
+ end
109
+
110
+ def rebuild_recipients(envelope, with:, recipients:)
111
+ bytes = String(envelope).b
112
+ header, section, ciphertext, tag = parse_envelope(bytes)
113
+ dek = unwrap_dek(header, section, with)
114
+ verify_payload!(header, ciphertext, tag, dek)
115
+ public_keys = normalize_public_keys(recipients)
116
+ Format.validate_capacity!(header.recipient_capacity, public_keys.length)
117
+ new_section = build_recipient_section(
118
+ recipients: public_keys, capacity: header.recipient_capacity,
119
+ slot_size: header.slot_size, payload_id: header.payload_id,
120
+ header_hash: Native.sha256(header.raw), dek: dek,
121
+ wrap_suite_id: Format::WRAP_SUITE_MLKEM768_X25519_AEGIS256
122
+ )
123
+ header.raw + new_section + ciphertext + tag
124
+ ensure
125
+ wipe_string!(dek) if defined?(dek)
126
+ end
127
+
128
+ def add_recipient(envelope, with:, recipient:, current_recipients:)
129
+ rebuild_recipients(envelope, with: with, recipients: Array(current_recipients) + [recipient])
130
+ end
131
+
132
+ def drop_recipient_stanza(envelope, with:, remaining_recipients:)
133
+ rebuild_recipients(envelope, with: with, recipients: remaining_recipients)
134
+ end
135
+
136
+ def rotate_dek(envelope, with:, recipients:, padding: :preserve)
137
+ bytes = String(envelope).b
138
+ header, section, ciphertext, tag = parse_envelope(bytes)
139
+ dek = unwrap_dek(header, section, with)
140
+ header_hash = Native.sha256(header.raw)
141
+ inner = Native.aegis256_decrypt(dek, header.payload_nonce, header_hash, ciphertext, tag)
142
+ content, metadata, = Format.parse_verified_inner(inner)
143
+ padding = { to: bytes.bytesize } if padding == :preserve
144
+ encrypt(
145
+ content, to: recipients, metadata: metadata,
146
+ public_metadata: header.public_metadata,
147
+ recipient_capacity: header.recipient_capacity,
148
+ slot_size: header.slot_size, padding: padding
149
+ )
150
+ ensure
151
+ wipe_string!(dek) if defined?(dek)
152
+ wipe_string!(inner) if defined?(inner)
153
+ wipe_string!(content) if defined?(content)
154
+ wipe_string!(metadata) if defined?(metadata)
155
+ end
156
+
157
+ def materialize_crypto_parts(recipients:, capacity:, slot_size:, padding:, public_metadata:,
158
+ content_size:, metadata_size:)
159
+ padding_policy_id = Format.padding_policy_id_for(padding)
160
+ payload_id = Native.random_bytes(Format::PAYLOAD_ID_BYTES)
161
+ payload_nonce = Native.random_bytes(Format::NONCE_BYTES)
162
+ dek = Native.random_bytes(Format::DEK_BYTES)
163
+ inner_prefix = Format.inner_prefix(content_size, metadata_size)
164
+ raw_inner_length = inner_prefix.bytesize + metadata_size + content_size
165
+
166
+ placeholder = Format.build_header(
167
+ payload_id: payload_id, payload_nonce: payload_nonce,
168
+ recipient_capacity: capacity, slot_size: slot_size,
169
+ padded_inner_length: raw_inner_length, public_metadata: public_metadata,
170
+ padding_policy_id: padding_policy_id
171
+ )
172
+ fixed = placeholder.bytesize + Format.section_length_for(capacity, slot_size) + Format::TAG_BYTES
173
+ target = Padding.target(fixed + raw_inner_length, padding)
174
+ padded_inner_length = target - fixed
175
+ raise InvalidConfigurationError, "invalid padding target" if padded_inner_length < raw_inner_length
176
+
177
+ header = Format.build_header(
178
+ payload_id: payload_id, payload_nonce: payload_nonce,
179
+ recipient_capacity: capacity, slot_size: slot_size,
180
+ padded_inner_length: padded_inner_length, public_metadata: public_metadata,
181
+ padding_policy_id: padding_policy_id
182
+ )
183
+ header_hash = Native.sha256(header)
184
+ section = build_recipient_section(
185
+ recipients: recipients, capacity: capacity, slot_size: slot_size,
186
+ payload_id: payload_id, header_hash: header_hash, dek: dek,
187
+ wrap_suite_id: Format::WRAP_SUITE_MLKEM768_X25519_AEGIS256
188
+ )
189
+ {
190
+ header: header, section: section, dek: dek, payload_id: payload_id,
191
+ payload_nonce: payload_nonce, header_hash: header_hash,
192
+ inner_prefix: inner_prefix, raw_inner_length: raw_inner_length,
193
+ padded_inner_length: padded_inner_length
194
+ }
195
+ end
196
+
197
+ def enforce_plaintext_limit!(content, metadata, max_plaintext_bytes)
198
+ return if content.bytesize <= Integer(max_plaintext_bytes)
199
+ wipe_string!(content)
200
+ wipe_string!(metadata)
201
+ raise ResourceLimitError,
202
+ "plaintext #{content.bytesize} exceeds max_plaintext_bytes #{max_plaintext_bytes}"
203
+ end
204
+
205
+ def verify_payload!(header, ciphertext, tag, dek)
206
+ verified = Native.aegis256_decrypt(
207
+ dek, header.payload_nonce, Native.sha256(header.raw), ciphertext, tag
208
+ )
209
+ true
210
+ ensure
211
+ wipe_string!(verified) if defined?(verified)
212
+ end
213
+
214
+ def parse_envelope(bytes)
215
+ header = Format.parse_header(bytes)
216
+ section_offset = header.raw.bytesize
217
+ section = Format.parse_section(bytes, section_offset, header)
218
+ payload_offset = section_offset + section.raw.bytesize
219
+ expected = payload_offset + header.padded_inner_length + Format::TAG_BYTES
220
+ raise FormatError, "envelope length does not match header" unless bytes.bytesize == expected
221
+ ciphertext = bytes.byteslice(payload_offset, header.padded_inner_length).b
222
+ tag = bytes.byteslice(payload_offset + header.padded_inner_length, Format::TAG_BYTES).b
223
+ [header, section, ciphertext, tag]
224
+ end
225
+
226
+ def build_recipient_section(recipients:, capacity:, slot_size:, payload_id:, header_hash:, dek:, wrap_suite_id:)
227
+ section_id = Native.random_bytes(Format::SECTION_ID_BYTES)
228
+ slots = recipients.each_with_index.map do |public_key, index|
229
+ build_slot(public_key, index, slot_size, payload_id, section_id, header_hash, dek, wrap_suite_id)
230
+ end
231
+ (slots.length...capacity).each do |index|
232
+ dummy_pair = nil
233
+ dummy_dek = nil
234
+ begin
235
+ dummy_pair = PQCrypto::HybridKEM.generate(WRAP_KEM_ALGORITHM)
236
+ dummy_dek = Native.random_bytes(Format::DEK_BYTES)
237
+ slots << build_slot(dummy_pair.public_key, index, slot_size, payload_id, section_id, header_hash, dummy_dek, wrap_suite_id)
238
+ ensure
239
+ dummy_pair.secret_key.wipe! if dummy_pair && dummy_pair.secret_key.respond_to?(:wipe!)
240
+ wipe_string!(dummy_dek)
241
+ end
242
+ end
243
+ Binary.u16(wrap_suite_id) + section_id + slots.join
244
+ end
245
+
246
+ def build_slot(public_key, index, slot_size, payload_id, section_id, header_hash, dek, wrap_suite_id)
247
+ assert_xwing_public_key!(public_key)
248
+ hint = recipient_hint(public_key, payload_id, section_id, wrap_suite_id)
249
+ encapsulated = public_key.encapsulate
250
+ assert_xwing_encapsulation!(encapsulated)
251
+ wrap_nonce = Native.random_bytes(Format::NONCE_BYTES)
252
+ slot_padding = Native.random_bytes(slot_size - Format::STANZA_BYTES)
253
+ slot_ad = wrap_ad(header_hash, section_id, index, wrap_suite_id, hint, slot_padding)
254
+ kek = derive_kek(encapsulated.shared_secret, payload_id, section_id, wrap_suite_id, index)
255
+ wrapped, tag = Native.aegis256_encrypt(kek, wrap_nonce, slot_ad, dek)
256
+ hint + encapsulated.ciphertext + wrap_nonce + wrapped + tag + slot_padding
257
+ ensure
258
+ wipe_string!(kek) if defined?(kek)
259
+ wipe_string!(encapsulated.shared_secret) if defined?(encapsulated) && encapsulated
260
+ end
261
+
262
+ def unwrap_dek(header, section, secret_key)
263
+ secret_key, public_key = normalize_credentials(secret_key)
264
+ assert_xwing_public_key!(public_key)
265
+ expected_hint = recipient_hint(public_key, header.payload_id, section.section_id, section.wrap_suite_id)
266
+ header_hash = Native.sha256(header.raw)
267
+ successes = []
268
+
269
+ header.recipient_capacity.times do |index|
270
+ slot = section.raw.byteslice(section.slots_offset + index * header.slot_size, header.slot_size)
271
+ hint = slot.byteslice(0, Format::HINT_BYTES)
272
+ next unless secure_equal?(hint, expected_hint)
273
+
274
+ begin
275
+ fields = Format.split_slot(slot, header.slot_size)
276
+ slot_ad = wrap_ad(header_hash, section.section_id, index, section.wrap_suite_id, hint, fields[:padding])
277
+ shared = secret_key.decapsulate(fields[:kem_ciphertext])
278
+ assert_size!("X-Wing shared secret", shared.bytesize, Format::XWING_SHARED_SECRET_BYTES, FormatError)
279
+ kek = derive_kek(shared, header.payload_id, section.section_id, section.wrap_suite_id, index)
280
+ successes << Native.aegis256_decrypt(kek, fields[:wrap_nonce], slot_ad, fields[:wrapped_dek], fields[:wrap_tag])
281
+ rescue PQCrypto::Error, AuthenticationError, ArgumentError, FormatError
282
+ ensure
283
+ wipe_string!(shared) if defined?(shared)
284
+ wipe_string!(kek) if defined?(kek)
285
+ end
286
+ end
287
+
288
+ raise RecipientNotFoundError, "no matching recipient stanza" if successes.empty?
289
+ if successes.length != 1
290
+ successes.each { |candidate| wipe_string!(candidate) }
291
+ raise AmbiguousRecipientStanzas, "multiple recipient stanzas opened"
292
+ end
293
+ successes.first
294
+ end
295
+
296
+ def recipient_hint(public_key, payload_id, section_id, wrap_suite_id)
297
+ Native.sha256(HINT_DOMAIN + payload_id + section_id + Binary.u16(wrap_suite_id) + public_key.to_bytes)
298
+ end
299
+
300
+ def wrap_ad(header_hash, section_id, index, wrap_suite_id, hint, slot_padding)
301
+ Native.sha256(
302
+ WRAP_AD_DOMAIN + header_hash + section_id + Binary.u16(index) +
303
+ Binary.u16(wrap_suite_id) + hint + slot_padding
304
+ )
305
+ end
306
+
307
+ def derive_kek(shared_secret, payload_id, section_id, wrap_suite_id, index)
308
+ info = WRAP_KEY_DOMAIN + payload_id + section_id + Binary.u16(wrap_suite_id) + Binary.u16(index)
309
+ Native.hkdf_sha256(shared_secret, info, Format::DEK_BYTES)
310
+ end
311
+
312
+ def assert_size!(label, actual, expected, error = InvalidConfigurationError)
313
+ return if actual == expected
314
+ raise error, "#{label} must be #{expected} bytes, got #{actual}"
315
+ end
316
+
317
+ def assert_xwing_public_key!(public_key)
318
+ assert_size!("X-Wing public key", public_key.to_bytes.bytesize, Format::XWING_PUBLIC_KEY_BYTES)
319
+ end
320
+
321
+ def assert_xwing_encapsulation!(encapsulated)
322
+ assert_size!("X-Wing ciphertext", encapsulated.ciphertext.bytesize, Format::XWING_CIPHERTEXT_BYTES)
323
+ assert_size!("X-Wing shared secret", encapsulated.shared_secret.bytesize, Format::XWING_SHARED_SECRET_BYTES)
324
+ end
325
+
326
+ def normalize_public_keys(value)
327
+ keys = value.is_a?(Array) ? value : [value]
328
+ raise InvalidConfigurationError, "at least one recipient is required" if keys.empty?
329
+ keys.each { |key| validate_public_key!(key, "recipient must be an X-Wing public key (#{WRAP_KEM_ALGORITHM})") }
330
+ fingerprints = keys.map { |key| Native.sha256(key.to_bytes) }
331
+ if fingerprints.uniq.length != fingerprints.length
332
+ raise InvalidConfigurationError, "recipient list contains duplicate public keys"
333
+ end
334
+ keys
335
+ end
336
+
337
+ def normalize_credentials(value)
338
+ secret_key, public_key = credential_pair(value)
339
+ validate_secret_key!(secret_key)
340
+ validate_public_key!(public_key, "credential public key is invalid")
341
+ [secret_key, public_key]
342
+ end
343
+
344
+ def credential_pair(value)
345
+ case value
346
+ when PQCrypto::HybridKEM::Keypair then [value.secret_key, value.public_key]
347
+ when Credentials then [value.secret_key, value.public_key]
348
+ when Array
349
+ unless value.length == 2
350
+ raise InvalidConfigurationError,
351
+ "with: must be a HybridKEM::Keypair, Seal.credentials(...), or [secret_key, public_key]"
352
+ end
353
+ value
354
+ else
355
+ raise InvalidConfigurationError,
356
+ "with: must be a HybridKEM::Keypair, Seal.credentials(...), or [secret_key, public_key]"
357
+ end
358
+ end
359
+
360
+ def validate_public_key!(key, message)
361
+ unless key.is_a?(PQCrypto::HybridKEM::PublicKey) && key.algorithm == WRAP_KEM_ALGORITHM
362
+ raise InvalidConfigurationError, message
363
+ end
364
+ assert_xwing_public_key!(key)
365
+ end
366
+
367
+ def validate_secret_key!(key)
368
+ unless key.is_a?(PQCrypto::HybridKEM::SecretKey) && key.algorithm == WRAP_KEM_ALGORITHM
369
+ raise InvalidConfigurationError, "credential secret key is invalid"
370
+ end
371
+ end
372
+
373
+ def validate_private_metadata!(metadata)
374
+ if metadata.bytesize > Format::MAX_PRIVATE_METADATA_BYTES
375
+ raise InvalidConfigurationError, "private metadata is too large"
376
+ end
377
+ end
378
+
379
+ def secure_equal?(a, b)
380
+ return false unless a && b && a.bytesize == b.bytesize
381
+ Native.secure_equal(a, b)
382
+ end
383
+
384
+ def wipe_string!(value)
385
+ return unless value.is_a?(String) && !value.frozen?
386
+ value.replace("\0".b * value.bytesize)
387
+ rescue StandardError
388
+ nil
389
+ end
390
+
391
+ public_api = %i[
392
+ credentials encrypt decrypt open inspect_envelope digest
393
+ rebuild_recipients add_recipient drop_recipient_stanza rotate_dek
394
+ ]
395
+ private_names = singleton_methods(false).map(&:to_sym) - public_api
396
+ private_class_method(*private_names) unless private_names.empty?
397
+ end
398
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PQCrypto
4
+ module Seal
5
+ class Error < StandardError; end
6
+ class FormatError < Error; end
7
+ class AuthenticationError < Error; end
8
+ class UnsupportedSuiteError < FormatError; end
9
+ class RecipientNotFoundError < AuthenticationError; end
10
+ class AmbiguousRecipientStanzas < AuthenticationError; end
11
+ class InvalidConfigurationError < Error; end
12
+ class RecipientCapacityExceeded < InvalidConfigurationError; end
13
+ class ResourceLimitError < Error; end
14
+ end
15
+ end