Last active
April 4, 2024 15:09
-
-
Save yackermann/dbeb2c2b76362052e5268224660b6fbc to your computer and use it in GitHub Desktop.
WebAuthn Packed attestation verification sample in NodeJS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const crypto = require('crypto'); | |
const base64url = require('base64url'); | |
const cbor = require('cbor'); | |
const jsrsasign = require('jsrsasign'); | |
const elliptic = require('elliptic'); | |
const NodeRSA = require('node-rsa'); | |
let COSEKEYS = { | |
'kty' : 1, | |
'alg' : 3, | |
'crv' : -1, | |
'x' : -2, | |
'y' : -3, | |
'n' : -1, | |
'e' : -2 | |
} | |
let COSEKTY = { | |
'OKP': 1, | |
'EC2': 2, | |
'RSA': 3 | |
} | |
let COSERSASCHEME = { | |
'-3': 'pss-sha256', | |
'-39': 'pss-sha512', | |
'-38': 'pss-sha384', | |
'-65535': 'pkcs1-sha1', | |
'-257': 'pkcs1-sha256', | |
'-258': 'pkcs1-sha384', | |
'-259': 'pkcs1-sha512' | |
} | |
var COSECRV = { | |
'1': 'p256', | |
'2': 'p384', | |
'3': 'p521' | |
} | |
var COSEALGHASH = { | |
'-257': 'sha256', | |
'-258': 'sha384', | |
'-259': 'sha512', | |
'-65535': 'sha1', | |
'-39': 'sha512', | |
'-38': 'sha384', | |
'-37': 'sha256', | |
'-260': 'sha256', | |
'-261': 'sha512', | |
'-7': 'sha256', | |
'-36': 'sha512' | |
} | |
let hash = (alg, message) => { | |
return crypto.createHash(alg).update(message).digest(); | |
} | |
let base64ToPem = (b64cert) => { | |
let pemcert = ''; | |
for(let i = 0; i < b64cert.length; i += 64) | |
pemcert += b64cert.slice(i, i + 64) + '\n'; | |
return '-----BEGIN CERTIFICATE-----\n' + pemcert + '-----END CERTIFICATE-----'; | |
} | |
var getCertificateInfo = (certificate) => { | |
let subjectCert = new jsrsasign.X509(); | |
subjectCert.readCertPEM(certificate); | |
let subjectString = subjectCert.getSubjectString(); | |
let subjectParts = subjectString.slice(1).split('/'); | |
let subject = {}; | |
for(let field of subjectParts) { | |
let kv = field.split('='); | |
subject[kv[0]] = kv[1]; | |
} | |
let version = subjectCert.version; | |
let basicConstraintsCA = !!subjectCert.getExtBasicConstraints().cA; | |
return { | |
subject, version, basicConstraintsCA | |
} | |
} | |
var parseAuthData = (buffer) => { | |
let rpIdHash = buffer.slice(0, 32); buffer = buffer.slice(32); | |
let flagsBuf = buffer.slice(0, 1); buffer = buffer.slice(1); | |
let flagsInt = flagsBuf[0]; | |
let flags = { | |
up: !!(flagsInt & 0x01), | |
uv: !!(flagsInt & 0x04), | |
at: !!(flagsInt & 0x40), | |
ed: !!(flagsInt & 0x80), | |
flagsInt | |
} | |
let counterBuf = buffer.slice(0, 4); buffer = buffer.slice(4); | |
let counter = counterBuf.readUInt32BE(0); | |
let aaguid = undefined; | |
let credID = undefined; | |
let COSEPublicKey = undefined; | |
if(flags.at) { | |
aaguid = buffer.slice(0, 16); buffer = buffer.slice(16); | |
let credIDLenBuf = buffer.slice(0, 2); buffer = buffer.slice(2); | |
let credIDLen = credIDLenBuf.readUInt16BE(0); | |
credID = buffer.slice(0, credIDLen); buffer = buffer.slice(credIDLen); | |
COSEPublicKey = buffer; | |
} | |
return {rpIdHash, flagsBuf, flags, counter, counterBuf, aaguid, credID, COSEPublicKey} | |
} | |
let verifyPackedAttestation = (webAuthnResponse) => { | |
let attestationBuffer = base64url.toBuffer(webAuthnResponse.response.attestationObject); | |
let attestationStruct = cbor.decodeAllSync(attestationBuffer)[0]; | |
let authDataStruct = parseAuthData(attestationStruct.authData); | |
let clientDataHashBuf = hash('sha256', base64url.toBuffer(webAuthnResponse.response.clientDataJSON)); | |
let signatureBaseBuffer = Buffer.concat([attestationStruct.authData, clientDataHashBuf]); | |
let signatureBuffer = attestationStruct.attStmt.sig | |
let signatureIsValid = false; | |
if(attestationStruct.attStmt.x5c) { | |
/* ----- Verify FULL attestation ----- */ | |
let leafCert = base64ToPem(attestationStruct.attStmt.x5c[0].toString('base64')); | |
let certInfo = getCertificateInfo(leafCert); | |
if(certInfo.subject.OU !== 'Authenticator Attestation') | |
throw new Error('Batch certificate OU MUST be set strictly to "Authenticator Attestation"!'); | |
if(!certInfo.subject.CN) | |
throw new Error('Batch certificate CN MUST no be empty!'); | |
if(!certInfo.subject.O) | |
throw new Error('Batch certificate CN MUST no be empty!'); | |
if(!certInfo.subject.C || certInfo.subject.C.length !== 2) | |
throw new Error('Batch certificate C MUST be set to two character ISO 3166 code!'); | |
if(certInfo.basicConstraintsCA) | |
throw new Error('Batch certificate basic constraints CA MUST be false!'); | |
if(certInfo.version !== 3) | |
throw new Error('Batch certificate version MUST be 3(ASN1 2)!'); | |
signatureIsValid = crypto.createVerify('sha256') | |
.update(signatureBaseBuffer) | |
.verify(leafCert, signatureBuffer); | |
/* ----- Verify FULL attestation ENDS ----- */ | |
} else if(attestationStruct.attStmt.ecdaaKeyId) { | |
throw new Error('ECDAA IS NOT SUPPORTED YET!'); | |
} else { | |
/* ----- Verify SURROGATE attestation ----- */ | |
let pubKeyCose = cbor.decodeAllSync(authDataStruct.COSEPublicKey)[0]; | |
let hashAlg = COSEALGHASH[pubKeyCose.get(COSEKEYS.alg)]; | |
if(pubKeyCose.get(COSEKEYS.kty) === COSEKTY.EC2) { | |
let x = pubKeyCose.get(COSEKEYS.x); | |
let y = pubKeyCose.get(COSEKEYS.y); | |
let ansiKey = Buffer.concat([Buffer.from([0x04]), x, y]); | |
let signatureBaseHash = hash(hashAlg, signatureBaseBuffer); | |
let ec = new elliptic.ec(COSECRV[pubKeyCose.get(COSEKEYS.crv)]); | |
let key = ec.keyFromPublic(ansiKey); | |
signatureIsValid = key.verify(signatureBaseHash, signatureBuffer) | |
} else if(pubKeyCose.get(COSEKEYS.kty) === COSEKTY.RSA) { | |
let signingScheme = COSERSASCHEME[pubKeyCose.get(COSEKEYS.alg)]; | |
let key = new NodeRSA(undefined, { signingScheme }); | |
key.importKey({ | |
n: pubKeyCose.get(COSEKEYS.n), | |
e: 65537, | |
}, 'components-public'); | |
signatureIsValid = key.verify(signatureBaseBuffer, signatureBuffer) | |
} else if(pubKeyCose.get(COSEKEYS.kty) === COSEKTY.OKP) { | |
let x = pubKeyCose.get(COSEKEYS.x); | |
let signatureBaseHash = hash(hashAlg, signatureBaseBuffer); | |
let key = new elliptic.eddsa('ed25519'); | |
key.keyFromPublic(x) | |
signatureIsValid = key.verify(signatureBaseHash, signatureBuffer) | |
} | |
/* ----- Verify SURROGATE attestation ENDS ----- */ | |
} | |
if(!signatureIsValid) | |
throw new Error('Failed to verify the signature!'); | |
return true | |
} | |
let packedFullAttestationWebAuthnSample = { | |
"rawId": "wsLryOAxXMU54s2fCSWPzWjXHOBKPploN-UHftj4_rpIu6BZxNXppm82f7Y6iX9FEOKKeS5-N2TALeyzLnJfAA", | |
"id": "wsLryOAxXMU54s2fCSWPzWjXHOBKPploN-UHftj4_rpIu6BZxNXppm82f7Y6iX9FEOKKeS5-N2TALeyzLnJfAA", | |
"response": { | |
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJZTVdFVGYtUDc5aU1iLUJxZFRreVNOUmVPdmE3bksyaVZDOWZpQzhpR3ZZeXB1bkVPQ1pHWjYtWTVPVjFydk1pRGdBaldmRmk2VUMwV3lLR3NqQS1nQSIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4ub3JnIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9", | |
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAIzOihC6Ba80o5JnoYOJJ_EtEVmWQcAvxVCnsCFnVRQZAiAfeIddLPsPl1FeSX8B5xZANcQKGNoO7pb0TZPnuJdebGN4NWOBWQKzMIICrzCCAZegAwIBAgIESFs9tjANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQDDBZZdWJpY28gRklETyBQcmV2aWV3IENBMB4XDTE4MDQxMjEwNTcxMFoXDTE4MTIzMTEwNTcxMFowbzELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMTIxMzkzOTEyNjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPss3TBDKMVySlDM5vYLrX0nqRtZ4eZvKXuJydQ9wrLHeIm08P-dAijLlG384BsZWJtngEqsl38oGJzNsyV0yiijbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS42MBMGCysGAQQBguUcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEPigEfOMCk0VgAYXER-e3H0wDAYDVR0TAQH_BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAMvPkvVjXQiuvSZmGCB8NqTvGqhxyEfkoU-vz63PaaTsG3jEzjl0C7PZ26VxCvqWPJdM3P3e7Kp18sj4RjEHUmkya2PPipOwBd3p0qMQSQ8MeziCPLQ9uvGGb4YShcvaprMv4c21b4piza-znHneNCmmq-ZS4Y23o-vYv085_BEwyLPcmPjSZ5qWysCq7rVvZ7OWwcU1zu5RhSZyUKl8dzK9lAzs5OdRH2fzEewsW2OkB_Ow_jBvAxqwLXXTHuwMFaRfpmBoZuQlcofSrnwJ8KA-K-e0dKTz2zC8EbZrWYrSpbrHKyqxeBT6DkUd8H4tgAd5lOr_yqrtVmIaRfq07NmhhdXRoRGF0YVjElWkIjx7O4yMpVANdvRDXyuORMFonUbVZu4_Xy7IpvdRBAAAAAPigEfOMCk0VgAYXER-e3H0AQMLC68jgMVzFOeLNnwklj81o1xzgSj6ZaDflB37Y-P66SLugWcTV6aZvNn-2Ool_RRDiinkufjdkwC3ssy5yXwClAQIDJiABIVggAYD1TSpf120DSVxen8ki56kF1bmT4EXO-P0JnSk5mMwiWCB3TlMZBRqPY6llzDcfHd-oW0EHdaFNgBdlGGFobpHKlw" | |
} | |
} | |
let packedSurrogateAttestationWebAuthnSample = { | |
"id": "H6X2BnnjgOzu_Oj87vpRnwMJeJYVzwM3wtY1lhAfQ14", | |
"rawId": "H6X2BnnjgOzu_Oj87vpRnwMJeJYVzwM3wtY1lhAfQ14", | |
"response": { | |
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZzn__mNzaWdZAQCPypMLXWqtCZ1sc5QdjhH-pAzm8-adpfbemd5zsym2krscwV0EeOdTrdUOdy3hWj5HuK9dIX_OpNro2jKrHfUj_0Kp-u87iqJ3MPzs-D9zXOqkbWqcY94Zh52wrPwhGfJ8BiQp5T4Q97E042hYQRDKmtv7N-BT6dywiuFHxfm1sDbUZ_yyEIN3jgttJzjp_wvk_RJmb78bLPTlym83Y0Ws73K6FFeiqFNqLA_8a4V0I088hs_IEPlj8PWxW0wnIUhI9IcRf0GEmUwTBpbNDGpIFGOudnl_C3YuXuzK3R6pv2r7m9-9cIIeeYXD9BhSMBQ0A8oxBbVF7j-0xXDNrXHZaGF1dGhEYXRhWQFnSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAOKjVmSRjt0nqud40p1PeHgEAIB-l9gZ544Ds7vzo_O76UZ8DCXiWFc8DN8LWNZYQH0NepAEDAzn__iBZAQDAIqzybPPmgeL5OR6JKq9bWDiENJlN_LePQEnf1_sgOm4FJ9kBTbOTtWplfoMXg40A7meMppiRqP72A3tmILwZ5xKIyY7V8Y2t8X1ilYJol2nCKOpAEqGLTRJjF64GQxen0uFpi1tA6l6N-ZboPxjky4aidBdUP22YZuEPCO8-9ZTha8qwvTgZwMHhZ40TUPEJGGWOnHNlYmqnfFfk0P-UOZokI0rqtqqQGMwzV2RrH2kjKTZGfyskAQnrqf9PoJkye4KUjWkWnZzhkZbrDoLyTEX2oWvTTflnR5tAVMQch4UGgEHSZ00G5SFoc19nGx_UJcqezx5cLZsny-qQYDRjIUMBAAE", | |
"clientDataJSON": "eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJjaGFsbGVuZ2UiOiJBWGtYV1hQUDNnTHg4T0xscGtKM2FSUmhGV250blNFTmdnbmpEcEJxbDFuZ0tvbDd4V3dldlVZdnJwQkRQM0xFdmRyMkVPU3RPRnBHR3huTXZYay1WdyIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ" | |
}, | |
"type": "public-key" | |
} | |
verifyPackedAttestation(packedFullAttestationWebAuthnSample) | |
verifyPackedAttestation(packedSurrogateAttestationWebAuthnSample) |
Thanks Yuriy. So useful! So happy I found your stuff. Many guides that walk devs through registering and authentication leave assertion and attestation only at a high level. You've curated, and authored, a wonderful set of resources. I wonder if the publishers of https://webauthn.guide/ for example might add a link to your stuff at the end of their guide.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
thanks for this 👍