Initial commit

This commit is contained in:
pjht 2023-08-14 10:05:13 -05:00
commit 7f822885d8
Signed by: pjht
GPG Key ID: 7B5F6AFBEC7EE78E
8 changed files with 1482 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1184
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View 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"] }

View 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
View 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
View 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
View 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
View 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,
})
}
}