Skip to content

feat(auth): certificate infrastructure -- Certificate, VerifiableCertificate, MasterCertificate#425

Open
sgbett wants to merge 4 commits intomasterfrom
feat/419-certificate-infrastructure
Open

feat(auth): certificate infrastructure -- Certificate, VerifiableCertificate, MasterCertificate#425
sgbett wants to merge 4 commits intomasterfrom
feat/419-certificate-infrastructure

Conversation

@sgbett
Copy link
Copy Markdown
Owner

@sgbett sgbett commented Apr 13, 2026

Summary

  • Implement BSV::Auth::Certificate base class with binary serialisation, sign/verify, and from_hash/from_binary factory methods
  • Implement BSV::Auth::MasterCertificate for certificate issuance, field encryption, and verifier keyring creation
  • Implement BSV::Auth::VerifiableCertificate for selective field revelation and decryption
  • Add cross-SDK conformance tests verifying binary format compatibility with TS SDK
  • Verify Certificate#to_binary is byte-identical to existing CertificateSignature.serialise_preimage

Test plan

  • 134 auth specs passing (32 Certificate + 25 MasterCertificate + 12 VerifiableCertificate + 19 integration + 46 existing)
  • Full SDK test suite passes (3163 examples, 0 failures)
  • RuboCop clean (349 files, no offences)
  • Cross-SDK binary format verified against static TS test vector
  • End-to-end lifecycle: issue -> decrypt -> create verifier keyring -> selective revelation
  • Self-signed certificates work
  • Acceptance criteria verified

Closes #419

Generated with Claude Code

sgbett and others added 4 commits April 13, 2026 11:10
Adds the Certificate class with binary serialisation/deserialisation,
sign/verify, from_hash/to_h, and certificate_field_encryption_details.
All 32 test scenarios pass; binary format is byte-compatible with TS SDK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…suance (#421)

Adds MasterCertificate as a Certificate subclass with master keyring support.
Implements create_certificate_fields, create_keyring_for_verifier,
issue_certificate_for_subject, decrypt_fields, and decrypt_field class methods.
25 specs covering all 14 test scenarios from the issue pass cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…field revelation (#422)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ficates (#423)

Adds certificate_integration_spec.rb covering:
- Autoload resolution for all three certificate classes
- Binary format cross-SDK compatibility via a static hex test vector
  (byte-identical to TS SDK toBinary output for the same deterministic inputs)
- Cross-verification: Certificate#to_binary(no_sig) == CertificateSignature.serialise_preimage
- TS SDK fromObject test vector attribute parsing (camelCase and snake_case)
- Full end-to-end lifecycle: issue → subject decrypt → verifier keyring → VerifiableCertificate decrypt → verify
- Binary round-trip preserves all data including signature across the lifecycle
- Multi-field selective revelation (3 fields issued, 2 revealed, 1 hidden)
- Self-signed certificates (subject == certifier) including 'self' resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 13, 2026 10:26
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements foundational BSV::Auth certificate primitives (Certificate, MasterCertificate, VerifiableCertificate) with binary/hash serialisation, signature signing/verification, and selective field disclosure, plus a comprehensive RSpec suite including cross-SDK conformance vectors (TS SDK).

Changes:

  • Add BSV::Auth::Certificate with canonical binary format, signing, verification, and hash factories.
  • Add BSV::Auth::MasterCertificate (issuance + field encryption + verifier keyring creation) and BSV::Auth::VerifiableCertificate (verifier-side selective decryption).
  • Add extensive unit + integration specs, including static TS-compatible binary vectors and cross-checks against BSV::Wallet::CertificateSignature.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
gem/bsv-sdk/lib/bsv/auth/certificate.rb Base certificate model + binary/hash serialisation and signature sign/verify.
gem/bsv-sdk/lib/bsv/auth/master_certificate.rb Issuance + field encryption/decryption + verifier keyring generation.
gem/bsv-sdk/lib/bsv/auth/verifiable_certificate.rb Verifier-side selective decryption using verifier keyring.
gem/bsv-sdk/lib/bsv/auth.rb Autoload wiring for the new auth certificate classes.
gem/bsv-sdk/spec/bsv/auth/certificate_spec.rb Unit tests for Certificate behaviours, vectors, and round-trips.
gem/bsv-sdk/spec/bsv/auth/master_certificate_spec.rb Unit tests for MasterCertificate issuance/encryption/keyrings.
gem/bsv-sdk/spec/bsv/auth/verifiable_certificate_spec.rb Unit tests for VerifiableCertificate selective decryption and hash round-trips.
gem/bsv-sdk/spec/bsv/auth/certificate_integration_spec.rb Cross-SDK integration + binary-format compatibility tests.
.rubocop.yml Exclude auth directory from Metrics/ParameterLists.

Comment on lines +167 to +177
# 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')
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.
Comment on lines +84 to +86
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)
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
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)
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.
Comment on lines +122 to +150
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)
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.
Comment on lines +136 to +141
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')
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.
@decrypted_fields = result
result
rescue StandardError => e
raise "Failed to decrypt selectively revealed certificate fields using keyring: #{e.message}"
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.

