node.jsecdsawebauthnpasskey

How to properly extract public key from attestationObject?


I'm digging around passkeys and can't find a way to properly extract the Public Key from the attestationData sent from the client during the attestation (rigistration) step. Most tutorials I found refer to using the web api with javascript and calling the getPublicKey() method, but I'm using flutter and the library I'm using for passkeys does not have such method, so I must extract the public key manually on my server, here is what I have tried:

const attestation = {
    "attestationData": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NFAAAAAK3OAAI1vMYKZIsLJfHwVQMAILxBOipox2vwkvO0SAE7mme20vs2iVsGk8VPFu/6lQPxpQECAyYgASFYIIGsoPOPLUt8AB40ssEf95YNmqgO16rKvXydLpU+A3TSIlggLr7aHpKAoRMWN1lGVRiBsMS1kdB10QEf1pxryoDQZ8A=",
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoib202bjVIdFVjZ1Q4T0NiTjJUZXQ4OWJtU3BFaUhpNUY3aWlZOVhQZmFUSSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3RoZXJfa2V5c19jYW5fYmVfYWRkZWRfaGVyZSI6ImRvIG5vdCBjb21wYXJlIGNsaWVudERhdGFKU09OIGFnYWluc3QgYSB0ZW1wbGF0ZS4gU2VlIGh0dHBzOi8vZ29vLmdsL3lhYlBleCJ9",
    "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgayg848tS3wAHjSywR/3lg2aqA7Xqsq9fJ0ulT4DdNIuvtoekoChExY3WUZVGIGwxLWR0HXRAR/WnGvKgNBnwA=="
}

const assertion = {
    "signature": "MEYCIQCaO2mh+E8SEWUOGW1XLMPq3z/LofM67/vUr6ut/Z9apgIhALWuIhawe+nzWjd//Zd670IxrP9gksMW0o7Gh/FYHkcG",
    "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2MFAAAAAA=="
}

To make things easier I have used a simple web project with passkeys, all the values were encoded using base64 as follow:

function decodeBase64(data) {
    return Uint8Array.from(atob(data).split(""), (x) => x.charCodeAt(0));
}

function encodeBase64(data) {
    return btoa(String.fromCharCode(...new Uint8Array(data)));
}

Now for the NodeJS part where I need to recreate the public key:

const ctapMakeCredResp        = cbor.decodeFirstSync(decodeBase64(attestation.attestationData));
const authData = ctapMakeCredResp.authData;
const rpidHash = authData.subarray(0, 32);
const flags = authData.subarray(32, 33);
const counter = authData.subarray(33, 37).readUint32BE(0);

const aaguid = authData.subarray(37, 53);
const credIdLength = authData.subarray(53, 55).readUInt16BE(0);
const credID = authData.subarray(55, 55 + credIdLength);
const COSEPublicKey = authData.subarray(55  + credIdLength);
const publicKeyMap = cbor.decodeFirstSync(COSEPublicKey);
const publicKeyData = {
    kty: publicKeyMap.get(1),
    alg: publicKeyMap.get(3),
    crv: publicKeyMap.get(-1),
    x: publicKeyMap.get(-2),
    y: publicKeyMap.get(-3),
};

Here for debugging purposes, I also have the base64 encode of the public key generated on the frontend and when I extract the X and Y values from original public key, Y results to aways be the same the COSEPublicKey, but X is always different.

From now on, I'm not sure if I'm handling the signature verification correctly, I have tried creating the public key in the following methods:

const publicKey = crypto.createPublicKey({
    key: Buffer.from([0x04, ...publicKeyData.x, ...publicKeyData.y]),
    format: 'der',
    type: 'spki',
});

The above method results in an Error saying: Error: error:0680009B:asn1 encoding routines::too long.

And also tried like this, which results in the same error:

const publicKey = await crypto.subtle.importKey(
    'spki',
    Buffer.from([0x04, publicKeyData.x, publicKeyData.y]),
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['verify']
);

And for verification:

const signature = decodeBase64(authSignature);
const authDataDecoded = decodeBase64(authenticatorData);
const clientData = decodeBase64(clientDataJSON);

const rawSig = fromAsn1DERtoRSSignature(signature, 256);

const digest = concatBuffer(
    authDataDecoded,
    await crypto.subtle.digest('SHA-256', clientData)
);

const result = await crypto.subtle.verify(
    { name: 'ECDSA', hash: { name: 'SHA-256' } },
    publicKey,
    rawSig,
    digest
);

I have also tried recreating the public key using the original b64 encoded public key like this:

const publicKey = await crypto.subtle.importKey(
    'spki',
    base64Decode(attestation.publicKey),
    { name: 'ECDSA, namedCurve: 'P-256' },
    true,
    ['verify']
);

But the result of verify is false, so there must be something wrong with my code.

The source code for the fromAsn1DERtoRSSignature can be found here.

For most tutorials I've found are always in javascript and run within the browser as a given, but now also to learn better about the topic I want to learn how to handle such use case.

I've tried many solution that I've seen online and also using code assistants, none of them were of my help, they either gives an error or results into a false assertion.


Solution

  • First of all, I have not implemented the entire code and checked the verification, but only focused on the actual question, namely how the public key can be extracted/imported:

    WebCrypto does not support the direct import of public EC keys in raw format, but only the X.509/SPKI and JWK formats. The attestation.publicKey contained in your data is an ASN.1/DER encoded key in X.509/SPKI format (Base64 encoded) and could be imported directly (after Base64 decoding). However, since this is not available in the real data according to your comment, an import in JWK format is recommended, as this comes closest to the raw format.

    If publicKeyData.x and publicKeyData.y are determined for the test data you have posted, the result for the raw key is (hex encoded):

    publicKeyData.x 81ACA0F38F2D4B7C001E34B2C11FF7960D9AA80ED7AACABD7C9D2E953E0374D2
    publicKeyData.y 2EBEDA1E9280A11316375946551881B0C4B591D075D1011FD69C6BCA80D067C0
    

    This can be imported as CryptoKey as follows:

    (async () => {
    
    var x = hex2ab('81ACA0F38F2D4B7C001E34B2C11FF7960D9AA80ED7AACABD7C9D2E953E0374D2')
    var y = hex2ab('2EBEDA1E9280A11316375946551881B0C4B591D075D1011FD69C6BCA80D067C0')
    
    let publicKeyJwk = {
        crv: "P-256",
        kty: "EC",
        x: ab2b64url(x),
        y: ab2b64url(y)
    }
    
    const publicKey = await crypto.subtle.importKey(
        'jwk',
        publicKeyJwk,
        { name: 'ECDSA', namedCurve: 'P-256' },
        true,
        ['verify']
    )
    console.log(publicKey)
    
    // Test
    const publicKeyDer = await crypto.subtle.exportKey('spki', publicKey)
    console.log(ab2b64(publicKeyDer))
    
    // Helper -----------
    function hex2ab(hex){
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)}));
    }
    
    function ab2b64(arrayBuffer) {
        return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
    }
    
    function ab2b64url(arrayBuffer) {
        return ab2b64(arrayBuffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
    }
    
    })();

    As test, the imported key is exported in X.509/SPKI format ASN.1/DER encoded and then Base64 encoded. The result equals attestation.publicKey, which proves the equivalence of both keys.