Initial commit
This commit is contained in:
commit
7f822885d8
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
1184
Cargo.lock
generated
Normal file
1184
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ssh_attest_verifier"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
ciborium = "0.2.1"
|
||||
ctap-hid-fido2 = "3.5.0"
|
||||
ssh-encoding = "0.2.0"
|
||||
ssh-key = { version = "0.6.0" }
|
||||
thiserror = "1.0.44"
|
||||
x509-parser = { version = "0.15.1", features = ["verify"] }
|
19
root_cas/yubico_u2f_457200631.pem
Normal file
19
root_cas/yubico_u2f_457200631.pem
Normal file
@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZ
|
||||
dWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAw
|
||||
MDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290
|
||||
IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk
|
||||
5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep
|
||||
8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbw
|
||||
nebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT
|
||||
9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXw
|
||||
LvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJ
|
||||
hjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAN
|
||||
BgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4
|
||||
MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kt
|
||||
hX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2k
|
||||
LVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1U
|
||||
sG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqc
|
||||
U9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw==
|
||||
-----END CERTIFICATE-----
|
27
src/error.rs
Normal file
27
src/error.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use ssh_key::Algorithm;
|
||||
use thiserror::Error;
|
||||
use x509_parser::prelude::X509Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Attestation decoding failed: {0}")]
|
||||
AttestationDecodeError(ssh_encoding::Error),
|
||||
#[error("Attestation auth data invalid")]
|
||||
AttestationInvalidAuthData,
|
||||
#[error("Attestation certificate decoding failed: {0}")]
|
||||
AttestationCertDecodeError(X509Error),
|
||||
#[error("SSH public key not FIDO backed")]
|
||||
NonFidoSshKey,
|
||||
#[error("Attestation verification failed")]
|
||||
AttestationVerificationFailed,
|
||||
#[error("Attestation certificate signature invalid")]
|
||||
InvalidAttestationCertSignature,
|
||||
#[error("Invalid root CA {0}")]
|
||||
InvalidRootCa(String),
|
||||
#[error("FIDO public key type unsupported")]
|
||||
UnsupportedFidoKeyType,
|
||||
#[error("SSH public key type {} does not match FIDO public key type {}", .ssh.as_str(), .fido.as_str())]
|
||||
SshFidoTypeMismatch { ssh: Algorithm, fido: Algorithm },
|
||||
#[error("SSH public key des not match FIDO public key")]
|
||||
SshFidoKeyMismatch,
|
||||
}
|
90
src/lib.rs
Normal file
90
src/lib.rs
Normal file
@ -0,0 +1,90 @@
|
||||
mod error;
|
||||
mod ssh_attest;
|
||||
|
||||
use ctap_hid_fido2::{
|
||||
public_key::PublicKeyType,
|
||||
verifier,
|
||||
};
|
||||
pub use error::Error;
|
||||
use ssh_attest::SshAttestationInfo;
|
||||
use ssh_encoding::Decode;
|
||||
use ssh_key::{public::KeyData, Algorithm, PublicKey};
|
||||
pub use x509_parser;
|
||||
use x509_parser::{prelude::*, x509::X509Name};
|
||||
|
||||
fn get_first_cn_as_str<'a>(name: &'a X509Name<'_>) -> Option<&'a str> {
|
||||
name.iter_common_name()
|
||||
.next()
|
||||
.and_then(|cn| cn.as_str().ok())
|
||||
}
|
||||
|
||||
|
||||
pub fn verify_attestation(
|
||||
root_cas: Vec<X509Certificate<'static>>,
|
||||
ssh_attest: Vec<u8>,
|
||||
challenge: Vec<u8>,
|
||||
ssh_pubkey: PublicKey,
|
||||
) -> Result<(), Error> {
|
||||
// Decode the SSH attestation file
|
||||
let attestation = SshAttestationInfo::decode(&mut ssh_attest.as_slice())
|
||||
.map_err(|e| Error::AttestationDecodeError(e))?;
|
||||
let attestation_cert = attestation
|
||||
.decode_cert()
|
||||
.map_err(|e| Error::AttestationCertDecodeError(e))?;
|
||||
let attestation = attestation.to_fido_attestation().map_err(|_e| Error::AttestationInvalidAuthData)?;
|
||||
|
||||
// Extract the RPID from the SSH public key
|
||||
let application = match ssh_pubkey.key_data() {
|
||||
KeyData::SkEcdsaSha2NistP256(pubkey) => pubkey.application(),
|
||||
KeyData::SkEd25519(pubkey) => pubkey.application(),
|
||||
_ => return Err(Error::NonFidoSshKey.into()),
|
||||
};
|
||||
|
||||
// Verify the attestation RPID & signature
|
||||
let verify_result = verifier::verify_attestation(&application, &challenge, &attestation);
|
||||
if !verify_result.is_success {
|
||||
return Err(Error::AttestationVerificationFailed);
|
||||
}
|
||||
|
||||
// Verify the attestation certificate
|
||||
let attest_issuing_cn = get_first_cn_as_str(attestation_cert.tbs_certificate.issuer()).unwrap();
|
||||
let root_ca = root_cas
|
||||
.iter()
|
||||
.find(|root_ca| {
|
||||
get_first_cn_as_str(root_ca.tbs_certificate.subject()).unwrap() == attest_issuing_cn
|
||||
})
|
||||
.ok_or_else(|| Error::InvalidRootCa(attest_issuing_cn.to_string()))?;
|
||||
if !attestation_cert
|
||||
.verify_signature(Some(&root_ca.tbs_certificate.subject_pki))
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Error::InvalidAttestationCertSignature.into());
|
||||
}
|
||||
|
||||
let fido_pubkey = &verify_result.credential_public_key;
|
||||
|
||||
// Verify the FIDO public key is the same type as the SSH public key
|
||||
let fido_pubkey_alg = match fido_pubkey.key_type {
|
||||
PublicKeyType::Ecdsa256 => Algorithm::SkEcdsaSha2NistP256,
|
||||
PublicKeyType::Ed25519 => Algorithm::SkEd25519,
|
||||
PublicKeyType::Unknown => return Err(Error::UnsupportedFidoKeyType),
|
||||
};
|
||||
|
||||
if fido_pubkey_alg != ssh_pubkey.algorithm() {
|
||||
return Err(Error::SshFidoTypeMismatch {
|
||||
ssh: ssh_pubkey.algorithm(),
|
||||
fido: fido_pubkey_alg,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify that the SSH public key data matches the FIDO public key data
|
||||
let key_data = match ssh_pubkey.key_data() {
|
||||
KeyData::SkEcdsaSha2NistP256(key) => key.ec_point().as_bytes(),
|
||||
KeyData::SkEd25519(key) => key.public_key().0.as_slice(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if key_data != fido_pubkey.der {
|
||||
return Err(Error::SshFidoKeyMismatch.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
37
src/main.rs
Normal file
37
src/main.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ssh_attest_verifier::verify_attestation;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let root_cas = std::fs::read_dir("root_cas")?.collect::<Result<Vec<_>, _>>()?;
|
||||
let root_cas = root_cas
|
||||
.into_iter()
|
||||
.map(|entry| -> Result<_, anyhow::Error> {
|
||||
let root_ca = std::fs::read(entry.path())?;
|
||||
let entry_path = entry.path().to_string_lossy().into_owned();
|
||||
let root_ca = match parse_x509_pem(&root_ca) {
|
||||
Ok((rem, pem)) => {
|
||||
assert!(rem.is_empty());
|
||||
if pem.label != "CERTIFICATE" {
|
||||
return Err(anyhow!("{} is not a certificate", entry_path));
|
||||
}
|
||||
parse_x509_certificate(pem.contents.leak())
|
||||
.map_err(|e| anyhow!("X.509 parsing failed for {}: {:?}", entry_path, e))?
|
||||
.1
|
||||
}
|
||||
Err(e) => Err(anyhow!("PEM parsing failed for {}: {:?}", entry_path, e))?,
|
||||
};
|
||||
Ok(root_ca)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let ssh_attest = std::fs::read("id_ecdsa_rustgen_sk_attest.bin")?;
|
||||
let challenge = std::fs::read("id_ecdsa_rustgen_sk_attest_chall.bin")?;
|
||||
let ssh_pubkey = ssh_key::PublicKey::read_openssh_file(Path::new("id_ecdsa_rustgen_sk.pub"))?;
|
||||
|
||||
verify_attestation(root_cas, ssh_attest, challenge, ssh_pubkey)?;
|
||||
println!("Attestation verified successfully");
|
||||
Ok(())
|
||||
}
|
109
src/ssh_attest.rs
Normal file
109
src/ssh_attest.rs
Normal file
@ -0,0 +1,109 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use ciborium::Value;
|
||||
use ctap_hid_fido2::fidokey::make_credential::{Attestation, make_credential_response};
|
||||
use ssh_encoding::{Decode, Label, LabelError};
|
||||
use x509_parser::{nom::Finish, prelude::*};
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Version {
|
||||
V00,
|
||||
V01,
|
||||
}
|
||||
|
||||
impl AsRef<str> for Version {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
Self::V00 => "ssh-sk-attest-v00",
|
||||
Self::V01 => "ssh-sk-attest-v01",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Version {
|
||||
type Err = LabelError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"ssh-sk-attest-v00" => Ok(Self::V00),
|
||||
"ssh-sk-attest-v01" => Ok(Self::V01),
|
||||
_ => Err(LabelError::new(s.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Label for Version {}
|
||||
|
||||
#[non_exhaustive]
|
||||
pub struct SshAttestationInfo {
|
||||
pub version: Version,
|
||||
pub attestation_cert: Vec<u8>,
|
||||
pub enroll_sig: Vec<u8>,
|
||||
pub auth_data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SshAttestationInfo {
|
||||
pub fn decode_cert(&self) -> Result<X509Certificate<'_>, X509Error> {
|
||||
X509Certificate::from_der(&self.attestation_cert)
|
||||
.finish()
|
||||
.map(|(_, cert)| cert)
|
||||
}
|
||||
|
||||
pub fn to_fido_attestation(&self) -> Result<Attestation, anyhow::Error> {
|
||||
let mut attestation_map_bytes = Vec::new();
|
||||
ciborium::into_writer(
|
||||
&Value::from(
|
||||
[
|
||||
(0x01.into(), "packed".into()),
|
||||
(
|
||||
0x02.into(),
|
||||
self.auth_data.as_deref().unwrap().into(),
|
||||
),
|
||||
(
|
||||
0x03.into(),
|
||||
[
|
||||
// TODO: Don't hardcode algorithm
|
||||
// -7: ECDSA P256
|
||||
("alg".into(), (-7).into()),
|
||||
("sig".into(), self.enroll_sig.as_slice().into()),
|
||||
(
|
||||
"x5c".into(),
|
||||
[Value::from(self.attestation_cert.as_slice())]
|
||||
.as_slice()
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
.as_slice()
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
.as_slice(),
|
||||
),
|
||||
&mut attestation_map_bytes,
|
||||
)
|
||||
.expect("Failed to serialize CBOR attestation info");
|
||||
make_credential_response::parse_cbor(&attestation_map_bytes)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl Decode for SshAttestationInfo {
|
||||
type Error = ssh_encoding::Error;
|
||||
|
||||
fn decode(reader: &mut impl ssh_encoding::Reader) -> core::result::Result<Self, Self::Error> {
|
||||
let version = Version::decode(reader)?;
|
||||
let attestation_cert = Vec::decode(reader)?;
|
||||
let enroll_sig = Vec::decode(reader)?;
|
||||
let auth_data = match version {
|
||||
Version::V00 => None,
|
||||
Version::V01 => Some(Vec::decode(reader)?),
|
||||
};
|
||||
Ok(Self {
|
||||
version,
|
||||
attestation_cert,
|
||||
enroll_sig,
|
||||
auth_data,
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user