VerifiableCertificate#decrypt_fields wraps all errors and re-raises a new exception string, which drops the original exception class and backtrace (making debugging and observability much harder). Prefer re-raising with the original backtrace, or use Ruby’s cause: chaining so callers can inspect the root failure without leaking sensitive plaintext.

Suggested change
raise "Failed to decrypt selectively revealed certificate fields using keyring: #{e.message}"
raise RuntimeError, 'Failed to decrypt selectively revealed certificate fields using keyring.', cause: e

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +137
result = {}
@keyring.each do |field_name, encrypted_key_b64|
dec_args = {
ciphertext: Base64.strict_decode64(encrypted_key_b64).bytes,
counterparty: @subject,
privileged: privileged,
privileged_reason: privileged_reason
}.merge(Certificate.certificate_field_encryption_details(field_name, @serial_number))

field_revelation_key = verifier_wallet.decrypt(dec_args)[:plaintext]

sym_key = BSV::Primitives::SymmetricKey.new(field_revelation_key.pack('C*'))
decrypted_bytes = sym_key.decrypt(Base64.strict_decode64(@fields[field_name]))
result[field_name] = decrypted_bytes.force_encoding('UTF-8')
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.

VerifiableCertificate#decrypt_fields assumes every keyring entry has a matching encrypted value in @fields (@fields[field_name]). If a caller builds a VerifiableCertificate from untrusted hash data, a missing field will currently surface as a generic RuntimeError from the rescue block. Add an explicit check that @fields.key?(field_name) (and non-empty) and raise ArgumentError for a malformed certificate/keyring pairing.

Copilot uses AI. Check for mistakes.
Comment on lines +241 to +246
it 'accepts camelCase keyring key in from_hash' do
h = verifiable_cert.to_h
h['keyring'] = h.delete('keyring')
restored = described_class.from_hash(h)
expect(restored.keyring).to eq(verifier_keyring)
end
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.

The example "accepts camelCase keyring key in from_hash" doesn’t actually change the key name: h['keyring'] = h.delete('keyring') is a no-op, so this spec can’t fail even if camelCase/symbol handling regresses. Either remove the camelCase wording or change the test to exercise an actual alternate key (or symbol key) that from_hash is expected to accept.

Copilot uses AI. Check for mistakes.
Comment on lines +263 to +281
begin
decrypted = {}
fields.each_key do |field_name|
decrypted[field_name] = decrypt_field(
wallet,
master_keyring,
field_name,
fields[field_name],
counterparty,
privileged: privileged,
privileged_reason: privileged_reason
)[:decrypted_field_value]
end
decrypted
rescue ArgumentError
raise
rescue StandardError
raise 'Failed to decrypt all master certificate fields.'
end
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.

MasterCertificate.decrypt_fields rescues StandardError and raises a generic message, losing the original exception and backtrace (and which field failed). This makes operational debugging difficult, especially when decrypting multiple fields. Consider re-raising with exception chaining (cause:) and/or including the field name that failed (field names are not secret) while keeping plaintext out of logs.

Copilot uses AI. Check for mistakes.
Comment on lines +304 to +323
begin
dec_args = {
ciphertext: Base64.strict_decode64(master_keyring[field_name]).bytes,
counterparty: counterparty,
privileged: privileged,
privileged_reason: privileged_reason
}.merge(Certificate.certificate_field_encryption_details(field_name))

field_revelation_key = wallet.decrypt(dec_args)[:plaintext]

sym_key = BSV::Primitives::SymmetricKey.new(field_revelation_key.pack('C*'))
decrypted_bytes = sym_key.decrypt(Base64.strict_decode64(field_value))
decrypted_field_value = decrypted_bytes.force_encoding('UTF-8')

{ field_revelation_key: field_revelation_key, decrypted_field_value: decrypted_field_value }
rescue ArgumentError
raise
rescue StandardError
raise 'Failed to decrypt certificate field!'
end
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.

decrypt_field assumes master_keyring[field_name] exists and is valid Base64; if it’s missing (nil) or invalid, Base64.strict_decode64 will raise a TypeError/ArgumentError and the method converts it into a generic Failed to decrypt certificate field! without indicating which field/keyring entry was malformed. Add an explicit presence check for the keyring entry and raise ArgumentError including the field_name to make malformed keyrings easier to diagnose.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[HLR] Certificate infrastructure — Certificate, VerifiableCertificate, MasterCertificate (F8.6)

2 participants