Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Metrics/ParameterLists:
- 'gem/bsv-sdk/lib/bsv/overlay/**/*'
- 'gem/bsv-sdk/lib/bsv/identity/**/*'
- 'gem/bsv-sdk/lib/bsv/registry/**/*'
- 'gem/bsv-sdk/lib/bsv/auth/**/*'
- 'gem/bsv-wallet/lib/bsv/wallet_interface/**/*'

# The interpreter condition stack uses :true/:false symbols intentionally
Expand Down
3 changes: 3 additions & 0 deletions gem/bsv-sdk/lib/bsv/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ module Auth
autoload :SessionManager, 'bsv/auth/session_manager'
autoload :Transport, 'bsv/auth/transport'
autoload :Peer, 'bsv/auth/peer'
autoload :Certificate, 'bsv/auth/certificate'
autoload :VerifiableCertificate, 'bsv/auth/verifiable_certificate'
autoload :MasterCertificate, 'bsv/auth/master_certificate'

# Protocol version
AUTH_VERSION = '0.1'
Expand Down
287 changes: 287 additions & 0 deletions gem/bsv-sdk/lib/bsv/auth/certificate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
# frozen_string_literal: true

require 'base64'

module BSV
module Auth
# Identity certificate as per the BRC-52 Wallet interface specification.
#
# A certificate binds identity attributes (fields) to a subject public key,
# and is signed by a certifier. The binary serialisation format is shared
# across all BSV SDKs (Go, TypeScript, Python, Ruby) so that certificates
# produced by one SDK can be verified by another.
#
# All field values are expected to be Base64-encoded encrypted strings.
# Signing and verification use BRC-42 key derivation:
#
# - Protocol: +[2, 'certificate signature']+
# - Key ID: +"#{type} #{serial_number}"+
# - Counterparty on sign: +'anyone'+ (default for +create_signature+)
# - Counterparty on verify: the certifier's compressed public key hex
#
# Wallet parameters are duck-typed — any object responding to
# +create_signature+, +verify_signature+, and +get_public_key+ is accepted.
# No direct dependency on +BSV::Wallet::ProtoWallet+ is introduced here.
#
# @see https://hub.bsvblockchain.org/brc/wallet/0052 BRC-52
class Certificate
CERT_SIG_PROTOCOL = [2, 'certificate signature'].freeze
CERT_FIELD_ENC_PROTOCOL = [2, 'certificate field encryption'].freeze

# @return [String] Base64 string decoding to 32 bytes
attr_reader :type

# @return [String] Base64 string decoding to 32 bytes
attr_reader :serial_number

# @return [String] compressed public key hex (66 characters)
attr_reader :subject

# @return [String] compressed public key hex (66 characters)
attr_accessor :certifier

# @return [String] outpoint string +"<txid_hex>.<output_index>"+
attr_reader :revocation_outpoint

# @return [Hash] mapping field name strings to value strings
attr_reader :fields

# @return [String, nil] DER-encoded signature as hex string, or nil if unsigned
attr_accessor :signature

# @param type [String] Base64 string (32 bytes decoded)
# @param serial_number [String] Base64 string (32 bytes decoded)
# @param subject [String] compressed public key hex
# @param certifier [String] compressed public key hex
# @param revocation_outpoint [String] +"<txid_hex>.<output_index>"+
# @param fields [Hash] field name strings to value strings
# @param signature [String, nil] DER-encoded signature hex, or nil
def initialize(type:, serial_number:, subject:, certifier:, revocation_outpoint:, fields:, signature: nil)
@type = type
@serial_number = serial_number
@subject = subject
@certifier = certifier
@revocation_outpoint = revocation_outpoint
@fields = fields
@signature = signature
end

# Serialise the certificate into its binary format.
#
# The binary format is byte-compatible with all other BSV SDK
# implementations. Fields are sorted lexicographically by name.
#
# @param include_signature [Boolean] whether to append the signature bytes
# @return [String] binary string
def to_binary(include_signature: true)
buf = String.new(encoding: Encoding::ASCII_8BIT)

buf << Base64.strict_decode64(@type)
buf << Base64.strict_decode64(@serial_number)
buf << [@subject].pack('H*')
buf << [@certifier].pack('H*')

txid_hex, output_index_str = @revocation_outpoint.to_s.split('.', 2)
buf << [txid_hex].pack('H*')
buf << BSV::Transaction::VarInt.encode(output_index_str.to_i)
Comment on lines +84 to +86
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to_binary parses revocation_outpoint via split('.', 2) and then uses output_index_str.to_i, which silently converts invalid strings (including nil) to 0. That can serialize/sign a different outpoint than the caller intended. Validate the outpoint format (txid hex + '.' + non-negative integer) and raise ArgumentError on invalid input rather than silently coercing.

