Skip to content

Instantly share code, notes, and snippets.

@joostd
Last active April 23, 2024 07:34
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joostd/ed790ade9ddd4b711af6c1b80eaed7ca to your computer and use it in GitHub Desktop.
Save joostd/ed790ade9ddd4b711af6c1b80eaed7ca to your computer and use it in GitHub Desktop.
Verify an OpenSSH key attestation to cryptographically prove that a given key is hardware-backed.
#!/usr/bin/env python
# verify attestation information to cryptographically prove that a given key is hardware-backed.
# For instance:
#
# ./ssh-sk-attest.py --key id.pub --attestation attestation.bin --challenge challenge.bin --mds mds.jwt
# To generate an SSH pubkey, a challenge, and an attestation:
# openssl rand 128 > challenge.bin
# ssh-keygen -t ${KEYTYPE} -f ./id -N "" -O challenge=challenge.bin -O write-attestation=attestation.bin
#
# use OpenSSH 8.2 or later
# KEYTYPE can be "ecdsa-sk" or "ed25519-sk"
# This script requires FIDO Metadata to validate attestation certificates
# Download an mds blob from the FIDO Alliance:
#
# curl -Ls https://mds3.fidoalliance.org/ --output mds.jwt
# This script requires
# requests - for downloading MDS3 metadata
# fido2 - Yubico's FIDO 2 library to process attestations
# install using pip:
# pip install fido2 requests
import sys
import argparse
import requests
from base64 import b64decode
from struct import unpack
from hashlib import sha256
from fido2 import cbor, mds3, webauthn, cose
from cryptography import x509, exceptions
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import ed25519
# read a list of type-lenght-value triplets from binary data
def tlvs(data):
while data:
t, l = unpack('>hh', data[:4])
assert t == 0
v = data[4:4+l]
data = data[4+l:]
yield v
# attestation information format, see
# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.u2f
#
# string "ssh-sk-attest-v01"
# string attestation certificate
# string enrollment signature
# string authenticator data (CBOR encoded)
# uint32 reserved flags
# string reserved string
# NOTE: there is currently a bug in libfido2 preventing clients like ssh-keygen to obtain intermediate certificates
# from a FIDO attestation statement. Consequently, attestation certificate validation only works for security keys
# with attestation certificates that are direcly issued by a root certificate registered in MDS
# parse SSH attestation file
def parseAttestation(s):
version, certificate, signature, authData, reserved_flags, reserved_string = tlvs(s)
version = str(version, 'utf-8')
assert version == 'ssh-sk-attest-v01'
certificate = x509.load_der_x509_certificate(certificate)
authData = cbor.decode(authData)
assert reserved_flags== b''
assert reserved_string == b''
return dict( version=version, certificate=certificate, signature=signature, authData=authData)
def verifyAttestation(attestation, challenge):
authData = attestation['authData']
clientDataHash = sha256(challenge).digest()
signedData = b''.join([authData, clientDataHash])
signature = attestation['signature']
attestation_certificate = attestation['certificate']
assert isinstance( attestation_certificate.public_key(), ec.EllipticCurvePublicKey )
attestation_certificate.public_key().verify(signature, signedData, ec.ECDSA(hashes.SHA256()))
def verifyAttestationU2F(attestation, challenge):
authData = webauthn.AuthenticatorData(attestation['authData'])
credentialData = authData.credential_data
key = b''.join([b'\04', credentialData.public_key[-2], credentialData.public_key[-3]])
signedData = b''.join([b'\00', authData.rp_id_hash, sha256(challenge).digest(), credentialData.credential_id, key])
signature = attestation['signature']
attestation_certificate = attestation['certificate']
assert isinstance( attestation_certificate.public_key(), ec.EllipticCurvePublicKey )
attestation_certificate.public_key().verify(signature, signedData, ec.ECDSA(hashes.SHA256()))
# parse SSH pubkey file
# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.u2f
def parsePubkey(key):
key_type, pubkey, *_ = key.split(" ")
key=b64decode(pubkey)
match key_type:
# The format of a sk-ecdsa-sha2-nistp256@openssh.com public key is:
# string "sk-ecdsa-sha2-nistp256@openssh.com"
# string curve name
# ec_point Q
# string application (user-specified, but typically "ssh:")
case 'sk-ecdsa-sha2-nistp256@openssh.com':
(kt,curve_name,ec_point,*application) = tlvs(key)
assert str(kt,'utf-8') == key_type
publicKey = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ec_point)
return cose.ES256.from_cryptography_key(publicKey)
# The format of a sk-ssh-ed25519@openssh.com public key is:
# string "sk-ssh-ed25519@openssh.com"
# string public key
# string application (user-specified, but typically "ssh:")
case 'sk-ssh-ed25519@openssh.com':
(kt,pk,*application) = tlvs(key)
assert str(kt,'utf-8') == key_type
publicKey = ed25519.Ed25519PublicKey.from_public_bytes(pk)
return cose.EdDSA.from_cryptography_key(publicKey)
case _:
raise Exception('unsupported SSH key type')
# the fido alliance metadata URL
mdsurl = 'https://mds3.fidoalliance.org/'
# the root CA used to verify the FIDO Metadata Statement blob
MDS_CA = b64decode(
"""
MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4
MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8
RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT
gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm
KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd
QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ
XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw
DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o
LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU
RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp
jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK
6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX
mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs
Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH
WD9f"""
)
def getMetadata(file=None):
if file:
with open(file, "rb") as f:
data = f.read()
else:
response = requests.get(mdsurl, allow_redirects=True)
data = response.content
return mds3.parse_blob(data, MDS_CA)
# verify if an SSH key has hardware key protection by verifying its attestation
# currently only works for SSH keys backed by FIDO security keys
#
# procedure - given an SSH public key and its attestation:
# 1. verify attestation signature using attestation certificate
# 2. match SSH public key with key in attestation
# 3. extract AAGUID and lookup authenticator metadata using FIDO Metadata Service
# 4. validate attestation certificate using registered root certificates,
# or using provided issuer certificate
# 5. check metadata for hardware and secure_element key protection
# 6. check metadata for on_chip matcher protection
# process command line arguments
parser = argparse.ArgumentParser(description='evaluate an SSH SK attestation')
parser.add_argument('-k', '--key', dest='key_file', default = 'id.pub', help='specify SSH pubkey file to validate attestation for')
parser.add_argument('-a', '--attestation', dest='attestation_file', default = 'attestation.bin', help='specify attestation file')
parser.add_argument('-c', '--challenge', dest='challenge_file', default = 'challenge.bin', help='specify challenge file')
parser.add_argument('-m', '--mds', dest='mds_file', help='specify MDS JWT file')
parser.add_argument('-i', '--issuer', dest='issuer_file', help='specify PEM-encoded issuer certificate file for validating attestation certificate')
args = parser.parse_args()
try:
with open(args.key_file, mode='r') as f:
publicKey = parsePubkey(f.read())
except FileNotFoundError:
print(f"❌ SSH pubkey file not found ({args.key_file}), use --key FILE to specify SSH pubkey file to use", file=sys.stderr)
sys.exit(1)
except AssertionError:
print(f"❌ SSH pubkey file malformed ({args.key_file})", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"❌ {e}", file=sys.stderr)
sys.exit(1)
try:
with open(args.attestation_file, mode='rb') as f:
attestation = parseAttestation(f.read())
except AssertionError:
print(f"❌ Attestation file malformed ({args.attestation_file})", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(f"❌ Attestation file not found ({args.attestation_file}), use --attestation FILE to specify attestation file to use", file=sys.stderr)
sys.exit(1)
try:
with open(args.challenge_file, mode='rb') as f:
challenge = f.read()
except FileNotFoundError:
print(f"❌ Challenge file not found ({args.challenge_file}), use --challenge FILE to specify attestation file to use", file=sys.stderr)
sys.exit(1)
# verify attestation signature, assuming packed attestation
try:
verifyAttestation(attestation, challenge)
except AssertionError:
print(f"❌ Attestation certificate uses an unsupported key type", file=sys.stderr)
sys.exit(1)
except exceptions.InvalidSignature:
# Invalid packed attestation signature, retry with fido-u2f
try:
verifyAttestationU2F(attestation, challenge)
except exceptions.InvalidSignature:
print("❌ Invalid attestation signature, or unsupported attestation format", file=sys.stderr)
sys.exit(1)
issuer = None
if args.issuer_file != None:
try:
with open(args.issuer_file, mode='r') as f:
issuer = x509.load_pem_x509_certificate(str.encode(f.read()),default_backend())
except FileNotFoundError:
print(f"❌ Issuer file not found ({args.issuer_file})", file=sys.stderr)
sys.exit(1)
# match public keys
credentialData = webauthn.AuthenticatorData(attestation['authData']).credential_data
if credentialData.public_key != publicKey:
print(f"❌ Public key in {args.key_file} does not match public key in attestation")
print(credentialData.public_key[-2].hex())
print(credentialData.public_key[-3].hex())
print(publicKey[-2].hex())
print(publicKey[-3].hex())
sys.exit(1)
# lookup metadata in MDS
metadata_entry = None
try:
if(credentialData.aaguid == webauthn.Aaguid.NONE):
print('❗ No AAGUID present in attestation, cannot lookup metadata', file=sys.stderr)
else:
if not args.mds_file:
print(f"❗ No MDS blob specified, downloading from {mdsurl} ", file=sys.stderr)
metadata = getMetadata(args.mds_file)
metadata_entry = mds3.MdsAttestationVerifier(metadata).find_entry_by_aaguid(credentialData.aaguid)
except ValueError:
print(f"❌ FIDO Metadata file malformed ({args.mds_file or mdsurl})", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(f"❌ FIDO Metadata file not found ({args.mds_file})", file=sys.stderr)
sys.exit(1)
# validate attestation certificate
try:
attestation_certificate = attestation['certificate']
# validate attestation certificate using registered root certificates
if metadata_entry:
issuers = [ x509.load_der_x509_certificate(cert, default_backend()) for cert in metadata_entry.metadata_statement.attestation_root_certificates ]
elif issuer != None:
issuers = [issuer]
else:
issuers = []
trusted = False
for cert in issuers:
if cert.subject == attestation_certificate.issuer:
attestation_certificate.verify_directly_issued_by(cert)
trusted = True
if not trusted:
print(f"❌ Cannot validate attestation certificate ({attestation_certificate.subject.rfc4514_string({x509.oid.NameOID.EMAIL_ADDRESS: 'E'})}) is not signed by a trusted issuer", file=sys.stderr)
sys.exit(1)
except exceptions.InvalidSignature:
print('❌ Invalid signature on attestation certificate', file=sys.stderr)
sys.exit(1)
except ValueError:
print(f"❌ Invalid issuer certificate ({cert.subject.rfc4514_string({x509.oid.NameOID.EMAIL_ADDRESS: 'E'})})", file=sys.stderr)
sys.exit(1)
except TypeError:
print(f"❌ Unsupported issuer public key type ({cert.public_key()})", file=sys.stderr)
sys.exit(1)
if metadata_entry:
status_list = [s.status for s in metadata_entry.status_reports]
if 'FIDO_CERTIFIED' not in status_list:
print(f"❌ Security key is not FIDO certified ({ ', '.join(status_list) })", file=sys.stderr)
sys.exit(1)
# https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#key-protection-types
# software, hardware, tee, secure_element, remote_handle
if 'hardware' in metadata_entry.metadata_statement.key_protection:
if 'secure_element' not in metadata_entry.metadata_statement.key_protection:
print(f"➖ security key has hardware key protection but not using a secure element ({metadata_entry.metadata_statement.key_protection})", file=sys.stderr)
else:
print(f"➖ security key has no hardware key protection ({metadata_entry.metadata_statement.key_protection})", file=sys.stderr)
# https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#matcher-protection-types
# software, tee, on_chip
if 'on_chip' not in metadata_entry.metadata_statement.matcher_protection:
print(f"➖ security key has no on_chip matcher protection ({metadata_entry.metadata_statement.key_protection})", file=sys.stderr)
if metadata_entry:
print(f"✅ valid attestation for hardware authenticator ({credentialData.aaguid}): {metadata_entry.metadata_statement.description}", file=sys.stderr)
elif args.issuer_file:
print(f"✅ valid attestation for authenticator with issuer {issuer.subject.rfc4514_string({x509.oid.NameOID.EMAIL_ADDRESS: 'E'})}", file=sys.stderr)
else:
print(f"✅ valid attestation for unknown authenticator", file=sys.stderr)
@joostd
Copy link
Author

joostd commented Feb 7, 2024

For some attestations, additional issuer certificates are required in order to validate the attestation certificate.
Use the --issuer option to specify issuer certificates you have validated out-of-band or that you trust for some other reason.

Examples:

Some of Google's older U2F security key are using a self-signed attestation certificate.
Attestation certificates can be extracted using tools like fido2-cred.
For reference, here's one (CN=Security Key, O=Google):

-----BEGIN CERTIFICATE-----
MIIBWTCCAQCgAwIBAgIBATAKBggqhkjOPQQDAjAoMRUwEwYDVQQDEwxTZWN1cml0
eSBLZXkxDzANBgNVBAoTBkdvb2dsZTAiGA8yMDAwMDEwMTAwMDAwMFoYDzIwOTkx
MjMxMjM1OTU5WjAoMRUwEwYDVQQDEwxTZWN1cml0eSBLZXkxDzANBgNVBAoTBkdv
b2dsZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD/Vm24iF5BOc+tg1sUgwTV+
Wa3pGOZft1RBuk5TAiXiWE38jqA6JYFqhyqthYmUZXJc3i5hXfWIpjvdnDUSOFqj
FzAVMBMGCysGAQQBguUcAgEBBAQDAgQwMAoGCCqGSM49BAMCA0cAMEQCIEocuUAQ
kWajSh/ciI7oB+DaSfxGafTBriaXgA1+wFb0AiBq5oumI/0sYFFdhLuGrUH4ZLOX
jRXw9H+TYeRnvpEO7A==
-----END CERTIFICATE-----

In general, U2F security keys are not listed in MDS, as U2F doesn't use AAGUIDs. You will need to get attestation roots elsewhere.
For instance, Yubico has its U2F root published here

Feitian FIDO2 security keys like the BioPass USB key use intermediate CA certificates. For instance (C=US, O=Feitian Technologies, CN=Feitian FIDO CA 01):

-----BEGIN CERTIFICATE-----
MIICQzCCAeigAwIBAgIQHfK1WlHcS2iFo9meaX/tFDAKBggqhkjOPQQDAjBJMQsw
CQYDVQQGEwJVUzEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxGzAZBgNV
BAMMEkZlaXRpYW4gRklETyBDQSAwMTAgFw0xODA2MjEwMDAwMDBaGA8yMDMzMDYy
MDIzNTk1OVowbzELMAkGA1UEBhMCVVMxHTAbBgNVBAoMFEZlaXRpYW4gVGVjaG5v
bG9naWVzMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMR0wGwYD
VQQDDBRGVCBCaW9QYXNzIEZJRE8yIFVTQjBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABGBQ+G7hJNkWhdUzIUHRL+5Nnhdd2wSDHnKtilv9DYLPng1Fa7FdgaitdV1t
LDonjgXPGB4n6bl2dGuY1ritv0KjgYkwgYYwHQYDVR0OBBYEFAHywrTcuV57hh/b
TpnKWJ83ezP1MB8GA1UdIwQYMBaAFH/slP9KuSNg6BVbjL07RVBUxxwkMAwGA1Ud
EwEB/wQCMAAwEwYLKwYBBAGC5RwCAQEEBAMCBSAwIQYLKwYBBAGC5RwBAQQEEgQQ
dwEL1yEqT8myNtLKXp1AhDAKBggqhkjOPQQDAgNJADBGAiEAjQ4/TrF/qK8LZ8Hk
MmmCUne0uQIE4uI6mf53ffW//W0CIQD73wYOoPrL7heIbcga7fm1kjSp8jzUhcwL
dqaEtws5ZA==
-----END CERTIFICATE-----

@joostd
Copy link
Author

joostd commented Feb 7, 2024

Example use:

$ openssl rand 32 > challenge.bin
$ ssh-keygen -t ${KEYTYPE} -f ./id -N "" -O challenge=challenge.bin -O write-attestation=attestation.bin
...
$ curl -sL https://mds3.fidoalliance.org/ > mds.jwt
$ ./ssh-sk-attest.py --mds mds.jwt  
✅ valid attestation for hardware authenticator (ee882879-721c-4913-9775-3dfcce97072a): YubiKey 5 Series

@joostd
Copy link
Author

joostd commented Feb 7, 2024

To install in a virtual environment:

$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install fido2 requests
...
(venv) $ chmod +x ssh-sk-attest.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment