confium 0.4.1-aarch64-linux → 0.6.0-aarch64-linux
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/Cargo.lock +432 -12
- data/lib/confium/audit/otlp_sink.rb +120 -0
- data/lib/confium/audit.rb +2 -0
- data/lib/confium/composite.rb +39 -1
- data/lib/confium/native_windows.rb +31 -0
- data/lib/confium/tc/coordinator.rb +28 -27
- data/lib/confium/tc/network_coordinator.rb +208 -0
- data/lib/confium/tc/signing_session.rb +93 -0
- data/lib/confium/tc.rb +7 -2
- data/lib/confium/version.rb +1 -1
- data/lib/confium.rb +10 -16
- data/lib/confium_native/3.1/confium_native.so +0 -0
- data/lib/confium_native/3.2/confium_native.so +0 -0
- data/lib/confium_native/3.3/confium_native.so +0 -0
- data/lib/confium_native/3.4/confium_native.so +0 -0
- data/lib/confium_native/4.0/confium_native.so +0 -0
- metadata +8 -9
- data/lib/confium/cfm.rb +0 -23
- data/lib/confium/crypto.rb +0 -51
- data/lib/confium/digest.rb +0 -62
- data/lib/confium/ffi.rb +0 -23
- data/lib/confium/lib.rb +0 -33
- data/lib/confium/tc/session.rb +0 -51
- data/lib/confium/tc/session_stub.rb +0 -43
|
@@ -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
data/lib/confium/composite.rb
CHANGED
|
@@ -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
|
|
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
|
|
6
|
-
#
|
|
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:
|
|
12
|
-
#
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
45
|
+
fetch(session_id).state
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def submit_commitment(session_id, signer_id, commitment_bytes)
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
57
|
+
fetch(session_id).aggregate
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require_relative 'coordinator'
|
|
6
|
+
|
|
7
|
+
module Confium
|
|
8
|
+
module TC
|
|
9
|
+
# Networked threshold-signing coordinator service.
|
|
10
|
+
#
|
|
11
|
+
# Wraps Confium::TC::Coordinator (real CMP20/GG18 combine) behind
|
|
12
|
+
# a TCP socket so signers on separate machines submit commitments
|
|
13
|
+
# and shares from their own processes:
|
|
14
|
+
#
|
|
15
|
+
# service = Confium::TC::NetworkCoordinator.new(quorum_id: 'root')
|
|
16
|
+
# service.start # binds 127.0.0.1:<ephemeral>
|
|
17
|
+
# client = Confium::TC::SignerClient.new(port: service.port)
|
|
18
|
+
# sid = client.create_session(message: data, threshold: 3)
|
|
19
|
+
# client.submit_share(sid, 'signer-1', share_blob)
|
|
20
|
+
# client.aggregate(sid) # => 64-byte signature
|
|
21
|
+
#
|
|
22
|
+
# Protocol: one JSON object per line (NDJSON); binary fields are
|
|
23
|
+
# hex-encoded. Unknown operations and Coordinator errors come
|
|
24
|
+
# back as {"error": ..., "message": ...} lines.
|
|
25
|
+
#
|
|
26
|
+
# Transport security: none yet — plain TCP intended for loopback
|
|
27
|
+
# or private networks. The upstream noise-transport session
|
|
28
|
+
# protocol replaces this wholesale (see
|
|
29
|
+
# TODO.full/01-multi-host-threshold-signing.md).
|
|
30
|
+
class NetworkCoordinator
|
|
31
|
+
HANDLERS = {
|
|
32
|
+
'create' => lambda do |coord, req|
|
|
33
|
+
session_id = coord.create_session(
|
|
34
|
+
message: [req.fetch('message_hex')].pack('H*'),
|
|
35
|
+
threshold: req.fetch('threshold'),
|
|
36
|
+
scheme: req['scheme'] || 'CMP20-ECDSA-P256'
|
|
37
|
+
)
|
|
38
|
+
{ 'session_id' => session_id }
|
|
39
|
+
end,
|
|
40
|
+
'commitment' => lambda do |coord, req|
|
|
41
|
+
session_id = req.fetch('session_id')
|
|
42
|
+
coord.submit_commitment(
|
|
43
|
+
session_id, req.fetch('signer_id'),
|
|
44
|
+
[req.fetch('bytes_hex')].pack('H*')
|
|
45
|
+
)
|
|
46
|
+
{ 'ok' => true, 'state' => coord.session_state(session_id).to_s }
|
|
47
|
+
end,
|
|
48
|
+
'share' => lambda do |coord, req|
|
|
49
|
+
coord.submit_share(
|
|
50
|
+
req.fetch('session_id'), req.fetch('signer_id'),
|
|
51
|
+
[req.fetch('bytes_hex')].pack('H*')
|
|
52
|
+
)
|
|
53
|
+
{ 'ok' => true }
|
|
54
|
+
end,
|
|
55
|
+
'aggregate' => lambda do |coord, req|
|
|
56
|
+
signature = coord.aggregate(req.fetch('session_id'))
|
|
57
|
+
{ 'signature_hex' => signature.unpack1('H*') }
|
|
58
|
+
end
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
class RequestError < StandardError; end
|
|
62
|
+
|
|
63
|
+
attr_reader :quorum_id, :port
|
|
64
|
+
|
|
65
|
+
def initialize(quorum_id:, host: '127.0.0.1', port: 0, coordinator: nil)
|
|
66
|
+
@quorum_id = quorum_id
|
|
67
|
+
@host = host
|
|
68
|
+
@port = port
|
|
69
|
+
@coordinator = coordinator || Coordinator.new(quorum_id: quorum_id)
|
|
70
|
+
@server = nil
|
|
71
|
+
@accept_thread = nil
|
|
72
|
+
@mutex = Mutex.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def start
|
|
76
|
+
raise 'already started' if @server
|
|
77
|
+
|
|
78
|
+
# RBS socket lib lacks the (host, port) overload for new
|
|
79
|
+
@server = TCPServer.new(@host.to_s, @port) # steep:ignore
|
|
80
|
+
@port = @server.addr[1]
|
|
81
|
+
@accept_thread = Thread.new { accept_loop }
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop
|
|
86
|
+
return unless @server
|
|
87
|
+
|
|
88
|
+
server = @server
|
|
89
|
+
@server = nil
|
|
90
|
+
server.close
|
|
91
|
+
@accept_thread&.join(5)
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def running?
|
|
96
|
+
!@server.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def accept_loop
|
|
102
|
+
loop do
|
|
103
|
+
server = @server
|
|
104
|
+
break unless server
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
socket = server.accept
|
|
108
|
+
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
109
|
+
break # listener closed by #stop
|
|
110
|
+
end
|
|
111
|
+
Thread.new(socket) { |conn| serve(conn) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def serve(socket)
|
|
116
|
+
socket.each_line do |line|
|
|
117
|
+
request = JSON.parse(line)
|
|
118
|
+
response = @mutex.synchronize { dispatch(request) }
|
|
119
|
+
socket.write("#{JSON.generate(response)}\n")
|
|
120
|
+
end
|
|
121
|
+
rescue JSON::ParserError => e
|
|
122
|
+
write_error(socket, 'RequestError', "malformed JSON: #{e.message}")
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
write_error(socket, e.class.name, e.message)
|
|
125
|
+
ensure
|
|
126
|
+
socket.close
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dispatch(request)
|
|
130
|
+
op = request['op'] or raise RequestError, 'missing "op"'
|
|
131
|
+
handler = HANDLERS[op] or raise RequestError, "unknown op: #{op}"
|
|
132
|
+
|
|
133
|
+
handler.call(@coordinator, request)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_error(socket, klass, message)
|
|
137
|
+
socket.write("#{JSON.generate({ 'error' => klass, 'message' => message })}\n")
|
|
138
|
+
rescue IOError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Client side for NetworkCoordinator. One instance per signer
|
|
144
|
+
# process; each call opens its own connection, so signers never
|
|
145
|
+
# share state.
|
|
146
|
+
class SignerClient
|
|
147
|
+
class RemoteError < StandardError
|
|
148
|
+
attr_reader :remote_class
|
|
149
|
+
|
|
150
|
+
def initialize(remote_class, message)
|
|
151
|
+
@remote_class = remote_class
|
|
152
|
+
super("#{remote_class}: #{message}")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def initialize(port:, host: '127.0.0.1')
|
|
157
|
+
@host = host
|
|
158
|
+
@port = port
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def create_session(message:, threshold:, scheme: 'CMP20-ECDSA-P256')
|
|
162
|
+
response = call(
|
|
163
|
+
'op' => 'create',
|
|
164
|
+
'message_hex' => message.to_s.unpack1('H*'),
|
|
165
|
+
'threshold' => threshold,
|
|
166
|
+
'scheme' => scheme
|
|
167
|
+
)
|
|
168
|
+
response.fetch('session_id')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def submit_commitment(session_id, signer_id, commitment_bytes)
|
|
172
|
+
call(
|
|
173
|
+
'op' => 'commitment',
|
|
174
|
+
'session_id' => session_id,
|
|
175
|
+
'signer_id' => signer_id,
|
|
176
|
+
'bytes_hex' => commitment_bytes.unpack1('H*')
|
|
177
|
+
).fetch('ok')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def submit_share(session_id, signer_id, share_bytes)
|
|
181
|
+
call(
|
|
182
|
+
'op' => 'share',
|
|
183
|
+
'session_id' => session_id,
|
|
184
|
+
'signer_id' => signer_id,
|
|
185
|
+
'bytes_hex' => share_bytes.unpack1('H*')
|
|
186
|
+
).fetch('ok')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def aggregate(session_id)
|
|
190
|
+
response = call('op' => 'aggregate', 'session_id' => session_id)
|
|
191
|
+
[response.fetch('signature_hex')].pack('H*')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def call(request)
|
|
197
|
+
TCPSocket.open(@host, @port) do |socket|
|
|
198
|
+
socket.write("#{JSON.generate(request)}\n")
|
|
199
|
+
line = socket.readline
|
|
200
|
+
response = JSON.parse(line)
|
|
201
|
+
raise RemoteError.new(response['error'], response['message']) if response['error']
|
|
202
|
+
|
|
203
|
+
response
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Confium
|
|
4
|
+
module TC
|
|
5
|
+
# One threshold-signing session: the state machine and the combine
|
|
6
|
+
# behind every coordinator surface. Coordinator (in-process) and
|
|
7
|
+
# NetworkCoordinator (TCP/NDJSON) are adapters over this class —
|
|
8
|
+
# they own registries and framing; the session owns the semantics:
|
|
9
|
+
#
|
|
10
|
+
# pending → commitments_collected (T distinct commitments)
|
|
11
|
+
# → completed (aggregate runs the scheme combine)
|
|
12
|
+
#
|
|
13
|
+
# Aggregate may be called again after completion — the combine
|
|
14
|
+
# randomizes its nonce, so each call yields a fresh (verifiable)
|
|
15
|
+
# signature over the same shares, which is what a network client
|
|
16
|
+
# retrying after a dropped response needs.
|
|
17
|
+
#
|
|
18
|
+
# Signer identity is load-bearing: one signer submitting two
|
|
19
|
+
# shares must never count twice toward the threshold, so
|
|
20
|
+
# commitments and shares are keyed by signer_id and duplicates
|
|
21
|
+
# raise.
|
|
22
|
+
class SigningSession
|
|
23
|
+
DEFAULT_UNLOCK_WINDOW = 14_400
|
|
24
|
+
|
|
25
|
+
SCHEMES = {
|
|
26
|
+
'CMP20-ECDSA-P256' => ->(shares, threshold, message) { Cmp20.sign(shares, threshold, message) },
|
|
27
|
+
'GG18-ECDSA-P256' => ->(shares, threshold, message) { Gg18.sign(shares, threshold, message) }
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :message, :threshold, :unlock_window, :scheme, :state
|
|
31
|
+
|
|
32
|
+
def initialize(message:, threshold:, unlock_window: DEFAULT_UNLOCK_WINDOW, scheme: 'CMP20-ECDSA-P256')
|
|
33
|
+
raise ArgumentError, "unknown scheme: #{scheme} (known: #{SCHEMES.keys.join(', ')})" unless SCHEMES.key?(scheme)
|
|
34
|
+
|
|
35
|
+
@message = message
|
|
36
|
+
@threshold = threshold
|
|
37
|
+
@unlock_window = unlock_window
|
|
38
|
+
@scheme = scheme
|
|
39
|
+
@state = :pending
|
|
40
|
+
# @type ivar @commitments: Hash[String, String]
|
|
41
|
+
@commitments = {}
|
|
42
|
+
# @type ivar @shares: Hash[String, String]
|
|
43
|
+
@shares = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_commitment(signer_id, commitment_bytes)
|
|
47
|
+
record(@commitments, signer_id, commitment_bytes)
|
|
48
|
+
@state = :commitments_collected if @commitments.length >= @threshold
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_share(signer_id, share_bytes)
|
|
53
|
+
record(@shares, signer_id, share_bytes)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def commitment_count
|
|
58
|
+
@commitments.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def share_count
|
|
62
|
+
@shares.length
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def threshold_met?
|
|
66
|
+
@shares.length >= @threshold
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def aggregate
|
|
70
|
+
unless threshold_met?
|
|
71
|
+
raise ThresholdError.new('Threshold not met', have_count: @shares.length, need_count: @threshold)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
signature = SCHEMES.fetch(@scheme).call(@shares.values, @threshold, @message)
|
|
75
|
+
@state = :completed
|
|
76
|
+
signature
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def record(store, signer_id, bytes)
|
|
82
|
+
if store.key?(signer_id)
|
|
83
|
+
raise ValidationError.new(
|
|
84
|
+
"duplicate submission from signer #{signer_id}",
|
|
85
|
+
param: :signer_id, expected: 'one submission per signer', actual: signer_id
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
store[signer_id] = bytes
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/confium/tc.rb
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
# for the full interface specification.
|
|
12
12
|
module Confium
|
|
13
13
|
module TC
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
# The native extension defines Confium::TC; this namespace file
|
|
15
|
+
# is eager-required from confium.rb, so autoloads never fire —
|
|
16
|
+
# the pure-Ruby companions load with the namespace directly.
|
|
17
|
+
require_relative 'tc/signing_session'
|
|
18
|
+
require_relative 'tc/coordinator'
|
|
19
|
+
require_relative 'tc/network_coordinator'
|
|
20
|
+
require_relative 'tc/share_file'
|
|
16
21
|
end
|
|
17
22
|
end
|
data/lib/confium/version.rb
CHANGED
data/lib/confium.rb
CHANGED
|
@@ -10,21 +10,14 @@
|
|
|
10
10
|
# are loaded lazily via autoload — see lib/confium/<name>.rb.
|
|
11
11
|
|
|
12
12
|
require_relative 'confium/version'
|
|
13
|
+
require_relative 'confium/native_windows'
|
|
13
14
|
|
|
14
15
|
begin
|
|
15
|
-
# Pre-built platform gems ship one extension per
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# 3.1 and 3.2 get exact-minor builds while 3.3 covers 3.3+).
|
|
19
|
-
# Source builds install the extension flat, without a version
|
|
20
|
-
# directory.
|
|
21
|
-
minor = RUBY_VERSION[/\A\d+\.\d+/]
|
|
16
|
+
# Pre-built platform gems ship one extension per ABI window (the
|
|
17
|
+
# window rules live in Confium::NativeWindows). Source builds
|
|
18
|
+
# install the extension flat, without a version directory.
|
|
22
19
|
dlext = RbConfig::CONFIG['DLEXT'] || 'so'
|
|
23
|
-
|
|
24
|
-
# Windows gems carry an exact-minor window per Ruby (a PE import
|
|
25
|
-
# names the version-specific ruby DLL); other platforms share the
|
|
26
|
-
# 3.3 window for 3.3+. Prefer the exact minor when present.
|
|
27
|
-
windowed = candidates.filter_map do |w|
|
|
20
|
+
windowed = Confium::NativeWindows.candidates(RUBY_VERSION).filter_map do |w|
|
|
28
21
|
path = File.expand_path("confium_native/#{w}/confium_native.#{dlext}", __dir__ || '.')
|
|
29
22
|
w if File.exist?(path)
|
|
30
23
|
end
|
|
@@ -92,7 +85,8 @@ require_relative 'confium/transparency'
|
|
|
92
85
|
# of that module.
|
|
93
86
|
require_relative 'confium/audit'
|
|
94
87
|
|
|
95
|
-
# Eager-load the TC
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
88
|
+
# Eager-load the TC namespace file: `Confium::TC` is native-defined,
|
|
89
|
+
# so an autoload here would never fire (the PKI pattern). It
|
|
90
|
+
# registers the pure-Ruby Session/Coordinator companions and the
|
|
91
|
+
# ShareFile class for filesystem-backed share persistence.
|
|
92
|
+
require_relative 'confium/tc'
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|