-
Notifications
You must be signed in to change notification settings - Fork 1
fix(adhoc-webauthn-js): 3 review findings in webauthn.js #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,7 @@ module.exports.CreateWebAuthnModule = function () { | |
| }; | ||
| } | ||
|
|
||
| obj.verifyAuthenticatorAttestationResponse = function (webauthnResponse) { | ||
| obj.verifyAuthenticatorAttestationResponse = function (webauthnResponse, expectedChallenge, expectedOrigin) { | ||
| const attestationBuffer = Buffer.from(webauthnResponse.attestationObject, 'base64'); | ||
| const ctapMakeCredResp = cbor.decodeAllSync(attestationBuffer)[0]; | ||
| const authrDataStruct = parseMakeCredAuthData(ctapMakeCredResp.authData); | ||
|
Comment on lines
26
to
32
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 𦩠π΄ WebAuthn challenge is never stored or verified β replay and cross-origin attacks are possible Finding: challenge never stored or verified. WHAT CHANGED: Both π€ Prompt for AI agentsfix confidence: π‘ 68 medium β react π/π to teach the reviewer |
||
|
|
@@ -35,7 +35,26 @@ module.exports.CreateWebAuthnModule = function () { | |
|
|
||
| const response = { 'verified': false }; | ||
|
|
||
| if ((ctapMakeCredResp.fmt === 'none') || (ctapMakeCredResp.fmt === 'fido-u2f') || (ctapMakeCredResp.fmt === 'packed')) { | ||
| // Verify clientDataJSON challenge, origin, and type | ||
| if (expectedChallenge || expectedOrigin) { | ||
| let clientData; | ||
| try { | ||
| clientData = JSON.parse(Buffer.from(webauthnResponse.clientDataJSON, 'base64').toString('utf8')); | ||
| } catch (e) { | ||
| throw new Error('Failed to parse clientDataJSON: ' + e.message); | ||
| } | ||
| if (expectedChallenge && clientData.challenge !== expectedChallenge) { | ||
| throw new Error('Registration challenge mismatch'); | ||
| } | ||
| if (expectedOrigin && clientData.origin !== expectedOrigin) { | ||
| throw new Error('Registration origin mismatch'); | ||
| } | ||
| if (clientData.type !== 'webauthn.create') { | ||
| throw new Error('Registration clientData type mismatch'); | ||
| } | ||
| } | ||
|
|
||
| if (ctapMakeCredResp.fmt === 'none') { | ||
| if (!(authrDataStruct.flags & 0x01)) { throw new Error('User was NOT presented during authentication!'); } // U2F_USER_PRESENTED | ||
|
|
||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey); | ||
|
|
@@ -49,104 +68,64 @@ module.exports.CreateWebAuthnModule = function () { | |
| keyId: authrDataStruct.credID.toString('base64') | ||
| }; | ||
| } | ||
| } | ||
| /* | ||
| else if (ctapMakeCredResp.fmt === 'fido-u2f') { | ||
| if (!(authrDataStruct.flags & 0x01)) // U2F_USER_PRESENTED | ||
| throw new Error('User was NOT presented during authentication!'); | ||
| } else if (ctapMakeCredResp.fmt === 'fido-u2f') { | ||
| if (!(authrDataStruct.flags & 0x01)) { throw new Error('User was NOT presented during authentication!'); } // U2F_USER_PRESENTED | ||
|
|
||
| const clientDataHash = hash(webauthnResponse.clientDataJSON) | ||
| const clientDataHash = hash(Buffer.from(webauthnResponse.clientDataJSON, 'base64')); | ||
| const reservedByte = Buffer.from([0x00]); | ||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey) | ||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey); | ||
| const signatureBase = Buffer.concat([reservedByte, authrDataStruct.rpIdHash, clientDataHash, authrDataStruct.credID, publicKey]); | ||
|
|
||
| if (!ctapMakeCredResp.attStmt || !ctapMakeCredResp.attStmt.x5c || !ctapMakeCredResp.attStmt.sig) { | ||
| throw new Error('fido-u2f attestation missing x5c or sig'); | ||
| } | ||
| const PEMCertificate = ASN1toPEM(ctapMakeCredResp.attStmt.x5c[0]); | ||
| const signature = ctapMakeCredResp.attStmt.sig; | ||
|
|
||
| response.verified = verifySignature(signature, signatureBase, PEMCertificate) | ||
| response.verified = verifySignature(signature, signatureBase, PEMCertificate); | ||
|
|
||
| if (response.verified) { | ||
| response.authrInfo = { | ||
| fmt: 'fido-u2f', | ||
| publicKey: ASN1toPEM(publicKey), | ||
| counter: authrDataStruct.counter, | ||
| keyId: authrDataStruct.credID.toString('base64') | ||
| } | ||
| }; | ||
| } | ||
| } else if (ctapMakeCredResp.fmt === 'packed' && ctapMakeCredResp.attStmt.hasOwnProperty('x5c')) { | ||
| if (!(authrDataStruct.flags & 0x01)) // U2F_USER_PRESENTED | ||
| throw new Error('User was NOT presented durring authentication!'); | ||
| } else if (ctapMakeCredResp.fmt === 'packed') { | ||
| if (!(authrDataStruct.flags & 0x01)) { throw new Error('User was NOT presented during authentication!'); } // U2F_USER_PRESENTED | ||
|
|
||
| const clientDataHash = hash(webauthnResponse.clientDataJSON) | ||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey) | ||
| const clientDataHash = hash(Buffer.from(webauthnResponse.clientDataJSON, 'base64')); | ||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey); | ||
| const signatureBase = Buffer.concat([ctapMakeCredResp.authData, clientDataHash]); | ||
|
|
||
| const PEMCertificate = ASN1toPEM(ctapMakeCredResp.attStmt.x5c[0]); | ||
| if (!ctapMakeCredResp.attStmt || !ctapMakeCredResp.attStmt.sig) { | ||
| throw new Error('packed attestation missing sig'); | ||
| } | ||
| const signature = ctapMakeCredResp.attStmt.sig; | ||
|
|
||
| const pem = Certificate.fromPEM(PEMCertificate); | ||
|
|
||
| // Getting requirements from https://www.w3.org/TR/webauthn/#packed-attestation | ||
| const aaguid_ext = pem.getExtension('1.3.6.1.4.1.45724.1.1.4') | ||
|
|
||
| response.verified = // Verify that sig is a valid signature over the concatenation of authenticatorData | ||
| // and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg. | ||
| verifySignature(signature, signatureBase, PEMCertificate) && | ||
| // version must be 3 (which is indicated by an ASN.1 INTEGER with value 2) | ||
| pem.version == 3 && | ||
| // ISO 3166 valid country | ||
| typeof iso_3166_1.whereAlpha2(pem.subject.countryName) !== 'undefined' && | ||
| // Legal name of the Authenticator vendor (UTF8String) | ||
| pem.subject.organizationName && | ||
| // Literal string βAuthenticator Attestationβ (UTF8String) | ||
| pem.subject.organizationalUnitName === 'Authenticator Attestation' && | ||
| // A UTF8String of the vendorβs choosing | ||
| pem.subject.commonName && | ||
| // The Basic Constraints extension MUST have the CA component set to false | ||
| !pem.extensions.isCA && | ||
| // If attestnCert contains an extension with OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) | ||
| // verify that the value of this extension matches the aaguid in authenticatorData. | ||
| // The extension MUST NOT be marked as critical. | ||
| (aaguid_ext != null ? | ||
| (authrDataStruct.hasOwnProperty('aaguid') ? | ||
| !aaguid_ext.critical && aaguid_ext.value.slice(2).equals(authrDataStruct.aaguid) : false) | ||
| : true); | ||
|
|
||
| if (response.verified) { | ||
| response.authrInfo = { | ||
| fmt: 'fido-u2f', | ||
| publicKey: publicKey, | ||
| counter: authrDataStruct.counter, | ||
| keyId: authrDataStruct.credID.toString('base64') | ||
| } | ||
| const alg = ctapMakeCredResp.attStmt.alg; | ||
|
|
||
| if (ctapMakeCredResp.attStmt.x5c) { | ||
| // Full attestation: verify with certificate | ||
| const PEMCertificate = ASN1toPEM(ctapMakeCredResp.attStmt.x5c[0]); | ||
| response.verified = verifySignature(signature, signatureBase, PEMCertificate); | ||
| } else { | ||
| // Self attestation: verify with the credential public key | ||
| const PEMPublicKey = ASN1toPEM(publicKey); | ||
| response.verified = verifySignature(signature, signatureBase, PEMPublicKey) && alg === -7; | ||
| } | ||
|
|
||
| // Self signed | ||
| } else if (ctapMakeCredResp.fmt === 'packed') { | ||
| if (!(authrDataStruct.flags & 0x01)) // U2F_USER_PRESENTED | ||
| throw new Error('User was NOT presented durring authentication!'); | ||
|
|
||
| const clientDataHash = hash(webauthnResponse.clientDataJSON) | ||
| const publicKey = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey) | ||
| const signatureBase = Buffer.concat([ctapMakeCredResp.authData, clientDataHash]); | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 𦩠π΄ WebAuthn assertion counter is returned but never checked for replay β authenticator cloning is undetected Finding: assertion counter not checked against stored counter. WHAT CHANGED: In π€ Prompt for AI agentsfix confidence: π’ 90 high β react π/π to teach the reviewer |
||
| const PEMCertificate = ASN1toPEM(publicKey); | ||
|
|
||
| const { attStmt: { sig: signature, alg } } = ctapMakeCredResp | ||
|
|
||
| response.verified = // Verify that sig is a valid signature over the concatenation of authenticatorData | ||
| // and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg. | ||
| verifySignature(signature, signatureBase, PEMCertificate) && alg === -7 | ||
|
|
||
| if (response.verified) { | ||
| response.authrInfo = { | ||
| fmt: 'fido-u2f', | ||
| fmt: 'packed', | ||
| publicKey: ASN1toPEM(publicKey), | ||
| counter: authrDataStruct.counter, | ||
| keyId: authrDataStruct.credID.toString('base64') | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| } else if (ctapMakeCredResp.fmt === 'android-safetynet') { | ||
| } | ||
| /* | ||
| else if (ctapMakeCredResp.fmt === 'android-safetynet') { | ||
| console.log("Android safetynet request\n") | ||
| console.log(ctapMakeCredResp) | ||
|
|
||
|
|
@@ -197,11 +176,37 @@ module.exports.CreateWebAuthnModule = function () { | |
| return response; | ||
| } | ||
|
|
||
| obj.verifyAuthenticatorAssertionResponse = function (webauthnResponse, authr) { | ||
| obj.verifyAuthenticatorAssertionResponse = function (webauthnResponse, authr, expectedChallenge, expectedOrigin) { | ||
| const response = { 'verified': false } | ||
|
|
||
| // Verify clientDataJSON challenge, origin, and type | ||
| if (expectedChallenge || expectedOrigin) { | ||
| let clientData; | ||
| try { | ||
| clientData = JSON.parse(Buffer.from(webauthnResponse.clientDataJSON, 'base64').toString('utf8')); | ||
| } catch (e) { | ||
| throw new Error('Failed to parse clientDataJSON: ' + e.message); | ||
| } | ||
| if (expectedChallenge && clientData.challenge !== expectedChallenge) { | ||
| throw new Error('Assertion challenge mismatch'); | ||
| } | ||
| if (expectedOrigin && clientData.origin !== expectedOrigin) { | ||
| throw new Error('Assertion origin mismatch'); | ||
| } | ||
| if (clientData.type !== 'webauthn.get') { | ||
| throw new Error('Assertion clientData type mismatch'); | ||
| } | ||
| } | ||
|
|
||
| if (['fido-u2f'].includes(authr.fmt)) { | ||
| const authrDataStruct = parseGetAssertAuthData(webauthnResponse.authenticatorData); | ||
| if (!(authrDataStruct.flags & 0x01)) { throw new Error('User was not presented durring authentication!'); } // U2F_USER_PRESENTED | ||
|
|
||
| // Check counter to detect cloned authenticators | ||
| if (authrDataStruct.counter !== 0 && authrDataStruct.counter <= authr.counter) { | ||
| throw new Error('Counter did not increment β possible authenticator clone detected'); | ||
| } | ||
|
|
||
| response.counter = authrDataStruct.counter; | ||
| response.verified = verifySignature(webauthnResponse.signature, Buffer.concat([authrDataStruct.rpIdHash, authrDataStruct.flagsBuf, authrDataStruct.counterBuf, hash(webauthnResponse.clientDataJSON)]), authr.publicKey); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
𦩠π΄ WebAuthn attestation verification skips signature validation for 'fido-u2f' and 'packed' formats β only 'none' is effectively verified
Finding: attestation signature not verified for 'fido-u2f' and 'packed'. WHAT CHANGED: In
verifyAuthenticatorAttestationResponse, the single combined branch(fmt === 'none') || (fmt === 'fido-u2f') || (fmt === 'packed')that unconditionally setresponse.verified = truewas split into three separateif/else ifbranches. The'none'branch retains the original unconditional-verify behaviour (acceptable per spec). The'fido-u2f'branch now performs the signature verification using the x5c certificate and the U2F signature base (reservedByte + rpIdHash + clientDataHash + credID + publicKey), requiringattStmt.x5candattStmt.sigto be present. The'packed'branch now performs signature verification: with x5c certificate if present (full attestation), or with the credential public key and alg===-7 check (self attestation). TheclientDataJSONpassed in is base64-decoded before hashing, matching the U2F/packed spec. Risk: theclientDataJSONfield inwebauthnResponseis assumed to be base64-encoded; if callers pass it differently this will break. The'packed'full-attestation path does not validate certificate fields (aaguid extension, CA=false, etc.) because theCertificate/iso_3166_1dependencies are commented out β this is noted in the existing commented-out code and is a pre-existing limitation.π€ Prompt for AI agents
fix confidence: π‘ 72 medium β react π/π to teach the reviewer