Skip to content

Commit 7bad4ae

Browse files
authored
Merge pull request #425 from sgbett/feat/419-certificate-infrastructure
feat(auth): certificate infrastructure -- Certificate, VerifiableCertificate, MasterCertificate
2 parents 8461cdf + 150089d commit 7bad4ae

File tree

9 files changed

+2361
-0
lines changed

9 files changed

+2361
-0
lines changed

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Metrics/ParameterLists:
131131
- 'gem/bsv-sdk/lib/bsv/overlay/**/*'
132132
- 'gem/bsv-sdk/lib/bsv/identity/**/*'
133133
- 'gem/bsv-sdk/lib/bsv/registry/**/*'
134+
- 'gem/bsv-sdk/lib/bsv/auth/**/*'
134135
- 'gem/bsv-wallet/lib/bsv/wallet_interface/**/*'
135136

136137
# The interpreter condition stack uses :true/:false symbols intentionally

gem/bsv-sdk/lib/bsv/auth.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ module Auth
88
autoload :SessionManager, 'bsv/auth/session_manager'
99
autoload :Transport, 'bsv/auth/transport'
1010
autoload :Peer, 'bsv/auth/peer'
11+
autoload :Certificate, 'bsv/auth/certificate'
12+
autoload :VerifiableCertificate, 'bsv/auth/verifiable_certificate'
13+
autoload :MasterCertificate, 'bsv/auth/master_certificate'
1114

