bsv-wallet 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 805b24a04420fa0daee7a9fc68ee08aff7d940c31268a2317b3f00bc9e254e8d
4
- data.tar.gz: fe999637c2e283f9b8e73b2bc85bf9c28ae58ebca0651168d8d44e91fe3e5f2f
3
+ metadata.gz: 1f3c7d9f919b64229f0c2c9c99a05fbd58850803af74861157f884ade90f7af6
4
+ data.tar.gz: c655fbf296f6a4a3ed554681b8ab673c8dd3d108d37e6832cbf48814b3ed2169
5
5
  SHA512:
6
- metadata.gz: f49ae42829e1895a44bf0f414a6367bea7ed27295355f563d785e16ebc2a351f172ea9390549eae0e2e9497ef9c40cdaecd4ae56ec82c15fccbf681edc1d3019
7
- data.tar.gz: 2d74586aa8cfc467d56da95834cf03830b90a1f7e7990ed462c7d8529f7c2e0f54f47b0061a05cddf3fa6e0c719ee1abbb01dad45394848c0b6229b33e4237c4
6
+ metadata.gz: 87e2b8653d787445a281fb78393e380857799852908487cbef82af26a3e1e04f16df5f67ce295ed4162742527d7ebc5d1380b4670f8a949b78d4c44524e6ab66
7
+ data.tar.gz: 9d288d1787f7448278eb95de1e96e41384e0c06141c755812549b9bc693c0269b8361cf31982f6b38472be3418ef575e7f373bde11d3ef0892743041c14dab10
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module BSV
6
+ module Wallet
7
+ # BRC-52 identity certificate signature verification.
8
+ #
9
+ # Certificates carry a signature from the certifier over a canonical
10
+ # binary serialisation of their fields (excluding the signature itself).
11
+ # This module builds that canonical serialisation and delegates
12
+ # verification to a {ProtoWallet}-compatible verifier.
13
+ #
14
+ # Every field is included in the preimage in this order:
15
+ #
16
+ # - +type+ (base64 → 32 bytes)
17
+ # - +serial_number+ (base64 → 32 bytes)
18
+ # - +subject+ (hex → 33-byte compressed pubkey)
19
+ # - +certifier+ (hex → 33-byte compressed pubkey)
20
+ # - +revocation_outpoint+: txid hex (32 bytes) + output index VarInt
21
+ # - +fields+: VarInt count, then for each field (sorted
22
+ # lexicographically by name): VarInt name length + UTF-8 name bytes
23
+ # + VarInt value length + UTF-8 value bytes
24
+ #
25
+ # Signing uses BRC-42 key derivation with:
26
+ #
27
+ # - protocol ID: +[2, 'certificate signature']+
28
+ # - key ID: +"\#{type} \#{serial_number}"+
29
+ # - counterparty on sign: +'anyone'+ (default of
30
+ # +ProtoWallet#create_signature+ in TS — Ruby consumers should pass
31
+ # it explicitly since Ruby defaults to +'self'+)
32
+ # - counterparty on verify: the certifier's public key hex
33
+ #
34
+ # @see https://hub.bsvblockchain.org/brc/wallet/0052 BRC-52
35
+ module CertificateSignature
36
+ PROTOCOL_ID = [2, 'certificate signature'].freeze
37
+
38
+ # Error raised when a certificate's signature cannot be verified.
39
+ class InvalidError < InvalidSignatureError
40
+ def initialize(detail)
41
+ super("certificate signature verification failed: #{detail}")
42
+ end
43
+ end
44
+
45
+ module_function
46
+
47
+ # Verify a certificate's certifier signature.
48
+ #
49
+ # Raises {InvalidError} if the signature is missing, malformed, or
50
+ # does not match the expected certifier.
51
+ #
52
+ # @param cert [Hash] certificate fields. Required keys:
53
+ # +:type+, +:serial_number+, +:subject+, +:certifier+,
54
+ # +:revocation_outpoint+, +:fields+, +:signature+
55
+ # @param verifier [#verify_signature] optional verifier; defaults to
56
+ # a fresh +ProtoWallet.new('anyone')+
57
+ # @return [true] when the signature verifies
58
+ # @raise [InvalidError] otherwise
59
+ def verify!(cert, verifier: ProtoWallet.new('anyone'))
60
+ signature_hex = cert[:signature]
61
+ raise InvalidError, 'signature is missing' if signature_hex.nil? || signature_hex.empty?
62
+
63
+ preimage = serialise_preimage(cert)
64
+ sig_bytes = hex_to_bytes(signature_hex)
65
+
66
+ verifier.verify_signature({
67
+ data: preimage.unpack('C*'),
68
+ signature: sig_bytes,
69
+ protocol_id: PROTOCOL_ID,
70
+ key_id: "#{cert[:type]} #{cert[:serial_number]}",
71
+ counterparty: cert[:certifier]
72
+ })
73
+
74
+ true
75
+ rescue InvalidSignatureError => e
76
+ raise if e.is_a?(InvalidError)
77
+
78
+ raise InvalidError, e.message
79
+ rescue ArgumentError, EncodingError => e
80
+ # EncodingError covers Encoding::InvalidByteSequenceError and
81
+ # Encoding::UndefinedConversionError, which `encode_fields`
82
+ # raises for non-UTF-8 field names or values. Callers of
83
+ # `acquire_certificate` expect `InvalidError` on bad cert input
84
+ # — leaking EncodingError would break that contract.
85
+ raise InvalidError, e.message
86
+ end
87
+
88
+ # Build the BRC-52 canonical preimage for signing or verifying.
89
+ #
90
+ # @param cert [Hash] certificate fields (see {.verify!})
91
+ # @return [String] binary string suitable for +sha256+ (via
92
+ # {ProtoWallet#verify_signature})
93
+ def serialise_preimage(cert)
94
+ buf = String.new(encoding: Encoding::ASCII_8BIT)
95
+ buf << decode_base64_exact(cert[:type], 32, 'type')
96
+ buf << decode_base64_exact(cert[:serial_number], 32, 'serial_number')
97
+ buf << decode_hex_exact(cert[:subject], 33, 'subject')
98
+ buf << decode_hex_exact(cert[:certifier], 33, 'certifier')
99
+
100
+ buf << encode_revocation_outpoint(cert[:revocation_outpoint])
101
+ buf << encode_fields(cert[:fields])
102
+ buf
103
+ end
104
+
105
+ class << self
106
+ private
107
+
108
+ def encode_revocation_outpoint(outpoint)
109
+ raise ArgumentError, 'revocation_outpoint is missing' if outpoint.nil? || outpoint.empty?
110
+
111
+ txid_hex, output_index_str = outpoint.to_s.split('.', 2)
112
+ raise ArgumentError, "revocation_outpoint #{outpoint.inspect} missing '.<output_index>'" if output_index_str.nil?
113
+
114
+ unless output_index_str.match?(/\A\d+\z/)
115
+ raise ArgumentError, "revocation_outpoint output index must be a non-negative integer, got #{output_index_str.inspect}"
116
+ end
117
+
118
+ buf = String.new(encoding: Encoding::ASCII_8BIT)
119
+ buf << decode_hex_exact(txid_hex, 32, 'revocation_outpoint txid')
120
+ buf << BSV::Transaction::VarInt.encode(output_index_str.to_i)
121
+ buf
122
+ end
123
+
124
+ def encode_fields(fields)
125
+ raise ArgumentError, 'fields must be a Hash' unless fields.is_a?(Hash)
126
+
127
+ normalised = normalise_field_keys(fields)
128
+
129
+ buf = String.new(encoding: Encoding::ASCII_8BIT)
130
+ sorted_names = normalised.keys.sort
131
+ buf << BSV::Transaction::VarInt.encode(sorted_names.length)
132
+
133
+ sorted_names.each do |name|
134
+ name_bytes = name.encode('UTF-8').b
135
+ value_bytes = normalised[name].to_s.encode('UTF-8').b
136
+
137
+ buf << BSV::Transaction::VarInt.encode(name_bytes.bytesize)
138
+ buf << name_bytes
139
+ buf << BSV::Transaction::VarInt.encode(value_bytes.bytesize)
140
+ buf << value_bytes
141
+ end
142
+ buf
143
+ end
144
+
145
+ # Normalise Hash keys to strings and reject post-normalisation
146
+ # duplicates (e.g. both `:email` and `'email'`). Without this,
147
+ # `fields.keys.map(&:to_s)` silently produces duplicate entries
148
+ # with ambiguous value ordering, which makes the BRC-52 preimage
149
+ # non-deterministic.
150
+ def normalise_field_keys(fields)
151
+ normalised = {}
152
+ fields.each do |key, value|
153
+ str_key = key.to_s
154
+ if normalised.key?(str_key)
155
+ raise ArgumentError,
156
+ "duplicate field name #{str_key.inspect} " \
157
+ '(once as string, once as symbol)'
158
+ end
159
+
160
+ normalised[str_key] = value
161
+ end
162
+ normalised
163
+ end
164
+
165
+ def decode_base64_exact(value, expected_length, field_name)
166
+ raise ArgumentError, "#{field_name} is missing" if value.nil? || value.empty?
167
+
168
+ # strict_decode64 (vs permissive decode64) rejects whitespace,
169
+ # non-base64 characters, and non-canonical padding. The rest
170
+ # of bsv-wallet (e.g. the wire serialiser) uses strict mode,
171
+ # and the BRC-52 preimage must be unambiguous — a cert with
172
+ # whitespace-injected type/serial_number would decode to the
173
+ # right length but produce a different canonical form than
174
+ # the same data re-submitted cleanly.
175
+ bytes = begin
176
+ Base64.strict_decode64(value)
177
+ rescue ArgumentError => e
178
+ raise ArgumentError, "#{field_name} is not valid base64: #{e.message}"
179
+ end
180
+
181
+ if bytes.bytesize != expected_length
182
+ raise ArgumentError,
183
+ "#{field_name} must decode to #{expected_length} bytes, got #{bytes.bytesize}"
184
+ end
185
+
186
+ bytes.b
187
+ end
188
+
189
+ def decode_hex_exact(value, expected_length, field_name)
190
+ raise ArgumentError, "#{field_name} is missing" if value.nil? || value.empty?
191
+ raise ArgumentError, "#{field_name} must be a hex string" unless value.match?(/\A\h+\z/)
192
+ raise ArgumentError, "#{field_name} hex length must be even" unless value.length.even?
193
+
194
+ bytes = [value].pack('H*')
195
+ if bytes.bytesize != expected_length
196
+ raise ArgumentError,
197
+ "#{field_name} must decode to #{expected_length} bytes, got #{bytes.bytesize}"
198
+ end
199
+
200
+ bytes.b
201
+ end
202
+
203
+ def hex_to_bytes(hex)
204
+ raise ArgumentError, 'signature must be a hex string' unless hex.is_a?(String) && hex.match?(/\A\h+\z/)
205
+ raise ArgumentError, 'signature hex length must be even' unless hex.length.even?
206
+
207
+ [hex].pack('H*').unpack('C*')
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletInterface
5
- VERSION = '0.3.3'
5
+ VERSION = '0.3.4'
6
6
  end
