Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verifiable Credential Issuance and Verifiable Presentation Consumption #68

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ 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)
end

# 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
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions plugins/credential_issuance/credential_issuance.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

require_relative 'simple_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
conf = PluginLoader.configuration('credential_issuance')
metadata['credential_issuer'] = conf['credential_issuer'] if conf['credential_issuer']
end
PluginLoader.register 'STATIC_METADATA', method(:add_to_metadata)
88 changes: 88 additions & 0 deletions plugins/credential_issuance/simple_credential.rb
Original file line number Diff line number Diff line change
@@ -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)