Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: MIT
import { describe, expect, it } from "vitest";
import * as b64u from "../../src/base64url";
import { FakeAuthenticator } from "./fakeAuthenticator";
import { Encoder } from "cbor-x";

describe("FakeAuthenticator", () => {
it("returns a credential whose clientDataJSON embeds the challenge and correct type", async () => {
const auth = new FakeAuthenticator();
const challenge = crypto.getRandomValues(new Uint8Array(32));

const credentialCreateOptions = makeOptions("example.com", challenge);
const credential = await auth.create(credentialCreateOptions);

const clientData = decodeClientData(credential);
expect(clientData.type).toBe("webauthn.create");
expect(b64u.decode(clientData.challenge)).toEqual(challenge);
expect(clientData.origin).toBe("https://example.com");
});

it("returns a packed attestation with a signature that verifies against the embedded public key", async () => {
const auth = new FakeAuthenticator();
const challenge = crypto.getRandomValues(new Uint8Array(32));

const credential = await auth.create(makeOptions("example.com", challenge));

const response = credential.response as AuthenticatorAttestationResponse;
const attestation = await decodeAttestationData(response);
expect(attestation.fmt).toBe("packed");
const authData = attestation.authData;
const verificationData = await toBeSigned(authData, response.clientDataJSON);
const valid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
attestation.publicKey,
attestation.signature,
verificationData,
);
expect(valid).toBe(true);
});

it("encodes rawId as base64url in the credential id field", async () => {
const auth = new FakeAuthenticator();
const credential = await auth.create(
makeOptions("example.com", new Uint8Array(32)),
);
expect(credential.id).toBe(b64u.encode(credential.rawId));
});
});

async function toBeSigned(authData: Uint8Array<ArrayBufferLike>, clientDataJSON: ArrayBuffer): Promise<Uint8Array<ArrayBuffer>> {
const clientDataJSONBytes = new Uint8Array(clientDataJSON);
const clientDataHash = new Uint8Array(
await crypto.subtle.digest("SHA-256", clientDataJSONBytes),
);
const verificationData = new Uint8Array(authData.length + clientDataHash.length);
verificationData.set(authData);
verificationData.set(clientDataHash, authData.length);
return verificationData;
}

interface AttestationData {
fmt: string;
statement: Map<string, unknown>;
signature: Uint8Array<ArrayBuffer>;
publicKey: CryptoKey;
authData: Uint8Array;
}

async function decodeAttestationData(attestation: AuthenticatorAttestationResponse): Promise<AttestationData> {
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
const attObj = cbor.decode(new Uint8Array(attestation.attestationObject)) as Map<string, unknown>;
const authData = attObj.get("authData") as Uint8Array;
const attStmt = attObj.get("attStmt") as Map<string, unknown>;
const sig = attStmt.get("sig") as Uint8Array<ArrayBuffer>;

// Extract COSE key from authData at offset 55 + credIdLen
const view = new DataView(authData.buffer, authData.byteOffset);
const credIdLen = view.getUint16(53);
const coseKey = cbor.decode(authData.slice(55 + credIdLen)) as Map<number, unknown>;

const xBytes = coseKey.get(-2) as Uint8Array;
const yBytes = coseKey.get(-3) as Uint8Array;
const rawPoint = new Uint8Array(65);
rawPoint[0] = 0x04;
rawPoint.set(xBytes, 1);
rawPoint.set(yBytes, 33);

const publicKey = await crypto.subtle.importKey(
"raw",
rawPoint,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);

return {
fmt: attObj.get("fmt") as string,
statement: attStmt,
signature: sig,
publicKey: publicKey,
authData: authData
}
}

