diff --git a/Cargo.toml b/Cargo.toml index 475633f3..d50dfdd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ tag-message = "dkregistry v{{version}}" [dependencies] base64 = "0.13" futures = "0.3" -http = "0.2" # Pin libflate <1.3.0 # https://github.com/sile/libflate/commit/aba829043f8a2d527b6c4984034fbe5e7adb0da6 @@ -55,7 +54,9 @@ url = "2.1.1" [dev-dependencies] dirs = "4.0" env_logger = "0.8" +hyper = "0.14.28" mockito = "0.30" +native-tls = "0.2" spectral = "0.6" test-case = "1.0.0" tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } diff --git a/certificate/.gitignore b/certificate/.gitignore new file mode 100644 index 00000000..e4efdec6 --- /dev/null +++ b/certificate/.gitignore @@ -0,0 +1,3 @@ +# Don't commit the binaries; they are only needed to occasionally regenerate the certificates. +cfssl +cfssljson diff --git a/certificate/README.md b/certificate/README.md new file mode 100644 index 00000000..7c643002 --- /dev/null +++ b/certificate/README.md @@ -0,0 +1,32 @@ + +# Certificate Generation and Persistence for Tests + +While it is possible to automagically generate certificates using the [rcgen](https://github.com/est31/rcgen) +crate, that library (as of version 0.10.0) has a dependency on the [ring](https://github.com/briansmith/ring) +crate, which has a non-trivial set of licenses. + +To avoid potential problems with the licenses applying to `ring`, `rcgen` is not used to generate +test certificates. + +## Generating and Persisting Test Certificates + +The tests require a self-signed certificate authority, and a private key / server certificate pair signed by +that same CA. + +Certificates are defined in json files, generated using [cfssl](https://github.com/cloudflare/cfssl), and +committed into git. + +### Install `cfssl` and Re-generate Certificates + + $ ./download-cfssl.sh + $ ./create-ca.sh + $ ./create-localhost.sh + +Note: You should not have to regenerate any certificates unless they expire, the ciphers become insecure, +or the certificates otherwise become rejected by future versions of cryptography libraries. + +### Definitions + +* [profiles.json](profiles.json) +* [ca.json](ca.json) +* [localhost.json](localhost.json) diff --git a/certificate/ca.json b/certificate/ca.json new file mode 100644 index 00000000..48c94e24 --- /dev/null +++ b/certificate/ca.json @@ -0,0 +1,13 @@ +{ + "CN": "Automated Testing CA", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "USA" + } + ] +} + diff --git a/certificate/create-ca.sh b/certificate/create-ca.sh new file mode 100755 index 00000000..dcc81244 --- /dev/null +++ b/certificate/create-ca.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +pushd output + +../cfssl gencert \ + -config ../profiles.json \ + -initca ../ca.json \ + | ../cfssljson -bare ca + +popd + diff --git a/certificate/create-localhost.sh b/certificate/create-localhost.sh new file mode 100755 index 00000000..41fea1a6 --- /dev/null +++ b/certificate/create-localhost.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +pushd output + +../cfssl gencert \ + -ca ca.pem \ + -ca-key ca-key.pem \ + -config ../profiles.json \ + -profile=server \ + ../localhost.json \ + | ../cfssljson -bare localhost + +cat localhost.pem ca.pem > localhost.crt + +openssl \ + pkcs8 \ + -topk8 \ + -inform PEM \ + -outform PEM \ + -nocrypt \ + -in localhost-key.pem \ + -out localhost-key-pkcs8.pem + +popd + diff --git a/certificate/download-cfssl.sh b/certificate/download-cfssl.sh new file mode 100755 index 00000000..e0f13717 --- /dev/null +++ b/certificate/download-cfssl.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +version=1.6.1 + +rm -f cfssl +wget -O cfssl https://github.com/cloudflare/cfssl/releases/download/v${version}/cfssl_${version}_linux_amd64 +chmod +x cfssl + +rm -f cfssljson +wget -O cfssljson https://github.com/cloudflare/cfssl/releases/download/v${version}/cfssljson_${version}_linux_amd64 +chmod +x cfssljson + diff --git a/certificate/localhost.json b/certificate/localhost.json new file mode 100644 index 00000000..e7fb8754 --- /dev/null +++ b/certificate/localhost.json @@ -0,0 +1,15 @@ +{ + "CN": "localhost", + "key": { + "algo": "ecdsa", + "size": 256 + }, + "names": [ + { + "C": "USA" + } + ], + "hosts": [ + "localhost" + ] +} diff --git a/certificate/output/.gitignore b/certificate/output/.gitignore new file mode 100644 index 00000000..6335f1ea --- /dev/null +++ b/certificate/output/.gitignore @@ -0,0 +1,11 @@ +# For CA, only need public key after server certificate is created +ca.csr +ca-key.pem + +# For server, only need private key and chained public key +localhost.csr +localhost.pem + +# Throw away pkcs1 flavor and keep pkcs8 flavor +localhost-key.pem + diff --git a/certificate/output/ca.pem b/certificate/output/ca.pem new file mode 100644 index 00000000..304d1eb2 --- /dev/null +++ b/certificate/output/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUIm0u2dDryGQArfPLSadKLGAJ+MAwDQYJKoZIhvcNAQEL +BQAwLTEMMAoGA1UEBhMDVVNBMR0wGwYDVQQDExRBdXRvbWF0ZWQgVGVzdGluZyBD +QTAeFw0yMjExMDgxMzE2MDBaFw0yNzExMDcxMzE2MDBaMC0xDDAKBgNVBAYTA1VT +QTEdMBsGA1UEAxMUQXV0b21hdGVkIFRlc3RpbmcgQ0EwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDBxBtMTvxybYrSrPbka3xD+Pzoj7MG6Fldh5j2vPsw +Nz+SFwIGvU8XeJcSKIcAwFBCJ/GkYF8Uoa1/l6AXvafn1SmtricV3AYxYq40vXL+ +P1WY2HlXP4pwjbMF6uPiOm5r5HBpK5uptgJZRxMhbdqtoJP1/Acbrn62DYy4eqZN +i9f+eiVKewn7Z40TONigzNyz1J1ffH3fA18MmcrXGfWF0figbSL3XpSn4nu3R2mm +0rVpPQf6E+OHRS2NF0ekN7Xn8oMCQYHOXme3V8i2Sth6jyv9bhlvAGGJVY6XgIKa +URSIjm9M87S0bYzi3YSMP6p2rxmHV/gOxnZ3e0wBihivAgMBAAGjQjBAMA4GA1Ud +DwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR4TW/Tbj2b74Fi +a2kaPkCKj+JZUjANBgkqhkiG9w0BAQsFAAOCAQEAnRIkllxB6vtIxoV7HYizdxEo +biS5dB0ErqMYFkOOYyLA9RCgqaFNmEvwzxg+yE9AggGs3Me68hma8Oe+1iydGUjv +Emhh3XK/0ZCKJ63071wBAr5I9kOzbtPytyF6gaxPtpqqUcp6WyE0snFQt/1Vq/S8 +AMxPvU60thYUR1xPSSaPa3cEHMcgC/O4DCjmoJaILlrNShqvPcV2QD75D+HjcK68 +EIKhqluRwZsh/LrH8btUgtl5nAPNFRe4QiEeLCJHGPZ29mBCSeQXTKeRaSQe3Ixp +q/ObtXVbTanhQG5WAvxJAb7MdFD+N4q0C3D6HmlXL1g/zyMF9PTHRtCBjp7ytA== +-----END CERTIFICATE----- diff --git a/certificate/output/localhost-key-pkcs8.pem b/certificate/output/localhost-key-pkcs8.pem new file mode 100644 index 00000000..a5ca3289 --- /dev/null +++ b/certificate/output/localhost-key-pkcs8.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgQeEZs5zOQRA/JcoZ +eX9hLSUk9CNqbb3fmAhqn5f7q8WhRANCAAQhtz6gl0uLfATyk1B9AhFsXgHEDMpQ +Poa0UUrNkJye3LZe6iGnCTWHAS3Qr/hecohUY6mNQplnufBtdAE9jJd1 +-----END PRIVATE KEY----- diff --git a/certificate/output/localhost.crt b/certificate/output/localhost.crt new file mode 100644 index 00000000..8fc3f38e --- /dev/null +++ b/certificate/output/localhost.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIICnzCCAYegAwIBAgIUG2PchR5jyYocFHa+6LWgMO1MTJIwDQYJKoZIhvcNAQEL +BQAwLTEMMAoGA1UEBhMDVVNBMR0wGwYDVQQDExRBdXRvbWF0ZWQgVGVzdGluZyBD +QTAeFw0yMjExMDgxMzE2MDBaFw0zMjExMDUxMzE2MDBaMCIxDDAKBgNVBAYTA1VT +QTESMBAGA1UEAxMJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +Ibc+oJdLi3wE8pNQfQIRbF4BxAzKUD6GtFFKzZCcnty2Xuohpwk1hwEt0K/4XnKI +VGOpjUKZZ7nwbXQBPYyXdaOBjDCBiTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAww +CgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUzBknP8vwv3KAPEcE +q3OYho/q3FAwHwYDVR0jBBgwFoAUeE1v0249m++BYmtpGj5Aio/iWVIwFAYDVR0R +BA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQCeQ4rwIp6wZBTZDflm +0Olj4czaOfsLMhoTYVoarfAzB57uV1yP87kOMFHaMycLViZzPi+T1rOjDCIQWNLh +h6EMoGDkPLNZSG2KxVRKnOFQgE50CPobgEGZFmAIuBNjHX7MG8I1J/HO0X9Krzz6 +wqdyy0IBtv64W7wrty2ab+okBiNPlgV1mxzWlRJk8zcPY/aLOkJ+5Gd40YQNtWAd +dPPevJIF/Dh+OadvUXtkiwmoJzn6pWwFwzyTp9kcSYVZYo5LWzV5U6l/HJVFNq/f +a3U1Grw2T4Nb33G1cGn5xfEqnMvaWEAmDK7bb/smY/dTocnUUD3FGBmkNMXqE4FK +nC9q +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUIm0u2dDryGQArfPLSadKLGAJ+MAwDQYJKoZIhvcNAQEL +BQAwLTEMMAoGA1UEBhMDVVNBMR0wGwYDVQQDExRBdXRvbWF0ZWQgVGVzdGluZyBD +QTAeFw0yMjExMDgxMzE2MDBaFw0yNzExMDcxMzE2MDBaMC0xDDAKBgNVBAYTA1VT +QTEdMBsGA1UEAxMUQXV0b21hdGVkIFRlc3RpbmcgQ0EwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDBxBtMTvxybYrSrPbka3xD+Pzoj7MG6Fldh5j2vPsw +Nz+SFwIGvU8XeJcSKIcAwFBCJ/GkYF8Uoa1/l6AXvafn1SmtricV3AYxYq40vXL+ +P1WY2HlXP4pwjbMF6uPiOm5r5HBpK5uptgJZRxMhbdqtoJP1/Acbrn62DYy4eqZN +i9f+eiVKewn7Z40TONigzNyz1J1ffH3fA18MmcrXGfWF0figbSL3XpSn4nu3R2mm +0rVpPQf6E+OHRS2NF0ekN7Xn8oMCQYHOXme3V8i2Sth6jyv9bhlvAGGJVY6XgIKa +URSIjm9M87S0bYzi3YSMP6p2rxmHV/gOxnZ3e0wBihivAgMBAAGjQjBAMA4GA1Ud +DwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR4TW/Tbj2b74Fi +a2kaPkCKj+JZUjANBgkqhkiG9w0BAQsFAAOCAQEAnRIkllxB6vtIxoV7HYizdxEo +biS5dB0ErqMYFkOOYyLA9RCgqaFNmEvwzxg+yE9AggGs3Me68hma8Oe+1iydGUjv +Emhh3XK/0ZCKJ63071wBAr5I9kOzbtPytyF6gaxPtpqqUcp6WyE0snFQt/1Vq/S8 +AMxPvU60thYUR1xPSSaPa3cEHMcgC/O4DCjmoJaILlrNShqvPcV2QD75D+HjcK68 +EIKhqluRwZsh/LrH8btUgtl5nAPNFRe4QiEeLCJHGPZ29mBCSeQXTKeRaSQe3Ixp +q/ObtXVbTanhQG5WAvxJAb7MdFD+N4q0C3D6HmlXL1g/zyMF9PTHRtCBjp7ytA== +-----END CERTIFICATE----- diff --git a/certificate/profiles.json b/certificate/profiles.json new file mode 100644 index 00000000..72c5f069 --- /dev/null +++ b/certificate/profiles.json @@ -0,0 +1,19 @@ +{ + "signing": { + "default": { + "expiry": "87600h" + }, + "profiles": { + "server": { + "usages": [ + "signing", + "digital signing", + "key encipherment", + "server auth" + ], + "expiry": "87600h" + } + } + } +} + diff --git a/certificate/reset.sh b/certificate/reset.sh new file mode 100755 index 00000000..0ad6abb8 --- /dev/null +++ b/certificate/reset.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +pushd output + +rm -f *.csr *.pem *.crt + +popd diff --git a/src/errors.rs b/src/errors.rs index f78e6fb1..d063c0aa 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,7 +6,7 @@ pub enum Error { #[error("base64 decode error")] Base64Decode(#[from] base64::DecodeError), #[error("header parse error")] - HeaderParse(#[from] http::header::ToStrError), + HeaderParse(#[from] reqwest::header::ToStrError), #[error("json error")] Json(#[from] serde_json::Error), #[error("http transport error: {0}")] @@ -28,7 +28,7 @@ pub enum Error { #[error("missing authentication header {0}")] MissingAuthHeader(&'static str), #[error("unexpected HTTP status {0}")] - UnexpectedHttpStatus(http::StatusCode), + UnexpectedHttpStatus(reqwest::StatusCode), #[error("invalid auth token '{0}'")] InvalidAuthToken(String), #[error("API V2 not supported")] @@ -38,9 +38,9 @@ pub enum Error { #[error("www-authenticate header parse error")] Www(#[from] crate::v2::WwwHeaderParseError), #[error("request failed with status {status}")] - Client { status: http::StatusCode }, + Client { status: reqwest::StatusCode }, #[error("request failed with status {status}")] - Server { status: http::StatusCode }, + Server { status: reqwest::StatusCode }, #[error("content digest error")] ContentDigestParse(#[from] crate::v2::ContentDigestError), #[error("no header Content-Type given and no workaround to apply")] diff --git a/src/v2/config.rs b/src/v2/config.rs index f5c18d87..20d2df36 100644 --- a/src/v2/config.rs +++ b/src/v2/config.rs @@ -1,4 +1,5 @@ use crate::{mediatypes::MediaTypes, v2::*}; +use reqwest::Certificate; /// Configuration for a `Client`. #[derive(Debug)] @@ -9,6 +10,7 @@ pub struct Config { username: Option, password: Option, accept_invalid_certs: bool, + root_certificates: Vec, accepted_types: Option)>>, } @@ -31,6 +33,12 @@ impl Config { self } + /// Add a root certificate the client should trust for TLS verification + pub fn add_root_certificate(mut self, certificate: Certificate) -> Self { + self.root_certificates.push(certificate); + self + } + /// Set custom Accept headers pub fn accepted_types( mut self, @@ -87,9 +95,15 @@ impl Config { p.unwrap_or_else(|| "".into()), )), }; - let client = reqwest::ClientBuilder::new() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .build()?; + + let mut builder = + reqwest::ClientBuilder::new().danger_accept_invalid_certs(self.accept_invalid_certs); + + for ca in self.root_certificates { + builder = builder.add_root_certificate(ca) + } + + let client = builder.build()?; let accepted_types = match self.accepted_types { Some(a) => a, @@ -130,6 +144,7 @@ impl Default for Config { index: "registry-1.docker.io".into(), insecure_registry: false, accept_invalid_certs: false, + root_certificates: Default::default(), accepted_types: None, user_agent: Some(crate::USER_AGENT.to_owned()), username: None, diff --git a/tests/mock/base_client.rs b/tests/mock/base_client.rs index 00a34d20..465cfaf2 100644 --- a/tests/mock/base_client.rs +++ b/tests/mock/base_client.rs @@ -90,3 +90,167 @@ fn test_base_custom_useragent() { mockito::reset(); } + +mod test_custom_root_certificate { + use dkregistry::v2::Client; + use native_tls::{HandshakeError, Identity, TlsStream}; + use reqwest::Certificate; + use std::error::Error; + use std::net::TcpListener; + use std::path::{Path, PathBuf}; + + fn run_server(listener: TcpListener, identity: Identity) -> Result<(), std::io::Error> { + println!("Will accept tls connections at {}", listener.local_addr()?); + + let mut incoming = listener.incoming(); + + let test_server = native_tls::TlsAcceptor::new(identity).unwrap(); + + if let Some(stream_result) = incoming.next() { + println!("Incoming"); + + let stream = stream_result?; + + println!("Accepting incoming as tls"); + + let accept_result = test_server.accept(stream); + + if let Err(e) = map_tls_io_error(accept_result) { + eprintln!("Accept failed: {:?}", e); + } + + println!("Done with stream"); + } else { + panic!("Never received an incoming connection"); + } + + println!("No longer accepting connections"); + + Ok(()) + } + + async fn run_client(ca_certificate: Option, client_host: String) { + println!("Client creating"); + + let mut config = Client::configure().registry(&client_host); + + if let Some(ca) = &ca_certificate { + config = config.add_root_certificate(ca.clone()); + } + + let registry = config.build().unwrap(); + + let err = registry.is_auth().await.unwrap_err(); + + if let dkregistry::errors::Error::Reqwest(r) = err { + if let Some(s) = r.source() { + let oh: Option<&hyper::Error> = s.downcast_ref(); + + if let Some(he) = oh { + println!("Hyper error: {:?}", he); + + if ca_certificate.is_some() { + assert!( + he.is_closed(), + "is a ChannelClosed error, not a certificate error" + ); + } else { + assert!( + he.is_connect(), + "is a Connect error, with a certificate failure as a cause" + ); + + let hec = he.source().unwrap(); + + let message = format!("{}", hec); + assert!( + message.contains("certificate verify failed"), + "'certificate verify failed' contained in: {}", + message + ); + } + return; + } + } + } else { + eprintln!("Unexpected error: {:?}", err); + } + } + + fn map_tls_io_error( + tls_result: Result, HandshakeError>, + ) -> Result, String> + where + S: std::io::Read + std::io::Write, + { + match tls_result { + Ok(stream) => Ok(stream), + Err(he) => { + match he { + HandshakeError::Failure(e) => Err(format!("{}", e)), + // Can't directly unwrap because TlsStream doesn't implement Debug trait + HandshakeError::WouldBlock(_) => Err("Would block".into()), + } + } + } + } + + fn output() -> PathBuf { + PathBuf::from(file!()) + .canonicalize() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("certificate") + .join("output") + } + + fn read_output_file>(file_name: F) -> Vec { + std::fs::read(output().join(file_name)).unwrap() + } + + #[tokio::test] + async fn without_ca() { + with_ca_cert(None).await + } + + #[tokio::test] + pub async fn with_ca() { + let ca_bytes = read_output_file("ca.pem"); + let ca = Certificate::from_pem(&ca_bytes).unwrap(); + + with_ca_cert(Some(ca)).await; + } + + async fn with_ca_cert(ca_certificate: Option) { + let registry_bytes = read_output_file("localhost.crt"); + + let registry_key_bytes = read_output_file("localhost-key-pkcs8.pem"); + let registry_identity = Identity::from_pkcs8(®istry_bytes, ®istry_key_bytes).unwrap(); + + let listener = TcpListener::bind("localhost:0").unwrap(); + + // local_addr returns an IP address, but we need to use a name for TLS, + // so extract only the port number. + let listener_port = listener.local_addr().unwrap().port(); + + let client_host = format!("localhost:{}", listener_port); + + let t_server = std::thread::spawn(move || run_server(listener, registry_identity)); + + let t_client = + tokio::task::spawn(async move { run_client(ca_certificate, client_host).await }); + + println!("Joining client"); + t_client.await.unwrap(); + + println!("Joining server"); + t_server.join().unwrap().unwrap(); + + println!("Done"); + } +}