confium 0.4.1 → 0.6.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.
@@ -16,6 +16,14 @@ crate-type = ["cdylib"]
16
16
 
17
17
  build = "build.rs"
18
18
 
19
+ [features]
20
+ # OpenPGP signature verification via a vendored librnp (librnp +
21
+ # Botan + json-c). OFF by default: the C/C++ build would restore the
22
+ # multi-minute compile times and heavy binaries 0.4.0 removed (armor
23
+ # itself is pure Ruby). Source builders opt in; pre-compiled platform
24
+ # gems always build default features.
25
+ pgp = ["rnp"]
26
+
19
27
  [dependencies]
20
28
  # rb-sys for Ruby C API access.
21
29
  rb-sys = { version = "0.9.124", features = ["global-allocator"] }
@@ -57,3 +65,11 @@ serde_json = "1"
57
65
 
58
66
  [profile.release]
59
67
  lto = "off"
68
+
69
+ # OpenPGP verification (feature "pgp") — vendored librnp via rnp-rs.
70
+ # No system librnp needed; adds the Botan/json-c C/C++ build.
71
+ [dependencies.rnp]
72
+ package = "rnp-rs"
73
+ version = "0.1.10"
74
+ features = ["vendored"]
75
+ optional = true
@@ -7,4 +7,9 @@ create_rust_makefile('confium_native') do |r|
7
7
  r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :dev).to_sym
8
8
  r.use_stable_api_compiled_fallback = true
9
9
  r.force_install_rust_toolchain = false
10
+ # rb_sys only threads --features through to cargo when the builder
11
+ # has a non-empty feature list; seeding it from the env var makes
12
+ # RB_SYS_CARGO_FEATURES=pgp bundle exec rake compile work.
13
+ r.features = ENV.fetch('RB_SYS_CARGO_FEATURES', '')
14
+ .split(',').map(&:strip).reject(&:empty?)
10
15
  end
@@ -9,6 +9,7 @@ mod attributes;
9
9
  mod composite;
10
10
  mod deployment;
11
11
  mod ers;
12
+ mod openpgp_verify;
12
13
  mod path;
13
14
  mod pki;
14
15
  mod tc;
@@ -42,6 +43,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
42
43
  confium.define_module_function("core_version", function!(core_version, 0))?;
43
44
 
44
45
  transparency::init(ruby, confium)?;
46
+ openpgp_verify::init(ruby, confium)?;
47
+ #[cfg(feature = "pgp")]
48
+ openpgp_verify::init_pgp(ruby, &confium)?;
45
49
  composite::init(ruby, confium)?;
46
50
  attributes::init(ruby, confium)?;
47
51
  pki::init(ruby, confium)?;