Suggested change
txid_hex, output_index_str = @revocation_outpoint.to_s.split('.', 2)
buf << [txid_hex].pack('H*')
buf << BSV::Transaction::VarInt.encode(output_index_str.to_i)
outpoint = @revocation_outpoint
match = outpoint.is_a?(String) && /\A([0-9a-fA-F]{64})\.(0|[1-9]\d*)\z/.match(outpoint)
raise ArgumentError, 'invalid revocation_outpoint format' unless match
txid_hex = match[1]
output_index = match[2].to_i
buf << [txid_hex].pack('H*')
buf << BSV::Transaction::VarInt.encode(output_index)

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +86
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to_binary uses pack('H*') for subject, certifier, and txid_hex. Ruby’s pack('H*') is non-strict and can silently drop non-hex characters / odd-length input, producing malformed binary that may still get signed/verified as a different message. Since this is a public parse/serialize surface, prefer strict decoding (e.g., BSV::Primitives::Hex.decode(..., name: ...)) and validate expected lengths (33-byte pubkeys, 32-byte txid).

Copilot uses AI. Check for mistakes.

sorted_names = @fields.keys.sort
buf << BSV::Transaction::VarInt.encode(sorted_names.length)
sorted_names.each do |name|
name_bytes = name.to_s.encode('UTF-8').b
value_bytes = @fields[name].to_s.encode('UTF-8').b
buf << BSV::Transaction::VarInt.encode(name_bytes.bytesize)
buf << name_bytes
buf << BSV::Transaction::VarInt.encode(value_bytes.bytesize)
buf << value_bytes
end

buf << [@signature].pack('H*') if include_signature && @signature && !@signature.empty?

buf
end

# Deserialise a certificate from its binary format.
#
# When a signature is present in the trailing bytes, it is parsed via
# {BSV::Primitives::Signature.from_der} to ensure strict DER normalisation
# before being re-serialised as hex.
#
# @param data [String] binary string
# @return [Certificate]
def self.from_binary(data)
data = data.b
pos = 0

type_bytes = data.byteslice(pos, 32)
pos += 32
serial_bytes = data.byteslice(pos, 32)
pos += 32
subject_bytes = data.byteslice(pos, 33)
pos += 33
certifier_bytes = data.byteslice(pos, 33)
pos += 33

txid_bytes = data.byteslice(pos, 32)
pos += 32
output_index, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len

num_fields, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
fields = {}
num_fields.times do
name_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
name = data.byteslice(pos, name_len).force_encoding('UTF-8')
pos += name_len

value_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
value = data.byteslice(pos, value_len).force_encoding('UTF-8')
Comment on lines +138 to +143
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from_binary uses force_encoding('UTF-8') for field names/values but never validates the bytes are valid UTF-8. That can create invalid strings that raise later (or behave inconsistently across Ruby versions). Consider validating (str.valid_encoding?) and raising ArgumentError on invalid UTF-8, or normalizing via encode('UTF-8', invalid: :replace, undef: :replace) depending on the desired “recognise everything, construct only what's valid” policy.

Suggested change
name = data.byteslice(pos, name_len).force_encoding('UTF-8')
pos += name_len
value_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
value = data.byteslice(pos, value_len).force_encoding('UTF-8')
name = data.byteslice(pos, name_len).force_encoding(Encoding::UTF_8)
raise ArgumentError, 'invalid UTF-8 in certificate field name' unless name.valid_encoding?
pos += name_len
value_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
value = data.byteslice(pos, value_len).force_encoding(Encoding::UTF_8)
raise ArgumentError, 'invalid UTF-8 in certificate field value' unless value.valid_encoding?

Copilot uses AI. Check for mistakes.
pos += value_len

fields[name] = value
end

signature = nil
if pos < data.bytesize
sig_bytes = data.byteslice(pos, data.bytesize - pos)
parsed = BSV::Primitives::Signature.from_der(sig_bytes)
Comment on lines +124 to +152
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from_binary reads fixed-size slices and length-prefixed fields without checking remaining bytes. On truncated/malformed input, byteslice can return nil/short strings and this method will raise TypeError/NoMethodError (or produce invalid state) rather than a controlled ArgumentError. Since this can be fed untrusted data, add explicit bounds checks for each read (including name_len/value_len not exceeding remaining bytes) and raise a descriptive parse error.

