Skip to content
Merged
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
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ and do not establish a presumption of conformity.
didlint instance is set with `DIDLINT_URL` (default
`https://didlint.ownyourdata.eu`); it is the only service dpplint calls
besides the passport and the resources it links.
- Criteria with `check.type: proof` verify integrity proofs of the passport
against a key of the economic operator:
- Criteria with `check.type: proof` check integrity proofs of the passport.
Without `key_from` (DPP-SEC-002) every proof found is verified with the key
it names:
- W3C Data Integrity proofs in the passport (`DataIntegrityProof`,
cryptosuite `eddsa-jcs-2022`);
- the passport as compact JWS (VC-JOSE-COSE, `EdDSA` or `ES256`, key named
by `kid`). With `GET`, dpplint also requests the identifier with
cryptosuite `eddsa-jcs-2022`, key from `verificationMethod`);
- the passport as compact JWS (VC-JOSE-COSE, `EdDSA` or `ES256`, key from
`kid`). With `GET`, dpplint also requests the identifier with
`Accept: application/vc+jwt, application/jwt, application/jose`; a JWS
delivered that way has to carry the same passport as the JSON answer.

delivered that way has to carry the same passport as the JSON answer;
- the passport DID (`digitalProductPassportId`, `did:oyd`): its DID
document, resolved by didlint in the current version, carries in the
service of type `DigitalProductPassport` a `payloadHash` (SHA-256
Expand All @@ -55,11 +55,12 @@ and do not establish a presumption of conformity.
`serviceEndpoint` and with the bytes delivered for the product identifier
(with `POST`: with the content sent).

Keys for Data Integrity and JWS are taken from a `did:key` or from the DID
document resolved by didlint (Ed25519 or P-256, as `publicKeyMultibase` or
`publicKeyJwk`).
Passports without a proof, and other proof formats, are reported as
`skipped`.
With `key_from` (DPP-SEC-013) at least one verified proof has to be issued
with a key of the DID at that path (the economic operator); otherwise the
result is a warning. Keys are taken from a `did:key` or from the DID document
resolved by didlint (Ed25519 or P-256, as `publicKeyMultibase` or
`publicKeyJwk`). Passports without a proof, and other proof formats, are
reported as `skipped`.
- Criteria with `check.type: links` check every `RelatedResource` of the
passport for the required attributes and send a HEAD request to its URL
(at most 20 URLs per passport). A URL that does not answer gives a warning.
Expand Down
13 changes: 13 additions & 0 deletions app/services/didlint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Unavailable < StandardError; end

def initialize(base = Rails.configuration.x.dpplint.didlint)
@base = base.chomp("/")
@cache = {}
end

# {"valid" => true} or {"valid" => false, "error" => ..., "errors" => [...]}
Expand All @@ -28,7 +29,19 @@ def resolve!(did)

private

# Answers are cached per instance (one instance per validation).
def get(path)
@cache[path] ||= begin
fetch(path)
rescue Unavailable => e
e
end
raise @cache[path] if @cache[path].is_a?(Unavailable)

@cache[path]
end

def fetch(path)
uri = URI("#{@base}/#{path}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 60) do |http|
http.request(Net::HTTP::Get.new(uri, "Accept" => "application/json"))
Expand Down
215 changes: 120 additions & 95 deletions app/services/proof_check.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
require "openssl"

# Runs a criterion with check.type proof (DPP-SEC-002): integrity proofs of the
# passport are verified against a key of the DID found at check.key_from (the
# economic operator).
# Runs criteria with check.type proof.
#
# Supported formats:
# Without check.key_from (DPP-SEC-002): the integrity of the passport has to be
# verifiable with at least one of check.formats; every proof found is verified
# with the key it names.
# With check.key_from (DPP-SEC-013): at least one verified proof has to be
# issued by the DID at key_from (the economic operator); otherwise a warning.
#
# Formats:
# - vc-data-integrity: W3C Data Integrity proofs (member "proof") with the
# cryptosuite eddsa-jcs-2022;
# cryptosuite eddsa-jcs-2022; key from verificationMethod;
# - vc-jose-cose: the passport as compact JWS (application/vc+jwt), signed
# with EdDSA or ES256, whose header kid names the key. If the passport was
# also delivered as plain JSON, the JWS payload has to be the same passport;
# with EdDSA or ES256; key from the header kid. If the passport was also
# delivered as plain JSON, the JWS payload has to be the same passport;
# - did-oyd-log: the DID document of digitalProductPassportId (did:oyd,
# resolved by didlint, current version) carries in its service of type
# DigitalProductPassport a payloadHash: SHA-256 multihash (base58btc) of the
# passport bytes as delivered by that service's serviceEndpoint. The bytes
# delivered for the product identifier have to be the same.
# delivered for the product identifier have to be the same. The attestation
# is made with the key of the passport DID.
# Passports without a proof, and proofs in formats this version does not
# verify, are skipped. Keys for Data Integrity and JWS come from did:key
# directly or from the DID document resolved by didlint.
# verify, are skipped. Keys come from did:key directly or from the DID
# document resolved by didlint.
class ProofCheck
Outcome = Struct.new(:skipped, :messages, keyword_init: true)
# One proof found in the passport: format, DID of the signer, and the
# messages of its verification (none with severity violation = verified).
Proof = Struct.new(:format, :signer, :messages, keyword_init: true) do
def verified? = messages.none? { |m| m[:severity] == "violation" }
end

CRYPTOSUITES = %w[eddsa-jcs-2022].freeze
PASSPORT_SERVICE = "DigitalProductPassport".freeze
Expand All @@ -39,25 +49,8 @@ def initialize(check, passport, raw: nil, jws: nil, jws_only: false, didlint: Di
end

def outcome
@messages = []
@verified = 0
@unsupported = []
@notes = []

if @passport.key?("proof") || @jws
operator = @passport[@check["key_from"].to_s.delete_prefix("$.")]
if did?(operator)
data_integrity(operator) if @passport.key?("proof")
jose(operator) if @jws
else
@notes << "#{@check['key_from']} is not a DID, so the key of the economic operator cannot be determined"
end
end
oyd_log if Array(@check["formats"]).include?("did-oyd-log")

return Outcome.new(messages: @messages) unless @messages.empty? && @verified.zero?

skip(skip_reason)
analyse
@check["key_from"] ? issuer_outcome : integrity_outcome
end

# SHA-256 multihash, base58btc with prefix z (zQm...).
Expand All @@ -81,6 +74,42 @@ def self.eddsa_jcs_valid?(secured, proof, key, signature)

private

def formats = Array(@check["formats"])

def analyse
@proofs = []
@unsupported = []
@notes = []
data_integrity if formats.include?("vc-data-integrity") && @passport.key?("proof")
jose if formats.include?("vc-jose-cose") && @jws
oyd_log if formats.include?("did-oyd-log")
end

def integrity_outcome
return skip(skip_reason) if @proofs.empty?

Outcome.new(messages: @proofs.flat_map(&:messages))
end

def issuer_outcome
verified = @proofs.select(&:verified?)
return skip("no verified integrity proof (#{skip_reason.presence || 'see the integrity check'})") if verified.empty?

operator = @passport[@check["key_from"].delete_prefix("$.")]
return skip("#{@check['key_from']} is not a DID, so the issuer of the proof cannot be compared with it") unless did?(operator)
return Outcome.new(messages: []) if verified.any? { |p| p.signer == operator }

Outcome.new(messages: verified.map { |p| warning(issuer_message(p, operator)) })
end

def issuer_message(proof, operator)
if proof.format == "did-oyd-log"
"the content is attested with the key of the passport DID #{proof.signer}, which is not linked to the economic operator #{operator}"
else
"the #{proof.format} proof is signed by #{proof.signer}, not by the economic operator #{operator}; an authorised representative cannot be recognised automatically"
end
end

def skip_reason
reasons = []
unless @passport.key?("proof") || @jws || @notes.any? { |n| n.start_with?("the DID document") }
Expand All @@ -93,6 +122,65 @@ def skip_reason
(reasons + @notes).join("; ")
end

# --- vc-data-integrity ---

def data_integrity
proofs = @passport["proof"].is_a?(Array) ? @passport["proof"] : [@passport["proof"]]
proofs.each do |proof|
next add("vc-data-integrity", nil, [violation("proof is not a JSON object")]) unless proof.is_a?(Hash)
if proof["type"] != "DataIntegrityProof" || !CRYPTOSUITES.include?(proof["cryptosuite"])
next @unsupported << [proof["type"], proof["cryptosuite"]].compact.join(" ")
end

vm = proof["verificationMethod"]
vm = vm["id"] if vm.is_a?(Hash)
add("vc-data-integrity", vm.is_a?(String) ? vm.split("#").first : nil, verify_data_integrity(proof, vm))
end
end

def verify_data_integrity(proof, vm)
return [violation("proof has no verificationMethod")] unless vm.is_a?(String) && did?(vm)

key = @keys.key(vm, vm.split("#").first)
return [violation("verification method #{vm} is not an Ed25519 or P-256 key that can be resolved")] unless key

signature = decode_multibase(proof["proofValue"])
return [violation("proofValue is not a multibase base58btc value")] unless signature

out = []
unless self.class.eddsa_jcs_valid?(@passport, proof, key, signature)
out << violation("signature of the #{proof['cryptosuite']} proof by #{vm} does not verify: the passport content does not match the signed content")
end
if proof["proofPurpose"] != "assertionMethod"
out << warning("proofPurpose is #{proof['proofPurpose'].inspect}, expected \"assertionMethod\"")
end
out
end

# --- vc-jose-cose ---

def jose
return @unsupported << "JWS #{@jws.alg}" unless @jws.supported?

kid = @jws.header["kid"]
vm = kid.is_a?(String) && kid.start_with?("#") && did?(@jws.header["iss"]) ? "#{@jws.header['iss']}#{kid}" : kid
add("vc-jose-cose", vm.is_a?(String) ? vm.split("#").first : nil, verify_jws(vm))
end

def verify_jws(vm)
return [violation("JWS header has no kid with a DID URL, so the signing key cannot be determined")] unless vm.is_a?(String) && did?(vm)

key = @keys.key(vm, vm.split("#").first)
return [violation("verification method #{vm} is not an Ed25519 or P-256 key that can be resolved")] unless key
return [violation("JWS signature (#{@jws.alg}) by #{vm} does not verify")] unless @jws.valid_with?(key)
return [violation("JWS payload is not a JSON object")] unless @jws.payload.is_a?(Hash)
return [] if @jws_only || Jcs.dump(@jws.payload) == Jcs.dump(@passport.except("proof"))

[violation("JWS payload differs from the passport delivered as JSON")]
end

# --- did-oyd-log ---

def oyd_log
did = @passport["digitalProductPassportId"]
return unless did.is_a?(String) && did.start_with?("did:oyd:")
Expand All @@ -106,7 +194,7 @@ def oyd_log
return @notes << "the DID document of #{did} binds only the location of the passport, not its content (no payloadHash)"
end

record(compare_payload(expected, service["serviceEndpoint"]))
add("did-oyd-log", did, compare_payload(expected, service["serviceEndpoint"]))
rescue Didlint::Unavailable => e
@notes << "did-oyd-log not checked (#{e.message})"
end
Expand Down Expand Up @@ -138,72 +226,9 @@ def same_content?(body)
false
end

def data_integrity(operator)
proofs = @passport["proof"].is_a?(Array) ? @passport["proof"] : [@passport["proof"]]
proofs.each do |proof|
next @messages << violation("proof is not a JSON object") unless proof.is_a?(Hash)
if proof["type"] != "DataIntegrityProof" || !CRYPTOSUITES.include?(proof["cryptosuite"])
next @unsupported << [proof["type"], proof["cryptosuite"]].compact.join(" ")
end

record(verify_data_integrity(proof, operator))
end
end

def verify_data_integrity(proof, operator)
vm = proof["verificationMethod"]
vm = vm["id"] if vm.is_a?(Hash)
return [violation("proof has no verificationMethod")] unless vm.is_a?(String)
# --- helpers ---

key, problem = operator_key(vm, operator, "proof")
return [problem] if problem

signature = decode_multibase(proof["proofValue"])
return [violation("proofValue is not a multibase base58btc value")] unless signature

out = []
unless self.class.eddsa_jcs_valid?(@passport, proof, key, signature)
out << violation("signature of the #{proof['cryptosuite']} proof by #{vm} does not verify: the passport content does not match the signed content")
end
if proof["proofPurpose"] != "assertionMethod"
out << warning("proofPurpose is #{proof['proofPurpose'].inspect}, expected \"assertionMethod\"")
end
out
end

def jose(operator)
return @unsupported << "JWS #{@jws.alg}" unless @jws.supported?

record(verify_jws(operator))
end

def verify_jws(operator)
kid = @jws.header["kid"]
return [violation("JWS header has no kid, so the signing key cannot be determined")] unless kid.is_a?(String) && kid.present?

vm = kid.start_with?("#") ? "#{operator}#{kid}" : kid
key, problem = operator_key(vm, operator, "JWS")
return [problem] if problem
return [violation("JWS signature (#{@jws.alg}) by #{vm} does not verify")] unless @jws.valid_with?(key)
return [violation("JWS payload is not a JSON object")] unless @jws.payload.is_a?(Hash)
return [] if @jws_only || Jcs.dump(@jws.payload) == Jcs.dump(@passport.except("proof"))

[violation("JWS payload differs from the passport delivered as JSON")]
end

def operator_key(vm, operator, what)
unless vm.split("#").first == operator
return [nil, violation("#{what} is signed with #{vm}, not with a key of the economic operator #{operator}")]
end

key = @keys.key(vm, operator)
key ? [key, nil] : [nil, violation("verification method #{vm} is not an Ed25519 or P-256 key in the DID document of #{operator}")]
end

def record(messages)
@messages.concat(messages)
@verified += 1 if messages.none? { |m| m[:severity] == "violation" }
end
def add(format, signer, messages) = @proofs << Proof.new(format: format, signer: signer, messages: messages)

def decode_multibase(value)
return unless value.is_a?(String) && value.start_with?("z")
Expand Down
20 changes: 15 additions & 5 deletions test/integration/validations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,22 @@ def criterion(result, id) = result["criteria"].find { |c| c["id"] == id }
assert_equal "no RelatedResource elements", criterion(result, "DPP-DAT-011")["reason"]
end

test "passport signed by its economic operator passes the integrity check" do
test "passport signed by its economic operator passes the integrity and issuer checks" do
passport = reference.merge("economicOperatorId" => did_key)
signed = sign(passport, verification_method: "#{did_key}##{key_multibase}")
assert_equal "passed", criterion(lint(signed), "DPP-SEC-002")["result"]
changed = signed.merge("dppStatus" => "Inactive")
assert_equal "failed", criterion(lint(changed), "DPP-SEC-002")["result"]
signed = lint(sign(passport, verification_method: "#{did_key}##{key_multibase}"))
assert_equal "passed", criterion(signed, "DPP-SEC-002")["result"]
assert_equal "passed", criterion(signed, "DPP-SEC-013")["result"]
changed = lint(sign(passport, verification_method: "#{did_key}##{key_multibase}").merge("dppStatus" => "Inactive"))
assert_equal "failed", criterion(changed, "DPP-SEC-002")["result"]
assert_equal "skipped", criterion(changed, "DPP-SEC-013")["result"]
end

test "passport signed by another key passes the integrity check with a warning on the issuer" do
other = OpenSSL::PKey.generate_key("ED25519")
passport = reference.merge("economicOperatorId" => did_key)
result = lint(sign(passport, verification_method: "#{did_key(other)}##{key_multibase(other)}", key: other))
assert_equal "passed", criterion(result, "DPP-SEC-002")["result"]
assert_equal "warning", criterion(result, "DPP-SEC-013")["result"]
end

test "passport posted as JWS is checked and its signature verified" do
Expand Down
Loading
Loading