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