7
7
  end
@@ -803,7 +803,7 @@ module BSV
803
803
  end
804
804
 
805
805
  def acquire_via_direct(args)
806
- {
806
+ cert = {
807
807
  type: args[:type],
808
808
  subject: @key_deriver.identity_key,
809
809
  serial_number: args[:serial_number],
@@ -813,6 +813,15 @@ module BSV
813
813
  fields: args[:fields],
814
814
  keyring: args[:keyring_for_subject]
815
815
  }
816
+
817
+ # BRC-52: verify the certifier's signature against the canonical
818
+ # serialisation before persisting. Fixes F8.15 from the 2026-04-08
819
+ # cross-SDK compliance review (#305) — prior to this check, callers
820
+ # could pass any value in `args[:signature]` and it would be stored
821
+ # as if certifier-authentic.
822
+ CertificateSignature.verify!(cert)
823
+
824
+ cert
816
825
  end
817
826
 
818
827
  def acquire_via_issuance(args)
@@ -833,7 +842,7 @@ module BSV
833
842
 
834
843
  body = JSON.parse(response.body)
835
844
 
836
- {
845
+ cert = {
837
846
  type: body['type'] || args[:type],
838
847
  subject: @key_deriver.identity_key,
839
848
  serial_number: body['serialNumber'],
@@ -843,6 +852,14 @@ module BSV
843
852
  fields: body['fields'] || args[:fields],
844
853
  keyring: body['keyringForSubject']
845
854
  }
855
+
856
+ # BRC-52: the certifier's HTTP response is untrusted network data;
857
+ # verify the signature before persisting. Closes the F8.15 class of
858
+ # bug on the issuance path too (the finding's "Same issue as F8.15"
859
+ # note from F8.16).
860
+ CertificateSignature.verify!(cert)
861
+
862
+ cert
846
863
  rescue JSON::ParserError
847
864
  raise WalletError, 'Certificate issuance failed: invalid JSON response'
848
865
  end
@@ -20,6 +20,7 @@ module BSV
20
20
  autoload :NullChainProvider, 'bsv/wallet_interface/null_chain_provider'
21
21
  autoload :WalletClient, 'bsv/wallet_interface/wallet_client'
22
22
  autoload :Wire, 'bsv/wallet_interface/wire'
23
+ autoload :CertificateSignature, 'bsv/wallet_interface/certificate_signature'
23
24
 
24
25
  # Error classes
25
26
  autoload :WalletError, 'bsv/wallet_interface/errors/wallet_error'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-wallet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-06 00:00:00.000000000 Z
10
+ date: 2026-04-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -27,16 +27,22 @@ dependencies:
27
27
  name: bsv-sdk
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.8.2
33
+ - - "<"
31
34
  - !ruby/object:Gem::Version
32
- version: '0.4'
35
+ version: '1.0'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
- - - "~>"
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 0.8.2
43
+ - - "<"
38
44
  - !ruby/object:Gem::Version
39
- version: '0.4'
45
+ version: '1.0'
40
46
  description: Implements the BRC-100 standard wallet-to-application interface for the
41
47
  BSV Blockchain.
42
48
  executables: []
@@ -46,6 +52,7 @@ files:
46
52
  - LICENSE
47
53
  - lib/bsv-wallet.rb
48
54
  - lib/bsv/wallet_interface.rb
55
+ - lib/bsv/wallet_interface/certificate_signature.rb
49
56
  - lib/bsv/wallet_interface/chain_provider.rb
50
57
  - lib/bsv/wallet_interface/errors/invalid_hmac_error.rb
51
58
  - lib/bsv/wallet_interface/errors/invalid_parameter_error.rb