From a8ab0e34fec873e69c86affdcf38e02b97c7c7d7 Mon Sep 17 00:00:00 2001 From: Thomas Bellebaum Date: Wed, 21 Sep 2022 13:48:08 +0200 Subject: [PATCH 1/2] Basic Credential Issuance for Demo Credential --- lib/keys.rb | 8 +- .../credential_issuance.rb | 103 ++++++++++++++++++ plugins/credential_issuance/id_credential.rb | 62 +++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 plugins/credential_issuance/credential_issuance.rb create mode 100644 plugins/credential_issuance/id_credential.rb diff --git a/lib/keys.rb b/lib/keys.rb index 9ad5934..9402fdc 100644 --- a/lib/keys.rb +++ b/lib/keys.rb @@ -68,7 +68,7 @@ def self.store_key(bind) # Certificates if key_material['certs'].nil? - File.delete "#{filename}.cert" if File.exist? "#{filename}.cert" + FileUtils.rm_rf "#{filename}.cert" else pem = key_material['certs'].map(&:to_pem).join("\n") File.write("#{filename}.cert", pem) @@ -76,7 +76,7 @@ def self.store_key(bind) # Keys if key_material['sk'].nil? - File.delete "#{filename}.key" if File.exist? "#{filename}.key" + FileUtils.rm_rf "#{filename}.key" else File.write("#{filename}.key", key_material['sk']) end @@ -107,8 +107,8 @@ def self.load_key(bind) raise 'Certificate not yet valid' if certs[0].not_before > Time.now result['certs'] = certs if result['sk'].nil? || (certs[0].check_private_key result['sk']) - rescue StandardError - p 'Loading certificate failed' + rescue StandardError => e + p "Loading certificate failed: #{e}" end end result diff --git a/plugins/credential_issuance/credential_issuance.rb b/plugins/credential_issuance/credential_issuance.rb new file mode 100644 index 0000000..0f99ce1 --- /dev/null +++ b/plugins/credential_issuance/credential_issuance.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require_relative 'id_credential' + +# Cache for nonces +class NonceCache + class << self; attr_accessor :acceptable_nonces end + @acceptable_nonces = {} # Mapping from client_ids to nonces + + def self.get_nonce(client_id) + nonce = SecureRandom.uuid + (@acceptable_nonces[client_id] ||= []) << nonce + nonce + end + + def self.verify_nonce(client_id, nonce) + @acceptable_nonces[client_id]&.delete(nonce) + end +end + +# Credential issuance endpoint +endpoint '/credential_issuance', ['POST'], public_endpoint: true do + token = Token.decode env.fetch('HTTP_AUTHORIZATION', '')&.slice(7..-1), nil + client = Client.find_by_id token['client_id'] + user = User.find_by_id token['sub'] + json = JSON.parse request.body.read + raise 'no_user_or_client' unless user && client + raise 'no_type_specified' unless json['type'] + # Determine using the scopes whether a credential may be issued + raise 'insufficient_scope' unless token['scope'].split.include? "credential:#{json['type']}" + + # Optionally verify PoP + if json['proof'] + id = verify_identifier json['proof'], client + raise 'unaccepted_proof' unless id + end + + # Build credential + credential = build_credential json['type'], json['format'], user, id + raise 'issuing_failed' unless credential&.dig('format') && credential&.dig('credential') + + halt 200, { 'Content-Type' => 'application/json' }, credential.to_json +rescue StandardError => e + p e if debug + c_nonce = NonceCache.get_nonce client.client_id if client + halt 400, { 'Content-Type' => 'application/json' }, { + error: e.to_s, + c_nonce: c_nonce, + c_nonce_expires_in: 86_400 + }.compact.to_json +end + +# Verifies control over a cryptographic secret, whose public counterpart may be +# resolvable from an identifier (e.g. DID) or included in the proof (e.g. JWT). +# Consumes a nonce +def verify_identifier(pop, client) + return unless pop&.dig('proof_type') + + case pop['proof_type'] + when 'jwt' + verify_options = { + algorithms: %w[RS256 RS512 ES256 ES512], + verify_iat: true, + iss: client.client_id, + verify_iss: true, + aud: Config.base_config['issuer'], + verify_aud: true + } + body, header = JWT.decode pop['jwt'], nil, true, verify_options do |header, _body| + # We only support JWKs atm. TODO: Support for x5c + JWT::JWK.import(header['jwk']).keypair.public_key + end + # check nonce + return unless NonceCache.verify_nonce client.client_id, body['nonce'] + + jwk_thumbprint header['jwk'] + end +end + +# Temporary helper, until JWT can do this for us properly +def jwk_thumbprint(jwk) + jwk = jwk.clone + jwk.delete(:kid) + digest = Digest::SHA256.new + digest << jwk.sort.to_h.to_json + digest.base64digest.gsub('+', '-').gsub('/', '_').gsub('=', '') +end + +# Calls other plugins to build a credential +def build_credential(type, format, subject, subject_id) + (PluginLoader.fire "PLUGIN_CREDENTIAL_ISSUANCE_BUILD_#{type.upcase}", binding).compact.first +end + +# Adds the necessary data to the metadata +# Credentials are defined by other plugins +def add_to_metadata(bind) + metadata = bind.local_variable_get :metadata + metadata['credential_endpoint'] = "#{Config.base_config['front_url']}/credential_issuance" + credentials_supported = {} + PluginLoader.fire 'PLUGIN_CREDENTIAL_ISSUANCE_LIST', binding + metadata['credentials_supported'] = credentials_supported +end +PluginLoader.register 'STATIC_METADATA', method(:add_to_metadata) diff --git a/plugins/credential_issuance/id_credential.rb b/plugins/credential_issuance/id_credential.rb new file mode 100644 index 0000000..8696a75 --- /dev/null +++ b/plugins/credential_issuance/id_credential.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +def build_id_credential(bind) + req_format = bind.local_variable_get :format + subject = bind.local_variable_get :subject + subject_id = bind.local_variable_get :subject_id + + # Require binding to an identifier + return unless subject_id + + # We only support `vc_jwt` + return unless req_format == 'jwt_vc' + + # Our ID Credential consists of a Name and birth date, + # and we only issue the credential if we have at least the following + return unless (subject.claim? 'given_name') && (subject.claim? 'family_name') && (subject.claim? 'birthdate') + + credential_subject = { name: {} } + subject.attributes.each do |a| + credential_subject[:name][a['key']] = a['value'] if %w[given_name middle_name family_name].include? a['key'] + credential_subject[a['key']] = a['value'] if a['key'] == 'birthdate' + end + + # Assemble the JWT-VC + base_config = Config.base_config + now = Time.new.to_i + jwt_body = { + 'iss' => base_config['issuer'], + 'sub' => subject_id, + 'jti' => SecureRandom.uuid, + 'nbf' => now, + 'iat' => now, + 'exp' => now + (3600 * 24 * 365), + 'nonce' => SecureRandom.uuid, + 'vc' => { + '@context' => ['https://www.w3.org/2018/credentials/v1'], + 'type' => %w[VerifiableCredential IDCredential], + 'credentialSubject' => credential_subject + } + } + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true + credential = JWT.encode jwt_body, key_pair['sk'], 'RS256', { typ: 'at+jwt', kid: key_pair['kid'] } + { 'format' => 'jwt_vc', 'credential' => credential } +end +PluginLoader.register 'PLUGIN_CREDENTIAL_ISSUANCE_BUILD_ID_CREDENTIAL', method(:build_id_credential) + +def id_credential_metadata(bind) + credentials = bind.local_variable_get :credentials_supported + credentials['id_credential'] = { + display: { + name: 'ID Credential' + }, + formats: { + 'jwt_vc' => { + 'types' => %w[VerifiableCredential IDCredential], + 'cryptographic_binding_methods_supported' => ['jwk'], + 'cryptographic_suites_supported' => %w[RS256 RS512 ES256 ES512] + } + } + } +end +PluginLoader.register 'PLUGIN_CREDENTIAL_ISSUANCE_LIST', method(:id_credential_metadata) From db4c8bca84c68e48ca060f69b90b214efcdd42e8 Mon Sep 17 00:00:00 2001 From: Thomas Bellebaum Date: Wed, 21 Sep 2022 17:10:32 +0200 Subject: [PATCH 2/2] Configurable simple credential formats --- .../credential_issuance.rb | 4 +- plugins/credential_issuance/id_credential.rb | 62 ------------- .../credential_issuance/simple_credential.rb | 88 +++++++++++++++++++ 3 files changed, 91 insertions(+), 63 deletions(-) delete mode 100644 plugins/credential_issuance/id_credential.rb create mode 100644 plugins/credential_issuance/simple_credential.rb diff --git a/plugins/credential_issuance/credential_issuance.rb b/plugins/credential_issuance/credential_issuance.rb index 0f99ce1..1127607 100644 --- a/plugins/credential_issuance/credential_issuance.rb +++ b/plugins/credential_issuance/credential_issuance.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'id_credential' +require_relative 'simple_credential' # Cache for nonces class NonceCache @@ -99,5 +99,7 @@ def add_to_metadata(bind) credentials_supported = {} PluginLoader.fire 'PLUGIN_CREDENTIAL_ISSUANCE_LIST', binding metadata['credentials_supported'] = credentials_supported + conf = PluginLoader.configuration('credential_issuance') + metadata['credential_issuer'] = conf['credential_issuer'] if conf['credential_issuer'] end PluginLoader.register 'STATIC_METADATA', method(:add_to_metadata) diff --git a/plugins/credential_issuance/id_credential.rb b/plugins/credential_issuance/id_credential.rb deleted file mode 100644 index 8696a75..0000000 --- a/plugins/credential_issuance/id_credential.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -def build_id_credential(bind) - req_format = bind.local_variable_get :format - subject = bind.local_variable_get :subject - subject_id = bind.local_variable_get :subject_id - - # Require binding to an identifier - return unless subject_id - - # We only support `vc_jwt` - return unless req_format == 'jwt_vc' - - # Our ID Credential consists of a Name and birth date, - # and we only issue the credential if we have at least the following - return unless (subject.claim? 'given_name') && (subject.claim? 'family_name') && (subject.claim? 'birthdate') - - credential_subject = { name: {} } - subject.attributes.each do |a| - credential_subject[:name][a['key']] = a['value'] if %w[given_name middle_name family_name].include? a['key'] - credential_subject[a['key']] = a['value'] if a['key'] == 'birthdate' - end - - # Assemble the JWT-VC - base_config = Config.base_config - now = Time.new.to_i - jwt_body = { - 'iss' => base_config['issuer'], - 'sub' => subject_id, - 'jti' => SecureRandom.uuid, - 'nbf' => now, - 'iat' => now, - 'exp' => now + (3600 * 24 * 365), - 'nonce' => SecureRandom.uuid, - 'vc' => { - '@context' => ['https://www.w3.org/2018/credentials/v1'], - 'type' => %w[VerifiableCredential IDCredential], - 'credentialSubject' => credential_subject - } - } - key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true - credential = JWT.encode jwt_body, key_pair['sk'], 'RS256', { typ: 'at+jwt', kid: key_pair['kid'] } - { 'format' => 'jwt_vc', 'credential' => credential } -end -PluginLoader.register 'PLUGIN_CREDENTIAL_ISSUANCE_BUILD_ID_CREDENTIAL', method(:build_id_credential) - -def id_credential_metadata(bind) - credentials = bind.local_variable_get :credentials_supported - credentials['id_credential'] = { - display: { - name: 'ID Credential' - }, - formats: { - 'jwt_vc' => { - 'types' => %w[VerifiableCredential IDCredential], - 'cryptographic_binding_methods_supported' => ['jwk'], - 'cryptographic_suites_supported' => %w[RS256 RS512 ES256 ES512] - } - } - } -end -PluginLoader.register 'PLUGIN_CREDENTIAL_ISSUANCE_LIST', method(:id_credential_metadata) diff --git a/plugins/credential_issuance/simple_credential.rb b/plugins/credential_issuance/simple_credential.rb new file mode 100644 index 0000000..95a3c8d --- /dev/null +++ b/plugins/credential_issuance/simple_credential.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +def simple_credential_map_attributes(conf, subject) + credential_subject = {} + conf['mapping']&.each do |m| + return if m['required'] && !subject.claim?(m['attribute']) + + subject.attributes.each do |a| + next unless a['key'] == m['attribute'] + + path = m['target'].split('/') + current = credential_subject + current = (current[path.shift] ||= {}) while path.length > 1 + current[path.shift] = a['value'] + end + end + credential_subject +end + +def build_simple_credential(bind) + type = bind.local_variable_get :type + req_format = bind.local_variable_get :format + subject = bind.local_variable_get :subject + subject_id = bind.local_variable_get :subject_id + + conf = PluginLoader.configuration('credential_issuance')&.dig('simple_credentials', type) + return unless conf + + # Optionally require binding to an identifier + return if conf['binding'] && subject_id.nil? + + # We only support `vc_jwt` for simple_credentials + return unless req_format == 'jwt_vc' + + # Check prerequisites with subject and fill in values + return unless (credential_subject = simple_credential_map_attributes(conf, subject)) + + # Assemble the JWT-VC + base_config = Config.base_config + now = Time.new.to_i + jwt_body = { + 'iss' => base_config['issuer'], + 'sub' => subject_id, + 'jti' => SecureRandom.uuid, + 'nbf' => now, + 'iat' => now, + 'exp' => now + (3600 * 24 * 365), + 'nonce' => SecureRandom.uuid, + 'vc' => { + '@context' => conf['context'], + 'type' => conf['types'], + 'credentialSubject' => credential_subject + }.compact + } + key_pair = Keys.load_key KEYS_TARGET_OMEJDN, 'omejdn', create_key: true + credential = JWT.encode jwt_body, key_pair['sk'], 'RS256', { typ: 'at+jwt', kid: key_pair['kid'] } + { 'format' => 'jwt_vc', 'credential' => credential } +end + +# Register plugin handler for each simple credential type +PluginLoader.configuration('credential_issuance')&.dig('simple_credentials')&.each do |id, _| + PluginLoader.register "PLUGIN_CREDENTIAL_ISSUANCE_BUILD_#{id.upcase}", method(:build_simple_credential) +end + +def id_credential_metadata(bind) + credentials = bind.local_variable_get :credentials_supported + + conf = PluginLoader.configuration('credential_issuance')&.dig('simple_credentials') + return unless conf + + conf.each do |id, data| + credentials[id] = { + display: data['display'], + formats: { + 'jwt_vc' => { + 'types' => data['types'] + } + } + } + next unless data['binding'] + + credentials.dig(id, :formats, 'jwt_vc').merge!({ + 'cryptographic_binding_methods_supported' => ['jwk'], + 'cryptographic_suites_supported' => %w[RS256 RS512 ES256 ES512] + }) + end +end +PluginLoader.register 'PLUGIN_CREDENTIAL_ISSUANCE_LIST', method(:id_credential_metadata)