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
31 changes: 15 additions & 16 deletions clients/passkeys-browser/test/ceremonies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,15 @@ describe("PkAuthCeremonyClient http test", () => {
const registration = new RegistrationService("localhost", "alice", "bob")
using server= await startHttpServer({
[startPath]: newStartHandler(registration),
[finishPath]: errorHttpHandler
[finishPath]: newFinishHandler(registration)
});
const credentialsContainer = noOpCredentialsContainer();
const client = new PkAuthCeremonyClient(
{ apiBase: server.url },
{ credentials: credentialsContainer },
);

await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow();

await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow(/creation was cancelled/);
expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential
})

Expand Down Expand Up @@ -202,16 +201,28 @@ describe("PkAuthCeremonyClient http test", () => {
[startPath]: newStartHandler(registration),
[finishPath]: newFinishHandler(registration)
});
const authenticator = new FakeAuthenticator();
const client = new PkAuthCeremonyClient(
{ apiBase: server.url },
{ credentials: new FakeAuthenticator() },
{ credentials: authenticator },
);

const response = await client.register({ username: "alice", label: "key" });
const challenge = crypto.getRandomValues(new Uint8Array(32));
const credentialRequestOptions = {
publicKey: {
challenge: challenge,
rpId: "localhost"
}
}
const retrievedCredential = await authenticator.get(credentialRequestOptions);

expect(response.credential.label).to.equal("key");
expect(response.credential.credentialId).not.toBe("");
expect(response.credential.userHandle).not.toBe("");
expect(response.credential.authenticatorData).not.toBe("");
expect(retrievedCredential?.id).toEqual(response.credential.credentialId);
expect(retrievedCredential?.type).toEqual("public-key");
})

const startPath = "/auth/passkeys/registration/start";
Expand Down Expand Up @@ -270,18 +281,6 @@ describe("PkAuthCeremonyClient.register (start-body contract)", () => {
expect(bodies["/registration/start"]).toMatchObject({ displayName: "Bobby", label: "yubikey" });
expect(bodies["/registration/finish"]).toMatchObject({ label: "yubikey" });
});

it("rejects when the authenticator returns no credential (create cancelled)", async () => {
const { fetchImpl } = ceremonyFetch({
"/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON },
});
const fakeCreds = { create: vi.fn(async () => null), get: vi.fn() } as unknown as CredentialsContainer;
const client = new PkAuthCeremonyClient(
{ apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch },
{ credentials: fakeCreds },
);
await expect(client.register({ username: "alice" })).rejects.toThrow(/creation was cancelled/);
});
});

describe("PkAuthCeremonyClient.authenticate (end-to-end with stubbed credentials)", () => {
Expand Down
88 changes: 88 additions & 0 deletions clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,103 @@ function makeOptions(rpId: string, challenge: Uint8Array): CredentialCreationOpt
}


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

await auth.create(makeOptions("example.com", crypto.getRandomValues(new Uint8Array(32))));
const credential = await auth.get(makeGetOptions("example.com", assertionChallenge));

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

it("returns an assertion whose signature verifies against the registered public key", async () => {
const auth = new FakeAuthenticator();
const assertionChallenge = crypto.getRandomValues(new Uint8Array(32));

const registered = await auth.create(makeOptions("example.com", crypto.getRandomValues(new Uint8Array(32))));
const assertion = await auth.get(makeGetOptions("example.com", assertionChallenge));

const publicKey = await extractPublicKey(registered);
const response = assertion!.response as AuthenticatorAssertionResponse;
const sigData = await toBeSigned(new Uint8Array(response.authenticatorData), response.clientDataJSON);
const valid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
response.signature,
sigData,
);
expect(valid).toBe(true);
});

it("uses the same credential id as the registered credential", async () => {
const auth = new FakeAuthenticator();

const registered = await auth.create(makeOptions("example.com", new Uint8Array(32)));
const assertion = await auth.get(makeGetOptions("example.com", new Uint8Array(32)));

expect(assertion!.id).toBe(registered.id);
expect(new Uint8Array(assertion!.rawId)).toEqual(new Uint8Array(registered.rawId));
});
});

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

function makeGetOptions(rpId: string, challenge: Uint8Array): CredentialRequestOptions {
return {
publicKey: {
challenge: challenge.buffer.slice(
challenge.byteOffset,
challenge.byteOffset + challenge.byteLength,
) as ArrayBuffer,
rpId,
userVerification: "preferred",
allowCredentials: [],
},
};
}

function decodeClientData(credential: PublicKeyCredential): ClientData {
const response = credential.response as AuthenticatorAttestationResponse;
return JSON.parse(
new TextDecoder().decode(response.clientDataJSON),
)
}

function decodeAssertionClientData(credential: PublicKeyCredential): ClientData {
const response = credential.response as AuthenticatorAssertionResponse;
return JSON.parse(new TextDecoder().decode(response.clientDataJSON));
}

