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

FI-2912: Implement trust verification of authorization server certificates #1

Merged
merged 21 commits into from
Aug 9, 2024

Conversation

alisawallace
Copy link
Collaborator

Summary

This PR implements Inferno (as a mock UDAP client) establishing trust with the authorization server's X.509 certificates provided in the server's Discovery response, specifically the signed_metadata JWT element. This behavior is included in an additional, optional test and requires testers to provide one or more trust anchor certificates (i.e., root CAs) as test inputs.

Additional notes:

  • JWT signature verification and trust chain verification of the x5c header certificates are intentionally separated into two separate steps to better support unit testing and error debugging of both capabilities.
    • As a result, this test kit includes two separate sets of test certificates:
      • EMR Direct test certificates, obtained from the Discovery metadata response from the reference implementation at https://stage.healthtogo.me:8181/fhir/r4/stage, used to test cert trust chain verification tests.
      • Inferno self-generated certificates, used for testing the generation and verification of UDAP JWTs (since we need access to a private key to make JWTs)
  • The UDAP JWT builder module now support multiple certificates being included in the x5c header. Dynamic Client registration tests and their inputs were updated to accept a comma separated list of one or more certs instead of a single cert
  • Input instructions and omitting the use of auto-generated certs prior to release will be handled in a separate PR

Testing Guidance

  • All new capabilities are thoroughly unit tested and all spec tests pass
  • Discovery test group can be tested against the reference implementation with the following values as inputs:
    • FHIR Base Server URL: https://stage.healthtogo.me:8181/fhir/r4/stage
    • Trust anchor inputs: provide the PEM text (separated by a comma) for both EMRDirectTestIntermediateCA.pem and EMRDirectTestRootCA.pem (in lib/udap_security/certs/testing)

