diff --git a/README.md b/README.md index 7b12515..c52f2e2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/app/services/didlint.rb b/app/services/didlint.rb index 9728adc..e6e425a 100644 --- a/app/services/didlint.rb +++ b/app/services/didlint.rb @@ -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" => [...]} @@ -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")) diff --git a/app/services/proof_check.rb b/app/services/proof_check.rb index d17dc1f..5f96422 100644 --- a/app/services/proof_check.rb +++ b/app/services/proof_check.rb @@ -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 @@ -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...). @@ -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") } @@ -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:") @@ -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 @@ -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") diff --git a/test/integration/validations_test.rb b/test/integration/validations_test.rb index 21a0ab6..20a1315 100644 --- a/test/integration/validations_test.rb +++ b/test/integration/validations_test.rb @@ -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 diff --git a/test/services/proof_check_test.rb b/test/services/proof_check_test.rb index dec14b7..b55e235 100644 --- a/test/services/proof_check_test.rb +++ b/test/services/proof_check_test.rb @@ -3,8 +3,8 @@ class ProofCheckTest < ActiveSupport::TestCase include SigningHelper - CHECK = { "type" => "proof", "key_from" => "$.economicOperatorId", - "formats" => %w[vc-data-integrity vc-jose-cose did-oyd-log] }.freeze + CHECK = { "type" => "proof", "formats" => %w[vc-data-integrity vc-jose-cose did-oyd-log] }.freeze + ISSUER = CHECK.merge("key_from" => "$.economicOperatorId").freeze OYD = "did:oyd:zQmOperator".freeze # Returns a fixed DID document instead of asking didlint. @@ -32,8 +32,8 @@ def get(url, accept: nil) def passport(operator = did_key) = { "uniqueProductIdentifier" => "https://dpp.example.org/01/1", "economicOperatorId" => operator, "dppStatus" => "Active" } - def outcome(passport, didlint: FakeDidlint.new, jws: nil, jws_only: false, raw: nil, resolver: FakeResolver.new({})) - ProofCheck.new(CHECK, passport, raw: raw, jws: jws, jws_only: jws_only, didlint: didlint, resolver: resolver).outcome + def outcome(passport, didlint: FakeDidlint.new, jws: nil, jws_only: false, raw: nil, resolver: FakeResolver.new({}), check: CHECK) + ProofCheck.new(check, passport, raw: raw, jws: jws, jws_only: jws_only, didlint: didlint, resolver: resolver).outcome end def oyd_document(key = signing_key) @@ -56,9 +56,10 @@ def oyd_document(key = signing_key) assert_match(/no payloadHash for the passport DID/, result.skipped) end - test "proof by a did:key of the economic operator passes" do + test "proof by a did:key of the economic operator passes both checks" do signed = sign(passport, verification_method: "#{did_key}##{key_multibase}") assert_empty outcome(signed).messages + assert_empty outcome(signed, check: ISSUER).messages end test "changed passport fails" do @@ -68,10 +69,19 @@ def oyd_document(key = signing_key) assert_match(/does not verify/, result.messages.first[:message]) end - test "proof by another key than the economic operator fails" do + test "proof by another key verifies but gives a warning on the issuer" do other = OpenSSL::PKey.generate_key("ED25519") signed = sign(passport, verification_method: "#{did_key(other)}##{key_multibase(other)}", key: other) - assert_match(/not with a key of the economic operator/, outcome(signed).messages.first[:message]) + assert_empty outcome(signed).messages + result = outcome(signed, check: ISSUER) + assert_equal ["warning"], result.messages.map { |m| m[:severity] } + assert_match(/not by the economic operator .* authorised representative cannot be recognised automatically/, result.messages.first[:message]) + end + + test "issuer check is skipped without a verified proof" do + assert_match(/no verified integrity proof/, outcome(passport, check: ISSUER).skipped) + signed = sign(passport, verification_method: "#{did_key}##{key_multibase}").merge("dppStatus" => "Inactive") + assert_match(/no verified integrity proof/, outcome(signed, check: ISSUER).skipped) end test "key from the resolved DID document of the economic operator" do @@ -102,9 +112,10 @@ def oyd_document(key = signing_key) assert_match(/not verified in this version/, outcome(signed).skipped) end - test "economic operator that is not a DID is skipped" do + test "economic operator that is not a DID skips only the issuer check" do signed = sign(passport("urn:example:operator"), verification_method: "#{did_key}##{key_multibase}") - assert_match(/not a DID/, outcome(signed).skipped) + assert_empty outcome(signed).messages + assert_match(/not a DID/, outcome(signed, check: ISSUER).skipped) end test "unreachable didlint is passed on" do @@ -120,7 +131,7 @@ def oyd_document(key = signing_key) test "JWS signed with ES256 and a key from the DID document passes" do ec = OpenSSL::PKey::EC.generate("prime256v1") doc = { "id" => OYD, "verificationMethod" => [{ "id" => "#key-p256", "type" => "Multikey", "publicKeyMultibase" => key_multibase(ec) }] } - jws = Jws.parse(sign_jws(passport(OYD), kid: "#key-p256", key: ec)) + jws = Jws.parse(sign_jws(passport(OYD), kid: "#key-p256", key: ec, iss: OYD)) assert_empty outcome(jws.payload, jws: jws, jws_only: true, didlint: FakeDidlint.new(doc)).messages end @@ -207,6 +218,13 @@ def oyd_outcome(raw:, passport: attested_passport, document: passport_did_docume assert_match(/cannot be retrieved/, oyd_outcome(raw: nil, endpoint_body: nil).messages.first[:message]) end + test "content attested by the passport DID gives a warning on the issuer" do + result = outcome(attested_passport, raw: attested_bytes, check: ISSUER, + didlint: FakeDidlint.new(docs: { PASSPORT_DID => passport_did_document }), + resolver: FakeResolver.new(ENDPOINT => attested_bytes)) + assert_match(/attested with the key of the passport DID #{PASSPORT_DID}, which is not linked/, result.messages.first[:message]) + end + test "did-oyd-log without didlint is skipped" do result = outcome(attested_passport, raw: attested_bytes, didlint: FakeDidlint.new(unavailable: true)) assert_match(/did-oyd-log not checked \(didlint not reachable/, result.skipped) diff --git a/test/support_signing.rb b/test/support_signing.rb index 13ee311..c9b2fca 100644 --- a/test/support_signing.rb +++ b/test/support_signing.rb @@ -22,9 +22,10 @@ def sign(passport, verification_method:, key: signing_key, purpose: "assertionMe def b64url(bytes) = Base64.urlsafe_encode64(bytes, padding: false) - def sign_jws(passport, kid:, key: signing_key) + def sign_jws(passport, kid:, key: signing_key, iss: nil) alg = key.is_a?(OpenSSL::PKey::EC) ? "ES256" : "EdDSA" - input = "#{b64url({ 'alg' => alg, 'kid' => kid, 'typ' => 'vc+jwt' }.to_json)}.#{b64url(passport.to_json)}" + header = { "alg" => alg, "kid" => kid, "typ" => "vc+jwt", "iss" => iss }.compact + input = "#{b64url(header.to_json)}.#{b64url(passport.to_json)}" signature = if alg == "ES256" OpenSSL::ASN1.decode(key.sign("SHA256", input)).value.map { |i| i.value.to_s(2).rjust(32, "\x00".b) }.join else