async function extractPublicKey(credential: PublicKeyCredential): Promise<CryptoKey> {
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
const response = credential.response as AuthenticatorAttestationResponse;
const attObj = cbor.decode(new Uint8Array(response.attestationObject)) as Map<string, unknown>;
const authData = attObj.get("authData") as Uint8Array;
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);

return crypto.subtle.importKey(
"raw",
rawPoint,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
}
84 changes: 77 additions & 7 deletions clients/passkeys-browser/test/helpers/fakeAuthenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,40 @@ import {Encoder} from "cbor-x";
import * as b64u from "../../src/base64url";
import {BufferUint8} from "./buffer";

type StoredCredential = { keyPair: CryptoKeyPair; rawId: Uint8Array };

export class FakeAuthenticator implements CredentialsContainer {
// map id to keypair. The id is the base64 encoding of the randomly generated id [rawId].
private readonly stored : Map<string, StoredCredential>;
// lastId is used when get method is invoked w/o specifying a specific id.
private lastId: string | null;

constructor() {
this.stored = new Map<string, StoredCredential>();
this.lastId = null;
}

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 clientDataBytes = encodeClientData(challenge, rpId, "webauthn.create");
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);

const id = b64u.encode(rawId);
this.stored.set(id, { keyPair, rawId });
this.lastId = id;

return {
rawId: rawId.buffer,
id: b64u.encode(rawId),
id,
type: "public-key",
authenticatorAttachment: null,
getClientExtensionResults: () => ({}),
Expand All @@ -33,8 +49,49 @@ export class FakeAuthenticator implements CredentialsContainer {
};
}

async get(_options: CredentialRequestOptions): Promise<PublicKeyCredential | null> {
throw new Error("FakeAuthenticator.get() not yet implemented");
async get(options: CredentialRequestOptions): Promise<PublicKeyCredential | null> {
const pk = options.publicKey;
if (!pk) throw new Error("FakeAuthenticator.get: publicKey options are required");
const rpId = pk.rpId;
if (!rpId) throw new Error("FakeAuthenticator.get: rpId is required");

const credential = this.findCredential(pk.allowCredentials);
if (!credential) throw new Error("FakeAuthenticator.get: no matching credential found");

const challenge = new Uint8Array(pk.challenge as ArrayBuffer);
const clientDataBytes = encodeClientData(challenge, rpId, "webauthn.get");
const authData = await assertionAuthData(rpId);
const signature = await sign(authData, clientDataBytes, credential.keyPair);
const { rawId } = credential;

return {
rawId: newArrayBuffer(rawId),
id: b64u.encode(rawId),
type: "public-key",
authenticatorAttachment: null,
getClientExtensionResults: () => ({}),
response: {
clientDataJSON: newArrayBuffer(clientDataBytes),
authenticatorData: newArrayBuffer(authData),
signature: newArrayBuffer(signature),
userHandle: null,
} as AuthenticatorAssertionResponse,
toJSON: () => ({} as AuthenticationResponseJSON),
};
}

private findCredential(
allowCredentials: PublicKeyCredentialDescriptor[] | undefined,
): StoredCredential | undefined {
if (allowCredentials && allowCredentials.length > 0) {
for (const desc of allowCredentials) {
const id = b64u.encode(new Uint8Array(desc.id as ArrayBuffer));
const entry = this.stored.get(id);
if (entry) return entry;
}
return undefined;
}
return this.lastId ? this.stored.get(this.lastId) : undefined;
}

async preventSilentAccess() { /*no impl*/ }
Expand Down Expand Up @@ -83,6 +140,17 @@ async function newAuthData(keyPair: CryptoKeyPair, rpId: string, id: Uint8Array)
bytes();
}

async function assertionAuthData(rpId: string): Promise<Uint8Array> {
const rpIdHash = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)),
);
return new BufferUint8()
.uint8array(rpIdHash)
.uint8(0x05) // UP | UV flags (no AT — assertion carries no attested credential data)
.skip(4) // counter = 0
.bytes();
}

type Curve = "P-256" | "P-384" | "P-512"
function ecdsa(curve: Curve) : Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
Expand Down Expand Up @@ -141,10 +209,12 @@ function curveValue(publicKey : CryptoKey) : number {
throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`)
}

function encodeClientData(challenge: Uint8Array, rpId: string): Uint8Array<ArrayBuffer> {
type WebAuthnType = "webauthn.create" | "webauthn.get";

function encodeClientData(challenge: Uint8Array, rpId: string, webAuthnType: WebAuthnType): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(
JSON.stringify({
type: "webauthn.create",
type: webAuthnType,
challenge: b64u.encode(challenge),
origin: `https://${rpId}`,
crossOrigin: false,
Expand All @@ -158,5 +228,5 @@ function encodeAttestationObject(authData: Uint8Array<ArrayBufferLike>, signatur
fmt: "packed",
attStmt: { alg: -7, sig: signature },
authData,
}) as Uint8Array;
});
}
Loading