diff --git a/Gemfile b/Gemfile index 7389a0c..33ccb72 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gemspec gem 'base32', '~> 0.3.4' gem 'bitcoin-ruby', '~> 0.0.20' +gem 'bls12-381', '~> 0.3.0' gem 'byebug', '~> 11.1', '>= 11.1.3' gem 'cbor', '~> 0.5.9.6' gem 'ctf-party', '~> 2.3' diff --git a/Gemfile.lock b/Gemfile.lock index ad11284..d394436 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,10 @@ PATH remote: . specs: - ic_agent (0.1.4) + ic_agent (0.2.0) base32 (~> 0.3.4) bitcoin-ruby (~> 0.0.20) + bls12-381 (~> 0.3.0) cbor (~> 0.5.9.6) ctf-party (~> 2.3) ecdsa (~> 1.2) @@ -23,6 +24,8 @@ GEM eventmachine ffi scrypt + bls12-381 (0.3.0) + h2c (~> 0.2.0) byebug (11.1.3) cbor (0.5.9.6) coderay (1.1.3) @@ -34,7 +37,7 @@ GEM ecdsa (1.2.0) ed25519 (1.3.0) eventmachine (1.2.7) - faraday (2.7.4) + faraday (2.7.10) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) @@ -42,13 +45,15 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake - i18n (1.12.0) + h2c (0.2.0) + ecdsa (~> 1.2.0) + i18n (1.14.1) concurrent-ruby (~> 1.0) json (2.6.3) leb128 (1.0.0) method_source (1.0.0) - mini_portile2 (2.8.2) - pkg-config (1.5.1) + mini_portile2 (2.8.4) + pkg-config (1.5.2) polyglot (0.3.5) pry (0.14.2) coderay (~> 1.1) @@ -62,19 +67,19 @@ GEM rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) rspec-mocks (~> 3.12.0) - rspec-core (3.12.1) + rspec-core (3.12.2) rspec-support (~> 3.12.0) - rspec-expectations (3.12.2) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-support (3.12.0) + rspec-support (3.12.1) ruby-enum (0.9.0) i18n ruby2_keywords (0.0.5) - rubytree (2.0.0) + rubytree (2.0.2) json (~> 2.0, > 2.3.1) rubyzip (2.3.2) scrypt (3.0.7) @@ -88,6 +93,7 @@ PLATFORMS DEPENDENCIES base32 (~> 0.3.4) bitcoin-ruby (~> 0.0.20) + bls12-381 (~> 0.3.0) byebug (~> 11.1, >= 11.1.3) cbor (~> 0.5.9.6) ctf-party (~> 2.3) diff --git a/ic_agent.gemspec b/ic_agent.gemspec index 44b0ba6..f4edab1 100644 --- a/ic_agent.gemspec +++ b/ic_agent.gemspec @@ -17,6 +17,7 @@ Gem::Specification.new do |spec| spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = spec.homepage spec.metadata['changelog_uri'] = spec.homepage + spec.metadata['documentation_uri'] = 'https://tuminfei.github.io/ic_agent.github.com/' # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -33,6 +34,7 @@ Gem::Specification.new do |spec| # spec.add_dependency "example-gem", "~> 1.0" spec.add_dependency 'base32', '~> 0.3.4' spec.add_dependency 'bitcoin-ruby', '~> 0.0.20' + spec.add_dependency 'bls12-381', '~> 0.3.0' spec.add_dependency 'cbor', '~> 0.5.9.6' spec.add_dependency 'ctf-party', '~> 2.3' spec.add_dependency 'ecdsa', '~> 1.2' diff --git a/lib/ic_agent.rb b/lib/ic_agent.rb index d72c1f9..d93f2a2 100644 --- a/lib/ic_agent.rb +++ b/lib/ic_agent.rb @@ -30,10 +30,15 @@ module IcAgent class Error < StandardError; end class ValueError < StandardError; end class TypeError < StandardError; end + class AgentError < StandardError; end + class BaseException < StandardError; end - IC_REQUEST_DOMAIN_SEPARATOR = "\x0Aic-request".freeze - IC_ROOT_KEY = "\x4E\x9A\xF9\x9F\x06\x13\x26\x81\xE7\xD2\x55\x2A\x26\x17\x98\x51\xE9\xC3\x79\xB3\xC7\xBE\x88\x27\xB8\x35\x17\xFC\x84\x4E\x4C\x4F".freeze + IC_REQUEST_DOMAIN_SEPARATOR = "\x0Aic-request" + IC_ROOT_KEY = "\x4E\x9A\xF9\x9F\x06\x13\x26\x81\xE7\xD2\x55\x2A\x26\x17\x98\x51\xE9\xC3\x79\xB3\xC7\xBE\x88\x27\xB8\x35\x17\xFC\x84\x4E\x4C\x4F" IC_PUBKEY_ED_DER_HEAD = '302a300506032b6570032100' IC_PUBKEY_SECP_DER_HERD = '3056301006072a8648ce3d020106052b8104000a034200' DEFAULT_POLL_TIMEOUT_SECS = 60 + IC_STATE_ROOT_DOMAIN_SEPARATOR = "\ric-state-root".str2hex + BLS_KEY_LENGTH = 96 + BLS_DER_PREFIX = '308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100' end diff --git a/lib/ic_agent/agent.rb b/lib/ic_agent/agent.rb index c461ed1..2c7559e 100644 --- a/lib/ic_agent/agent.rb +++ b/lib/ic_agent/agent.rb @@ -1,8 +1,15 @@ require 'cbor' +require 'bls' require 'ctf_party' +require 'bitcoin' module IcAgent class Request + # Signs a request with an identity's signature and encodes it using CBOR. + # + # @param req [Hash] The request to be signed. + # @param iden [Identity] The identity used for signing. + # @return [Array] The request ID and the encoded signed request. def self.sign_request(req, iden) req_id = IcAgent::Utils.to_request_id(req) msg = IcAgent::IC_REQUEST_DOMAIN_SEPARATOR + req_id @@ -26,6 +33,13 @@ def self.sign_request(req, iden) class Agent attr_accessor :identity, :client, :ingress_expiry, :root_key, :nonce_factory + # Initializes a new IC agent. + # + # @param identity [Identity] The identity associated with the agent. + # @param client [Client] The client used for communication with the IC network. + # @param nonce_factory [NonceFactory] The factory for generating nonces. + # @param ingress_expiry [Integer] The expiration time for ingress requests. + # @param root_key [String] The IC root key used for verification. def initialize(identity, client, nonce_factory = nil, ingress_expiry = 300, root_key = IcAgent::IC_ROOT_KEY) @identity = identity @client = client @@ -34,14 +48,25 @@ def initialize(identity, client, nonce_factory = nil, ingress_expiry = 300, root @nonce_factory = nonce_factory end + # Retrieves the principal associated with the agent's identity. + # + # @return [Principal] The principal associated with the agent. def get_principal @identity.sender end + # Calculates the expiration date for ingress requests. + # + # @return [Integer] The expiration date in nanoseconds. def get_expiry_date ((Time.now.to_i + @ingress_expiry) * 10**9).to_i end + # Sends a query request to a canister and decodes the response using CBOR. + # + # @param canister_id [String] The ID of the target canister. + # @param data [Hash] The data to be sent in the query request. + # @return [Object] The decoded response from the canister. def query_endpoint(canister_id, data) ret = @client.query(canister_id, data) decode_ret = nil @@ -54,16 +79,34 @@ def query_endpoint(canister_id, data) decode_ret end + # Calls a method on a canister and returns the request ID. + # + # @param canister_id [String] The ID of the target canister. + # @param request_id [String] The ID of the request. + # @param data [Hash] The data to be sent in the call request. + # @return [String] The request ID. def call_endpoint(canister_id, request_id, data) @client.call(canister_id, request_id, data) request_id end + # Reads the state of a canister. + # + # @param canister_id [String] The ID of the target canister. + # @param data [Hash] The data to be sent in the read state request. + # @return [Object] The response from the canister. def read_state_endpoint(canister_id, data) - result = @client.read_state(canister_id, data) - result + @client.read_state(canister_id, data) end + # Sends a raw query request to a canister and handles the response. + # + # @param canister_id [String] The ID of the target canister. + # @param method_name [String] The name of the method to be called. + # @param arg [String] The argument to be passed to the method. + # @param return_type [Object] The expected type of the return value. + # @param effective_canister_id [String] The effective canister ID (optional). + # @return [Object] The decoded response from the canister. def query_raw(canister_id, method_name, arg, return_type = nil, effective_canister_id = nil) req_canister_id = canister_id.is_a?(String) ? Principal.from_str(canister_id).bytes : canister_id.bytes req = { @@ -92,6 +135,15 @@ def query_raw(canister_id, method_name, arg, return_type = nil, effective_canist end end + # Sends a raw update request to a canister and handles the response. + # + # @param canister_id [String] The ID of the target canister. + # @param method_name [String] The name of the method to be called. + # @param arg [String] The argument to be passed to the method. + # @param return_type [Object] The expected type of the return value. + # @param effective_canister_id [String] The effective canister ID (optional). + # @param kwargs [Hash] Additional keyword arguments. + # @return [Object] The decoded response from the canister. def update_raw(canister_id, method_name, arg, return_type = nil, effective_canister_id = nil, **kwargs) req_canister_id = canister_id.is_a?(String) ? Principal.from_str(canister_id).bytes : canister_id.bytes req = { @@ -120,7 +172,13 @@ def update_raw(canister_id, method_name, arg, return_type = nil, effective_canis end end - def read_state_raw(canister_id, paths) + # Sends a raw read state request to a canister and handles the response. + # + # @param canister_id [String] The ID of the target canister. + # @param paths [Array] The paths to read from the canister's state. + # @param [TrueClass] bls_verify + # @return [Object] The decoded response from the canister. + def read_state_raw(canister_id, paths, bls_verify = true) req = { 'request_type' => 'read_state', 'sender' => @identity.sender.bytes, @@ -140,9 +198,20 @@ def read_state_raw(canister_id, paths) rescue StandardError raise ValueError, "Unable to decode cbor value: #{ret}" end - CBOR.decode(d.value['certificate']) + cert = CBOR.decode(d.value['certificate']) + + if bls_verify + verify(cert, canister_id) ? cert : false + else + cert + end end + # Retrieves the status and certificate of a request from a canister. + # + # @param canister_id [String] The ID of the target canister. + # @param req_id [String] The ID of the request. + # @return [Array] The status and certificate of the request. def request_status_raw(canister_id, req_id) paths = [['request_status', req_id]] cert = read_state_raw(canister_id, paths) @@ -150,6 +219,12 @@ def request_status_raw(canister_id, req_id) [status, cert] end + # Polls a canister for the status of a request. + # + # @param canister_id [String] The ID of the target canister. + # @param req_id [String] The ID of the request. + # @param delay [Integer] The delay between each poll attempt (in seconds). + # @param timeout [Integer] The maximum timeout for polling. def poll(canister_id, req_id, delay = 1, timeout = IcAgent::DEFAULT_POLL_TIMEOUT_SECS) status = nil cert = nil @@ -172,5 +247,75 @@ def poll(canister_id, req_id, delay = 1, timeout = IcAgent::DEFAULT_POLL_TIMEOUT [status, _] end end + + def verify(cert, canister_id) + signature_hex = IcAgent::Certificate.signature(cert).str2hex + tree = IcAgent::Certificate.tree(cert) + delegation = IcAgent::Certificate.delegation(cert) + root_hash = IcAgent::Certificate.reconstruct(tree).str2hex + msg = IcAgent::IC_STATE_ROOT_DOMAIN_SEPARATOR + root_hash + der_key = check_delegation(delegation, canister_id, true) + public_key_hash = extract_der(der_key).str2hex + + public_key = BLS::PointG2.from_hex(public_key_hash) + signature = BLS::PointG1.from_hex(signature_hex) + BLS.verify(signature, msg, public_key) + end + + def check_delegation(delegation, effective_canister_id, disable_range_check) + return @root_key unless delegation + + begin + cert = CBOR.decode(delegation['certificate']) + rescue CBOR::MalformedFormatError => e + raise TypeError, "certificate CBOR::MalformedFormatError: #{delegation['certificate']}" + end + + path = ['subnet', delegation['subnet_id'], 'canister_ranges'] + canister_range = IcAgent::Certificate.lookup(path, cert) + + begin + ranges = [] + ranges_json = CBOR.decode(canister_range).values[1] + + ranges_json.each do |range_json| + range = {} + range['low'] = Principal.from_hex(range_json[0]) + range['high'] = Principal.from_hex(range_json[1]) + ranges << range + end + + if !disable_range_check && !principal_is_within_ranges(effective_canister_id, ranges) + raise AgentError 'certificate CERTIFICATE_NOT_AUTHORIZED' + end + rescue Exception => e + raise AgentError "certificate INVALID_CBOR_DATA, canister_range: #{canister_range.to_s}" + end + + path = ['subnet', delegation['subnet_id'], 'public_key'] + IcAgent::Certificate.lookup(path, cert) + end + + def principal_is_within_ranges(principal, ranges) + ranges.each do |range| + return true if range['low'].lt_eq(principal) && range['high'].gt_eq(principal) + end + false + end + + def extract_der(der_buf) + bls_der_prefix = OpenSSL::BN.from_hex(IcAgent::BLS_DER_PREFIX).to_s(2) + expected_length = bls_der_prefix.bytesize + IcAgent::BLS_KEY_LENGTH + if der_buf.bytesize != expected_length + raise TypeError, "BLS DER-encoded public key must be #{expected_length} bytes long" + end + + prefix = der_buf.byteslice(0, bls_der_prefix.bytesize) + if prefix != bls_der_prefix + raise TypeError, "BLS DER-encoded public key is invalid. Expect the following prefix: #{bls_der_prefix}, but get #{prefix}" + end + + der_buf.byteslice(bls_der_prefix.bytesize..-1) + end end end diff --git a/lib/ic_agent/candid.rb b/lib/ic_agent/candid.rb index 35478fb..a09fad9 100644 --- a/lib/ic_agent/candid.rb +++ b/lib/ic_agent/candid.rb @@ -174,6 +174,8 @@ def encode_type(type_table) end end + # Represents an IDL Null + # check None == Null ? class NullClass < PrimitiveType def initialize() super @@ -205,6 +207,7 @@ def id end end + # Represents an IDL Empty, a type which has no inhabitants. class EmptyClass < PrimitiveType def initialize super @@ -235,6 +238,7 @@ def id end end + # Represents an IDL Bool class BoolClass < PrimitiveType def initialize super @@ -275,6 +279,7 @@ def id end end + # Represents an IDL Reserved class ReservedClass < PrimitiveType def initialize super @@ -308,6 +313,7 @@ def id end end + # Represents an IDL Text class TextClass < PrimitiveType def initialize super @@ -343,6 +349,7 @@ def id end end + # Represents an IDL Int class IntClass < PrimitiveType def initialize super @@ -374,6 +381,7 @@ def id end end + # Represents an IDL Nat class NatClass < PrimitiveType def initialize super @@ -405,6 +413,7 @@ def id end end + # Represents an IDL Float class FloatClass < PrimitiveType def initialize(bits) super() @@ -460,6 +469,7 @@ def id end end + # Represents an IDL fixed-width Int(n) class FixedIntClass < PrimitiveType def initialize(bits) super() @@ -535,6 +545,7 @@ def id end end + # Represents an IDL fixed-width Nat(n) class FixedNatClass < PrimitiveType def initialize(bits) super() @@ -605,6 +616,7 @@ def id end end + # Represents an IDL principal reference class PrincipalClass < PrimitiveType def initialize super @@ -658,6 +670,7 @@ def id end end + # Represents an IDL Array class VecClass < ConstructType def initialize(_type) super() @@ -704,6 +717,7 @@ def display end end + # Represents an IDL Option class OptClass < ConstructType def initialize(_type) super() @@ -756,6 +770,7 @@ def display end end + # Represents an IDL Record class RecordClass < ConstructType def initialize(field) super() @@ -845,6 +860,7 @@ def display end end + # Represents Tuple, a syntactic sugar for Record. class TupleClass < RecordClass attr_accessor :components @@ -908,6 +924,7 @@ def display end end + # Represents an IDL Variant class VariantClass < ConstructType attr_accessor :fields @@ -996,6 +1013,7 @@ def display end end + # Represents a reference to an IDL type, used for defining recursive data types. class RecClass < ConstructType @@counter = 0 @@ -1073,6 +1091,7 @@ def display end end + #Represents an IDL Func reference class FuncClass < ConstructType attr_accessor :arg_types, :ret_types, :annotations @@ -1170,6 +1189,7 @@ def _encode_annotation(ann) end end + # Represents an IDL Service reference class ServiceClass < ConstructType def initialize(field) super() @@ -1344,6 +1364,7 @@ def self.service(t) end end + # through Pipe to decode bytes def self.leb128u_decode(pipe) res = StringIO.new loop do @@ -1575,8 +1596,8 @@ def self.get_type(raw_table, table, t) return table[t] end - # params = [{type, value}] - # data = b'DIDL' + len(params) + encoded types + encoded values + # @param [Object] params = [{type, value}] + # @return data = b'DIDL' + len(params) + encoded types + encoded values def self.encode(params) arg_types = [] args = [] @@ -1617,7 +1638,8 @@ def self.encode(params) return pre + table + length + typs + vals end - # decode a bytes value + # @param [Object] data: decode a bytes value + # @param [nil] ret_types # def decode(retTypes, data): def self.decode(data, ret_types = nil) pipe = Pipe.new(data) @@ -1664,8 +1686,7 @@ def self.decode(data, ret_types = nil) 'value' => t.decode_value(pipe, types[i]) }) end - - return outputs + outputs end end end diff --git a/lib/ic_agent/certificate.rb b/lib/ic_agent/certificate.rb index cdca106..47ffe59 100644 --- a/lib/ic_agent/certificate.rb +++ b/lib/ic_agent/certificate.rb @@ -8,10 +8,56 @@ class NodeId end class Certificate + # Performs a lookup operation in the certificate tree based on the given path. + # + # Parameters: + # - path: The path to lookup. + # - cert: The certificate object containing the tree. + # + # Returns: The value found at the specified path in the tree. def self.lookup(path, cert) lookup_path(path, cert.value['tree']) end + # Retrieves the signature from a certificate. + # + # Parameters: + # - cert: The certificate object. + # + # Returns: The signature value. + def self.signature(cert) + cert.value['signature'] + end + + # Retrieves the delegation from a certificate. + # + # Parameters: + # - cert: The certificate object. + # + # Returns: The delegation value. + def self.delegation(cert) + cert.value['delegation'] + end + + # Retrieves the tree from a certificate. + # + # Parameters: + # - cert: The certificate object. + # + # Returns: The tree value. + def self.tree(cert) + cert.value['tree'] + end + + private + + # Recursive helper method for performing the lookup operation. + # + # Parameters: + # - path: The remaining path to lookup. + # - tree: The current tree node to search in. + # + # Returns: The value found at the specified path in the tree. def self.lookup_path(path, tree) offset = 0 if path.length == 0 @@ -29,6 +75,12 @@ def self.lookup_path(path, tree) end end + # Flattens fork nodes in the tree into a single array. + # + # Parameters: + # - t: The tree node to flatten. + # + # Returns: The flattened array of tree nodes. def self.flatten_forks(t) if t[0] == NodeId::EMPTY [] @@ -42,6 +94,13 @@ def self.flatten_forks(t) end end + # Finds a labeled tree node with the specified label in the given array of trees. + # + # Parameters: + # - l: The label to search for. + # - trees: The array of trees to search in. + # + # Returns: The labeled tree node with the matching label, or nil if not found. def self.find_label(l, trees) trees.each do |t| if t[0] == NodeId::LABELED @@ -51,5 +110,44 @@ def self.find_label(l, trees) end nil end + + # Recursively reconstructs the hash value of a tree node. + # + # Parameters: + # - t: The tree node to reconstruct. + # + # Returns: The reconstructed hash value of the tree node. + def self.reconstruct(t) + case t[0] + when IcAgent::NodeId::EMPTY + domain_sep = domain_sep('ic-hashtree-empty') + Digest::SHA256.digest(domain_sep) + when IcAgent::NodeId::PRUNED + t[1] + when IcAgent::NodeId::LEAF + domain_sep = domain_sep('ic-hashtree-leaf') + Digest::SHA256.digest(domain_sep + t[1]) + when IcAgent::NodeId::LABELED + domain_sep = domain_sep('ic-hashtree-labeled') + Digest::SHA256.digest(domain_sep + t[1] + reconstruct(t[2])) + when IcAgent::NodeId::FORK + domain_sep = domain_sep('ic-hashtree-fork') + Digest::SHA256.digest(domain_sep + reconstruct(t[1]) + reconstruct(t[2])) + else + raise 'unreachable' + end + end + + # Generates the domain separation prefix for hash computations. + # + # Parameters: + # - s: The domain separation string. + # + # Returns: The domain separation prefix as a binary string. + def self.domain_sep(s) + len = [s.bytesize].pack('C') + str = s.encode(Encoding::UTF_8) + len + str + end end end diff --git a/lib/ic_agent/client.rb b/lib/ic_agent/client.rb index 8ed60bc..4e00660 100644 --- a/lib/ic_agent/client.rb +++ b/lib/ic_agent/client.rb @@ -5,6 +5,10 @@ class Client DEFAULT_TIMEOUT = 120 DEFAULT_TIMEOUT_QUERY = 30 + # Initializes a new instance of the Client class. + # + # Parameters: + # - url: The URL of the IC agent. Defaults to 'https://ic0.app'. def initialize(url = 'https://ic0.app') @url = url @conn = Faraday.new(url: url) do |faraday| @@ -16,6 +20,13 @@ def initialize(url = 'https://ic0.app') end end + # Sends a query to a canister. + # + # Parameters: + # - canister_id: The ID of the canister to query. + # - data: The data to send with the query. + # + # Returns: The response from the canister as a UTF-8 encoded string. def query(canister_id, data) endpoint = "/api/v2/canister/#{canister_id}/query" ret = @conn.post(endpoint, data) @@ -23,6 +34,14 @@ def query(canister_id, data) ret.body end + # Calls a function on a canister. + # + # Parameters: + # - canister_id: The ID of the canister to call. + # - req_id: The request ID. + # - data: The data to send with the call. + # + # Returns: The request ID. def call(canister_id, req_id, data) endpoint = "/api/v2/canister/#{canister_id}/call" ret = @conn.post(endpoint, data) @@ -30,6 +49,13 @@ def call(canister_id, req_id, data) req_id end + # Reads the state of a canister. + # + # Parameters: + # - canister_id: The ID of the canister to read the state from. + # - data: The data to send with the read_state request. + # + # Returns: The response from the canister as a UTF-8 encoded string. def read_state(canister_id, data) endpoint = "/api/v2/canister/#{canister_id}/read_state" ret = @conn.post(endpoint, data) @@ -37,6 +63,12 @@ def read_state(canister_id, data) ret.body end + # Retrieves the status of the IC agent. + # + # Parameters: + # - timeout: The timeout for the status request. Defaults to DEFAULT_TIMEOUT_QUERY. + # + # Returns: The response from the status endpoint as a UTF-8 encoded string. def status(timeout: DEFAULT_TIMEOUT_QUERY) endpoint = '/api/v2/status' ret = @conn.get(endpoint, timeout: timeout) diff --git a/lib/ic_agent/identity.rb b/lib/ic_agent/identity.rb index a49ed1f..13d6bba 100644 --- a/lib/ic_agent/identity.rb +++ b/lib/ic_agent/identity.rb @@ -11,6 +11,12 @@ module IcAgent class Identity attr_reader :privkey, :pubkey, :der_pubkey, :sk, :vk, :key_type + # Initializes a new instance of the Identity class. + # + # Parameters: + # - privkey: The private key of the identity in hexadecimal format. Defaults to an empty string. + # - type: The key type of the identity. Defaults to 'ed25519'. + # - anonymous: A flag indicating whether the identity is anonymous. Defaults to false. def initialize(privkey = '', type = 'ed25519', anonymous = false) privkey = [privkey].pack('H*') @anonymous = anonymous @@ -37,6 +43,12 @@ def initialize(privkey = '', type = 'ed25519', anonymous = false) end end + # Creates a new Identity instance from a seed phrase (mnemonic). + # + # Parameters: + # - mnemonic: The seed phrase (mnemonic) used to generate the identity. + # + # Returns: The Identity instance. def self.from_seed(mnemonic) seed = Bitcoin::Trezor::Mnemonic.to_seed(mnemonic) privkey = seed[0..63] @@ -44,6 +56,9 @@ def self.from_seed(mnemonic) Identity.new(privkey = privkey, type = key_type) end + # Returns the sender Principal associated with the Identity. + # + # Returns: The sender Principal. def sender if @anonymous IcAgent::Principal.anonymous @@ -52,6 +67,12 @@ def sender end end + # Signs a message using the Identity. + # + # Parameters: + # - msg: The message to sign. + # + # Returns: An array containing the DER-encoded public key and the signature. def sign(msg) if @anonymous [nil, nil] @@ -65,6 +86,13 @@ def sign(msg) end end + # Verifies a message signature using the Identity. + # + # Parameters: + # - msg: The message to verify. + # - sig: The signature to verify. + # + # Returns: `true` if the signature is valid, otherwise `false`. def verify(msg, sig) if @anonymous false @@ -73,6 +101,9 @@ def verify(msg, sig) end end + # Returns the PEM-encoded private key of the Identity. + # + # Returns: The PEM-encoded private key. def to_pem der = @key_type == 'secp256k1' ? "#{IcAgent::IC_PUBKEY_SECP_DER_HERD}#{@sk.data.unpack1('H*')}".hex2str : "#{IcAgent::IC_PUBKEY_ED_DER_HEAD}#{@sk.to_bytes.unpack1('H*')}".hex2str b64 = Base64.strict_encode64(der) @@ -92,20 +123,41 @@ def to_s class DelegateIdentity attr_reader :identity, :delegations, :der_pubkey + # Initializes a new instance of the DelegateIdentity class. + # + # Parameters: + # - identity: The Identity associated with the DelegateIdentity. + # - delegation: The delegation JSON object containing the delegated keys. def initialize(identity, delegation) @identity = identity @delegations = delegation['delegations'].map { |d| d } @der_pubkey = [delegation['publicKey']].pack('H*') end + # Signs a message using the DelegateIdentity. + # + # Parameters: + # - msg: The message to sign. + # + # Returns: An array containing the DER-encoded public key and the signature. def sign(msg) @identity.sign(msg) end + # Returns the sender Principal associated with the DelegateIdentity. + # + # Returns: The sender Principal. def sender Principal.self_authenticating(@der_pubkey) end + # Creates a new DelegateIdentity instance from JSON representations of the Identity and delegation. + # + # Parameters: + # - ic_identity: The JSON representation of the Identity. + # - ic_delegation: The JSON representation of the delegation. + # + # Returns: The DelegateIdentity instance. def self.from_json(ic_identity, ic_delegation) parsed_ic_identity = JSON.parse(ic_identity) parsed_ic_delegation = JSON.parse(ic_delegation) diff --git a/lib/ic_agent/principal.rb b/lib/ic_agent/principal.rb index e2c0aad..c0a2b8a 100644 --- a/lib/ic_agent/principal.rb +++ b/lib/ic_agent/principal.rb @@ -7,16 +7,21 @@ module IcAgent MAX_LENGTH_IN_BYTES = 29 class PrincipalSort - OpaqueId = 1 - SelfAuthenticating = 2 - DerivedId = 3 - Anonymous = 4 + OPAQUE_ID = 1 + SELF_AUTHENTICATING = 2 + DERIVED_ID = 3 + ANONYMOUS = 4 # Unassigned end + # Base class for Principal. class Principal attr_reader :len, :bytes, :is_principal, :hex + # Initializes a new instance of the Principal class. + # + # Parameters: + # - bytes: The bytes representing the principal. Defaults to an empty string. def initialize(bytes: ''.b) @len = bytes.length @bytes = bytes @@ -24,23 +29,41 @@ def initialize(bytes: ''.b) @is_principal = true end + # Creates a new Principal instance representing the management canister. + # + # Returns: The Principal instance representing the management canister. def self.management_canister Principal.new end + # Creates a new self-authenticating Principal. + # + # Parameters: + # - pubkey: The public key associated with the self-authenticating Principal. + # + # Returns: The self-authenticating Principal instance. def self.self_authenticating(pubkey) # check pubkey.size for is ed25519 or secp256k1 pubkey = [pubkey].pack('H*') if pubkey.size != 44 && pubkey.size != 88 hash_ = OpenSSL::Digest::SHA224.digest(pubkey) - hash_ += [PrincipalSort::SelfAuthenticating].pack('C') + hash_ += [PrincipalSort::SELF_AUTHENTICATING].pack('C') Principal.new(bytes: hash_) end + # Creates a new anonymous Principal. + # + # Returns: The anonymous Principal instance. def self.anonymous Principal.new(bytes: "\x04".b) end + # Creates a new Principal from a string representation. + # + # Parameters: + # - s: The string representation of the Principal. + # + # Returns: The Principal instance. def self.from_str(s) s1 = s.delete('-') pad_len = ((s1.length / 8.0).ceil * 8) - s1.length @@ -53,10 +76,19 @@ def self.from_str(s) p end + # Creates a new Principal from a hexadecimal string representation. + # + # Parameters: + # - s: The hexadecimal string representation of the Principal. + # + # Returns: The Principal instance. def self.from_hex(s) Principal.new(bytes: [s].pack('H*')) end + # Converts the Principal to a string representation. + # + # Returns: The string representation of the Principal. def to_str checksum = Zlib.crc32(@bytes) & 0xFFFFFFFF b = '' @@ -71,6 +103,12 @@ def to_str ret + s end + # Converts the Principal to an AccountIdentifier. + # + # Parameters: + # - sub_account: The sub-account identifier. Defaults to 0. + # + # Returns: The AccountIdentifier instance. def to_account_id(sub_account = 0) AccountIdentifier.generate(self, sub_account) end @@ -78,17 +116,70 @@ def to_account_id(sub_account = 0) def to_s to_str end + + # Compares the Principal with another Principal. + # + # Parameters: + # - other: The other Principal to compare with. + # + # Returns: The comparison result as a string ('lt', 'eq', or 'gt'). + def compare_to(other) + (0...[self.bytes.length, other.bytes.length].min).each do |i| + if self.bytes[i] < other.bytes[i] + return 'lt' + elsif self.bytes[i] > other.bytes[i] + return 'gt' + end + end + + if self.bytes.length < other.bytes.length + 'lt' + elsif self.bytes.length > other.bytes.length + 'gt' + else + 'eq' + end + end + + # Utility method checking whether a provided Principal is less than or equal to the current one using the `compare_to` method. + # + # Parameters: + # - other: The other Principal to compare with. + # + # Returns: `true` if the current Principal is less than or equal to the provided Principal, otherwise `false`. + def lt_eq(other) + cmp = compare_to(other) + %w[lt eq].include?(cmp) + end + + # Utility method checking whether a provided Principal is greater than or equal to the current one using the `compare_to` method. + # + # Parameters: + # - other: The other Principal to compare with. + # + # Returns: `true` if the current Principal is greater than or equal to the provided Principal, otherwise `false`. + def gt_eq(other) + cmp = compare_to(other) + %w[gt eq].include?(cmp) + end end class AccountIdentifier attr_reader :bytes + # Initializes a new instance of the AccountIdentifier class. + # + # Parameters: + # - hash: The hash representing the AccountIdentifier. def initialize(hash) raise 'Invalid hash length' unless hash.length == 32 @bytes = hash end + # Converts the AccountIdentifier to a string representation. + # + # Returns: The string representation of the AccountIdentifier. def to_str '0x' + @bytes.unpack1('H*') end @@ -97,12 +188,19 @@ def to_s to_str end + # Generates a new AccountIdentifier from a Principal. + # + # Parameters: + # - principal: The Principal associated with the AccountIdentifier. + # - sub_account: The sub-account identifier. Defaults to 0. + # + # Returns: The AccountIdentifier instance. def self.generate(principal, sub_account = 0) sha224 = OpenSSL::Digest::SHA224.new sha224 << "\naccount-id" sha224 << principal.bytes format_sub_account = "%08d" % sub_account - sub_account = format_sub_account.chars.map {|c| c.to_i}.pack('N*') + sub_account = format_sub_account.chars.map { |c| c.to_i }.pack('N*') sha224 << sub_account hash = sha224.digest checksum = Zlib.crc32(hash) & 0xFFFFFFFF diff --git a/lib/ic_agent/system_state.rb b/lib/ic_agent/system_state.rb index eae14c1..3469829 100644 --- a/lib/ic_agent/system_state.rb +++ b/lib/ic_agent/system_state.rb @@ -5,6 +5,13 @@ module IcAgent class SyetemState + # Retrieves the system time from a canister's state. + # + # Parameters: + # - agent: The IcAgent::Client instance. + # - canister_id: The ID of the canister. + # + # Returns: The system time as a timestamp. def self.time(agent, canister_id) cert = agent.read_state_raw(canister_id, [['time']]) timestamp = Certificate.lookup(['time'], cert) @@ -12,6 +19,14 @@ def self.time(agent, canister_id) LEB128.decode_signed(str_io) end + # Retrieves the public key of a subnet from a canister's state. + # + # Parameters: + # - agent: The IcAgent::Client instance. + # - canister_id: The ID of the canister. + # - subnet_id: The ID of the subnet. + # + # Returns: The public key of the subnet in hexadecimal format. def self.subnet_public_key(agent, canister_id, subnet_id) path = ['subnet', Principal.from_str(subnet_id).bytes, 'public_key'] cert = agent.read_state_raw(canister_id, [path]) @@ -19,6 +34,14 @@ def self.subnet_public_key(agent, canister_id, subnet_id) pubkey.str2hex end + # Retrieves the canister ranges of a subnet from a canister's state. + # + # Parameters: + # - agent: The IcAgent::Client instance. + # - canister_id: The ID of the canister. + # - subnet_id: The ID of the subnet. + # + # Returns: An array of canister ranges, where each range is represented as an array of Principal instances. def self.subnet_canister_ranges(agent, canister_id, subnet_id) path = ['subnet', Principal.from_str(subnet_id).bytes, 'canister_ranges'] cert = agent.read_state_raw(canister_id, [path]) @@ -26,6 +49,13 @@ def self.subnet_canister_ranges(agent, canister_id, subnet_id) CBOR.decode(ranges).value.map { |range| range.map { |item| Principal.new(bytes: item) } } end + # Retrieves the module hash of a canister from a canister's state. + # + # Parameters: + # - agent: The IcAgent::Client instance. + # - canister_id: The ID of the canister. + # + # Returns: The module hash of the canister in hexadecimal format. def self.canister_module_hash(agent, canister_id) path = ['canister', Principal.from_str(canister_id).bytes, 'module_hash'] cert = agent.read_state_raw(canister_id, [path]) @@ -33,6 +63,13 @@ def self.canister_module_hash(agent, canister_id) module_hash.str2hex end + # Retrieves the controllers of a canister from a canister's state. + # + # Parameters: + # - agent: The IcAgent::Client instance. + # - canister_id: The ID of the canister. + # + # Returns: An array of Principal instances representing the controllers of the canister. def self.canister_controllers(agent, canister_id) path = ['canister', Principal.from_str(canister_id).bytes, 'controllers'] cert = agent.read_state_raw(canister_id, [path]) diff --git a/lib/ic_agent/utils.rb b/lib/ic_agent/utils.rb index f405b94..f730fbd 100644 --- a/lib/ic_agent/utils.rb +++ b/lib/ic_agent/utils.rb @@ -3,6 +3,12 @@ module IcAgent module Utils + # Encodes a list of items into a binary string. + # + # Parameters: + # - l: The list of items to encode. + # + # Returns: The binary string representation of the encoded list. def self.encode_list(l) ret = '' l.each do |item| @@ -21,7 +27,12 @@ def self.encode_list(l) ret end - # used for sort record by key + # Computes a hash value for sorting records by key. + # + # Parameters: + # - s: The key to hash. + # + # Returns: The computed hash value. def self.label_hash(s) if s =~ /(^_\d+_$)|(^_0x[0-9a-fA-F]+_$)/ num = s[1..-2] @@ -41,6 +52,12 @@ def self.label_hash(s) idl_hash(s) end + # Computes a hash value for an IDL string. + # + # Parameters: + # - s: The IDL string to hash. + # + # Returns: The computed hash value. def self.idl_hash(s) h = 0 s.bytes.each do |c| @@ -49,6 +66,12 @@ def self.idl_hash(s) h end + # Converts a data structure into a request ID. + # + # Parameters: + # - d: The data structure to convert. + # + # Returns: The request ID as a binary string. def self.to_request_id(d) return nil unless d.is_a?(Hash) @@ -67,9 +90,14 @@ def self.to_request_id(d) Digest::SHA256.digest(s) end + # Decodes a binary blob into a string. + # + # Parameters: + # - blob_bytes: The binary blob to decode. + # + # Returns: The decoded string. def self.decode_blob(blob_bytes) blob_bytes.pack('C*') end end end - diff --git a/lib/ic_agent/version.rb b/lib/ic_agent/version.rb index 6ca96d6..abe34a1 100644 --- a/lib/ic_agent/version.rb +++ b/lib/ic_agent/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module IcAgent - VERSION = '0.1.4' + VERSION = '0.2.0' end diff --git a/spec/ic_agent/agent_spec.rb b/spec/ic_agent/agent_spec.rb index 190de3e..90e1fc9 100644 --- a/spec/ic_agent/agent_spec.rb +++ b/spec/ic_agent/agent_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'byebug' describe IcAgent::Agent do it 'IcAgent::Agent call' do @@ -24,6 +25,17 @@ expect(result.size).to eql(1) expect(result[0]).to include('type' => 'rec_0') end + + it 'IcAgent::Agent read_state_raw and bls verify certificate' do + iden = IcAgent::Identity.new + client = IcAgent::Client.new + agent = IcAgent::Agent.new(iden, client) + canister_id = 'gvbup-jyaaa-aaaah-qcdwa-cai' + + cert = agent.read_state_raw(canister_id, [['time']]) + verify = agent.verify(cert, canister_id) + expect(verify).to eql(true) + end end diff --git a/spec/version_spec.rb b/spec/version_spec.rb index 1cd0d53..a049154 100644 --- a/spec/version_spec.rb +++ b/spec/version_spec.rb @@ -1,5 +1,5 @@ describe IcAgent::VERSION do it 'IcAgent::VERSION' do - expect(IcAgent::VERSION).to eql('0.1.4') + expect(IcAgent::VERSION).to eql('0.2.0') end end \ No newline at end of file