pq_crypto 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,8 @@ typedef struct {
48
48
  uint8_t x25519_pk[X25519_PUBLICKEYBYTES];
49
49
  } hybrid_expanded_secret_key_t;
50
50
 
51
+ #define HYBRID_EXPANDED_SECRETKEYBYTES (sizeof(hybrid_expanded_secret_key_t))
52
+
51
53
  typedef struct {
52
54
  uint8_t mlkem_ct[MLKEM_CIPHERTEXTBYTES];
53
55
  uint8_t x25519_ephemeral[X25519_PUBLICKEYBYTES];
@@ -176,9 +178,10 @@ const char *pq_version(void);
176
178
  #define PQ_MLKEM_SHAREDSECRETBYTES MLKEM_SHAREDSECRETBYTES
177
179
 
178
180
  #define PQ_HYBRID_PUBLICKEYBYTES HYBRID_PUBLICKEYBYTES
179
- #define PQ_HYBRID_SECRETKEYBYTES HYBRID_SECRETKEYBYTES
180
- #define PQ_HYBRID_CIPHERTEXTBYTES HYBRID_CIPHERTEXTBYTES
181
- #define PQ_HYBRID_SHAREDSECRETBYTES HYBRID_SHAREDSECRETBYTES
181
+ #define PQ_HYBRID_SECRETKEYBYTES HYBRID_SECRETKEYBYTES
182
+ #define PQ_HYBRID_EXPANDED_SECRETKEYBYTES HYBRID_EXPANDED_SECRETKEYBYTES
183
+ #define PQ_HYBRID_CIPHERTEXTBYTES HYBRID_CIPHERTEXTBYTES
184
+ #define PQ_HYBRID_SHAREDSECRETBYTES HYBRID_SHAREDSECRETBYTES
182
185
 
183
186
  #define PQ_MLDSA_PUBLICKEYBYTES MLDSA_PUBLICKEYBYTES
184
187
  #define PQ_MLDSA_SECRETKEYBYTES MLDSA_SECRETKEYBYTES
@@ -203,6 +206,12 @@ void pq_mu_builder_release(void *state);
203
206
  int pq_hybrid_kem_keypair(uint8_t *public_key, uint8_t *secret_key);
204
207
  int pq_hybrid_kem_encapsulate(uint8_t *ciphertext, uint8_t *shared_secret,
205
208
  const uint8_t *public_key);
209
+ int pq_hybrid_kem_expand_secret_key(uint8_t *expanded_secret_key, const uint8_t *secret_key);
210
+ int pq_hybrid_kem_decapsulate_expanded(uint8_t *shared_secret, const uint8_t *ciphertext,
211
+ const uint8_t *expanded_secret_key);
212
+ int pq_hybrid_kem_decapsulate_expanded_pkey(uint8_t *shared_secret, const uint8_t *ciphertext,
213
+ const uint8_t *expanded_secret_key,
214
+ void *x25519_private_pkey);
206
215
  int pq_hybrid_kem_decapsulate(uint8_t *shared_secret, const uint8_t *ciphertext,
207
216
  const uint8_t *secret_key);
208
217
 
@@ -2,6 +2,6 @@
2
2
  #ifndef PQCRYPTO_VERSION_H
3
3
  #define PQCRYPTO_VERSION_H
4
4
 
5
- #define PQCRYPTO_VERSION "0.5.0"
5
+ #define PQCRYPTO_VERSION "0.5.2"
6
6
 
7
7
  #endif
@@ -1,10 +1,12 @@
1
+ # pq_crypto vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb
1
2
  backend=PQ Code Package native only
2
3
  pqclean=removed
3
4
  mlkem_native_repo=https://github.com/pq-code-package/mlkem-native.git
4
5
  mlkem_native_ref=v1.1.0
5
6
  mlkem_native_commit=d2cae2be522a67bfae26100fdb520576f1b2ef90
6
- mlkem_native_tree_sha256=368ad66b3a8092dd919d5646eb8507b8336e8f9f09c43b779dbf864700b5b8fb
7
+ mlkem_native_tree_sha256=c225de87a69e6d6360cddc4b5839b03e65fa9d5a1112a5f19700c905b7e74512
7
8
  mldsa_native_repo=https://github.com/pq-code-package/mldsa-native.git
8
9
  mldsa_native_ref=v1.0.0-beta
9
10
  mldsa_native_commit=db65535319d9750d75d34c6d170677415f9d2c46
10
- mldsa_native_tree_sha256=9c73cd6c185bb6885a7cf0ecb56a5282a5657aa5e6c32f68f442d941baa92b3d
11
+ mldsa_native_tree_sha256=3b2cb648dade4540191f08d606b422042bf781fb37b434934ab02b58a0121f5c
12
+ manifest_sha256=aeb28860537e30f4da0d28dc2961ba6bb06e700195a56f1648e5caddf1b6e1be
@@ -79,13 +79,22 @@ module PQCrypto
79
79
 
80
80
  class SecretKey < KEM::SecretKey
81
81
  def decapsulate(ciphertext)
82
- PQCrypto.__send__(:native_hybrid_kem_decapsulate, String(ciphertext).b, @bytes)
82
+ PQCrypto.__send__(:native_hybrid_kem_decapsulate_expanded_object, String(ciphertext).b, expanded_key_for_native)
83
83
  rescue ArgumentError => e
84
84
  raise InvalidCiphertextError, e.message
85
85
  end
86
86
 
87
+ def wipe!
88
+ @expanded_key = nil
89
+ super
90
+ end
91
+
87
92
  private
88
93
 
94
+ def expanded_key_for_native
95
+ @expanded_key ||= PQCrypto.__send__(:native_hybrid_kem_expand_secret_key_object, @bytes)
96
+ end
97
+
89
98
  def validate_length!
90
99
  expected = HybridKEM.details(@algorithm).fetch(:secret_key_bytes)
91
100
  raise InvalidKeyError, "Invalid hybrid KEM secret key length" unless @bytes.bytesize == expected
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PQCrypto
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
data/lib/pq_crypto.rb CHANGED
@@ -63,7 +63,11 @@ module PQCrypto
63
63
  ml_kem_1024_decapsulate
64
64
  hybrid_kem_keypair
65
65
  hybrid_kem_encapsulate
66
+ hybrid_kem_expand_secret_key
67
+ hybrid_kem_expand_secret_key_object
66
68
  hybrid_kem_decapsulate
69
+ hybrid_kem_decapsulate_expanded
70
+ hybrid_kem_decapsulate_expanded_object
67
71
  sign_keypair
68
72
  sign
69
73
  verify
@@ -4,48 +4,106 @@
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require "open3"
7
+ require "optparse"
7
8
  require "tmpdir"
8
9
 
9
10
  VENDOR_DIR = File.expand_path("../ext/pqcrypto/vendor", __dir__)
10
11
  MANIFEST_PATH = File.join(VENDOR_DIR, ".vendored")
11
12
 
12
- DEFAULTS = {
13
+ PINS = {
13
14
  mlkem: {
14
15
  repo: "https://github.com/pq-code-package/mlkem-native.git",
15
16
  ref: "v1.1.0",
17
+ commit: "d2cae2be522a67bfae26100fdb520576f1b2ef90",
18
+ tree_sha256: "c225de87a69e6d6360cddc4b5839b03e65fa9d5a1112a5f19700c905b7e74512",
16
19
  target: "mlkem-native",
17
20
  source_dir: "mlkem"
18
21
  },
19
22
  mldsa: {
20
23
  repo: "https://github.com/pq-code-package/mldsa-native.git",
21
24
  ref: "v1.0.0-beta",
25
+ commit: "db65535319d9750d75d34c6d170677415f9d2c46",
26
+ tree_sha256: "3b2cb648dade4540191f08d606b422042bf781fb37b434934ab02b58a0121f5c",
22
27
  target: "mldsa-native",
23
28
  source_dir: "mldsa"
24
29
  }
25
30
  }.freeze
26
31
 
27
- WARNING = <<~TEXT.freeze
28
- WARNING: this script vendors a minimal PQ Code Package source snapshot.
32
+ VENDORED_DOCS = %w[LICENSE LICENSE.txt README.md SECURITY.md BUILDING.md RELEASE.md META.yml].freeze
33
+ NORMALIZED_MTIME = Time.utc(2000, 1, 1).freeze
34
+ MANIFEST_HEADER = "# pq_crypto vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb"
29
35
 
30
- pq_crypto now has no PQClean fallback. Only the files required by the native
31
- extension are copied into ext/pqcrypto/vendor; upstream examples, .git
32
- directories, tests, proofs, and symlink-heavy trees are intentionally omitted
33
- so source gems are portable and do not emit RubyGems symlink warnings.
34
- TEXT
36
+ options = { mode: :sync }
37
+ OptionParser.new do |opts|
38
+ opts.banner = "Usage: vendor_libs.rb [--verify | --sync | --bump]"
39
+ opts.on("--verify", "Verify existing vendor tree against pinned tree_sha256 (no network)") { options[:mode] = :verify }
40
+ opts.on("--sync", "Re-clone at pinned commits and rebuild vendor tree (idempotent)") { options[:mode] = :sync }
41
+ opts.on("--bump", "Re-clone and print new tree_sha256 values to update PINS in this file") { options[:mode] = :bump }
42
+ end.parse!
35
43
 
36
44
  def sh!(cmd)
37
- puts "+ #{cmd.join(" ")}"
38
45
  system(*cmd) || abort("command failed: #{cmd.join(" ")}")
39
46
  end
40
47
 
48
+ def capture!(*cmd)
49
+ out, status = Open3.capture2(*cmd)
50
+ abort("command failed: #{cmd.join(" ")}") unless status.success?
51
+ out.strip
52
+ end
53
+
54
+ def vendor_candidate?(path)
55
+ return false if File.symlink?(path)
56
+ return false unless File.file?(path)
57
+ return false if path.split(File::SEPARATOR).any? { |seg| seg.start_with?(".") && seg != "." && seg != ".." }
58
+ true
59
+ end
60
+
61
+ def normalize_tree!(directory)
62
+ Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).each do |path|
63
+ base = File.basename(path)
64
+ next if base == "." || base == ".."
65
+ next if File.symlink?(path)
66
+ if File.file?(path)
67
+ File.chmod(0o644, path)
68
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, path)
69
+ elsif File.directory?(path)
70
+ File.chmod(0o755, path)
71
+ end
72
+ end
73
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, directory) if File.directory?(directory)
74
+ end
75
+
76
+ def copy_sources!(source_root, target_root, source_dir)
77
+ required = File.join(source_root, source_dir)
78
+ abort "missing required upstream directory: #{required}" unless Dir.exist?(required)
79
+
80
+ FileUtils.rm_rf(File.join(target_root, source_dir))
81
+ FileUtils.mkdir_p(File.join(target_root, source_dir))
82
+
83
+ Dir.glob(File.join(required, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
84
+ next unless vendor_candidate?(path)
85
+ relative = path.sub(/\A#{Regexp.escape(required)}\//, "")
86
+ dest = File.join(target_root, source_dir, relative)
87
+ FileUtils.mkdir_p(File.dirname(dest))
88
+ FileUtils.cp(path, dest, preserve: false)
89
+ end
90
+
91
+ VENDORED_DOCS.each do |relative|
92
+ src = File.join(source_root, relative)
93
+ next if File.symlink?(src)
94
+ next unless File.file?(src)
95
+ FileUtils.cp(src, File.join(target_root, relative), preserve: false)
96
+ end
97
+ end
98
+
41
99
  def tree_sha256_for(directory)
42
100
  entries = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
43
- .reject { |path| File.directory?(path) }
101
+ .reject { |p| File.directory?(p) || File.symlink?(p) || %w[. ..].include?(File.basename(p)) }
44
102
  .sort
45
103
 
46
104
  digest = Digest::SHA256.new
47
105
  entries.each do |path|
48
- relative = path.delete_prefix("#{directory}/")
106
+ relative = path.sub(/\A#{Regexp.escape(directory)}\/?/, "")
49
107
  digest << relative << "\0"
50
108
  digest << File.binread(path)
51
109
  digest << "\0"
@@ -53,79 +111,166 @@ def tree_sha256_for(directory)
53
111
  digest.hexdigest
54
112
  end
55
113
 
56
- VENDORED_DOCS = %w[
57
- LICENSE
58
- README.md
59
- SECURITY.md
60
- BUILDING.md
61
- RELEASE.md
62
- META.yml
63
- ].freeze
64
-
65
- def copy_file_without_symlink(source, target)
66
- return if File.symlink?(source)
67
- return unless File.file?(source)
68
-
69
- FileUtils.mkdir_p(File.dirname(target))
70
- FileUtils.cp(source, target)
114
+ def vendor_one(name, pin)
115
+ target = File.join(VENDOR_DIR, pin[:target])
116
+ FileUtils.rm_rf(target)
117
+ FileUtils.mkdir_p(target)
118
+
119
+ Dir.mktmpdir("pqcrypto-#{name}-") do |tmpdir|
120
+ clone_dir = File.join(tmpdir, pin[:target])
121
+ sh!(["git", "clone", "--depth", "1", "--branch", pin[:ref], pin[:repo], clone_dir])
122
+ actual_commit = capture!("git", "-C", clone_dir, "rev-parse", "HEAD")
123
+
124
+ if actual_commit != pin[:commit]
125
+ sh!(["git", "-C", clone_dir, "fetch", "--depth", "1", "origin", pin[:commit]])
126
+ sh!(["git", "-C", clone_dir, "checkout", "--detach", pin[:commit]])
127
+ actual_commit = capture!("git", "-C", clone_dir, "rev-parse", "HEAD")
128
+ end
129
+
130
+ abort "commit mismatch for #{name}: expected #{pin[:commit]}, got #{actual_commit}" unless actual_commit == pin[:commit]
131
+
132
+ copy_sources!(clone_dir, target, pin[:source_dir])
133
+ end
134
+
135
+ normalize_tree!(target)
136
+ tree_sha256_for(target)
137
+ end
138
+
139
+ def manifest_body_lines(results)
140
+ lines = ["backend=PQ Code Package native only", "pqclean=removed"]
141
+ results.each do |name, data|
142
+ prefix = "#{name}_native"
143
+ lines << "#{prefix}_repo=#{data[:repo]}"
144
+ lines << "#{prefix}_ref=#{data[:ref]}"
145
+ lines << "#{prefix}_commit=#{data[:commit]}"
146
+ lines << "#{prefix}_tree_sha256=#{data[:tree_sha256]}"
147
+ end
148
+ lines
71
149
  end
72
150
 
73
- def copy_required_snapshot(source_root, target_root, source_dir)
74
- required_source = File.join(source_root, source_dir)
75
- abort "missing required upstream directory: #{required_source}" unless Dir.exist?(required_source)
151
+ def manifest_signature(body_lines)
152
+ Digest::SHA256.hexdigest(body_lines.join("\n") + "\n")
153
+ end
76
154
 
77
- FileUtils.mkdir_p(target_root)
78
- FileUtils.cp_r(required_source, File.join(target_root, source_dir), remove_destination: true)
155
+ def write_manifest(results)
156
+ body = manifest_body_lines(results)
157
+ sig = manifest_signature(body)
158
+ content = ([MANIFEST_HEADER] + body + ["manifest_sha256=#{sig}"]).join("\n") + "\n"
159
+ File.write(MANIFEST_PATH, content)
160
+ File.chmod(0o644, MANIFEST_PATH)
161
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
162
+ end
79
163
 
80
- VENDORED_DOCS.each do |relative|
81
- copy_file_without_symlink(File.join(source_root, relative), File.join(target_root, relative))
164
+ def parse_manifest(path)
165
+ return { kv: {}, body: [], signature: nil } unless File.exist?(path)
166
+ body = []
167
+ kv = {}
168
+ signature = nil
169
+ File.readlines(path, chomp: true).each do |line|
170
+ next if line.start_with?("#")
171
+ next if line.empty?
172
+ if line.start_with?("manifest_sha256=")
173
+ signature = line.split("=", 2).last
174
+ else
175
+ body << line
176
+ k, v = line.split("=", 2)
177
+ kv[k] = v if k && v
178
+ end
82
179
  end
180
+ { kv: kv, body: body, signature: signature }
83
181
  end
84
182
 
85
- def clone_project(name, config)
86
- env_prefix = name.to_s.upcase
87
- repo = ENV["#{env_prefix}_NATIVE_REPO"] || config[:repo]
88
- ref = ENV["#{env_prefix}_NATIVE_REF"] || config[:ref]
89
- target = File.join(VENDOR_DIR, config[:target])
183
+ case options[:mode]
184
+ when :verify
185
+ manifest = parse_manifest(MANIFEST_PATH)
186
+ failures = []
90
187
 
91
- FileUtils.rm_rf(target)
188
+ if manifest[:signature].nil?
189
+ failures << "manifest: missing manifest_sha256 line"
190
+ else
191
+ expected_sig = manifest_signature(manifest[:body])
192
+ failures << "manifest: signature mismatch (manifest_sha256=#{manifest[:signature]}, computed=#{expected_sig})" if expected_sig != manifest[:signature]
193
+ end
92
194
 
93
- Dir.mktmpdir("pqcrypto-#{name}-") do |tmpdir|
94
- clone_dir = File.join(tmpdir, config[:target])
95
- sh!(["git", "clone", "--depth", "1", "--branch", ref, repo, clone_dir])
195
+ PINS.each do |name, pin|
196
+ target = File.join(VENDOR_DIR, pin[:target])
197
+ unless Dir.exist?(target)
198
+ failures << "#{name}: vendor directory missing (#{target})"
199
+ next
200
+ end
96
201
 
97
- commit, status = Open3.capture2("git", "-C", clone_dir, "rev-parse", "HEAD")
98
- commit = status.success? ? commit.strip : "unknown"
202
+ manifest_commit = manifest[:kv]["#{name}_native_commit"]
203
+ if manifest_commit != pin[:commit]
204
+ failures << "#{name}: manifest commit (#{manifest_commit.inspect}) != PINS commit (#{pin[:commit]})"
205
+ end
99
206
 
100
- copy_required_snapshot(clone_dir, target, config[:source_dir])
207
+ manifest_tree = manifest[:kv]["#{name}_native_tree_sha256"]
208
+ if manifest_tree != pin[:tree_sha256]
209
+ failures << "#{name}: manifest tree_sha256 (#{manifest_tree.inspect}) != PINS tree_sha256 (#{pin[:tree_sha256]})"
210
+ end
101
211
 
102
- [repo, ref, commit, tree_sha256_for(target)]
212
+ actual_tree = tree_sha256_for(target)
213
+ if actual_tree != pin[:tree_sha256]
214
+ failures << "#{name}: filesystem tree_sha256 (#{actual_tree}) != PINS tree_sha256 (#{pin[:tree_sha256]})"
215
+ end
216
+ end
217
+
218
+ if failures.empty?
219
+ puts "vendor verify: ok"
220
+ exit 0
221
+ else
222
+ failures.each { |f| warn f }
223
+ exit 1
103
224
  end
104
- end
105
225
 
106
- puts WARNING
107
- puts "Vendoring into #{VENDOR_DIR}"
108
-
109
- FileUtils.rm_rf(VENDOR_DIR)
110
- FileUtils.mkdir_p(VENDOR_DIR)
111
-
112
- mlkem_repo, mlkem_ref, mlkem_commit, mlkem_tree = clone_project(:mlkem, DEFAULTS[:mlkem])
113
- mldsa_repo, mldsa_ref, mldsa_commit, mldsa_tree = clone_project(:mldsa, DEFAULTS[:mldsa])
114
-
115
- File.write(
116
- MANIFEST_PATH,
117
- <<~TEXT
118
- backend=PQ Code Package native only
119
- pqclean=removed
120
- mlkem_native_repo=#{mlkem_repo}
121
- mlkem_native_ref=#{mlkem_ref}
122
- mlkem_native_commit=#{mlkem_commit}
123
- mlkem_native_tree_sha256=#{mlkem_tree}
124
- mldsa_native_repo=#{mldsa_repo}
125
- mldsa_native_ref=#{mldsa_ref}
126
- mldsa_native_commit=#{mldsa_commit}
127
- mldsa_native_tree_sha256=#{mldsa_tree}
128
- TEXT
129
- )
130
-
131
- puts "Done. Next step: bundle exec rake compile"
226
+ when :sync
227
+ if Dir.exist?(VENDOR_DIR) && File.exist?(MANIFEST_PATH)
228
+ manifest = parse_manifest(MANIFEST_PATH)
229
+ sig_ok = manifest[:signature] && manifest_signature(manifest[:body]) == manifest[:signature]
230
+ pins_ok = sig_ok && PINS.all? do |name, pin|
231
+ target = File.join(VENDOR_DIR, pin[:target])
232
+ Dir.exist?(target) &&
233
+ manifest[:kv]["#{name}_native_commit"] == pin[:commit] &&
234
+ manifest[:kv]["#{name}_native_tree_sha256"] == pin[:tree_sha256] &&
235
+ tree_sha256_for(target) == pin[:tree_sha256]
236
+ end
237
+
238
+ if pins_ok
239
+ puts "vendor already at pinned commits and tree_sha256; nothing to do"
240
+ exit 0
241
+ end
242
+ end
243
+
244
+ FileUtils.rm_rf(VENDOR_DIR)
245
+ FileUtils.mkdir_p(VENDOR_DIR)
246
+
247
+ results = {}
248
+ PINS.each do |name, pin|
249
+ actual_tree = vendor_one(name, pin)
250
+ if actual_tree != pin[:tree_sha256]
251
+ abort "#{name}: tree_sha256 drift (PINS=#{pin[:tree_sha256]}, actual=#{actual_tree}). " \
252
+ "Upstream changed under the pinned commit, or the vendor algorithm changed. " \
253
+ "If intentional, run with --bump to print new pins."
254
+ end
255
+ results[name] = pin.merge(tree_sha256: actual_tree)
256
+ end
257
+ write_manifest(results)
258
+ puts "vendor sync: ok"
259
+ results.each { |name, data| puts " #{name}: commit=#{data[:commit]} tree_sha256=#{data[:tree_sha256]}" }
260
+
261
+ when :bump
262
+ FileUtils.rm_rf(VENDOR_DIR)
263
+ FileUtils.mkdir_p(VENDOR_DIR)
264
+
265
+ results = {}
266
+ PINS.each do |name, pin|
267
+ actual_tree = vendor_one(name, pin)
268
+ results[name] = pin.merge(tree_sha256: actual_tree)
269
+ end
270
+ write_manifest(results)
271
+
272
+ puts "Update PINS in script/vendor_libs.rb to:"
273
+ results.each do |name, data|
274
+ puts " PINS[:#{name}][:tree_sha256] = #{data[:tree_sha256].inspect}"
275
+ end
276
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pq_crypto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov