confium 0.3.4-aarch64-linux → 0.5.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 +77 -0
- data/Cargo.lock +12 -432
- data/README.adoc +6 -10
- data/lib/confium/audit/otlp_sink.rb +103 -0
- data/lib/confium/audit.rb +2 -0
- data/lib/confium/composite.rb +39 -1
- data/lib/confium/openpgp.rb +138 -16
- data/lib/confium/tc/coordinator.rb +23 -10
- data/lib/confium/tc/network_coordinator.rb +208 -0
- data/lib/confium/tc.rb +8 -1
- data/lib/confium/version.rb +1 -1
- data/lib/confium.rb +29 -8
- 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 +6 -2
data/README.adoc
CHANGED
|
@@ -10,7 +10,7 @@ Confium supports three deployment modes:
|
|
|
10
10
|
* **Mode 2 — TC PKI replacement**: drop-in for existing PKI consumers (PKCS#11 server, OpenSSL 3.0 provider, JCE)
|
|
11
11
|
* **Mode 3 — TC Certificate PKI**: institutional deployments with custom certificate formats (OIML CNML, BIPM, pharma, accreditation)
|
|
12
12
|
|
|
13
|
-
This gem wraps the high-value Confium subsystems via a Rust native extension. Pre-compiled platform gems are published for Linux
|
|
13
|
+
This gem wraps the high-value Confium subsystems via a pure-Rust native extension (`rb_sys` + `magnus`, no C dependencies). Pre-compiled platform gems are published for Linux (glibc + musl), macOS, and Windows, so `gem install` needs no Rust toolchain there; other platforms build from source at install time.
|
|
14
14
|
|
|
15
15
|
== Installation
|
|
16
16
|
|
|
@@ -32,17 +32,13 @@ $ gem install confium
|
|
|
32
32
|
|
|
33
33
|
* Ruby ≥ 3.1
|
|
34
34
|
* Nothing else on the pre-compiled platforms: `x86_64-linux`,
|
|
35
|
-
`aarch64-linux`, `x86_64-
|
|
36
|
-
|
|
35
|
+
`aarch64-linux`, `x86_64-linux-musl`, `aarch64-linux-musl`,
|
|
36
|
+
`x86_64-darwin`, `arm64-darwin`, `x64-mingw-ucrt` (each gem
|
|
37
|
+
carries one extension per Ruby C-ABI window)
|
|
37
38
|
|
|
38
39
|
Source builds (other platforms, or installing from the repo) also
|
|
39
|
-
need
|
|
40
|
-
|
|
41
|
-
* Rust stable toolchain (`rustup default stable`)
|
|
42
|
-
* C toolchain (clang/gcc/Xcode CLT)
|
|
43
|
-
|
|
44
|
-
There is no separate `libconfium` to install — the extension
|
|
45
|
-
statically links everything.
|
|
40
|
+
need the Rust stable toolchain (`rustup default stable`) — and
|
|
41
|
+
nothing else: the extension has no C dependencies to satisfy.
|
|
46
42
|
|
|
47
43
|
== Quick start
|
|
48
44
|
|
|
@@ -0,0 +1,103 @@
|
|
|
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; failures are reported on
|
|
20
|
+
# $stderr and the record is dropped (the audit core's policy — a
|
|
21
|
+
# telemetry outage must never break signing). stdlib only.
|
|
22
|
+
class OtlpSink < Sink
|
|
23
|
+
DEFAULT_ENDPOINT = 'http://localhost:4318/v1/logs'
|
|
24
|
+
SEVERITY = { 'success' => 9, 'failure' => 17, 'error' => 17 }.freeze # INFO / ERROR
|
|
25
|
+
|
|
26
|
+
def initialize(endpoint: DEFAULT_ENDPOINT, headers: {}, service_name: 'confium', timeout: 5)
|
|
27
|
+
super()
|
|
28
|
+
@uri = URI.parse(endpoint)
|
|
29
|
+
@headers = headers
|
|
30
|
+
@service_name = service_name
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@dropped = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Records dropped after failed delivery (diagnostic only).
|
|
36
|
+
attr_reader :dropped
|
|
37
|
+
|
|
38
|
+
def write(record)
|
|
39
|
+
payload = envelope(record)
|
|
40
|
+
response = Net::HTTP.post(@uri, JSON.generate(payload), headers.merge('Content-Type' => 'application/json'))
|
|
41
|
+
raise "collector responded #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@dropped += 1
|
|
44
|
+
warn "confium: OTLP delivery failed, audit record dropped (##{@dropped}): #{e.class}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def close; end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def headers
|
|
52
|
+
{ 'User-Agent' => "confium-otlp-sink #{Confium::VERSION}" }.merge(@headers)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# OTLP/JSON logs request: one resource, one scope, one record.
|
|
56
|
+
def envelope(record)
|
|
57
|
+
{
|
|
58
|
+
'resourceLogs' => [
|
|
59
|
+
{
|
|
60
|
+
'resource' => {
|
|
61
|
+
'attributes' => [
|
|
62
|
+
{ 'key' => 'service.name', 'value' => { 'stringValue' => @service_name } },
|
|
63
|
+
{ 'key' => 'telemetry.sdk.name', 'value' => { 'stringValue' => 'confium' } },
|
|
64
|
+
{ 'key' => 'telemetry.sdk.version', 'value' => { 'stringValue' => Confium::VERSION } }
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
'scopeLogs' => [
|
|
68
|
+
{
|
|
69
|
+
'scope' => { 'name' => 'confium.audit' },
|
|
70
|
+
'logRecords' => [log_record(record)]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def log_record(record)
|
|
79
|
+
{
|
|
80
|
+
'timeUnixNano' => (parse_time(record['timestamp']) * 1_000_000_000).to_i.to_s,
|
|
81
|
+
'severityNumber' => SEVERITY.fetch(record['result'], 9),
|
|
82
|
+
'severityText' => record['result'] == 'success' ? 'INFO' : 'ERROR',
|
|
83
|
+
'body' => { 'stringValue' => "#{record['operation']} #{record['result']}".strip },
|
|
84
|
+
'attributes' => attributes_for(record)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def attributes_for(record)
|
|
89
|
+
record.except('timestamp').map do |key, value|
|
|
90
|
+
{ 'key' => key, 'value' => { 'stringValue' => value.to_s } }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_time(timestamp)
|
|
95
|
+
return Time.now.to_f unless timestamp.is_a?(String)
|
|
96
|
+
|
|
97
|
+
Time.parse(timestamp).to_f
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
Time.now.to_f
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
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)
|
data/lib/confium/openpgp.rb
CHANGED
|
@@ -1,33 +1,155 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Confium::OpenPGP — OpenPGP (RFC 9580) via bundled rnp-rs.
|
|
4
|
-
#
|
|
5
|
-
# The native extension provides _native_armor / _native_dearmor.
|
|
6
|
-
# This file adds the idiomatic Ruby wrappers with default args.
|
|
7
|
-
#
|
|
8
|
-
# Architecture: RNP is HARD-BUNDLED in the native extension. No
|
|
9
|
-
# external gem dependency. Users get OpenPGP armor encode/decode
|
|
10
|
-
# out of the box.
|
|
11
|
-
|
|
12
3
|
module Confium
|
|
4
|
+
# OpenPGP ASCII armor (RFC 9580 §6) — Radix-64 framing with a
|
|
5
|
+
# CRC-24 checksum, implemented in pure Ruby.
|
|
6
|
+
#
|
|
7
|
+
# This replaced the earlier native wrapper around librnp, which
|
|
8
|
+
# pulled a full vendored Botan/json-c C/C++ build into every
|
|
9
|
+
# install just to do armor framing. The wire format is unchanged;
|
|
10
|
+
# spec/fixtures/openpgp_armor_vectors.json holds differential
|
|
11
|
+
# vectors captured from the native implementation.
|
|
13
12
|
module OpenPGP
|
|
13
|
+
MESSAGE = 'message'
|
|
14
|
+
PUBLIC_KEY = 'public key'
|
|
15
|
+
SECRET_KEY = 'secret key'
|
|
16
|
+
SIGNATURE = 'signature'
|
|
17
|
+
CLEARTEXT = 'cleartext signed message'
|
|
18
|
+
|
|
19
|
+
LABELS = {
|
|
20
|
+
nil => 'MESSAGE',
|
|
21
|
+
MESSAGE => 'MESSAGE',
|
|
22
|
+
'public key' => 'PUBLIC KEY BLOCK',
|
|
23
|
+
SECRET_KEY => 'PRIVATE KEY BLOCK',
|
|
24
|
+
'private key' => 'PRIVATE KEY BLOCK',
|
|
25
|
+
SIGNATURE => 'SIGNATURE',
|
|
26
|
+
# Raw bytes cannot form a cleartext-signed message (that
|
|
27
|
+
# requires a signature packet); armor them as a plain message.
|
|
28
|
+
CLEARTEXT => 'MESSAGE',
|
|
29
|
+
'cleartext' => 'MESSAGE'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
CRC_POLY = 0x1864CFB
|
|
33
|
+
CRC_INIT = 0xB704CE
|
|
34
|
+
B64_CHARS = [('A'..'Z').to_a, ('a'..'z').to_a, ('0'..'9').to_a, %w[+ /]].flatten.freeze
|
|
35
|
+
CRC_TABLE = (0..255).map do |i|
|
|
36
|
+
c = i << 16
|
|
37
|
+
8.times do
|
|
38
|
+
c = c.nobits?(0x800_000) ? c << 1 : ((c << 1) ^ CRC_POLY)
|
|
39
|
+
c &= 0xFFFFFF
|
|
40
|
+
end
|
|
41
|
+
c
|
|
42
|
+
end.freeze
|
|
43
|
+
|
|
44
|
+
private_constant :LABELS, :CRC_POLY, :CRC_INIT, :B64_CHARS, :CRC_TABLE
|
|
45
|
+
|
|
14
46
|
class << self
|
|
15
|
-
# ASCII-armor encode raw bytes.
|
|
47
|
+
# ASCII-armor encode raw bytes. Output uses CRLF line endings
|
|
48
|
+
# and 76-character data lines, byte-for-byte matching the
|
|
49
|
+
# earlier native (rnp) implementation.
|
|
16
50
|
#
|
|
17
51
|
# @param data [String] Binary data to encode.
|
|
18
|
-
# @param type [String]
|
|
19
|
-
#
|
|
52
|
+
# @param type [String] One of MESSAGE, PUBLIC_KEY, SECRET_KEY,
|
|
53
|
+
# SIGNATURE, CLEARTEXT (armored as a plain message). Defaults
|
|
54
|
+
# to MESSAGE.
|
|
20
55
|
# @return [String] Armored ASCII string.
|
|
56
|
+
# @raise [ArgumentError] if +type+ is not a known armor type.
|
|
21
57
|
def armor(data, type = MESSAGE)
|
|
22
|
-
|
|
58
|
+
label = LABELS[type]
|
|
59
|
+
raise ArgumentError, "unknown armor type: #{type.inspect}" unless label
|
|
60
|
+
|
|
61
|
+
bytes = data.to_s.b
|
|
62
|
+
b64 = [bytes].pack('m0')
|
|
63
|
+
lines = b64.scan(/.{1,76}/)
|
|
64
|
+
<<~ARMOR.gsub("\n", "\r\n")
|
|
65
|
+
-----BEGIN PGP #{label}-----
|
|
66
|
+
|
|
67
|
+
#{lines.join("\n")}
|
|
68
|
+
=#{crc24_armor(bytes)}
|
|
69
|
+
-----END PGP #{label}-----
|
|
70
|
+
ARMOR
|
|
23
71
|
end
|
|
24
72
|
|
|
25
|
-
# Decode ASCII-armored data to raw bytes.
|
|
73
|
+
# Decode ASCII-armored data to raw bytes. Accepts LF or CRLF
|
|
74
|
+
# line endings, arbitrary line widths, and Armor Headers
|
|
75
|
+
# (Comment:, Version:, ...) between the BEGIN line and the
|
|
76
|
+
# blank line. The CRC-24 checksum line is verified when
|
|
77
|
+
# present.
|
|
26
78
|
#
|
|
27
79
|
# @param data [String] Armored ASCII string.
|
|
28
|
-
# @return [String] Raw binary data.
|
|
80
|
+
# @return [String] Raw binary data (ASCII-8BIT).
|
|
81
|
+
# @raise [Confium::ParseError] on missing delimiters, non-base64
|
|
82
|
+
# content, or a CRC mismatch.
|
|
29
83
|
def dearmor(data)
|
|
30
|
-
|
|
84
|
+
b64, crc_line = extract_body(data.to_s)
|
|
85
|
+
# @type var bytes: String
|
|
86
|
+
bytes = b64.unpack1('m0')
|
|
87
|
+
return ''.b if crc_line == crc24_armor(''.b) && b64.empty?
|
|
88
|
+
|
|
89
|
+
raise ParseError, 'armor CRC-24 checksum mismatch' if crc_line && crc24_armor(bytes) != crc_line.to_s
|
|
90
|
+
|
|
91
|
+
bytes
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Locate the armored block, skip the BEGIN line and any Armor
|
|
97
|
+
# Headers, and split the remaining lines into the joined
|
|
98
|
+
# Radix-64 data and the checksum line (if present).
|
|
99
|
+
def extract_body(text)
|
|
100
|
+
lines = block_lines(text)
|
|
101
|
+
b64, crc_line = collect_data(lines)
|
|
102
|
+
raise ParseError, 'armored block has no data' if b64.empty? && crc_line.nil?
|
|
103
|
+
|
|
104
|
+
[b64, crc_line]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The lines of the armored block between BEGIN and END, with
|
|
108
|
+
# the BEGIN line and any Armor Headers removed.
|
|
109
|
+
def block_lines(text)
|
|
110
|
+
slice = block_slice(text.gsub("\r\n", "\n"))
|
|
111
|
+
lines = slice.split("\n")
|
|
112
|
+
lines.shift # BEGIN
|
|
113
|
+
lines.shift while lines.first&.match?(/^\s|^[A-Za-z0-9-]+: /)
|
|
114
|
+
lines.shift if lines.first == ''
|
|
115
|
+
lines
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The text between the BEGIN and END delimiter lines.
|
|
119
|
+
def block_slice(normalized)
|
|
120
|
+
begin_line = normalized.index(/^-----BEGIN PGP [A-Z ]+-----$/)
|
|
121
|
+
raise ParseError, 'not an ASCII-armored block (no BEGIN line)' unless begin_line
|
|
122
|
+
|
|
123
|
+
end_match = normalized.match(/^-----END PGP [A-Z ]+-----$/)
|
|
124
|
+
raise ParseError, 'not an ASCII-armored block (no END line)' unless end_match
|
|
125
|
+
|
|
126
|
+
normalized[begin_line...end_match.begin(0)].to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def collect_data(lines)
|
|
130
|
+
b64 = +''
|
|
131
|
+
crc_line = nil
|
|
132
|
+
lines.each do |line|
|
|
133
|
+
next if line.empty?
|
|
134
|
+
|
|
135
|
+
if line.start_with?('=')
|
|
136
|
+
crc_line = line[1..]
|
|
137
|
+
elsif line.match?(%r{\A[A-Za-z0-9+/]+={0,2}\z})
|
|
138
|
+
b64 << line
|
|
139
|
+
else
|
|
140
|
+
raise ParseError, "invalid armor data line: #{line[0, 20].inspect}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
[b64, crc_line]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# CRC-24 (RFC 9580 §6.1), encoded as the four Radix-64
|
|
147
|
+
# characters of the 24-bit value as three big-endian bytes.
|
|
148
|
+
def crc24_armor(bytes)
|
|
149
|
+
crc = CRC_INIT
|
|
150
|
+
bytes.each_byte { |b| crc = ((crc << 8) & 0xFFFFFF) ^ CRC_TABLE[((crc >> 16) ^ b) & 0xFF] }
|
|
151
|
+
three_bytes = [crc >> 16, (crc >> 8) & 0xFF, crc & 0xFF].pack('C3')
|
|
152
|
+
[three_bytes].pack('m0')
|
|
31
153
|
end
|
|
32
154
|
end
|
|
33
155
|
end
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Confium::TC::Coordinator
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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 — the same in-process
|
|
7
|
+
# drivers behind Confium::TC::Cmp20 / Confium::TC::Gg18) and returns
|
|
8
|
+
# a 64-byte (r, s) signature verifiable under the quorum public key.
|
|
7
9
|
#
|
|
8
10
|
# Usage:
|
|
9
11
|
# coordinator = Confium::TC::Coordinator.new(quorum_id: "biml-root")
|
|
10
12
|
# session_id = coordinator.create_session(message: data,
|
|
11
|
-
# threshold:
|
|
12
|
-
#
|
|
13
|
+
# threshold: 3,
|
|
14
|
+
# scheme: "CMP20-ECDSA-P256")
|
|
13
15
|
# coordinator.submit_commitment(session_id, signer_id, commitment_bytes)
|
|
14
16
|
# # ... wait for T commitments ...
|
|
15
17
|
# coordinator.submit_share(session_id, signer_id, share_bytes)
|
|
@@ -25,12 +27,20 @@ module Confium
|
|
|
25
27
|
@sessions = {}
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
SCHEMES = {
|
|
31
|
+
'CMP20-ECDSA-P256' => ->(shares, threshold, message) { Cmp20.sign(shares, threshold, message) },
|
|
32
|
+
'GG18-ECDSA-P256' => ->(shares, threshold, message) { Gg18.sign(shares, threshold, message) }
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def create_session(message:, threshold:, unlock_window: 14_400, scheme: 'CMP20-ECDSA-P256')
|
|
36
|
+
raise ArgumentError, "unknown scheme: #{scheme} (known: #{SCHEMES.keys.join(', ')})" unless SCHEMES.key?(scheme)
|
|
37
|
+
|
|
29
38
|
session_id = "session-#{@sessions.length + 1}"
|
|
30
39
|
@sessions[session_id] = {
|
|
31
40
|
message: message,
|
|
32
41
|
threshold: threshold,
|
|
33
42
|
unlock_window: unlock_window,
|
|
43
|
+
scheme: scheme,
|
|
34
44
|
state: :pending,
|
|
35
45
|
commitments: [],
|
|
36
46
|
shares: []
|
|
@@ -57,11 +67,14 @@ module Confium
|
|
|
57
67
|
|
|
58
68
|
def aggregate(session_id)
|
|
59
69
|
session = @sessions[session_id] or raise "Unknown session: #{session_id}"
|
|
60
|
-
raise 'Threshold not met' if session[:shares].length < session[:threshold]
|
|
70
|
+
raise ThresholdError, 'Threshold not met' if session[:shares].length < session[:threshold]
|
|
61
71
|
|
|
62
72
|
session[:state] = :completed
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
SCHEMES.fetch(session[:scheme]).call(
|
|
74
|
+
session[:shares].map { |s| s[:bytes] },
|
|
75
|
+
session[:threshold],
|
|
76
|
+
session[:message]
|
|
77
|
+
)
|
|
65
78
|
end
|
|
66
79
|
end
|
|
67
80
|
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
|
data/lib/confium/tc.rb
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
# for the full interface specification.
|
|
12
12
|
module Confium
|
|
13
13
|
module TC
|
|
14
|
+
# The native extension defines Confium::TC; this namespace file is
|
|
15
|
+
# eager-required from confium.rb, so autoloads registered here do
|
|
16
|
+
# fire. Coordinator and ShareFile are pure Ruby and load with the
|
|
17
|
+
# namespace; Session wraps the engine via FFI and needs the
|
|
18
|
+
# external libconfium dylib, so it stays lazy.
|
|
14
19
|
autoload :Session, 'confium/tc/session'
|
|
15
|
-
|
|
20
|
+
require_relative 'tc/coordinator'
|
|
21
|
+
require_relative 'tc/network_coordinator'
|
|
22
|
+
require_relative 'tc/share_file'
|
|
16
23
|
end
|
|
17
24
|
end
|
data/lib/confium/version.rb
CHANGED