I'm writing a password manager browser extension that also supports passkeys. The extension injects a script to websites that intercepts navigator.credentials.create requests which then opens a popup that waits for the user to authenticate (if not already logged in) and click the Create Passkey button. I'm currently having trouble generating/encoding the attestationObject.
Auth data generation:
function generateAuthData(
rpIdHash: Uint8Array,
attestedCredentialData: Uint8Array
): Uint8Array {
const flags = 0x41; // User presence (UP) + Attested Credential Data (AT) flag
const counter = new Uint8Array([0, 0, 0, 1]); // Counter = 1
// Concatenate RP ID Hash (32 bytes), Flags (1 byte), Counter (4 bytes), and Attested Credential Data
return new Uint8Array([
...rpIdHash,
flags,
...counter,
...attestedCredentialData,
]);
}
Attestation object creation:
function createAttestationObject(
authData: Uint8Array,
credentialId: Uint8Array,
publicKey: Uint8Array
): Buffer {
const aaguid = new Uint8Array(16); // Typically zero-filled if unavailable
const attestedCredentialData = new Uint8Array(
aaguid.length + 2 + credentialId.length + publicKey.length
);
attestedCredentialData.set(aaguid, 0);
attestedCredentialData.set(
new Uint8Array([
(credentialId.length >> 8) & 0xff,
credentialId.length & 0xff,
]),
aaguid.length
);
attestedCredentialData.set(credentialId, aaguid.length + 2);
attestedCredentialData.set(
publicKey,
aaguid.length + 2 + credentialId.length
);
const attestationObject = {
authData,
fmt: "none",
attStmt: {},
};
// Encode using CBOR
return encode(attestationObject);
}
Credentials generation:
const createCustomCredential = async (
options: CredentialCreationOptions
): Promise<CustomCredential> => {
if (!options || !options.publicKey) {
throw new Error("Invalid options: PublicKey options are required.");
}
const { rp, challenge } = options.publicKey;
// Simulate RP ID hash (SHA-256 of RP ID)
const rpIdHash = new Uint8Array(
CryptoJS.SHA256(rp.id ?? "").words.flatMap((word) => [
(word >> 24) & 0xff,
(word >> 16) & 0xff,
(word >> 8) & 0xff,
word & 0xff,
])
);
// Simulate credential ID and public key
const credentialId = generateRandomBuffer(32); // 32-byte random ID
const keyPair = await crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256",
},
true,
["sign", "verify"]
);
const publicKey = new Uint8Array(
await crypto.subtle.exportKey("raw", keyPair.publicKey)
);
const publicKeyHex = Array.from(publicKey)
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
const pubKey =
"A5010203262001215820" +
publicKeyHex.substring(2, 66) +
"225820" +
publicKeyHex.substring(66, 130);
const aaguid = new Uint8Array(16);
const credentialIdLength = new Uint8Array([
(credentialId.length >> 8) & 0xff,
credentialId.length & 0xff,
]);
const attestedCredentialData = new Uint8Array(
aaguid.length +
credentialIdLength.length +
credentialId.length +
publicKey.length
);
attestedCredentialData.set(aaguid, 0);
attestedCredentialData.set(credentialIdLength, aaguid.length);
attestedCredentialData.set(
credentialId,
aaguid.length + credentialIdLength.length
);
attestedCredentialData.set(
publicKey,
aaguid.length + credentialIdLength.length + credentialId.length
);
// Generate authenticator data
const authData = generateAuthData(rpIdHash, attestedCredentialData);
const encoder = new TextEncoder();
// Create attestation object
const attestationObject = createAttestationObject(
authData,
credentialId,
encoder.encode(pubKey)
);
const challengeArray = new Uint8Array(
challenge instanceof ArrayBuffer ? challenge : challenge.buffer
);
return {
id: btoa(String.fromCharCode(...credentialId))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
rawId: credentialId,
response: {
clientDataJSON: btoa(
JSON.stringify({
challenge: btoa(String.fromCharCode(...challengeArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
origin: `https://${options.publicKey.rp.id}`,
type: "webauthn.create",
})
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
attestationObject: attestationObject,
},
type: "public-key",
};
};
And this is how I resolve the window.credentials.create request by sending back the generated credentials to the injected script:
const generateKey = async () => {
setLoading(true);
if (parsedData) {
const credential = createCustomCredential(parsedData);
if (tabId) {
browser.tabs.sendMessage(parseInt(tabId), {
type: "PASSKEY_RESULT",
success: true,
data: credential,
});
}
}
setLoading(false);
window.close();
};
I'm testing this via webauthn.io and I'm getting this error: Registration failed: Leftover bytes detected while parsing authenticator data