@@ -0,0 +1,189 @@
1
+ //! Confium::OpenPGP — OpenPGP (RFC 9580) signature verification via
2
+ //! the vendored librnp, behind the `pgp` cargo feature.
3
+ //!
4
+ //! Armor (encode/decode) is pure Ruby in lib/confium/openpgp.rb and
5
+ //! always available. This module adds verification only, and only
6
+ //! when the extension was built with `--features pgp`: the vendored
7
+ //! librnp brings a full Botan/json-c C/C++ build that default
8
+ //! (platform-gem) builds deliberately exclude.
9
+
10
+ use magnus::{Error, Module, RModule, Ruby, Value, function, prelude::*};
11
+
12
+ use crate::util::runtime;
13
+
14
+ fn stub_message(method: &str) -> String {
15
+ format!(
16
+ "Confium::OpenPGP.{} requires the pgp cargo feature — rebuild \
17
+ the extension with --features pgp (platform gems build \
18
+ default features)",
19
+ method
20
+ )
21
+ }
22
+
23
+ /// Registered when the extension builds WITHOUT the pgp feature:
24
+ /// `Confium::OpenPGP::PGP_AVAILABLE` is false and the verify methods
25
+ /// raise with instructions instead of pretending.
26
+ pub fn init(_ruby: &Ruby, parent: RModule) -> Result<(), Error> {
27
+ let openpgp = parent.define_module("OpenPGP")?;
28
+ let _ = openpgp.const_set("PGP_AVAILABLE", false);
29
+ openpgp.define_singleton_method(
30
+ "verify_detached",
31
+ function!(
32
+ move |_args: &[Value]| -> Result<Value, Error> {
33
+ Err(runtime(stub_message("verify_detached")))
34
+ },
35
+ -1
36
+ ),
37
+ )?;
38
+ openpgp.define_singleton_method(
39
+ "verify",
40
+ function!(
41
+ move |_args: &[Value]| -> Result<Value, Error> { Err(runtime(stub_message("verify"))) },
42
+ -1
43
+ ),
44
+ )?;
45
+ Ok(())
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // pgp feature implementation
50
+ // ---------------------------------------------------------------------------
51
+ #[cfg(feature = "pgp")]
52
+ mod pgp_impl {
53
+ use super::*;
54
+ use crate::util::{bytes_from_value, crypto_error, parse_error};
55
+
56
+ fn result_to_hash(ruby: &Ruby, result: &rnp::VerifyResult) -> Result<Value, Error> {
57
+ let hash = ruby.hash_new();
58
+ let _ = hash.aset("any_valid", result.any_valid().unwrap_or(false));
59
+ let _ = hash.aset(
60
+ "signature_count",
61
+ result.signature_count().unwrap_or(0) as i64,
62
+ );
63
+ let arr = ruby.ary_new();
64
+ for s in result.iter_signatures() {
65
+ let sh = ruby.hash_new();
66
+ let _ = sh.aset("valid", s.status_is_valid());
67
+ let _ = sh.aset("status", format!("{:?}", s.status()));
68
+ let _ = sh.aset("key_id", s.keyid().unwrap_or_else(|_| "?".to_string()));
69
+ let (created, expires) = s.times().unwrap_or((0, 0));
70
+ let _ = sh.aset("creation_time", created as i64);
71
+ let _ = sh.aset("expiration_time", expires as i64);
72
+ if let Ok(alg) = s.hash() {
73
+ let _ = sh.aset("hash", alg);
74
+ }
75
+ arr.push(sh)?;
76
+ }
77
+ let _ = hash.aset("signatures", arr);
78
+ Ok(hash.as_value())
79
+ }
80
+
81
+ fn negative_result(ruby: &Ruby) -> Result<Value, Error> {
82
+ // librnp reports a failed signature check as an error from
83
+ // rnp_op_verify_execute; for a verifier API that is the
84
+ // answer, not an exception.
85
+ let hash = ruby.hash_new();
86
+ let _ = hash.aset("any_valid", false);
87
+ let _ = hash.aset("signature_count", 0 as i64);
88
+ let _ = hash.aset("signatures", ruby.ary_new());
89
+ Ok(hash.as_value())
90
+ }
91
+
92
+ fn ctx() -> Result<rnp::Context, Error> {
93
+ rnp::Context::new().map_err(|e| crypto_error(e.to_string(), "OpenPGP", "openpgp"))
94
+ }
95
+
96
+ fn import_keys(context: &rnp::Context, keys: &[Value]) -> Result<(), Error> {
97
+ for k in keys {
98
+ let bytes = bytes_from_value(*k)?;
99
+ context
100
+ .import_keys(&bytes, rnp::LoadSaveFlags::PUBLIC)
101
+ .map_err(|e| {
102
+ parse_error(e.to_string(), "OpenPGP key import", Some("openpgp"), None)
103
+ })?;
104
+ }
105
+ Ok(())
106
+ }
107
+
108
+ /// `(message, signature, keys?)` → `(message, signature, keys)`
109
+ fn split_args(
110
+ args: &[Value],
111
+ min: usize,
112
+ method: &str,
113
+ ) -> Result<(Value, Option<Value>, Vec<Value>), Error> {
114
+ if args.len() < min || args.len() > min + 1 {
115
+ return Err(crate::util::arg_error(format!(
116
+ "Confium::OpenPGP.{}: wrong number of arguments (given {}, expected {}..{})",
117
+ method,
118
+ args.len(),
119
+ min,
120
+ min + 1
121
+ )));
122
+ }
123
+ let keys = if args.len() == min + 1 {
124
+ match <magnus::RArray as magnus::TryConvert>::try_convert(args[min]) {
125
+ Ok(array) => array.into_iter().collect(),
126
+ Err(_) => vec![args[min]],
127
+ }
128
+ } else {
129
+ Vec::new()
130
+ };
131
+ Ok((args[0], args.get(1).copied(), keys))
132
+ }
133
+
134
+ pub fn verify_detached(args: &[Value]) -> Result<Value, Error> {
135
+ let ruby = Ruby::get().map_err(|e| runtime(e.to_string()))?;
136
+ let (message, signature, keys) = split_args(args, 2, "verify_detached")?;
137
+ let msg = bytes_from_value(message)?;
138
+ let sig = bytes_from_value(
139
+ signature.ok_or_else(|| crate::util::arg_error("missing signature"))?,
140
+ )?;
141
+
142
+ let context = ctx()?;
143
+ import_keys(&context, &keys)?;
144
+ let result = match rnp::verify_detached(&context, &msg, &sig) {
145
+ Ok(result) => result_to_hash(&ruby, &result)?,
146
+ Err(e) if e.kind() == rnp::ErrorKind::SignatureInvalid => negative_result(&ruby)?,
147
+ Err(e) => {
148
+ return Err(parse_error(
149
+ e.to_string(),
150
+ "OpenPGP.verify_detached",
151
+ Some("openpgp"),
152
+ None,
153
+ ));
154
+ }
155
+ };
156
+ Ok(result)
157
+ }
158
+
159
+ pub fn verify(args: &[Value]) -> Result<Value, Error> {
160
+ let ruby = Ruby::get().map_err(|e| runtime(e.to_string()))?;
161
+ let (signed, _signature_ignored, keys) = split_args(args, 1, "verify")?;
162
+ let msg = bytes_from_value(signed)?;
163
+
164
+ let context = ctx()?;
165
+ import_keys(&context, &keys)?;
166
+ let result = match rnp::verify(&context, &msg) {
167
+ Ok(result) => result_to_hash(&ruby, &result)?,
168
+ Err(e) if e.kind() == rnp::ErrorKind::SignatureInvalid => negative_result(&ruby)?,
169
+ Err(e) => {
170
+ return Err(parse_error(
171
+ e.to_string(),
172
+ "OpenPGP.verify",
173
+ Some("openpgp"),
174
+ None,
175
+ ));
176
+ }
177
+ };
178
+ Ok(result)
179
+ }
180
+ }
181
+
182
+ #[cfg(feature = "pgp")]
183
+ pub fn init_pgp(_ruby: &Ruby, parent: &RModule) -> Result<(), Error> {
184
+ let openpgp = parent.define_module("OpenPGP")?;
185
+ let _ = openpgp.const_set("PGP_AVAILABLE", true);
186
+ openpgp.define_singleton_method("verify_detached", function!(pgp_impl::verify_detached, -1))?;
187
+ openpgp.define_singleton_method("verify", function!(pgp_impl::verify, -1))?;
188
+ Ok(())
189
+ }
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module Confium
8
+ module Audit
9
+ # Exports audit records to an OpenTelemetry Collector over
10
+ # OTLP/HTTP JSON (the logs signal — an audit event is a log
11
+ # record, not a span).
12
+ #
13
+ # Confium::Audit.sink = Confium::Audit::OtlpSink.new(
14
+ # endpoint: 'http://localhost:4318/v1/logs',
15
+ # headers: { 'Authorization' => "Bearer #{token}" },
16
+ # service_name: 'confium-issuer'
17
+ # )
18
+ #
19
+ # Delivery is synchronous per record, retried with exponential
20
+ # backoff (retries: attempts after the first, base doubled per
21
+ # attempt). After the final failure the record is dropped with a
22
+ # $stderr report — the audit core's policy: a telemetry outage
23
+ # must never break signing. stdlib only.
24
+ class OtlpSink < Sink
25
+ DEFAULT_ENDPOINT = 'http://localhost:4318/v1/logs'
26
+ SEVERITY = { 'success' => 9, 'failure' => 17, 'error' => 17 }.freeze # INFO / ERROR
27
+
28
+ def initialize(endpoint: DEFAULT_ENDPOINT, headers: {}, service_name: 'confium', timeout: 5,
29
+ retries: 2, retry_base: 0.1)
30
+ super()
31
+ @uri = URI.parse(endpoint)
32
+ @headers = headers
33
+ @service_name = service_name
34
+ @timeout = timeout
35
+ @retries = retries
36
+ @retry_base = retry_base
37
+ @dropped = 0
38
+ end
39
+
40
+ # Records dropped after failed delivery (diagnostic only).
41
+ attr_reader :dropped
42
+
43
+ def write(record)
44
+ payload = envelope(record)
45
+ deliver(JSON.generate(payload))
46
+ rescue StandardError => e
47
+ @dropped += 1
48
+ warn "confium: OTLP delivery failed, audit record dropped (##{@dropped}): #{e.class}: #{e.message}"
49
+ end
50
+
51
+ def close; end
52
+
53
+ private
54
+
55
+ # Attempts grow the backoff: retry_base * 2**attempt. When
56
+ # retry_base is 0 (specs) retries run without delay.
57
+ def deliver(body)
58
+ (@retries + 1).times do |attempt|
59
+ sleep(@retry_base * (2**attempt)) if attempt.positive? && @retry_base.positive?
60
+
61
+ response = Net::HTTP.post(@uri, body, headers.merge('Content-Type' => 'application/json'))
62
+ return response if response.is_a?(Net::HTTPSuccess)
63
+
64
+ raise "collector responded #{response.code}" if attempt == @retries
65
+ end
66
+ end
67
+
68
+ def headers
69
+ { 'User-Agent' => "confium-otlp-sink #{Confium::VERSION}" }.merge(@headers)
70
+ end
71
+
72
+ # OTLP/JSON logs request: one resource, one scope, one record.
73
+ def envelope(record)
74
+ {
75
+ 'resourceLogs' => [
76
+ {
77
+ 'resource' => {
78
+ 'attributes' => [
79
+ { 'key' => 'service.name', 'value' => { 'stringValue' => @service_name } },
80
+ { 'key' => 'telemetry.sdk.name', 'value' => { 'stringValue' => 'confium' } },
81
+ { 'key' => 'telemetry.sdk.version', 'value' => { 'stringValue' => Confium::VERSION } }
82
+ ]
83
+ },
84
+ 'scopeLogs' => [
85
+ {
86
+ 'scope' => { 'name' => 'confium.audit' },
87
+ 'logRecords' => [log_record(record)]
88
+ }
89
+ ]
90
+ }
91
+ ]
92
+ }
93
+ end
94
+
95
+ def log_record(record)
96
+ {
97
+ 'timeUnixNano' => (parse_time(record['timestamp']) * 1_000_000_000).to_i.to_s,
98
+ 'severityNumber' => SEVERITY.fetch(record['result'], 9),
99
+ 'severityText' => record['result'] == 'success' ? 'INFO' : 'ERROR',
100
+ 'body' => { 'stringValue' => "#{record['operation']} #{record['result']}".strip },
101
+ 'attributes' => attributes_for(record)
102
+ }
103
+ end
104
+
105
+ def attributes_for(record)
106
+ record.except('timestamp').map do |key, value|
107
+ { 'key' => key, 'value' => { 'stringValue' => value.to_s } }
108
+ end
109
+ end
110
+
111
+ def parse_time(timestamp)
112
+ return Time.now.to_f unless timestamp.is_a?(String)
113
+
114
+ Time.parse(timestamp).to_f
115
+ rescue ArgumentError
116
+ Time.now.to_f
117
+ end
118
+ end
119
+ end
120
+ end
data/lib/confium/audit.rb CHANGED
@@ -124,3 +124,5 @@ module Confium
124
124
  end
125
125
  end
126
126
  end
127
+
128
+ require_relative 'audit/otlp_sink'
@@ -16,6 +16,32 @@ module Confium
16
16
  class Signature
17
17
  BINARY_FIELDS = %w[public_key signature].freeze
18
18
 
19
+ # Serialize the components this instance was built from, in the
20
+ # canonical wire format. Instances remember their source: built
21
+ # from components, or from a JSON document (which is emitted
22
+ # verbatim when it was canonical).
23
+ def to_json(*_args)
24
+ cached = @confium_components_json
25
+ return cached if cached
26
+
27
+ components = @confium_components or
28
+ raise Confium::Error, 'Signature was not built with component data; use Signature.new or from_json'
29
+ Signature.components_to_json(components)
30
+ end
31
+
32
+ class << self
33
+ # magnus exposes the constructor as a class-level `new`;
34
+ # capture it before this reopen shadows it (plain `super`
35
+ # falls through to Class#new instead).
36
+ alias __native_new new
37
+
38
+ def new(*args)
39
+ sig = __native_new(*args)
40
+ sig.instance_variable_set(:@confium_components, args.first) if args.first.is_a?(Array)
41
+ sig
42
+ end
43
+ end
44
+
19
45
  def self.components_to_json(components)
20
46
  JSON.generate(components.map do |component|
21
47
  component.transform_values do |value|
@@ -35,7 +61,19 @@ module Confium
35
61
  raise ArgumentError, 'expected a non-empty "components" array'
36
62
  end
37
63
 
38
- new(components.map { |c| decode_binary_fields(c) })
64
+ sig = new(components.map { |c| decode_binary_fields(c) })
65
+ sig.instance_variable_set(:@confium_components, components)
66
+ sig.instance_variable_set(:@confium_components_json, canonical_json(json, data))
67
+ sig
68
+ end
69
+
70
+ # The canonical wire form of whatever the caller handed us: a
71
+ # String passes through untouched; parsed structures are
72
+ # re-generated from their components array.
73
+ def self.canonical_json(json, data)
74
+ return json if json.is_a?(String)
75
+
76
+ JSON.generate(data.is_a?(Hash) ? data['components'] : data)
39
77
  end
40
78
 
41
79
  def self.decode_binary_fields(component)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confium
4
+ # Pure resolution of which native-extension ABI windows to try, in
5
+ # order. Extracted from the loader so the decision — the piece with
6
+ # the release-incident history (3.1-window segfaults on 3.2, the
7
+ # cross-major 3.3→4.0 TypeError, windowless platform gems) — is
8
+ # testable without installing eight platform gems.
9
+ module NativeWindows
10
+ # Ordered candidate windows for +ruby_version+ (e.g. "3.4.8").
11
+ #
12
+ # Background: platform gems ship one extension per ABI window.
13
+ # Ruby 3.2 broke the 3.1 ABI (object shapes) and rb-sys references
14
+ # a VM pointer libruby stopped exporting in 3.3, so 3.1/3.2/4.0
15
+ # get exact-minor builds while a 3.3-window binary loads on both
16
+ # 3.3 and 3.4. The 3.3 fallback applies within the 3.x line
17
+ # alone: a cross-major load (4.x) fails TypedData class-identity
18
+ # checks, so 4.0 must not fall back to it.
19
+ def self.candidates(ruby_version)
20
+ minor = ruby_version[/\A\d+\.\d+/]
21
+ raise ArgumentError, "not a ruby version: #{ruby_version.inspect}" unless minor
22
+
23
+ major = ruby_version[/\A\d+/].to_i
24
+ if major == 3 && Gem::Version.new(ruby_version) >= Gem::Version.new('3.3')
25
+ [minor, '3.3'].uniq
26
+ else
27
+ [minor]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Confium::TC::Coordinator wraps the async session coordinator service.
3
+ # Confium::TC::Coordinator coordinates an in-process threshold
4
+ # signing session: signers submit commitments and shares at their own
5
+ # pace; once the threshold is met, aggregation runs the real
6
+ # threshold-ECDSA combine (CMP20 or GG18) and returns a 64-byte
7
+ # (r, s) signature verifiable under the quorum public key.
4
8
  #
5
- # The coordinator enables globally distributed threshold signers to
6
- # participate when convenient no simultaneity required.
9
+ # The session semantics (state machine, duplicate-signer rejection,
10
+ # the combine) live in SigningSession; this class is the session
11
+ # registry and quorum-scoped entry point. NetworkCoordinator is the
12
+ # TCP/NDJSON adapter over the same sessions.
7
13
  #
8
14
  # Usage:
9
15
  # coordinator = Confium::TC::Coordinator.new(quorum_id: "biml-root")
10
16
  # session_id = coordinator.create_session(message: data,
11
- # threshold: 5,
12
- # unlock_window: 14400)
17
+ # threshold: 3,
18
+ # scheme: "CMP20-ECDSA-P256")
13
19
  # coordinator.submit_commitment(session_id, signer_id, commitment_bytes)
14
20
  # # ... wait for T commitments ...
15
21
  # coordinator.submit_share(session_id, signer_id, share_bytes)
@@ -25,43 +31,38 @@ module Confium
25
31
  @sessions = {}
26
32
  end
27
33
 
28
- def create_session(message:, threshold:, unlock_window: 14_400)
34
+ def create_session(message:, threshold:, unlock_window: SigningSession::DEFAULT_UNLOCK_WINDOW,
35
+ scheme: 'CMP20-ECDSA-P256')
29
36
  session_id = "session-#{@sessions.length + 1}"
30
- @sessions[session_id] = {
31
- message: message,
32
- threshold: threshold,
33
- unlock_window: unlock_window,
34
- state: :pending,
35
- commitments: [],
36
- shares: []
37
- }
37
+ @sessions[session_id] = SigningSession.new(
38
+ message: message, threshold: threshold,
39
+ unlock_window: unlock_window, scheme: scheme
40
+ )
38
41
  session_id
39
42
  end
40
43
 
41
44
  def session_state(session_id)
42
- @sessions.dig(session_id, :state)
45
+ fetch(session_id).state
43
46
  end
44
47
 
45
48
  def submit_commitment(session_id, signer_id, commitment_bytes)
46
- session = @sessions[session_id] or raise "Unknown session: #{session_id}"
47
- session[:commitments] << { signer_id: signer_id, bytes: commitment_bytes }
48
- return unless session[:commitments].length >= session[:threshold]
49
-
50
- session[:state] = :commitments_collected
49
+ fetch(session_id).add_commitment(signer_id, commitment_bytes)
51
50
  end
52
51
 
53
52
  def submit_share(session_id, signer_id, share_bytes)
54
- session = @sessions[session_id] or raise "Unknown session: #{session_id}"
55
- session[:shares] << { signer_id: signer_id, bytes: share_bytes }
53
+ fetch(session_id).add_share(signer_id, share_bytes)
56
54
  end
57
55
 
58
56
  def aggregate(session_id)
59
- session = @sessions[session_id] or raise "Unknown session: #{session_id}"
60
- raise 'Threshold not met' if session[:shares].length < session[:threshold]
57
+ fetch(session_id).aggregate
58
+ end
59
+
60
+ private
61
61
 
62
- session[:state] = :completed
63
- # In real implementation, calls FFI to aggregate shares
64
- session[:shares].map { |s| s[:bytes] }.join
62
+ def fetch(session_id)
63
+ @sessions.fetch(session_id) do
64
+ raise NotFoundError.new("Unknown session: #{session_id}", kind: :session, identifier: session_id)
65
+ end
65
66
  end
66
67
  end
67
68
  end