|
| 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