diff --git a/libs/common/src/services/fido2/credential-id.ts b/libs/common/src/services/fido2/credential-id.ts new file mode 100644 index 00000000000..e51a37dda73 --- /dev/null +++ b/libs/common/src/services/fido2/credential-id.ts @@ -0,0 +1,42 @@ +import { Fido2Utils } from "../../abstractions/fido2/fido2-utils"; + +export class InvalidCredentialIdEncodingError extends Error { + constructor(readonly input: unknown) { + super("Could not create instance of credentialId: Input has unknown encoding"); + } +} + +export class CredentialId { + readonly raw: Uint8Array; + readonly encoded: string; + + constructor(rawOrEncoded: BufferSource | Uint8Array | string) { + if (rawOrEncoded instanceof Uint8Array) { + this.raw = rawOrEncoded; + this.encoded = CredentialId.encode(this.raw); + } else if (typeof rawOrEncoded === "string") { + this.encoded = rawOrEncoded; + this.raw = CredentialId.decode(this.encoded); + } else if (rawOrEncoded instanceof window.ArrayBuffer) { + this.raw = new Uint8Array(rawOrEncoded); + this.encoded = CredentialId.encode(this.raw); + } else if (rawOrEncoded.buffer) { + this.raw = new Uint8Array(rawOrEncoded.buffer); + this.encoded = CredentialId.encode(this.raw); + } else { + throw new InvalidCredentialIdEncodingError(rawOrEncoded); + } + } + + equals(other: CredentialId) { + return this.encoded === other.encoded; + } + + private static encode(raw: Uint8Array): string { + return Fido2Utils.bufferToString(raw); + } + + private static decode(encoded: string): Uint8Array { + return Fido2Utils.stringToBuffer(encoded); + } +} diff --git a/libs/common/src/services/fido2/ecdsa-utils.ts b/libs/common/src/services/fido2/ecdsa-utils.ts new file mode 100644 index 00000000000..1a4bf64de66 --- /dev/null +++ b/libs/common/src/services/fido2/ecdsa-utils.ts @@ -0,0 +1,124 @@ +/* + Copyright 2015 D2L Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +// Changes: +// - Cherry-pick the methods that we have a need for. +// - Add typings. +// - Original code is made for running in node, this version is adapted to work in the browser. + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/param-bytes-for-alg.js + +function getParamSize(keySize: number) { + const result = ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1); + return result; +} + +const paramBytesForAlg = { + ES256: getParamSize(256), + ES384: getParamSize(384), + ES512: getParamSize(521), +}; + +type Alg = keyof typeof paramBytesForAlg; + +function getParamBytesForAlg(alg: Alg) { + const paramBytes = paramBytesForAlg[alg]; + if (paramBytes) { + return paramBytes; + } + + throw new Error('Unknown algorithm "' + alg + '"'); +} + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/ecdsa-sig-formatter.js + +const MAX_OCTET = 0x80, + CLASS_UNIVERSAL = 0, + PRIMITIVE_BIT = 0x20, + TAG_SEQ = 0x10, + TAG_INT = 0x02, + ENCODED_TAG_SEQ = TAG_SEQ | PRIMITIVE_BIT | (CLASS_UNIVERSAL << 6), + ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6); + +function countPadding(buf: Uint8Array, start: number, stop: number) { + let padding = 0; + while (start + padding < stop && buf[start + padding] === 0) { + ++padding; + } + + const needsSign = buf[start + padding] >= MAX_OCTET; + if (needsSign) { + --padding; + } + + return padding; +} + +export function joseToDer(signature: Uint8Array, alg: Alg) { + const paramBytes = getParamBytesForAlg(alg); + + const signatureBytes = signature.length; + if (signatureBytes !== paramBytes * 2) { + throw new TypeError( + '"' + + alg + + '" signatures must be "' + + paramBytes * 2 + + '" bytes, saw "' + + signatureBytes + + '"' + ); + } + + const rPadding = countPadding(signature, 0, paramBytes); + const sPadding = countPadding(signature, paramBytes, signature.length); + const rLength = paramBytes - rPadding; + const sLength = paramBytes - sPadding; + + const rsBytes = 1 + 1 + rLength + 1 + 1 + sLength; + + const shortLength = rsBytes < MAX_OCTET; + + const dst = new Uint8Array((shortLength ? 2 : 3) + rsBytes); + + let offset = 0; + dst[offset++] = ENCODED_TAG_SEQ; + if (shortLength) { + dst[offset++] = rsBytes; + } else { + dst[offset++] = MAX_OCTET | 1; + dst[offset++] = rsBytes & 0xff; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = rLength; + if (rPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(0, paramBytes), offset); + offset += paramBytes; + } else { + dst.set(signature.subarray(rPadding, paramBytes), offset); + offset += paramBytes; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = sLength; + if (sPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(paramBytes), offset); + } else { + dst.set(signature.subarray(paramBytes + sPadding), offset); + } + + return dst; +} diff --git a/libs/common/src/services/fido2/fido2.service.ts b/libs/common/src/services/fido2/fido2.service.ts index 02c04361828..91d3bb76eeb 100644 --- a/libs/common/src/services/fido2/fido2.service.ts +++ b/libs/common/src/services/fido2/fido2.service.ts @@ -1,20 +1,238 @@ +import { CBOR } from "cbor-redux"; + import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +import { Fido2Utils } from "../../abstractions/fido2/fido2-utils"; import { CredentialRegistrationParams, Fido2Service as Fido2ServiceAbstraction, } from "../../abstractions/fido2/fido2.service.abstraction"; +import { Utils } from "../../misc/utils"; + +import { CredentialId } from "./credential-id"; +import { joseToDer } from "./ecdsa-utils"; + +const STANDARD_ATTESTATION_FORMAT = "packed"; + +interface BitCredential { + credentialId: CredentialId; + keyPair: CryptoKeyPair; + rpId: string; + origin: string; + userHandle: Uint8Array; +} export class Fido2Service implements Fido2ServiceAbstraction { + private credentials = new Map(); + constructor(private fido2UserInterfaceService: Fido2UserInterfaceService) {} async createCredential(params: CredentialRegistrationParams): Promise { await this.fido2UserInterfaceService.confirmNewCredential(); // eslint-disable-next-line no-console console.log("Fido2Service.registerCredential", params); - return "createCredential response"; + + const attestationFormat = STANDARD_ATTESTATION_FORMAT; + const encoder = new TextEncoder(); + const credentialId = new CredentialId(Utils.newGuid()); + + const clientData = encoder.encode( + JSON.stringify({ + type: "webauthn.create", + challenge: params.challenge, + origin, + }) + ); + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"] + ); + + const authData = await generateAuthData({ + rpId: params.rp.id, + credentialId, + userPresence: true, + userVerification: false, + keyPair, + attestationFormat: STANDARD_ATTESTATION_FORMAT, + }); + + const asn1Der_signature = await generateSignature({ + authData, + clientData, + keyPair, + }); + + const attestationObject = new Uint8Array( + CBOR.encode({ + fmt: attestationFormat, + attStmt: { + alg: -7, + sig: asn1Der_signature, + }, + authData, + }) + ); + + this.credentials.set(credentialId.encoded, { + credentialId, + keyPair, + origin, + rpId: params.rp.id, + userHandle: Fido2Utils.stringToBuffer(params.user.id), + }); + + return { + id: credentialId.encoded, + rawId: credentialId.raw, + type: "public-key", + response: { + clientDataJSON: clientData, + attestationObject: attestationObject, + } as AuthenticatorAttestationResponse, + getClientExtensionResults: () => ({}), + }; } assertCredential(): unknown { return undefined; } } + +interface AuthDataParams { + rpId: string; + credentialId: CredentialId; + userPresence: boolean; + userVerification: boolean; + keyPair?: CryptoKeyPair; + attestationFormat?: "packed" | "fido-u2f"; +} + +async function generateAuthData(params: AuthDataParams) { + const encoder = new TextEncoder(); + + const authData: Array = []; + + const rpIdHash = new Uint8Array( + await crypto.subtle.digest({ name: "SHA-256" }, encoder.encode(params.rpId)) + ); + authData.push(...rpIdHash); + + const flags = authDataFlags({ + extensionData: false, + attestationData: params.keyPair !== undefined, + userVerification: params.userVerification, + userPresence: params.userPresence, + }); + authData.push(flags); + + // add 4 bytes of counter - we use time in epoch seconds as monotonic counter + const now = new Date().getTime() / 1000; + authData.push( + ((now & 0xff000000) >> 24) & 0xff, + ((now & 0x00ff0000) >> 16) & 0xff, + ((now & 0x0000ff00) >> 8) & 0xff, + now & 0x000000ff + ); + + // attestedCredentialData + const attestedCredentialData: Array = []; + + // Use 0 because we're self-signing at the moment + const aaguid = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + attestedCredentialData.push(...aaguid); + + // credentialIdLength (2 bytes) and credential Id + const rawId = params.credentialId.raw; + const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; + attestedCredentialData.push(...credentialIdLength); + attestedCredentialData.push(...rawId); + + if (params.keyPair) { + const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey); + // COSE format of the EC256 key + const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x); + const keyY = Utils.fromUrlB64ToArray(publicKeyJwk.y); + + // const credPublicKeyCOSE = { + // "1": 2, // kty + // "3": -7, // alg + // "-1": 1, // crv + // "-2": keyX, + // "-3": keyY, + // }; + // const coseBytes = new Uint8Array(cbor.encode(credPublicKeyCOSE)); + + // Can't get `cbor-redux` to encode in CTAP2 canonical CBOR. So we do it manually: + const coseBytes = new Uint8Array(77); + coseBytes.set([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], 0); + coseBytes.set(keyX, 10); + coseBytes.set([0x22, 0x58, 0x20], 10 + 32); + coseBytes.set(keyY, 10 + 32 + 3); + + // credential public key - convert to array from CBOR encoded COSE key + const credPublicKeyBytes = coseBytes.subarray(0, -1); + attestedCredentialData.push(...credPublicKeyBytes); + + authData.push(...attestedCredentialData); + } + + return new Uint8Array(authData); +} + +interface SignatureParams { + authData: Uint8Array; + clientData: Uint8Array; + keyPair: CryptoKeyPair; +} + +async function generateSignature(params: SignatureParams) { + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, params.clientData); + const sigBase = new Uint8Array([...params.authData, ...new Uint8Array(clientDataHash)]); + const p1336_signature = new Uint8Array( + await window.crypto.subtle.sign( + { + name: "ECDSA", + hash: { name: "SHA-256" }, + }, + params.keyPair.privateKey, + sigBase + ) + ); + + const asn1Der_signature = joseToDer(p1336_signature, "ES256"); + + return asn1Der_signature; +} + +interface Flags { + extensionData: boolean; + attestationData: boolean; + userVerification: boolean; + userPresence: boolean; +} + +function authDataFlags(options: Flags): number { + let flags = 0; + + if (options.extensionData) { + flags |= 0b1000000; + } + + if (options.attestationData) { + flags |= 0b01000000; + } + + if (options.userVerification) { + flags |= 0b00000100; + } + + if (options.userPresence) { + flags |= 0b00000001; + } + + return flags; +}