Suggested change
certifier_bytes = data.byteslice(pos, 33)
pos += 33
txid_bytes = data.byteslice(pos, 32)
pos += 32
output_index, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
num_fields, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
fields = {}
num_fields.times do
name_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
name = data.byteslice(pos, name_len).force_encoding('UTF-8')
pos += name_len
value_len, vi_len = BSV::Transaction::VarInt.decode(data, pos)
pos += vi_len
value = data.byteslice(pos, value_len).force_encoding('UTF-8')
pos += value_len
fields[name] = value
end
signature = nil
if pos < data.bytesize
sig_bytes = data.byteslice(pos, data.bytesize - pos)
parsed = BSV::Primitives::Signature.from_der(sig_bytes)
parse_error = lambda do |message|
raise ArgumentError, "invalid certificate binary: #{message}"
end
read_bytes = lambda do |length, label|
remaining = data.bytesize - pos
parse_error.call("truncated #{label}: need #{length} bytes, have #{remaining}") if remaining < length
chunk = data.byteslice(pos, length)
parse_error.call("truncated #{label}: need #{length} bytes, have #{remaining}") if chunk.nil? || chunk.bytesize != length
pos += length
chunk
end
read_varint = lambda do |label|
begin
value, vi_len = BSV::Transaction::VarInt.decode(data, pos)
rescue StandardError => e
parse_error.call("invalid #{label}: #{e.message}")
end
remaining = data.bytesize - pos
if vi_len.nil? || vi_len <= 0 || vi_len > remaining
parse_error.call("truncated #{label}: invalid varint length")
end
pos += vi_len
value
end
certifier_bytes = read_bytes.call(33, 'certifier public key')
txid_bytes = read_bytes.call(32, 'revocation txid')
output_index = read_varint.call('revocation output index')
num_fields = read_varint.call('field count')
fields = {}
num_fields.times do |i|
name_len = read_varint.call("field #{i} name length")
name = read_bytes.call(name_len, "field #{i} name").force_encoding('UTF-8')
value_len = read_varint.call("field #{i} value length")
value = read_bytes.call(value_len, "field #{i} value").force_encoding('UTF-8')
fields[name] = value
end
signature = nil
if pos < data.bytesize
sig_bytes = read_bytes.call(data.bytesize - pos, 'signature')
begin
parsed = BSV::Primitives::Signature.from_der(sig_bytes)
rescue StandardError => e
parse_error.call("invalid signature: #{e.message}")
end

Copilot uses AI. Check for mistakes.
signature = parsed.to_hex
end

new(
type: Base64.strict_encode64(type_bytes),
serial_number: Base64.strict_encode64(serial_bytes),
subject: subject_bytes.unpack1('H*'),
certifier: certifier_bytes.unpack1('H*'),
revocation_outpoint: "#{txid_bytes.unpack1('H*')}.#{output_index}",
fields: fields,
signature: signature
)
end

# Verify the certificate's signature.
#
# Uses a fresh +'anyone'+ ProtoWallet as the verifier, which matches the
# TS SDK behaviour. If no signature is present, raises +ArgumentError+.
#
# @param verifier_wallet [#verify_signature, nil] wallet to verify with;
# defaults to +BSV::Wallet::ProtoWallet.new('anyone')+
# @return [Boolean] +true+ if the signature is valid
# @raise [ArgumentError] if the certificate has no signature
def verify(verifier_wallet = nil)
raise ArgumentError, 'certificate has no signature to verify' if @signature.nil? || @signature.empty?

verifier_wallet ||= BSV::Wallet::ProtoWallet.new('anyone')
Comment on lines +169 to +179
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certificate#verify defaults to BSV::Wallet::ProtoWallet.new('anyone'), but bsv-sdk doesn’t define BSV::Wallet::ProtoWallet and its gemspec doesn’t depend on bsv-wallet. In a typical require 'bsv-sdk' usage this will raise NameError. Either require/add a runtime dependency on bsv-wallet here, or remove the default and require callers to provide a verifier wallet (keeping the class truly duck-typed).