@@ -13,8 +13,9 @@ class GenerateClientCertsTest < Inferno::Test
input :udap_client_cert_pem,
title: 'X.509 Client Certificate(s) (PEM Format)',
description: %(
A client X.509 certificate in PEM format. It MUST be signed by a cert trusted by the authorization server
under test.
A comma-separted list of one or more X.509 certificates in PEM format. The first (leaf) certificate MUST
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a comma typically used as a delimiter for certs?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as my research has indicated, there isn't really a standard delimiting character for certificate chains, rather when making a chain of certs (in PEM format) they are just appended like so:

---- BEGIN CERTIFICATE ----
...
---- END CERTIFICATE ----
---- BEGIN CERTIFICATE ----
...
---- END CERTIFICATE ----

Ideally there would be an existing Ruby/OpenSSL library method that splits a cert chain PEM string into an array of certs, but the OpenSSL library only supports loading in from a file path (see this OpenSSL PR), which won't work for Inferno test inputs.

For time and simplicity's sake I went ahead with a comma delimiter. If there's a straightforward way of doing this without the comma let me know though, I'm not very familiar with Regexes or existing Ruby features that would make this easy to do.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ᐅ pry
[1] pry(main)> pem_string = <<~PEM
[1] pry(main)* ---- BEGIN CERTIFICATE ----
CERT1
---- END CERTIFICATE ----
---- BEGIN CERTIFICATE ----
CERT2
---- END CERTIFICATE ----
[1] pry(main)* PEM
=> "---- BEGIN CERTIFICATE ----\nCERT1\n---- END CERTIFICATE ----\n---- BEGIN CERTIFICATE ----\nCERT2\n---- END CERTIFICATE ----\n"
[2] pry(main)> regex = /(---- BEGIN CERTIFICATE ----
.*?
---- END CERTIFICATE ----)/
=> /(---- BEGIN CERTIFICATE ----
.*?
---- END CERTIFICATE ----)/
[3] pry(main)> pem_string.scan(regex)
=> [["---- BEGIN CERTIFICATE ----\nCERT1\n---- END CERTIFICATE ----"], ["---- BEGIN CERTIFICATE ----\nCERT2\n---- END CERTIFICATE ----"]]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jammjammjamm I updated the code to use a regex instead of a comma, and refactored the splitting of user input to its own method for code reuse.

assert token_header.key?('x5c'), 'JWT header does not contain `x5c` field'
assert token_header.key?('alg'), 'JWT header does not contain `alg` field'

anchor_certs_parsed = udap_server_trust_anchor_certs.split(',')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These certs don't seem parsed. You should probably get rid of this variable so that you don't have to come up with a name for it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, misnomer there, I meant "split" rather than parsed but removed the variable like you suggested.

OpenSSL::X509::Certificate.new(cert_der)
end
crl_uris = cert_chain.map(&:crl_uris).compact
crl_uris = crl_uris.flatten
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's no reason to separate flatten from the rest of the method calls.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover from a debugging session, fixed so it's all one line now!

# As a result, these capabilities are decoupled for testing purposes
JWT::X5cKeyFinder.new(trust_anchor_certs,
crls).from(x5c_header_encoded)
[true, nil]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hash is a much better way to return multiple values from a method than relying on array position to determine the meaning.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, I refactored both validate_trust_chain and validate_signature to return a hash with entries for success and error_message.

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel good about having someone else's certs in our repo.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I totally get that, unfortunately until our self-generated certs are up to snuff, others' certs are all that are available for testing the trust chain validation method. The good-ish news is that, based on the way I broke this up the validate trust chain method does not require UDAP compliant certs, just any valid set of certificates.

Have you heard of there being publicly available test certificates for use cases like this? If we can find some we could include those instead. The alternative is to remove the trust chain validation unit tests (for both the module and the Inferno test) from the test kit since both use the EMR Direct certs. What are your thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Files which are only used for unit tests should live in spec/fixtures.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, I moved the EMR Direct certs to spec/fixtures

Copy link
Contributor

@Jammjammjamm Jammjammjamm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good, but unit tests and linting need to pass.

result = run(runnable, signed_metadata_jwt: create_test_jwt,
udap_server_trust_anchor_certs: invalid_trust_anchor)
expect(result.result).to eq('fail')
expect(result.result_message).to match(self_signed_cert_error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running locally, I get:

Failures:

  1) UDAPSecurity::SignedMetadataTrustVerificationTest when JWT includes client, intermediate, and root certs fails when incorrect root CA is provided as trust anchor
     Failure/Error: expect(result.result_message).to match(self_signed_cert_error)

       expected "Trust could not be established with server certificates, error message: /Users/smacvicar/.asdf/insta...icar/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/rspec-core-3.12.0/exe/rspec:4:in `<main>'\n" to match "self-signed certificate in certificate chain"
       Diff:
       @@ -1,62 +1,123 @@
       -self-signed certificate in certificate chain
       +Trust could not be established with server certificates, error message: /Users/smacvicar/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/jwt-2.7.1/lib/jwt/x5c_key_finder.rb:30:in `from': Certificate verification failed: self signed certificate in certificate chain. Certificate subject: /C=US/ST=CA/L=San Diego/O=EMR Direct/OU=Certification Authority (certs.emrdirect.com)/CN=EMR Direct Test CA. (JWT::VerificationError)
       +	from /Users/smacvicar/code/udap-test-kit/lib/udap_security/udap_jwt_validator.rb:45:in `validate_trust_chain'
       +	from /Users/smacvicar/code/udap-test-kit/lib/udap_security/signed_metadata_trust_verification_test.rb:44:in `block in <class:SignedMetadataTrustVerificationTest>'
       +	from /Users/smacvicar/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/inferno_core-0.4.20/lib/inferno/test_runner.rb:77:in `instance_eval'
       +	from /Users/smacvicar/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/inferno_core-0.4.20/lib/inferno/test_runner.rb:77:in `run_test'
       +	from /Users/smacvicar/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/inferno_core-0.4.20/lib/inferno/test_runner.rb:51:in `run'
       +	from /Users/smacvicar/code/udap-test-kit/spec/udap_security/signed_metadata_trust_verification_test_spec.rb:65:in `run'
       +	from /Users/smacvicar/code/udap-test-kit/spec/udap_security/signed_metadata_trust_verification_test_spec.rb:120:in `block (3 levels) in <top (required)>'

I see it passes on CI, though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd, looks like semantically the error message is the same but on your machine it's self signed and on mine/CI it's self-signed. I updated the expected error messages to use a regex which should fix this.

@alisawallace
Copy link
Collaborator Author

@Jammjammjamm I believe I fixed the source of the failing unit test but it seems to be machine-dependent (I had no errors with it initially or now), so maybe verify it's also passing on your machine.

Also, I fixed all the linting errors locally and rubocop runs clean for me, but on CI I'm seeing the following error that I don't know how to fix:

Run bundle exec rubocop
vendor/bundle/ruby/3.1.0/gems/fhir_client-5.0.3/.rubocop_todo.yml: Metrics/LineLength has the wrong namespace - replace it with Layout/LineLength
Error: `Performance/*` has been extracted to the `rubocop-performance` gem.
(obsolete configuration found in vendor/bundle/ruby/3.1.0/gems/fhir_client-[5](https://github.com/inferno-framework/udap-test-kit/actions/runs/10322291366/job/28577196311?pr=1#step:4:6).0.3/.rubocop_todo.yml, please update it)
Error: Process completed with exit code 2.

@Jammjammjamm Jammjammjamm changed the title Implement trust verification of authorization server certificates FI-2912: Implement trust verification of authorization server certificates Aug 9, 2024
@alisawallace alisawallace merged commit 7735850 into main Aug 9, 2024
3 checks passed
@Jammjammjamm Jammjammjamm deleted the FI-2912-server-cert-trust branch August 12, 2024 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants