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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PQCrypto
4
+ module Seal
5
+ module Format
6
+ MAGIC = "PQCSEAL1".b
7
+ VERSION = 1
8
+ CONTENT_SUITE_AEGIS256 = 1
9
+ WRAP_SUITE_MLKEM768_X25519_AEGIS256 = 1
10
+ LOOKUP_HINT = 1
11
+ FLAGS = 0
12
+ INNER_VERSION = 1
13
+ INNER_FLAGS = 0
14
+
15
+ PADDING_NONE = 0
16
+ PADDING_PADME = 1
17
+ PADDING_FIXED = 2
18
+ PADDING_BUCKETS = 3
19
+ PADDING_POLICY_IDS = {
20
+ PADDING_NONE => true,
21
+ PADDING_PADME => true,
22
+ PADDING_FIXED => true,
23
+ PADDING_BUCKETS => true
24
+ }.freeze
25
+ PADDING_POLICY_BY_SYMBOL = {
26
+ nil => PADDING_NONE,
27
+ :none => PADDING_NONE,
28
+ :padme => PADDING_PADME
29
+ }.freeze
30
+
31
+ PAYLOAD_ID_BYTES = 32
32
+ NONCE_BYTES = 32
33
+ TAG_BYTES = 32
34
+ HINT_BYTES = 32
35
+ SECTION_ID_BYTES = 32
36
+
37
+ XWING_PUBLIC_KEY_BYTES = 1216
38
+ XWING_CIPHERTEXT_BYTES = 1120
39
+ XWING_SHARED_SECRET_BYTES = 32
40
+
41
+ DEK_BYTES = 32
42
+ STANZA_BYTES = HINT_BYTES + XWING_CIPHERTEXT_BYTES + NONCE_BYTES + DEK_BYTES + TAG_BYTES
43
+ DEFAULT_SLOT_SIZE = 2048
44
+ MIN_SLOT_SIZE = 2048
45
+ MAX_SLOT_SIZE = 8192
46
+ SLOT_GRANULARITY = 256
47
+ DEFAULT_RECIPIENT_CAPACITY = 4
48
+ MAX_RECIPIENT_CAPACITY = 32
49
+ MAX_PUBLIC_METADATA_BYTES = 1 * 1024 * 1024
50
+ MAX_PRIVATE_METADATA_BYTES = 16 * 1024 * 1024
51
+ MAX_HEADER_BYTES = 2 * 1024 * 1024
52
+
53
+ DEFAULT_MAX_PLAINTEXT_BYTES = 1 * 1024 * 1024 * 1024
54
+ DEFAULT_MAX_STAGING_BYTES = DEFAULT_MAX_PLAINTEXT_BYTES + MAX_PRIVATE_METADATA_BYTES + 64
55
+ DEFAULT_MAX_ENVELOPE_BYTES =
56
+ MAX_HEADER_BYTES +
57
+ (2 + SECTION_ID_BYTES + MAX_RECIPIENT_CAPACITY * MAX_SLOT_SIZE) +
58
+ DEFAULT_MAX_STAGING_BYTES +
59
+ TAG_BYTES
60
+
61
+ LIMIT_DEFAULTS = {
62
+ max_staging_bytes: DEFAULT_MAX_STAGING_BYTES,
63
+ max_plaintext_bytes: DEFAULT_MAX_PLAINTEXT_BYTES,
64
+ max_envelope_bytes: DEFAULT_MAX_ENVELOPE_BYTES
65
+ }.freeze
66
+
67
+ Header = Struct.new(
68
+ :raw, :content_suite_id, :lookup_mode, :flags, :padding_policy_id,
69
+ :payload_id, :payload_nonce, :recipient_capacity, :slot_size,
70
+ :padded_inner_length, :public_metadata,
71
+ keyword_init: true
72
+ )
73
+ Section = Struct.new(:wrap_suite_id, :section_id, :raw, :slots_offset, :slots_length, keyword_init: true)
74
+
75
+ SLOT_FIELDS = [
76
+ [:hint, HINT_BYTES],
77
+ [:kem_ciphertext, XWING_CIPHERTEXT_BYTES],
78
+ [:wrap_nonce, NONCE_BYTES],
79
+ [:wrapped_dek, DEK_BYTES],
80
+ [:wrap_tag, TAG_BYTES]
81
+ ].freeze
82
+
83
+ module_function
84
+
85
+ def validate_slot_size!(slot_size, error: InvalidConfigurationError)
86
+ n = Integer(slot_size)
87
+ unless n.between?(MIN_SLOT_SIZE, MAX_SLOT_SIZE) && (n % SLOT_GRANULARITY).zero?
88
+ raise error, "slot_size must be #{MIN_SLOT_SIZE}..#{MAX_SLOT_SIZE} and divisible by #{SLOT_GRANULARITY}"
89
+ end
90
+ raise error, "slot_size is too small for current wrap suite" if n < STANZA_BYTES
91
+ n
92
+ end
93
+
94
+ def validate_capacity!(capacity, recipient_count = nil, error: InvalidConfigurationError)
95
+ n = Integer(capacity)
96
+ unless n.between?(1, MAX_RECIPIENT_CAPACITY)
97
+ raise error, "recipient_capacity must be 1..#{MAX_RECIPIENT_CAPACITY}"
98
+ end
99
+ if recipient_count && recipient_count > n
100
+ raise RecipientCapacityExceeded, "#{recipient_count} recipients do not fit capacity #{n}"
101
+ end
102
+ n
103
+ end
104
+
105
+ def validate_slot_size_from_wire!(slot_size)
106
+ validate_slot_size!(slot_size, error: FormatError)
107
+ end
108
+
109
+ def validate_capacity_from_wire!(capacity)
110
+ validate_capacity!(capacity, error: FormatError)
111
+ end
112
+
113
+ def validate_padding_policy_id!(policy_id)
114
+ n = Integer(policy_id)
115
+ raise FormatError, "unsupported padding policy id #{n}" unless PADDING_POLICY_IDS.key?(n)
116
+ n
117
+ end
118
+
119
+ def padding_policy_id_for(policy)
120
+ return PADDING_POLICY_BY_SYMBOL.fetch(policy) if PADDING_POLICY_BY_SYMBOL.key?(policy)
121
+ case policy
122
+ when :preserve
123
+ raise InvalidConfigurationError, "padding: :preserve is only valid for rotate_dek"
124
+ when Hash
125
+ return PADDING_FIXED if policy.key?(:to)
126
+ return PADDING_BUCKETS if policy.key?(:buckets)
127
+ raise InvalidConfigurationError, "padding hash must contain :to or :buckets"
128
+ else
129
+ raise InvalidConfigurationError, "unsupported padding policy: #{policy.inspect}"
130
+ end
131
+ end
132
+
133
+ def build_header(payload_id:, payload_nonce:, recipient_capacity:, slot_size:,
134
+ padded_inner_length:, public_metadata:, padding_policy_id:)
135
+ public_metadata = String(public_metadata).b
136
+ raise InvalidConfigurationError, "public metadata is too large" if public_metadata.bytesize > MAX_PUBLIC_METADATA_BYTES
137
+ raise InvalidConfigurationError, "invalid payload_id" unless payload_id.bytesize == PAYLOAD_ID_BYTES
138
+ raise InvalidConfigurationError, "invalid payload nonce" unless payload_nonce.bytesize == NONCE_BYTES
139
+ validate_padding_policy_id!(padding_policy_id)
140
+
141
+ body = +"".b
142
+ body << Binary.u16(CONTENT_SUITE_AEGIS256)
143
+ body << Binary.u8(LOOKUP_HINT)
144
+ body << Binary.u16(FLAGS)
145
+ body << Binary.u8(Integer(padding_policy_id))
146
+ body << payload_id
147
+ body << payload_nonce
148
+ body << Binary.u16(recipient_capacity)
149
+ body << Binary.u16(slot_size)
150
+ body << Binary.u64(padded_inner_length)
151
+ body << Binary.u32(public_metadata.bytesize)
152
+ body << public_metadata
153
+ header = MAGIC + Binary.u8(VERSION) + Binary.u32(MAGIC.bytesize + 1 + 4 + body.bytesize) + body
154
+ raise InvalidConfigurationError, "header is too large" if header.bytesize > MAX_HEADER_BYTES
155
+ header
156
+ end
157
+
158
+ def parse_header(bytes)
159
+ bytes = String(bytes).b
160
+ offset = 0
161
+ magic, offset = Binary.read_bytes(bytes, offset, MAGIC.bytesize)
162
+ raise FormatError, "invalid envelope magic" unless magic == MAGIC
163
+ version, offset = Binary.read_u8(bytes, offset)
164
+ raise FormatError, "unsupported format version #{version}" unless version == VERSION
165
+ header_length, offset = Binary.read_u32(bytes, offset)
166
+ raise FormatError, "invalid header length" if header_length < offset || header_length > MAX_HEADER_BYTES
167
+ Binary.ensure_available!(bytes, 0, header_length)
168
+ raw = bytes.byteslice(0, header_length).b
169
+
170
+ content_suite, offset = Binary.read_u16(raw, offset)
171
+ raise UnsupportedSuiteError, "unsupported content suite #{content_suite}" unless content_suite == CONTENT_SUITE_AEGIS256
172
+ lookup_mode, offset = Binary.read_u8(raw, offset)
173
+ raise UnsupportedSuiteError, "unsupported lookup mode #{lookup_mode}" unless lookup_mode == LOOKUP_HINT
174
+ flags, offset = Binary.read_u16(raw, offset)
175
+ raise FormatError, "unknown header flags" unless flags == FLAGS
176
+ padding_policy_id, offset = Binary.read_u8(raw, offset)
177
+ validate_padding_policy_id!(padding_policy_id)
178
+ payload_id, offset = Binary.read_bytes(raw, offset, PAYLOAD_ID_BYTES)
179
+ payload_nonce, offset = Binary.read_bytes(raw, offset, NONCE_BYTES)
180
+ capacity, offset = Binary.read_u16(raw, offset)
181
+ validate_capacity_from_wire!(capacity)
182
+ slot_size, offset = Binary.read_u16(raw, offset)
183
+ validate_slot_size_from_wire!(slot_size)
184
+ padded_inner_length, offset = Binary.read_u64(raw, offset)
185
+ raise FormatError, "padded inner frame is too short" if padded_inner_length < 14
186
+ public_length, offset = Binary.read_u32(raw, offset)
187
+ raise FormatError, "public metadata is too large" if public_length > MAX_PUBLIC_METADATA_BYTES
188
+ public_metadata, offset = Binary.read_bytes(raw, offset, public_length)
189
+ raise FormatError, "non-canonical header length" unless offset == raw.bytesize
190
+
191
+ Header.new(
192
+ raw: raw, content_suite_id: content_suite, lookup_mode: lookup_mode,
193
+ flags: flags, padding_policy_id: padding_policy_id,
194
+ payload_id: payload_id, payload_nonce: payload_nonce,
195
+ recipient_capacity: capacity, slot_size: slot_size,
196
+ padded_inner_length: padded_inner_length, public_metadata: public_metadata
197
+ )
198
+ end
199
+
200
+ def section_length(header)
201
+ section_length_for(header.recipient_capacity, header.slot_size)
202
+ end
203
+
204
+ def section_length_for(capacity, slot_size)
205
+ 2 + SECTION_ID_BYTES + Integer(capacity) * Integer(slot_size)
206
+ end
207
+
208
+ def split_slot(slot, slot_size)
209
+ fields = {}
210
+ offset = 0
211
+ SLOT_FIELDS.each do |name, length|
212
+ fields[name] = slot.byteslice(offset, length)
213
+ offset += length
214
+ end
215
+ fields[:padding] = slot.byteslice(offset, slot_size - offset)
216
+ fields
217
+ end
218
+
219
+ def parse_section(bytes, offset, header)
220
+ length = section_length(header)
221
+ raw, = Binary.read_bytes(bytes, offset, length)
222
+ wrap_suite, slot_offset = Binary.read_u16(raw, 0)
223
+ section_id, slot_offset = Binary.read_bytes(raw, slot_offset, SECTION_ID_BYTES)
224
+ unless wrap_suite == WRAP_SUITE_MLKEM768_X25519_AEGIS256
225
+ raise UnsupportedSuiteError, "unsupported wrap suite #{wrap_suite}"
226
+ end
227
+ Section.new(
228
+ wrap_suite_id: wrap_suite, section_id: section_id, raw: raw,
229
+ slots_offset: slot_offset, slots_length: raw.bytesize - slot_offset
230
+ )
231
+ end
232
+
233
+ def inner_prefix(content_length, private_metadata_length)
234
+ Binary.u8(INNER_VERSION) + Binary.u8(INNER_FLAGS) +
235
+ Binary.u64(content_length) + Binary.u32(private_metadata_length)
236
+ end
237
+
238
+ def parse_verified_inner(bytes)
239
+ bytes = String(bytes).b
240
+ offset = 0
241
+ version, offset = Binary.read_u8(bytes, offset)
242
+ raise FormatError, "unsupported inner version" unless version == INNER_VERSION
243
+ flags, offset = Binary.read_u8(bytes, offset)
244
+ raise FormatError, "unknown inner flags" unless flags == INNER_FLAGS
245
+ content_length, offset = Binary.read_u64(bytes, offset)
246
+ metadata_length, offset = Binary.read_u32(bytes, offset)
247
+ raise FormatError, "private metadata is too large" if metadata_length > MAX_PRIVATE_METADATA_BYTES
248
+ metadata, offset = Binary.read_bytes(bytes, offset, metadata_length)
249
+ content, offset = Binary.read_bytes(bytes, offset, content_length)
250
+ [content, metadata, bytes.bytesize - offset]
251
+ end
252
+
253
+ def check_resource_limits!(padded_inner_length:, envelope_bytes: nil,
254
+ max_staging_bytes: DEFAULT_MAX_STAGING_BYTES,
255
+ max_envelope_bytes: DEFAULT_MAX_ENVELOPE_BYTES)
256
+ if padded_inner_length > Integer(max_staging_bytes)
257
+ raise ResourceLimitError,
258
+ "padded_inner_length #{padded_inner_length} exceeds max_staging_bytes #{max_staging_bytes}"
259
+ end
260
+ if envelope_bytes && envelope_bytes > Integer(max_envelope_bytes)
261
+ raise ResourceLimitError,
262
+ "envelope #{envelope_bytes} exceeds max_envelope_bytes #{max_envelope_bytes}"
263
+ end
264
+ true
265
+ end
266
+
267
+ def pre_auth_limits(limits)
268
+ {
269
+ max_staging_bytes: limits.fetch(:max_staging_bytes, DEFAULT_MAX_STAGING_BYTES),
270
+ max_envelope_bytes: limits.fetch(:max_envelope_bytes, DEFAULT_MAX_ENVELOPE_BYTES)
271
+ }
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "fileutils"
5
+
6
+ module PQCrypto
7
+ module Seal
8
+ module IOAPI
9
+ DEFAULT_CHUNK_SIZE = 1024 * 1024
10
+ module_function
11
+
12
+ def encrypt_io(input, output, size:, to:, metadata: "".b, public_metadata: "".b,
13
+ recipient_capacity: Format::DEFAULT_RECIPIENT_CAPACITY,
14
+ slot_size: Format::DEFAULT_SLOT_SIZE, padding: :padme,
15
+ chunk_size: DEFAULT_CHUNK_SIZE, strict_size: true)
16
+ content_size = Integer(size)
17
+ raise ArgumentError, "size must be non-negative" if content_size.negative?
18
+ metadata = String(metadata).b
19
+ seal(:validate_private_metadata!, metadata)
20
+ recipients = seal(:normalize_public_keys, to)
21
+ capacity = Format.validate_capacity!(recipient_capacity, recipients.length)
22
+ slot_size = Format.validate_slot_size!(slot_size)
23
+ chunk_size = validate_chunk_size(chunk_size)
24
+
25
+ parts = nil
26
+ parts = seal(
27
+ :materialize_crypto_parts,
28
+ recipients: recipients, capacity: capacity, slot_size: slot_size,
29
+ padding: padding, public_metadata: public_metadata,
30
+ content_size: content_size, metadata_size: metadata.bytesize
31
+ )
32
+ output.write(parts[:header])
33
+ output.write(parts[:section])
34
+ encryptor = Native::Encryptor.new(parts[:dek], parts[:payload_nonce], parts[:header_hash])
35
+ output.write(encryptor.update(parts[:inner_prefix]))
36
+ output.write(encryptor.update(metadata)) unless metadata.empty?
37
+ copied = copy_exact_encrypted(input, output, encryptor, content_size, chunk_size)
38
+ raise EOFError, "input ended after #{copied} of #{content_size} bytes" unless copied == content_size
39
+ if strict_size
40
+ raise ArgumentError, "input contains more bytes than declared size" unless input.read(1).nil?
41
+ end
42
+ write_random_encrypted(output, encryptor, parts[:padded_inner_length] - parts[:raw_inner_length], chunk_size)
43
+ output.write(encryptor.final)
44
+ output
45
+ ensure
46
+ seal(:wipe_string!, parts[:dek]) if parts
47
+ seal(:wipe_string!, metadata) if metadata.is_a?(String) && !metadata.frozen?
48
+ end
49
+
50
+ def decrypt_io(input, output, with:, staging_directory: nil, chunk_size: DEFAULT_CHUNK_SIZE,
51
+ strict_eof: true, **limits)
52
+ limits = Format::LIMIT_DEFAULTS.merge(limits)
53
+ chunk_size = validate_chunk_size(chunk_size)
54
+ staging = new_staging("pqcrypto-seal-inner", staging_directory)
55
+ opened = decrypt_to_staging(
56
+ input, staging, with: with, chunk_size: chunk_size, strict_eof: strict_eof, **limits
57
+ )
58
+ staging.flush
59
+ staging.rewind
60
+ content_offset, content_length, metadata = parse_verified_staging(staging, opened[:header].padded_inner_length)
61
+ if content_length > Integer(limits[:max_plaintext_bytes])
62
+ seal(:wipe_string!, metadata)
63
+ raise ResourceLimitError,
64
+ "plaintext #{content_length} exceeds max_plaintext_bytes #{limits[:max_plaintext_bytes]}"
65
+ end
66
+ staging.seek(content_offset, ::IO::SEEK_SET)
67
+ copy_exact(staging, output, content_length, chunk_size)
68
+ Opened.from_header(opened[:header], opened[:section], data: nil, metadata: metadata)
69
+ ensure
70
+ staging.close! if defined?(staging) && staging
71
+ end
72
+
73
+ def decrypt_frame_io(input, output, with:, **options)
74
+ decrypt_io(input, output, with: with, strict_eof: false, **options)
75
+ end
76
+
77
+ def encrypt_frame_io(input, output, size:, to:, **options)
78
+ encrypt_io(input, output, size: size, to: to, strict_size: false, **options)
79
+ end
80
+
81
+ def encrypt_file(source, destination, **options)
82
+ File.open(source, "rb") do |input|
83
+ size = input.stat.size
84
+ atomic_destination(destination) do |tmp|
85
+ encrypt_io(input, tmp, size: size, strict_size: true, **options)
86
+ end
87
+ end
88
+ destination
89
+ end
90
+
91
+ def decrypt_file(source, destination, with:, staging_directory: nil,
92
+ chunk_size: DEFAULT_CHUNK_SIZE, **limits)
93
+ File.open(source, "rb") do |input|
94
+ atomic_destination(destination) do |tmp|
95
+ decrypt_io(
96
+ input, tmp, with: with, staging_directory: staging_directory,
97
+ chunk_size: chunk_size, strict_eof: true, **limits
98
+ )
99
+ end
100
+ end
101
+ destination
102
+ end
103
+
104
+ def rebuild_recipients_file(source, destination, with:, recipients:, chunk_size: DEFAULT_CHUNK_SIZE)
105
+ chunk_size = validate_chunk_size(chunk_size)
106
+ File.open(source, "rb") do |input|
107
+ header = read_header(input)
108
+ old_section = Format.parse_section(read_exact(input, Format.section_length(header)), 0, header)
109
+ dek = seal(:unwrap_dek, header, old_section, with)
110
+ public_keys = seal(:normalize_public_keys, recipients)
111
+ Format.validate_capacity!(header.recipient_capacity, public_keys.length)
112
+ new_section = seal(
113
+ :build_recipient_section,
114
+ recipients: public_keys, capacity: header.recipient_capacity,
115
+ slot_size: header.slot_size, payload_id: header.payload_id,
116
+ header_hash: Native.sha256(header.raw), dek: dek,
117
+ wrap_suite_id: Format::WRAP_SUITE_MLKEM768_X25519_AEGIS256
118
+ )
119
+ atomic_destination(destination) do |tmp|
120
+ tmp.write(header.raw)
121
+ tmp.write(new_section)
122
+ verifier = Native::Decryptor.new(dek, header.payload_nonce, Native.sha256(header.raw))
123
+ remaining = header.padded_inner_length
124
+ while remaining.positive?
125
+ take = [remaining, chunk_size].min
126
+ ciphertext_chunk = read_exact(input, take)
127
+ tmp.write(ciphertext_chunk)
128
+ seal(:wipe_string!, verifier.update(ciphertext_chunk))
129
+ remaining -= take
130
+ end
131
+ payload_tag = read_exact(input, Format::TAG_BYTES)
132
+ raise FormatError, "trailing bytes after envelope" unless input.read(1).nil?
133
+ verifier.final(payload_tag)
134
+ tmp.write(payload_tag)
135
+ end
136
+ ensure
137
+ seal(:wipe_string!, dek) if defined?(dek)
138
+ end
139
+ destination
140
+ end
141
+
142
+ def add_recipient_file(source, destination, with:, recipient:, current_recipients:, **options)
143
+ rebuild_recipients_file(
144
+ source, destination, with: with,
145
+ recipients: Array(current_recipients) + [recipient], **options
146
+ )
147
+ end
148
+
149
+ def drop_recipient_stanza_file(source, destination, with:, remaining_recipients:, **options)
150
+ rebuild_recipients_file(source, destination, with: with, recipients: remaining_recipients, **options)
151
+ end
152
+
153
+ def rotate_dek_file(source, destination, with:, recipients:, padding: :preserve,
154
+ staging_directory: nil, chunk_size: DEFAULT_CHUNK_SIZE)
155
+ plaintext = new_staging("pqcrypto-seal-rotation", staging_directory)
156
+ opened = nil
157
+ File.open(source, "rb") do |input|
158
+ opened = decrypt_io(input, plaintext, with: with,
159
+ staging_directory: staging_directory, chunk_size: chunk_size, strict_eof: true)
160
+ end
161
+ plaintext.flush
162
+ plaintext.rewind
163
+ info = inspect_file(source)
164
+ padding = { to: info.envelope_bytes } if padding == :preserve
165
+ atomic_destination(destination) do |tmp|
166
+ encrypt_io(
167
+ plaintext, tmp, size: plaintext.stat.size, to: recipients,
168
+ metadata: opened.metadata, public_metadata: opened.public_metadata,
169
+ recipient_capacity: info.recipient_capacity, slot_size: info.slot_size,
170
+ padding: padding, chunk_size: chunk_size, strict_size: true
171
+ )
172
+ end
173
+ destination
174
+ ensure
175
+ plaintext.close! if defined?(plaintext) && plaintext
176
+ seal(:wipe_string!, opened.metadata) if defined?(opened) && opened && opened.metadata
177
+ end
178
+
179
+ def inspect_file(source)
180
+ File.open(source, "rb") do |input|
181
+ header = read_header(input)
182
+ section = Format.parse_section(read_exact(input, Format.section_length(header)), 0, header)
183
+ total = header.raw.bytesize + section.raw.bytesize + header.padded_inner_length + Format::TAG_BYTES
184
+ raise FormatError, "file length does not match envelope" unless File.size(source) == total
185
+ Inspection.from_header(header, section, envelope_bytes: total)
186
+ end
187
+ end
188
+
189
+ def decrypt_to_staging(input, staging, with:, chunk_size:, strict_eof:, **limits)
190
+ limits = Format::LIMIT_DEFAULTS.merge(limits)
191
+ header = read_header(input)
192
+ Format.check_resource_limits!(
193
+ padded_inner_length: header.padded_inner_length, **Format.pre_auth_limits(limits)
194
+ )
195
+ section = Format.parse_section(read_exact(input, Format.section_length(header)), 0, header)
196
+ estimated = header.raw.bytesize + section.raw.bytesize + header.padded_inner_length + Format::TAG_BYTES
197
+ Format.check_resource_limits!(
198
+ padded_inner_length: header.padded_inner_length,
199
+ envelope_bytes: estimated, **Format.pre_auth_limits(limits)
200
+ )
201
+ dek = seal(:unwrap_dek, header, section, with)
202
+ decryptor = Native::Decryptor.new(dek, header.payload_nonce, Native.sha256(header.raw))
203
+ remaining = header.padded_inner_length
204
+ while remaining.positive?
205
+ take = [remaining, chunk_size].min
206
+ staging.write(decryptor.update(read_exact(input, take)))
207
+ remaining -= take
208
+ end
209
+ tag = read_exact(input, Format::TAG_BYTES)
210
+ raise FormatError, "trailing bytes after envelope" if strict_eof && !input.read(1).nil?
211
+ decryptor.final(tag)
212
+ { header: header, section: section }
213
+ ensure
214
+ seal(:wipe_string!, dek) if defined?(dek)
215
+ end
216
+
217
+ def parse_verified_staging(staging, expected_size)
218
+ raise FormatError, "staging size mismatch" unless staging.stat.size == expected_size
219
+ staging.rewind
220
+ prefix = read_exact(staging, 14)
221
+ version = prefix.getbyte(0)
222
+ flags = prefix.getbyte(1)
223
+ raise FormatError, "unsupported inner version" unless version == Format::INNER_VERSION
224
+ raise FormatError, "unknown inner flags" unless flags == Format::INNER_FLAGS
225
+ hi, lo, metadata_length = prefix.byteslice(2, 12).unpack("NNN")
226
+ content_length = (hi << 32) | lo
227
+ raise FormatError, "private metadata is too large" if metadata_length > Format::MAX_PRIVATE_METADATA_BYTES
228
+ minimum = 14 + metadata_length + content_length
229
+ raise FormatError, "inner lengths exceed authenticated frame" if minimum > expected_size
230
+ metadata = read_exact(staging, metadata_length)
231
+ [14 + metadata_length, content_length, metadata]
232
+ end
233
+
234
+ def read_header(input)
235
+ prefix_len = Format::MAGIC.bytesize + 1 + 4
236
+ initial = read_exact(input, prefix_len)
237
+ header_length = initial.byteslice(Format::MAGIC.bytesize + 1, 4).unpack1("N")
238
+ raise FormatError, "invalid header length" if header_length < initial.bytesize || header_length > Format::MAX_HEADER_BYTES
239
+ Format.parse_header(initial + read_exact(input, header_length - initial.bytesize))
240
+ end
241
+
242
+ def seal(name, *args, **kwargs)
243
+ Seal.send(name, *args, **kwargs)
244
+ end
245
+
246
+ def atomic_destination(destination)
247
+ path = File.expand_path(destination)
248
+ directory = File.dirname(path)
249
+ FileUtils.mkdir_p(directory)
250
+ temp = Tempfile.new([".#{File.basename(path)}.", ".pqcseal"], directory)
251
+ temp.binmode
252
+ temp.chmod(0o600)
253
+ begin
254
+ yield temp
255
+ temp.flush
256
+ temp.fsync
257
+ temp.close
258
+ File.rename(temp.path, path)
259
+ fsync_directory(directory)
260
+ ensure
261
+ temp.close! rescue nil
262
+ end
263
+ end
264
+
265
+ def fsync_directory(directory)
266
+ File.open(directory, "r") { |dir| dir.fsync }
267
+ rescue SystemCallError, IOError
268
+ nil
269
+ end
270
+
271
+ def copy_exact_encrypted(input, output, encryptor, length, chunk_size)
272
+ copied = 0
273
+ while copied < length
274
+ chunk = input.read([chunk_size, length - copied].min)
275
+ break unless chunk && !chunk.empty?
276
+ chunk = chunk.b
277
+ output.write(encryptor.update(chunk))
278
+ copied += chunk.bytesize
279
+ end
280
+ copied
281
+ end
282
+
283
+ def write_random_encrypted(output, encryptor, length, chunk_size)
284
+ remaining = length
285
+ while remaining.positive?
286
+ take = [remaining, chunk_size].min
287
+ random = Native.random_bytes(take)
288
+ output.write(encryptor.update(random))
289
+ seal(:wipe_string!, random)
290
+ remaining -= take
291
+ end
292
+ end
293
+
294
+ def copy_exact(input, output, length, chunk_size)
295
+ remaining = length
296
+ while remaining.positive?
297
+ chunk = input.read([remaining, chunk_size].min)
298
+ raise EOFError, "staging input is truncated" unless chunk && !chunk.empty?
299
+ output.write(chunk)
300
+ remaining -= chunk.bytesize
301
+ end
302
+ end
303
+
304
+ def read_exact(io, length)
305
+ return "".b if length.zero?
306
+ buffer = +"".b
307
+ while buffer.bytesize < length
308
+ chunk = io.read(length - buffer.bytesize)
309
+ raise EOFError, "truncated envelope" unless chunk && !chunk.empty?
310
+ buffer << chunk.b
311
+ end
312
+ buffer
313
+ end
314
+
315
+ def new_staging(prefix, staging_directory)
316
+ tempfile = Tempfile.new([prefix, ".bin"], staging_directory)
317
+ tempfile.binmode
318
+ tempfile.chmod(0o600)
319
+ unlink_open_tempfile(tempfile)
320
+ tempfile
321
+ end
322
+
323
+ def unlink_open_tempfile(tempfile)
324
+ tempfile.unlink
325
+ rescue SystemCallError, IOError, NotImplementedError
326
+ nil
327
+ end
328
+
329
+ def validate_chunk_size(value)
330
+ size = Integer(value)
331
+ raise ArgumentError, "chunk_size must be positive" unless size.positive?
332
+ size
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pq_crypto/seal/pq_crypto_seal"
4
+
5
+ module PQCrypto
6
+ module Seal
7
+ module Native
8
+ KEY_BYTES = 32
9
+ NONCE_BYTES = 32
10
+ TAG_BYTES = 32
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PQCrypto
4
+ module Seal
5
+ module Padding
6
+ module_function
7
+
8
+ def padme_target(length)
9
+ n = Integer(length)
10
+ raise ArgumentError, "length must be non-negative" if n.negative?
11
+ return n if n <= 1
12
+
13
+ exponent = n.bit_length - 1
14
+ significant = exponent.bit_length
15
+ low_bits = exponent - significant
16
+ return n if low_bits <= 0
17
+
18
+ mask = (1 << low_bits) - 1
19
+ (n + mask) & ~mask
20
+ end
21
+
22
+ def target(base_length, policy)
23
+ case policy
24
+ when nil, :none then base_length
25
+ when :padme then padme_target(base_length)
26
+ when Hash then fixed_or_buckets(base_length, policy)
27
+ else
28
+ raise InvalidConfigurationError, "unsupported padding policy: #{policy.inspect}"
29
+ end
30
+ end
31
+
32
+ def fixed_or_buckets(base_length, policy)
33
+ if policy.key?(:to)
34
+ target = Integer(policy.fetch(:to))
35
+ raise InvalidConfigurationError, "padding target is smaller than envelope" if target < base_length
36
+ target
37
+ elsif policy.key?(:buckets)
38
+ bucket = Array(policy.fetch(:buckets)).map { |n| Integer(n) }.sort.find { |n| n >= base_length }
39
+ raise InvalidConfigurationError, "no padding bucket can hold envelope" unless bucket
40
+ bucket
41
+ else
42
+ raise InvalidConfigurationError, "padding hash must contain :to or :buckets"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module PQCrypto
3
+ module Seal
4
+ VERSION = "0.1.0"
5
+ end
6
+ end