1215
# Protocol version
1316
AUTH_VERSION = '0.1'
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# frozen_string_literal: true
2+
3+
require 'base64'
4+
5+
module BSV
6+
module Auth
7+
# Identity certificate as per the BRC-52 Wallet interface specification.
8+
#
9+
# A certificate binds identity attributes (fields) to a subject public key,
10+
# and is signed by a certifier. The binary serialisation format is shared
11+
# across all BSV SDKs (Go, TypeScript, Python, Ruby) so that certificates
12+
# produced by one SDK can be verified by another.
13+
#
14+
# All field values are expected to be Base64-encoded encrypted strings.
15+
# Signing and verification use BRC-42 key derivation:
16+
#
17+
# - Protocol: +[2, 'certificate signature']+
18+
# - Key ID: +"#{type} #{serial_number}"+
19+
# - Counterparty on sign: +'anyone'+ (default for +create_signature+)
20+
# - Counterparty on verify: the certifier's compressed public key hex
21+
#
22+
# Wallet parameters are duck-typed — any object responding to
23+
# +create_signature+, +verify_signature+, and +get_public_key+ is accepted.
24+
# No direct dependency on +BSV::Wallet::ProtoWallet+ is introduced here.
25+
#
26+
# @see https://hub.bsvblockchain.org/brc/wallet/0052 BRC-52
27+
class Certificate
28+
CERT_SIG_PROTOCOL = [2, 'certificate signature'].freeze
29+
CERT_FIELD_ENC_PROTOCOL = [2, 'certificate field encryption'].freeze
30+
31+
# @return [String] Base64 string decoding to 32 bytes
32+
attr_reader :type
33+
34+
# @return [String] Base64 string decoding to 32 bytes
35+
attr_reader :serial_number
36+
37+
# @return [String] compressed public key hex (66 characters)
38+
attr_reader :subject
39+
40+
# @return [String] compressed public key hex (66 characters)
41+
attr_accessor :certifier
42+
43+
# @return [String] outpoint string +"<txid_hex>.<output_index>"+
44+
attr_reader :revocation_outpoint
45+
46+
# @return [Hash] mapping field name strings to value strings
47+
attr_reader :fields
48+
49+
# @return [String, nil] DER-encoded signature as hex string, or nil if unsigned
50+
attr_accessor :signature
51+
52+
# @param type [String] Base64 string (32 bytes decoded)
53+
# @param serial_number [String] Base64 string (32 bytes decoded)
54+
# @param subject [String] compressed public key hex
55+
# @param certifier [String] compressed public key hex
56+
# @param revocation_outpoint [String] +"<txid_hex>.<output_index>"+
57+
# @param fields [Hash] field name strings to value strings
58+
# @param signature [String, nil] DER-encoded signature hex, or nil
59+
def initialize(type:, serial_number:, subject:, certifier:, revocation_outpoint:, fields:, signature: nil)
60+
@type = type
61+
@serial_number = serial_number
62+
@subject = subject
63+
@certifier = certifier
64+
@revocation_outpoint = revocation_outpoint
65+
@fields = fields
66+
@signature = signature
67+
end
68+
69+
# Serialise the certificate into its binary format.
70+
#
71+
# The binary format is byte-compatible with all other BSV SDK
72+
# implementations. Fields are sorted lexicographically by name.
73+
#
74+
# @param include_signature [Boolean] whether to append the signature bytes
75+
# @return [String] binary string
76+
def to_binary(include_signature: true)
77+
buf = String.new(encoding: Encoding::ASCII_8BIT)
78+
79+
buf << Base64.strict_decode64(@type)
80+
buf << Base64.strict_decode64(@serial_number)
81+
buf << [@subject].pack('H*')
82+
buf << [@certifier].pack('H*')
83+
84+
txid_hex, output_index_str = @revocation_outpoint.to_s.split('.', 2)
85+
buf << [txid_hex].pack('H*')
86+
buf << BSV::Transaction::VarInt.encode(output_index_str.to_i)
87+
88+
sorted_names = @fields.keys.sort
89+
buf << BSV::Transaction::VarInt.encode(sorted_names.length)
90+
sorted_names.each do |name|
91+
name_bytes = name.to_s.encode('UTF-8').b
92+
value_bytes = @fields[name].to_s.encode('UTF-8').b
93+
buf << BSV::Transaction::VarInt.encode(name_bytes.bytesize)
94+
buf << name_bytes
95+
buf << BSV::Transaction::VarInt.encode(value_bytes.bytesize)
96+
buf << value_bytes
97+
end
98+
99+
buf << [@signature].pack('H*') if include_signature && @signature && !@signature.empty?
100+
101+
buf
102+
end
103+
104+
# Deserialise a certificate from its binary format.
105+
#
106+
# When a signature is present in the trailing bytes, it is parsed via
107+
# {BSV::Primitives::Signature.from_der} to ensure strict DER normalisation
108+
# before being re-serialised as hex.
109+
#
110+
# @param data [String] binary string
111+
# @return [Certificate]
112+
def self.from_binary(data)
113+
data = data.b
114+
raise ArgumentError, "certificate binary too short (#{data.bytesize} bytes, minimum 163)" if data.bytesize < 163
115+
116+
pos = 0
117+
118+
type_bytes = data.byteslice(pos, 32)
119+
pos += 32
120+
serial_bytes = data.byteslice(pos, 32)
121+
pos += 32
122+
subject_bytes = data.byteslice(pos, 33)
123+
pos += 33
124+
certifier_bytes = data.byteslice(pos, 33)
125+
pos += 33
126+
127+
txid_bytes = data.byteslice(pos, 32)
128+
pos += 32
129+
output_index, vi_len = BSV::Transaction::VarInt.decode(data, pos)
130+
pos += vi_len
131+
132+
num_fields, vi_len = BSV::Transaction::VarInt.decode(data, pos)
133+
pos += vi_len
134+
fields = {}
135+
num_fields.times do
136+
name_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
137+
pos += vi_len
138+
name = data.byteslice(pos, name_len).force_encoding('UTF-8')
139+
pos += name_len
140+
141+
value_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
142+
pos += vi_len
143+
value = data.byteslice(pos, value_len).force_encoding('UTF-8')
144+
pos += value_len
145+
146+
fields[name] = value
147+
end
148+
149+
signature = nil
150+
if pos < data.bytesize
151+
sig_bytes = data.byteslice(pos, data.bytesize - pos)
152+
parsed = BSV::Primitives::Signature.from_der(sig_bytes)
153+
signature = parsed.to_hex
154+
end
155+
156+
new(
157+
type: Base64.strict_encode64(type_bytes),
158+
serial_number: Base64.strict_encode64(serial_bytes),
159+
subject: subject_bytes.unpack1('H*'),
160+
certifier: certifier_bytes.unpack1('H*'),
161+
revocation_outpoint: "#{txid_bytes.unpack1('H*')}.#{output_index}",
162+
fields: fields,
163+
signature: signature
164+
)
165+
end
166+
167+
# Verify the certificate's signature.
168+
#
169+
# Uses a fresh +'anyone'+ ProtoWallet as the verifier, which matches the
170+
# TS SDK behaviour. If no signature is present, raises +ArgumentError+.
171+
#
172+
# @param verifier_wallet [#verify_signature, nil] wallet to verify with;
173+
# defaults to +BSV::Wallet::ProtoWallet.new('anyone')+
174+
# @return [Boolean] +true+ if the signature is valid
175+
# @raise [ArgumentError] if the certificate has no signature
176+
def verify(verifier_wallet = nil)
177+
raise ArgumentError, 'certificate has no signature to verify' if @signature.nil? || @signature.empty?
178+
179+
verifier_wallet ||= BSV::Wallet::ProtoWallet.new('anyone')
180+
preimage = to_binary(include_signature: false)
181+
sig_bytes = [@signature].pack('H*').unpack('C*')
182+
183+
result = verifier_wallet.verify_signature({
184+
data: preimage.unpack('C*'),
185+
signature: sig_bytes,
186+
protocol_id: CERT_SIG_PROTOCOL,
187+
key_id: "#{@type} #{@serial_number}",
188+
counterparty: @certifier
189+
})
190+
191+
result.is_a?(Hash) && result[:valid] == true
192+
rescue BSV::Wallet::InvalidSignatureError
193+
false
194+
end
195+
196+
# Sign the certificate using the provided certifier wallet.
197+
#
198+
# The certifier field is updated to the wallet's identity key before
199+
# signing. Raises if the certificate is already signed.
200+
#
201+
# @param certifier_wallet [#create_signature, #get_public_key] certifier wallet
202+
# @raise [ArgumentError] if the certificate already has a signature
203+
def sign(certifier_wallet)
204+
raise ArgumentError, "certificate has already been signed: #{@signature}" if @signature && !@signature.empty?
205+
206+
@certifier = certifier_wallet.get_public_key({ identity_key: true })[:public_key]
207+
208+
preimage = to_binary(include_signature: false)
209+
result = certifier_wallet.create_signature({
210+
data: preimage.unpack('C*'),
211+
protocol_id: CERT_SIG_PROTOCOL,
212+
key_id: "#{@type} #{@serial_number}"
213+
})
214+
@signature = result[:signature].pack('C*').unpack1('H*')
215+
end
216+
217+
# Returns the protocol ID and key ID for certificate field encryption.
218+
#
219+
# When +serial_number+ is provided (for verifier keyring creation) the
220+
# key ID is +"#{serial_number} #{field_name}"+. Without a serial number
221+
# (for master keyring creation) the key ID is just the +field_name+.
222+
#
223+
# @param field_name [String] name of the certificate field
224+
# @param serial_number [String, nil] certificate serial number (Base64)
225+
# @return [Hash] +{ protocol_id:, key_id: }+
226+
def self.certificate_field_encryption_details(field_name, serial_number = nil)
227+
key_id = serial_number ? "#{serial_number} #{field_name}" : field_name
228+
{ protocol_id: CERT_FIELD_ENC_PROTOCOL, key_id: key_id }
229+
end
230+
231+
# Construct a Certificate from a plain Hash.
232+
#
233+
# Accepts both snake_case and camelCase key variants for each field
234+
# so that wire-format hashes can be passed in directly.
235+
#
236+
# @param hash [Hash] certificate data with snake_case or camelCase keys
237+
# @return [Certificate]
238+
def self.from_hash(hash)
239+
h = normalise_hash_keys(hash)
240+
new(
241+
type: h['type'],
242+
serial_number: h['serial_number'],
243+
subject: h['subject'],
244+
certifier: h['certifier'],
245+
revocation_outpoint: h['revocation_outpoint'],
246+
fields: h['fields'] || {},
247+
signature: h['signature']
248+
)
249+
end
250+
251+
# Return the certificate as a plain Hash with snake_case keys.
252+
#
253+
# JSON serialisation is simply +cert.to_h.to_json+.
254+
#
255+
# @return [Hash]
256+
def to_h
257+
{
258+
'type' => @type,
259+
'serial_number' => @serial_number,
260+
'subject' => @subject,
261+
'certifier' => @certifier,
262+
'revocation_outpoint' => @revocation_outpoint,
263+
'fields' => @fields.dup,
264+
'signature' => @signature
265+
}
266+
end
267+
268+
private_class_method def self.normalise_hash_keys(hash)
269+
mappings = {
270+
'type' => %w[type],
271+
'serial_number' => %w[serial_number serialNumber],
272+
'subject' => %w[subject],
273+
'certifier' => %w[certifier],
274+
'revocation_outpoint' => %w[revocation_outpoint revocationOutpoint],
275+
'fields' => %w[fields],
276+
'signature' => %w[signature]
277+
}
278+
279+
result = {}
280+
mappings.each do |canonical, aliases|
281+
aliases.each do |a|
282+
result[canonical] = hash[a] || hash[a.to_sym] if result[canonical].nil?
283+
end
284+
end
285+
result
286+
end
287+
end
288+
end
289+
end

0 commit comments

Comments
 (0)