UNPKG

bpt-pack-two

Version:

Study Passwordless authentication on aws project

633 lines (632 loc) 25.1 kB
import { createHash, randomBytes } from "crypto"; import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, UpdateCommand, PutCommand, } from "@aws-sdk/lib-dynamodb"; import { decodeFirstSync } from "cbor"; import { determineUserHandle, logger, handleConditionalCheckFailedException, UserFacingError, withCommonHeaders, } from "./common.js"; const ddbDocClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { marshallOptions: { removeUndefinedValues: true, }, }); const lambdaClient = new LambdaClient({}); const allowedRelyingPartyIds = (process.env.ALLOWED_RELYING_PARTY_IDS ?? "").split(","); const allowedRelyingPartyIdHashes = allowedRelyingPartyIds.map((relyingPartyId) => createHash("sha256").update(relyingPartyId).digest("base64url")); const relyingPartyName = process.env.RELYING_PARTY_NAME; const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") .map((href) => new URL(href)) .map((url) => url.origin) ?? []; if (!allowedOrigins.length) throw new Error("Environment variable ALLOWED_ORIGINS is not set"); const authenticatorRegistrationTimeout = Number(process.env.AUTHENTICATOR_REGISTRATION_TIMEOUT ?? "300000"); const notificationsEnabled = !!process.env.FIDO2_NOTIFICATION_LAMBDA_ARN; const allowedKty = { 2: "EC", 3: "RSA" }; const allowedAlg = { "-7": "ES256", "-257": "RS256" }; const headers = { "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Content-Type": "application/json", "Cache-Control": "no-store", }; const _handler = async (event) => { logger.debug(JSON.stringify(event, null, 2)); logger.info("FIDO2 credentials API invocation:", event.path); if (event.requestContext.authorizer?.claims.token_use !== "id") { logger.info("ERROR: This API must be accessed using the ID Token"); return { statusCode: 400, body: JSON.stringify({ message: "Use your ID token to access this API" }), headers, }; } try { const { sub, email, phone_number: phoneNumber, name, "cognito:username": cognitoUsername, } = event.requestContext.authorizer.claims; const userHandle = determineUserHandle({ sub, cognitoUsername }); const userName = email ?? phoneNumber ?? name ?? cognitoUsername; const displayName = name ?? email; if (event.path === "/register-authenticator/start") { logger.info("Starting a new authenticator registration ..."); if (!userName) { throw new Error("Unable to determine name for user"); } if (!displayName) { throw new Error("Unable to determine display name for user"); } const rpId = event.queryStringParameters?.rpId; if (!rpId) { throw new UserFacingError("Missing RP ID"); } if (!allowedRelyingPartyIds.includes(rpId)) { throw new UserFacingError("Unrecognized RP ID"); } const options = await requestCredentialsChallenge({ userId: userHandle, name: userName, displayName, rpId, }); logger.debug("Options:", JSON.stringify(options)); return { statusCode: 200, body: JSON.stringify(options), headers, }; } else if (event.path === "/register-authenticator/complete") { logger.info("Completing the new authenticator registration ..."); const storedCredential = await handleCredentialsResponse(userHandle, parseBody(event)); if (notificationsEnabled) { await enqueueFido2Notification({ cognitoUsername, eventType: "FIDO2_CREDENTIAL_CREATED", friendlyName: storedCredential.friendlyName, }); } return { statusCode: 200, body: JSON.stringify(storedCredential), headers, }; } else if (event.path === "/authenticators/list") { logger.info("Listing authenticators ..."); const rpId = event.queryStringParameters?.rpId; if (!rpId) { throw new UserFacingError("Missing RP ID"); } if (!allowedRelyingPartyIds.includes(rpId)) { throw new UserFacingError("Unrecognized RP ID"); } const authenticators = await getExistingCredentialsForUser({ userId: userHandle, rpId, }); return { statusCode: 200, body: JSON.stringify({ authenticators, }), headers, }; } else if (event.path === "/authenticators/delete") { logger.info("Deleting authenticator ..."); const parsed = parseBody(event); assertBodyIsObject(parsed); logger.debug("CredentialId:", parsed.credentialId); const deletedCredential = await deleteCredential({ userId: userHandle, credentialId: parsed.credentialId, }); if (deletedCredential && notificationsEnabled) { await enqueueFido2Notification({ cognitoUsername, eventType: "FIDO2_CREDENTIAL_DELETED", friendlyName: deletedCredential.friendlyName, }); } return { statusCode: 204, body: "", headers }; } else if (event.path === "/authenticators/update") { const parsed = parseBody(event); assertBodyIsObject(parsed); await updateCredential({ userId: userHandle, credentialId: parsed.credentialId, friendlyName: parsed.friendlyName, }); return { statusCode: 200, body: "", headers }; } return { statusCode: 404, body: JSON.stringify({ message: "Not found" }), headers, }; } catch (err) { logger.error(err); if (err instanceof UserFacingError) return { statusCode: 400, body: JSON.stringify({ message: err.message }), headers, }; return { statusCode: 500, body: JSON.stringify({ message: "Internal Server Error" }), headers, }; } }; export const handler = withCommonHeaders(_handler); async function getExistingCredentialsForUser({ userId, rpId, }) { const credentials = []; let exclusiveStartKey = undefined; do { { const { Items, LastEvaluatedKey } = await ddbDocClient.send(new QueryCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :sk)", ProjectionExpression: "createdAt,credentialId,friendlyName,lastSignIn,signCount,transports,aaguid,rpId", ExpressionAttributeValues: { ":pk": `USER#${userId}`, ":sk": "CREDENTIAL#", ":rpId": rpId, }, ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#rpId": "rpId", }, ExclusiveStartKey: exclusiveStartKey, FilterExpression: "#rpId = :rpId", })); Items?.forEach((item) => { credentials.push({ ...item, credentialId: Buffer.from(item.credentialId), aaguid: Buffer.from(item.aaguid), }); }); exclusiveStartKey = LastEvaluatedKey; } } while (exclusiveStartKey); return credentials.map((credential) => ({ ...credential, credentialId: credential.credentialId.toString("base64url"), aaguid: credential.aaguid.toString("base64url"), rpId, createdAt: new Date(credential.createdAt), lastSignIn: credential.lastSignIn && new Date(credential.lastSignIn), })); } async function deleteCredential({ userId, credentialId, }) { if (typeof credentialId !== "string") { throw new UserFacingError(`credentialId should be a string, received ${typeof credentialId}`); } const { Attributes: credential } = await ddbDocClient.send(new DeleteCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, Key: { pk: `USER#${userId}`, sk: `CREDENTIAL#${credentialId}`, }, ReturnValues: "ALL_OLD", })); return credential; } async function updateCredential({ userId, credentialId, friendlyName, }) { if (typeof credentialId !== "string") { throw new UserFacingError(`credentialId should be a string, received ${typeof credentialId}`); } if (typeof friendlyName !== "string") { throw new UserFacingError(`friendlyName should be a string, received ${typeof credentialId}`); } await ddbDocClient .send(new UpdateCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, Key: { pk: `USER#${userId}`, sk: `CREDENTIAL#${credentialId}`, }, UpdateExpression: "set #friendlyName = :friendlyName", ConditionExpression: "attribute_exists(pk) AND attribute_exists(sk)", ExpressionAttributeNames: { "#friendlyName": "friendlyName", }, ExpressionAttributeValues: { ":friendlyName": friendlyName, }, })) .catch(handleConditionalCheckFailedException("Unknown credential")); } async function storeAuthenticatorChallenge(options) { await ddbDocClient .send(new PutCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, Item: { pk: `USER#${options.user.id}`, sk: `CHALLENGE#${options.challenge}`, options: options, exp: Math.floor((Date.now() + options.timeout) / 1000), }, ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)", })) .catch(handleConditionalCheckFailedException("Duplicate challenge")); } async function requestCredentialsChallenge({ userId, name, displayName, rpId, }) { logger.info("Requesting credential challenge ..."); const existingCredentials = await getExistingCredentialsForUser({ userId, rpId, }); const options = { challenge: randomBytes(64).toString("base64url"), attestation: process.env.ATTESTATION ?? "none", rp: { name: relyingPartyName, id: rpId, }, user: { id: userId, name, displayName, }, pubKeyCredParams: Object.keys(allowedAlg).map((alg) => ({ type: "public-key", alg: Number(alg), })), authenticatorSelection: { userVerification: process.env .USER_VERIFICATION, authenticatorAttachment: process.env.AUTHENTICATOR_ATTACHMENT || undefined, residentKey: process.env.REQUIRE_RESIDENT_KEY || undefined, requireResidentKey: (process.env.REQUIRE_RESIDENT_KEY && process.env.REQUIRE_RESIDENT_KEY === "required") || undefined, }, timeout: authenticatorRegistrationTimeout, excludeCredentials: existingCredentials.map((credential) => ({ id: credential.credentialId, type: "public-key", })), }; await storeAuthenticatorChallenge(options); return options; } function assertBodyIsObject(body) { if (body === null || typeof body !== "object") { throw new UserFacingError(`Expected body to be an object, but got ${typeof body}`); } } function assertBodyIsCredentialsResponse(body) { assertBodyIsObject(body); ["attestationObjectB64", "clientDataJSON_B64", "friendlyName"].forEach((key) => { // eslint-disable-next-line security/detect-object-injection if (!body[key] || typeof body[key] !== "string") { throw new UserFacingError( // eslint-disable-next-line security/detect-object-injection `Expected ${key} to be a string, but got ${typeof body[key]}`); } }); if (body.transports) { if (!Array.isArray(body.transports)) { throw new UserFacingError(`Expected transports to be a string array, but got ${typeof body.transports}`); } body.transports.forEach((transport) => { if (typeof transport !== "string") { throw new UserFacingError(`Expected transport to be a string, but got ${typeof transport}`); } if (!["usb", "nfc", "ble", "internal", "hybrid"].includes(transport)) { throw new UserFacingError(`Expected transport to be one of "usb", "nfc", "ble", "internal", "hybrid", but got: ${transport}`); } }); } } function assertIsClientData(cd) { if (!cd || typeof cd !== "object" || !("type" in cd) || typeof cd.type !== "string" || !("challenge" in cd) || typeof cd.challenge !== "string" || !("origin" in cd) || typeof cd.origin !== "string") { throw new UserFacingError("Invalid client data"); } } async function handleCredentialsResponse(userId, body) { assertBodyIsCredentialsResponse(body); const clientData = JSON.parse(Buffer.from(body.clientDataJSON_B64, "base64url").toString()); logger.debug("clientData:", JSON.stringify(clientData)); assertIsClientData(clientData); if (typeof clientData !== "object") { throw new UserFacingError(`clientData is not an object: ${JSON.stringify(clientData)}`); } if (clientData.type !== "webauthn.create") { throw new UserFacingError(`Invalid clientData type: ${JSON.stringify(clientData)}`); } const { Attributes: storedChallenge } = await ddbDocClient .send(new DeleteCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, Key: { pk: `USER#${userId}`, sk: `CHALLENGE#${clientData.challenge}`, }, ReturnValues: "ALL_OLD", ConditionExpression: "attribute_exists(pk) AND attribute_exists(sk)", })) .catch(handleConditionalCheckFailedException("Challenge not found")); if (!storedChallenge || storedChallenge.exp * 1000 < Date.now()) { throw new UserFacingError("Challenge not found"); } logger.debug("Challenge found:", JSON.stringify(storedChallenge)); if (!allowedOrigins.includes(new URL(clientData.origin).origin)) { throw new UserFacingError(`Invalid clientData origin: ${clientData.origin}`); } const attestation = cborDecode(Buffer.from(body.attestationObjectB64, "base64url"), "attestation object"); logger.debug("CBOR attestation object:", attestation); assertIsAttestation(attestation); logger.debug("authDataB64:", attestation.authData.toString("base64url")); const authData = parseAttestationObjectAuthData(attestation.authData); logger.debug("Parsed authData:", JSON.stringify(authData)); const rpIdIndex = allowedRelyingPartyIdHashes.indexOf(authData.rpIdHash.toString("base64url")); if (rpIdIndex === -1) { throw new UserFacingError("Unrecognized rpIdHash"); } const rpId = allowedRelyingPartyIds.at(rpIdIndex); if (!authData.flagUserPresent) { throw new UserFacingError("User is not present"); } // Verify User Verified const userVerificationRequirement = process.env .USER_VERIFICATION; if ((!userVerificationRequirement || userVerificationRequirement === "required") && !authData.flagUserVerified) { throw new UserFacingError("User is not verified"); } if (!storedChallenge.options.pubKeyCredParams.find((param) => allowedAlg[param.alg] === authData.credentialPublicKey.alg)) { throw new UserFacingError("Unsupported public key alg"); } if (authData.credentialId.length > 1023) { throw new UserFacingError(`Credential ID longer than 1023 bytes: ${authData.credentialId.length} bytes`); } await assertCredentialIsNew(authData.credentialId); const createdAt = new Date(); await storeUserCredential({ userId, credentialId: authData.credentialId, jwk: authData.credentialPublicKey, signCount: authData.signCount, friendlyName: body.friendlyName, flagUserVerified: authData.flagUserVerified, flagBackupEligibility: authData.flagBackupEligibility, flagBackupState: authData.flagBackupState, transports: body.transports, aaguid: authData.aaguid, rpId, createdAt, }); return { credentialId: authData.credentialId.toString("base64url"), signCount: authData.signCount, friendlyName: body.friendlyName, flagUserVerified: authData.flagUserVerified, flagBackupEligibility: authData.flagBackupEligibility, flagBackupState: authData.flagBackupState, transports: body.transports, aaguid: authData.aaguid.toString("base64url"), rpId, createdAt, }; } function assertIsAttestation(a) { if (!a || typeof a !== "object" || !("authData" in a) || !Buffer.isBuffer(a.authData)) { throw new UserFacingError("Invalid attestation"); } } async function assertCredentialIsNew(credentialId) { const { Items: items } = await ddbDocClient.send(new QueryCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, IndexName: "credentialId", KeyConditionExpression: "#credentialId = :credentialId", ExpressionAttributeNames: { "#credentialId": "credentialId", }, ExpressionAttributeValues: { ":credentialId": credentialId, }, ProjectionExpression: "credentialId", Limit: 1, })); if (items && items.length) { throw new UserFacingError(`Credential already registered: ${credentialId.toString("base64url")}`); } } async function storeUserCredential({ userId, credentialId, jwk, signCount, friendlyName, flagUserVerified, flagBackupEligibility, flagBackupState, aaguid, transports, rpId, createdAt, }) { await ddbDocClient .send(new PutCommand({ TableName: process.env.DYNAMODB_AUTHENTICATORS_TABLE, Item: { pk: `USER#${userId}`, sk: `CREDENTIAL#${credentialId.toString("base64url")}`, userId, credentialId, jwk, signCount, friendlyName, flagUserVerified, flagBackupEligibility, flagBackupState, aaguid, transports, rpId, createdAt: createdAt.toISOString(), }, ConditionExpression: "attribute_not_exists(pk) AND attribute_not_exists(sk)", })) .catch(handleConditionalCheckFailedException("Duplicate credential")); } function parseAttestationObjectAuthData(authData) { const rpIdHash = authData.subarray(0, 32); const flags = authData.subarray(32, 33)[0]; const flagUserPresent = (flags & 0b1); const flagReservedFutureUse1 = ((flags >>> 1) & 0b1); const flagUserVerified = ((flags >>> 2) & 0b1); const flagBackupEligibility = ((flags >>> 3) & 0b1); const flagBackupState = ((flags >>> 4) & 0b1); const flagReservedFutureUse2 = ((flags >>> 5) & 0b1); const flagAttestedCredentialData = ((flags >>> 6) & 0b1); const flagExtensionDataIncluded = ((flags >>> 7) & 0b1); const signCount = authData.subarray(33, 37).readUInt32BE(0); const aaguid = authData.subarray(37, 53); const credentialIdLength = authData.subarray(53, 55).readUInt16BE(0); const credentialId = authData.subarray(55, 55 + credentialIdLength); const credentialPublicKey = authData.subarray(55 + credentialIdLength); return { rpIdHash, flagUserPresent, flagReservedFutureUse1, flagUserVerified, flagBackupEligibility, flagBackupState, flagReservedFutureUse2, flagAttestedCredentialData, flagExtensionDataIncluded, signCount, aaguid, credentialId, credentialPublicKey: decodeCredentialPublicKey(credentialPublicKey), }; } function decodeCredentialPublicKey(credentialPublicKey) { const decoded = cborDecode(credentialPublicKey, "public key"); logger.debug("CBOR decoded credential public key:", decoded); try { if (!(decoded instanceof Map)) { throw new UserFacingError("Invalid public key"); } const typedMap = new TypedMap(decoded); const kty = typedMap.getNumber(1); // eslint-disable-next-line security/detect-object-injection const ktyName = allowedKty[kty]; const kid = typedMap.getOptionalString(2); const alg = typedMap.getNumber(3); // eslint-disable-next-line security/detect-object-injection const algName = allowedAlg[alg]; if (kty === 2) { // EC2 const crv = typedMap.getNumber(-1); // eslint-disable-next-line security/detect-object-injection const crvName = { 1: "P-256" }[crv]; const x = typedMap.getBuffer(-2); const y = typedMap.getBuffer(-3); const jwk = { alg: algName, crv: crvName, kid, kty: ktyName, x: x.toString("base64url"), y: y.toString("base64url"), }; return jwk; } else if (kty === 3) { // RSA const n = typedMap.getBuffer(-1); const e = typedMap.getBuffer(-2); const jwk = { alg: algName, kid, kty: ktyName, n, e, }; return jwk; } else { throw new Error(`Unsupported public key kty: ${kty}`); } } catch (err) { logger.error(err); throw new UserFacingError("Invalid public key"); } } class TypedMap extends Map { constructor(m) { super(m); } ensureValue(key) { const value = super.get(key); if (value === undefined) { throw new Error(`Missing value for key ${key}`); } return value; } getOptionalString(key) { const value = this.get(key); if (value !== undefined && typeof value !== "string") { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid value for key ${key}: ${value}`); } return value; } getNumber(key) { const value = this.ensureValue(key); if (typeof value !== "number") { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid value for key ${key}: ${value}`); } return value; } getBuffer(key) { const value = this.ensureValue(key); if (typeof value !== "object" || !Buffer.isBuffer(value)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid value for key ${key}: ${value}`); } return value; } } function parseBody(event) { try { return event.body ? JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64url").toString() : event.body) : {}; } catch (err) { logger.error(err); throw new UserFacingError("Invalid body"); } } function cborDecode(b, name) { try { return decodeFirstSync(b); } catch (err) { logger.error(err); throw new UserFacingError(`Invalid ${name}`); } } async function enqueueFido2Notification(payload) { try { const command = new InvokeCommand({ FunctionName: process.env.FIDO2_NOTIFICATION_LAMBDA_ARN, InvocationType: "Event", Payload: JSON.stringify(payload), }); await lambdaClient.send(command); logger.info("Successfully enqueued notification to user"); } catch (error) { // Since the notification is best effort, we'll log but otherwise swallow the error logger.error("Failed to enqueue notification to user:", error); } }