function makeOptions(rpId: string, challenge: Uint8Array): CredentialCreationOptions {
return {
publicKey: {
rp: { id: rpId, name: "Test" },
user: {
id: new Uint8Array([1, 2, 3, 4]).buffer.slice(0, 4) as ArrayBuffer,
name: "alice",
displayName: "Alice",
},
challenge: challenge.buffer.slice(
challenge.byteOffset,
challenge.byteOffset + challenge.byteLength,
) as ArrayBuffer,
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
};
}


interface ClientData {
type: string;
challenge: string;
origin: string
}

function decodeClientData(credential: PublicKeyCredential): ClientData {
const response = credential.response as AuthenticatorAttestationResponse;
return JSON.parse(
new TextDecoder().decode(response.clientDataJSON),
)
}
162 changes: 162 additions & 0 deletions clients/passkeys-browser/test/helpers/fakeAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: MIT
import {Encoder} from "cbor-x";
import * as b64u from "../../src/base64url";
import {BufferUint8} from "./buffer";

export class FakeAuthenticator implements CredentialsContainer {
async create(options: CredentialCreationOptions): Promise<PublicKeyCredential> {
const pk = options.publicKey;
if (!pk) throw new Error("FakeAuthenticator.create: publicKey options are required");
const rpId = pk.rp.id;
if (!rpId) throw new Error("FakeAuthenticator.create: rp.id is required");

const challenge = new Uint8Array(pk.challenge as ArrayBuffer);
const clientDataBytes = encodeClientData(challenge, rpId);
const keyPair = await ecdsa("P-256")
const rawId = crypto.getRandomValues(new Uint8Array(16));
const authData = await newAuthData(keyPair, rpId, rawId);
const signature = await sign(authData, clientDataBytes, keyPair);
const atoBytes = encodeAttestationObject(authData, signature);

return {
rawId: rawId.buffer,
id: b64u.encode(rawId),
type: "public-key",
authenticatorAttachment: null,
getClientExtensionResults: () => ({}),
response: {
clientDataJSON: newArrayBuffer(clientDataBytes),
attestationObject: newArrayBuffer(atoBytes),
getTransports: () => ["internal"],
} as AuthenticatorAttestationResponse,
toJSON: () => ({} as RegistrationResponseJSON),
};
}

async get(_options: CredentialRequestOptions): Promise<PublicKeyCredential | null> {
throw new Error("FakeAuthenticator.get() not yet implemented");
}

async preventSilentAccess() { /*no impl*/ }

async store() { /*no impl*/ }
}

function newArrayBuffer(buf: Uint8Array<ArrayBuffer> | Uint8Array<ArrayBufferLike>): ArrayBuffer {
return buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength
) as ArrayBuffer
}

// sign authData || SHA-256(clientDataJSON)
async function sign(authData: Uint8Array, clientData: Uint8Array<ArrayBuffer>, keyPair: CryptoKeyPair): Promise<Uint8Array> {
const clientDataHash = new Uint8Array(
await crypto.subtle.digest("SHA-256", clientData),
);
const toSign = new BufferUint8().
uint8array(authData).
uint8array(clientDataHash).
bytes();
return new Uint8Array(
await crypto.subtle.sign(
{name: "ECDSA", hash: "SHA-256"},
keyPair.privateKey,
new Uint8Array(toSign),
),
);
}

async function newAuthData(keyPair: CryptoKeyPair, rpId: string, id: Uint8Array): Promise<Uint8Array> {
const coseKeyEncoded = await cose(keyPair.publicKey)
const rpIdHash = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)),
);
return new BufferUint8().
uint8array(rpIdHash).
uint8(0x45). // UP | UV | AT
skip(4). // counter = 0
skip(16). // AAGUID = all zeros
uint16(id.length).
uint8array(id).
uint8array(coseKeyEncoded).
bytes();
}

type Curve = "P-256" | "P-384" | "P-512"
function ecdsa(curve: Curve) : Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: curve },
true,
["sign", "verify"],
);
}

async function cose(publicKey : CryptoKey): Promise<Uint8Array> {
const kty = 1
const algorithm = 3;
const curve = -1;
const xCoord = -2;
const yCoord = -3;

const jwk = await crypto.subtle.exportKey("jwk", publicKey);
const xBytes = b64u.decode(jwk.x!);
const yBytes = b64u.decode(jwk.y!);
const coseKey = new Map<number, unknown>([
[kty, ktyValue(publicKey)],
[algorithm, algorithmValue(publicKey)],
[curve, curveValue(publicKey)],
[xCoord, xBytes],
[yCoord, yBytes],
]);
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
return cbor.encode(coseKey);
}

function ktyValue(publicKey : CryptoKey) : number {
const ktyECDSA = 2;
if (publicKey.algorithm.name === "ECDSA") {
return ktyECDSA;
}
throw new Error(`Unsupported key type: ${publicKey.algorithm.name}`)
}

function algorithmValue(publicKey : CryptoKey) : number {
const algorithmES256 = -7;
if (publicKey.algorithm.name === "ECDSA") {
return algorithmES256;
}
throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`)
}

function curveValue(publicKey : CryptoKey) : number {
const curveP256 = -7;
if (publicKey.algorithm.name === "ECDSA") {
const { namedCurve } = publicKey.algorithm as EcKeyAlgorithm
if (namedCurve === "P-256") {
return curveP256;
}
throw new Error(`Unsupported curve: ${namedCurve}`)
}
throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`)
}

function encodeClientData(challenge: Uint8Array, rpId: string): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(
JSON.stringify({
type: "webauthn.create",
challenge: b64u.encode(challenge),
origin: `https://${rpId}`,
crossOrigin: false,
}),
);
}

function encodeAttestationObject(authData: Uint8Array<ArrayBufferLike>, signature: Uint8Array<ArrayBufferLike>): Uint8Array {
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
return cbor.encode({
fmt: "packed",
attStmt: { alg: -7, sig: signature },
authData,
}) as Uint8Array;
}
Loading