feat(auth): certificate infrastructure -- Certificate, VerifiableCertificate, MasterCertificate#425
feat(auth): certificate infrastructure -- Certificate, VerifiableCertificate, MasterCertificate#425
Conversation
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>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
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::Certificatewith canonical binary format, signing, verification, and hash factories. - Add
BSV::Auth::MasterCertificate(issuance + field encryption + verifier keyring creation) andBSV::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. |
| # 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') |
There was a problem hiding this comment.
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).
| # 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) |
| 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) |
There was a problem hiding this comment.
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.
| 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) |
| 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) |
There was a problem hiding this comment.
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).
| 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) |
There was a problem hiding this comment.
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.
| 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 |
| 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') |
There was a problem hiding this comment.
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.
| 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? |
| @decrypted_fields = result | ||
| result | ||
| rescue StandardError => e | ||
| raise "Failed to decrypt selectively revealed certificate fields using keyring: #{e.message}" |
There was a problem hiding this comment.
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.
| 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 |
| 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') |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
Summary
BSV::Auth::Certificatebase class with binary serialisation, sign/verify, andfrom_hash/from_binaryfactory methodsBSV::Auth::MasterCertificatefor certificate issuance, field encryption, and verifier keyring creationBSV::Auth::VerifiableCertificatefor selective field revelation and decryptionCertificate#to_binaryis byte-identical to existingCertificateSignature.serialise_preimageTest plan
Closes #419
Generated with Claude Code