Suggested change
# Uses a fresh +'anyone'+ ProtoWallet as the verifier, which matches the
# TS SDK behaviour. If no signature is present, raises +ArgumentError+.
#
# @param verifier_wallet [#verify_signature, nil] wallet to verify with;
# defaults to +BSV::Wallet::ProtoWallet.new('anyone')+
# @return [Boolean] +true+ if the signature is valid
# @raise [ArgumentError] if the certificate has no signature
def verify(verifier_wallet = nil)
raise ArgumentError, 'certificate has no signature to verify' if @signature.nil? || @signature.empty?
verifier_wallet ||= BSV::Wallet::ProtoWallet.new('anyone')
# The verifier wallet is duck-typed and must respond to
# +verify_signature+. If no signature is present, raises +ArgumentError+.
#
# @param verifier_wallet [#verify_signature] wallet to verify with
# @return [Boolean] +true+ if the signature is valid
# @raise [ArgumentError] if the certificate has no signature or no
# verifier wallet is provided
def verify(verifier_wallet = nil)
raise ArgumentError, 'certificate has no signature to verify' if @signature.nil? || @signature.empty?
raise ArgumentError, 'verifier wallet is required' if verifier_wallet.nil?
raise ArgumentError, 'verifier wallet must respond to verify_signature' unless verifier_wallet.respond_to?(:verify_signature)

Copilot uses AI. Check for mistakes.
preimage = to_binary(include_signature: false)
sig_bytes = [@signature].pack('H*').unpack('C*')

verifier_wallet.verify_signature({
data: preimage.unpack('C*'),
signature: sig_bytes,
protocol_id: CERT_SIG_PROTOCOL,
key_id: "#{@type} #{@serial_number}",
counterparty: @certifier
})
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verify converts the signature hex to bytes with pack('H*'), which is non-strict and can silently accept invalid hex. Since BSV::Primitives::Hex exists specifically to avoid this, decode the signature using strict hex validation so malformed signatures fail loudly with ArgumentError instead of verifying against garbage bytes.

Copilot uses AI. Check for mistakes.

true
rescue BSV::Wallet::InvalidSignatureError
false
end

# Sign the certificate using the provided certifier wallet.
#
# The certifier field is updated to the wallet's identity key before
# signing. Raises if the certificate is already signed.
#
# @param certifier_wallet [#create_signature, #get_public_key] certifier wallet
# @raise [ArgumentError] if the certificate already has a signature
def sign(certifier_wallet)
raise ArgumentError, "certificate has already been signed: #{@signature}" if @signature && !@signature.empty?

@certifier = certifier_wallet.get_public_key({ identity_key: true })[:public_key]

preimage = to_binary(include_signature: false)
result = certifier_wallet.create_signature({
data: preimage.unpack('C*'),
protocol_id: CERT_SIG_PROTOCOL,
key_id: "#{@type} #{@serial_number}"
})
@signature = result[:signature].pack('C*').unpack1('H*')
end

# Returns the protocol ID and key ID for certificate field encryption.
#
# When +serial_number+ is provided (for verifier keyring creation) the
# key ID is +"#{serial_number} #{field_name}"+. Without a serial number
# (for master keyring creation) the key ID is just the +field_name+.
#
# @param field_name [String] name of the certificate field
# @param serial_number [String, nil] certificate serial number (Base64)
# @return [Hash] +{ protocol_id:, key_id: }+
def self.certificate_field_encryption_details(field_name, serial_number = nil)
key_id = serial_number ? "#{serial_number} #{field_name}" : field_name
{ protocol_id: CERT_FIELD_ENC_PROTOCOL, key_id: key_id }
end

# Construct a Certificate from a plain Hash.
#
# Accepts both snake_case and camelCase key variants for each field
# so that wire-format hashes can be passed in directly.
#
# @param hash [Hash] certificate data with snake_case or camelCase keys
# @return [Certificate]
def self.from_hash(hash)
h = normalise_hash_keys(hash)
new(
type: h['type'],
serial_number: h['serial_number'],
subject: h['subject'],
certifier: h['certifier'],
revocation_outpoint: h['revocation_outpoint'],
fields: h['fields'] || {},
signature: h['signature']
)
end

# Return the certificate as a plain Hash with snake_case keys.
#
# JSON serialisation is simply +cert.to_h.to_json+.
#
# @return [Hash]
def to_h
{
'type' => @type,
'serial_number' => @serial_number,
'subject' => @subject,
'certifier' => @certifier,
'revocation_outpoint' => @revocation_outpoint,
'fields' => @fields.dup,
'signature' => @signature
}
end

private_class_method def self.normalise_hash_keys(hash)
mappings = {
'type' => %w[type],
'serial_number' => %w[serial_number serialNumber],
'subject' => %w[subject],
'certifier' => %w[certifier],
'revocation_outpoint' => %w[revocation_outpoint revocationOutpoint],
'fields' => %w[fields],
'signature' => %w[signature]
}

result = {}
mappings.each do |canonical, aliases|
aliases.each do |a|
result[canonical] = hash[a] || hash[a.to_sym] if result[canonical].nil?
end
end
result
end
end
end
end
Loading
Loading