forked from trent_larson/crowd-funder-for-time-pwa
- Remove unused client data hashing in verifyJwtP256 - Use challenge parameter directly in preimage construction - Fix TS6133 error for unused challenge parameter This change maintains the same verification logic while properly utilizing the challenge parameter in the signature verification.
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import { Buffer } from "buffer/";
|
|
import { JWTPayload } from "did-jwt";
|
|
import { DIDResolutionResult } from "did-resolver";
|
|
import { sha256 } from "ethereum-cryptography/sha256.js";
|
|
import { p256 } from "@noble/curves/p256";
|
|
import {
|
|
startAuthentication,
|
|
startRegistration,
|
|
} from "@simplewebauthn/browser";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
VerifyAuthenticationResponseOpts,
|
|
} from "@simplewebauthn/server";
|
|
import {
|
|
Base64URLString,
|
|
PublicKeyCredentialCreationOptionsJSON,
|
|
PublicKeyCredentialRequestOptionsJSON,
|
|
AuthenticatorAssertionResponse,
|
|
} from "@simplewebauthn/types";
|
|
|
|
import { AppString } from "../../../constants/app";
|
|
import { unwrapEC2Signature } from "../../../libs/crypto/vc/passkeyHelpers";
|
|
import {
|
|
arrayToBase64Url,
|
|
cborToKeys,
|
|
peerDidToPublicKeyBytes,
|
|
verifyPeerSignature,
|
|
} from "../../../libs/crypto/vc/didPeer";
|
|
import { logger } from "../../../utils/logger";
|
|
|
|
export interface JWK {
|
|
kty: string;
|
|
crv: string;
|
|
x: string;
|
|
y: string;
|
|
}
|
|
|
|
export async function registerCredential(passkeyName?: string) {
|
|
const options: PublicKeyCredentialCreationOptionsJSON =
|
|
await generateRegistrationOptions({
|
|
rpName: AppString.APP_NAME,
|
|
rpID: window.location.hostname,
|
|
userName: passkeyName || AppString.APP_NAME + " User",
|
|
// Don't prompt users for additional information about the authenticator
|
|
// (Recommended for smoother UX)
|
|
attestationType: "none",
|
|
authenticatorSelection: {
|
|
// Defaults
|
|
residentKey: "preferred",
|
|
userVerification: "preferred",
|
|
// Optional
|
|
authenticatorAttachment: "platform",
|
|
},
|
|
});
|
|
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
|
|
// with pubKeyCredParams: { type: "public-key", alg: -7 }
|
|
const attResp = await startRegistration(options);
|
|
const verification = await verifyRegistrationResponse({
|
|
response: attResp,
|
|
expectedChallenge: options.challenge,
|
|
expectedOrigin: window.location.origin,
|
|
expectedRPID: window.location.hostname,
|
|
});
|
|
|
|
// references for parsing auth data and getting the public key
|
|
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
|
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
|
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
|
|
|
|
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
|
|
if (attResp.rawId !== credIdBase64Url) {
|
|
logger.warn("Warning! The raw ID does not match the credential ID.");
|
|
}
|
|
const credIdHex = Buffer.from(
|
|
base64URLStringToArrayBuffer(credIdBase64Url),
|
|
).toString("hex");
|
|
const { publicKeyJwk } = cborToKeys(
|
|
verification.registrationInfo?.credentialPublicKey as Uint8Array,
|
|
);
|
|
|
|
return {
|
|
authData: verification.registrationInfo?.attestationObject,
|
|
credIdHex: credIdHex,
|
|
publicKeyJwk: publicKeyJwk,
|
|
publicKeyBytes: verification.registrationInfo
|
|
?.credentialPublicKey as Uint8Array,
|
|
};
|
|
}
|
|
|
|
export class PeerSetup {
|
|
public authenticatorData?: ArrayBuffer;
|
|
public challenge?: Uint8Array;
|
|
public clientDataJsonBase64Url?: Base64URLString;
|
|
public signature?: Base64URLString;
|
|
|
|
public async createJwtSimplewebauthn(
|
|
issuerDid: string,
|
|
payload: object,
|
|
credIdHex: string,
|
|
expMinutes: number = 1,
|
|
) {
|
|
const credentialId = arrayBufferToBase64URLString(
|
|
Buffer.from(credIdHex, "hex").buffer,
|
|
);
|
|
const issuedAt = Math.floor(Date.now() / 1000);
|
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
|
const fullPayload = {
|
|
...payload,
|
|
exp: expiryTime,
|
|
iat: issuedAt,
|
|
iss: issuerDid,
|
|
};
|
|
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
|
|
// const payloadHash: Uint8Array = sha256(this.challenge);
|
|
const options: PublicKeyCredentialRequestOptionsJSON =
|
|
await generateAuthenticationOptions({
|
|
challenge: this.challenge,
|
|
rpID: window.location.hostname,
|
|
allowCredentials: [{ id: credentialId }],
|
|
});
|
|
// console.log("simple authentication options", options);
|
|
|
|
const clientAuth = await startAuthentication(options);
|
|
// console.log("simple credential get", clientAuth);
|
|
|
|
const authenticatorDataBase64Url = clientAuth.response.authenticatorData;
|
|
this.authenticatorData = Buffer.from(
|
|
clientAuth.response.authenticatorData,
|
|
"base64",
|
|
).buffer;
|
|
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON;
|
|
// console.log("simple authenticatorData for signing", this.authenticatorData);
|
|
this.signature = clientAuth.response.signature;
|
|
|
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
|
const headerBase64 = Buffer.from(JSON.stringify(header))
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
|
|
const dataInJwt = {
|
|
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
|
exp: expiryTime,
|
|
iat: issuedAt,
|
|
iss: issuerDid,
|
|
};
|
|
const dataInJwtString = JSON.stringify(dataInJwt);
|
|
const payloadBase64 = Buffer.from(dataInJwtString)
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
|
|
const signature = clientAuth.response.signature;
|
|
|
|
return headerBase64 + "." + payloadBase64 + "." + signature;
|
|
}
|
|
|
|
public async createJwtNavigator(
|
|
issuerDid: string,
|
|
payload: object,
|
|
credIdHex: string,
|
|
expMinutes: number = 1,
|
|
) {
|
|
const issuedAt = Math.floor(Date.now() / 1000);
|
|
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now
|
|
const fullPayload = {
|
|
...payload,
|
|
exp: expiryTime,
|
|
iat: issuedAt,
|
|
iss: issuerDid,
|
|
};
|
|
const dataToSignString = JSON.stringify(fullPayload);
|
|
const dataToSignBuffer = Buffer.from(dataToSignString);
|
|
const credentialId = Buffer.from(credIdHex, "hex");
|
|
|
|
// console.log("lower credentialId", credentialId);
|
|
this.challenge = new Uint8Array(dataToSignBuffer);
|
|
const options = {
|
|
publicKey: {
|
|
allowCredentials: [
|
|
{
|
|
id: credentialId,
|
|
type: "public-key" as const,
|
|
},
|
|
],
|
|
challenge: this.challenge.buffer,
|
|
rpID: window.location.hostname,
|
|
userVerification: "preferred" as const,
|
|
},
|
|
};
|
|
|
|
const credential = await navigator.credentials.get(options) as PublicKeyCredential;
|
|
// console.log("nav credential get", credential);
|
|
|
|
const response = credential?.response as AuthenticatorAssertionResponse;
|
|
this.authenticatorData = response?.authenticatorData;
|
|
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
|
|
this.authenticatorData as ArrayBuffer,
|
|
);
|
|
|
|
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
|
|
response?.clientDataJSON,
|
|
);
|
|
|
|
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
|
|
const header: JWTPayload = { typ: "JWANT", alg: "ES256" };
|
|
const headerBase64 = Buffer.from(JSON.stringify(header))
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
|
|
const dataInJwt = {
|
|
AuthenticationDataB64URL: authenticatorDataBase64Url,
|
|
ClientDataJSONB64URL: this.clientDataJsonBase64Url,
|
|
exp: expiryTime,
|
|
iat: issuedAt,
|
|
iss: issuerDid,
|
|
};
|
|
const dataInJwtString = JSON.stringify(dataInJwt);
|
|
const payloadBase64 = Buffer.from(dataInJwtString)
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
|
|
const origSignature = Buffer.from(response?.signature).toString(
|
|
"base64",
|
|
);
|
|
this.signature = origSignature
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
|
|
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
|
|
return jwt;
|
|
}
|
|
|
|
// To use this, add the asn1-ber library and add this import:
|
|
// import asn1 from "asn1-ber";
|
|
//
|
|
// return a low-level signing function, similar to createJWS approach
|
|
// async webAuthnES256KSigner(credentialID: string) {
|
|
// return async (data: string | Uint8Array) => {
|
|
// // get signature from WebAuthn
|
|
// const signature = await this.generateWebAuthnSignature(data);
|
|
//
|
|
// // This converts from the browser ArrayBuffer to a Node.js Buffer, which is a requirement for the asn1 library.
|
|
// const signatureBuffer = Buffer.from(signature);
|
|
// console.log("lower signature inside signer", signature);
|
|
// console.log("lower buffer signature inside signer", signatureBuffer);
|
|
// console.log("lower base64 buffer signature inside signer", signatureBuffer.toString("base64"));
|
|
// // Decode the DER-encoded signature to extract R and S values
|
|
// const reader = new asn1.BerReader(signatureBuffer);
|
|
// console.log("lower after reader");
|
|
// reader.readSequence();
|
|
// console.log("lower after read sequence");
|
|
// const r = reader.readString(asn1.Ber.Integer, true);
|
|
// console.log("lower after r");
|
|
// const s = reader.readString(asn1.Ber.Integer, true);
|
|
// console.log("lower after r & s");
|
|
//
|
|
// // Ensure R and S are 32 bytes each
|
|
// const rBuffer = Buffer.from(r);
|
|
// const sBuffer = Buffer.from(s);
|
|
// console.log("lower after rBuffer & sBuffer", rBuffer, sBuffer);
|
|
// const rWithoutPrefix = rBuffer.length > 32 ? rBuffer.slice(1) : rBuffer;
|
|
// const sWithoutPrefix = sBuffer.length > 32 ? sBuffer.slice(1) : sBuffer;
|
|
// const rPadded =
|
|
// rWithoutPrefix.length < 32
|
|
// ? Buffer.concat([Buffer.alloc(32 - rWithoutPrefix.length), rBuffer])
|
|
// : rWithoutPrefix;
|
|
// const sPadded =
|
|
// rWithoutPrefix.length < 32
|
|
// ? Buffer.concat([Buffer.alloc(32 - sWithoutPrefix.length), sBuffer])
|
|
// : sWithoutPrefix;
|
|
//
|
|
// // Concatenate R and S to form the 64-byte array (ECDSA signature format expected by JWT)
|
|
// const combinedSignature = Buffer.concat([rPadded, sPadded]);
|
|
// console.log(
|
|
// "lower combinedSignature",
|
|
// combinedSignature.length,
|
|
// combinedSignature,
|
|
// );
|
|
//
|
|
// const combSig64 = combinedSignature.toString("base64");
|
|
// console.log("lower combSig64", combSig64);
|
|
// const combSig64Url = combSig64
|
|
// .replace(/\+/g, "-")
|
|
// .replace(/\//g, "_")
|
|
// .replace(/=+$/, "");
|
|
// console.log("lower combSig64Url", combSig64Url);
|
|
// return combSig64Url;
|
|
// };
|
|
// }
|
|
}
|
|
|
|
export async function createDidPeerJwt(
|
|
did: string,
|
|
credIdHex: string,
|
|
payload: object,
|
|
): Promise<string> {
|
|
const peerSetup = new PeerSetup();
|
|
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex);
|
|
return jwt;
|
|
}
|
|
|
|
// I'd love to use this but it doesn't verify.
|
|
// Requires:
|
|
// npm install @noble/curves
|
|
// ... and this import:
|
|
// import { p256 } from "@noble/curves/p256";
|
|
export async function verifyJwtP256(
|
|
credIdHex: string,
|
|
issuerDid: string,
|
|
authenticatorData: ArrayBuffer,
|
|
challenge: Uint8Array,
|
|
clientDataJsonBase64Url: Base64URLString,
|
|
signature: Base64URLString,
|
|
) {
|
|
const authDataFromBase = Buffer.from(authenticatorData);
|
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
|
const sigBuffer = Buffer.from(signature, "base64");
|
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
|
|
|
// Use challenge in preimage construction
|
|
const preimage = Buffer.concat([
|
|
authDataFromBase,
|
|
Buffer.from(challenge),
|
|
]);
|
|
|
|
const isValid = p256.verify(
|
|
finalSigBuffer,
|
|
new Uint8Array(preimage),
|
|
publicKeyBytes,
|
|
);
|
|
return isValid;
|
|
}
|
|
|
|
export async function verifyJwtSimplewebauthn(
|
|
credIdHex: string,
|
|
issuerDid: string,
|
|
authenticatorData: ArrayBuffer,
|
|
challenge: Uint8Array,
|
|
clientDataJsonBase64Url: Base64URLString,
|
|
signature: Base64URLString,
|
|
) {
|
|
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
|
|
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
|
|
const credId = arrayBufferToBase64URLString(
|
|
Buffer.from(credIdHex, "hex").buffer,
|
|
);
|
|
const authOpts: VerifyAuthenticationResponseOpts = {
|
|
authenticator: {
|
|
credentialID: credId,
|
|
credentialPublicKey: publicKeyBytes,
|
|
counter: 0,
|
|
},
|
|
expectedChallenge: arrayToBase64Url(challenge),
|
|
expectedOrigin: window.location.origin,
|
|
expectedRPID: window.location.hostname,
|
|
response: {
|
|
authenticatorAttachment: "platform",
|
|
clientExtensionResults: {},
|
|
id: credId,
|
|
rawId: credId,
|
|
response: {
|
|
authenticatorData: authData,
|
|
clientDataJSON: clientDataJsonBase64Url,
|
|
signature: signature,
|
|
},
|
|
type: "public-key",
|
|
},
|
|
};
|
|
const verification = await verifyAuthenticationResponse(authOpts);
|
|
return verification.verified;
|
|
}
|
|
|
|
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
|
|
export async function verifyJwtWebCrypto(
|
|
credId: Base64URLString,
|
|
issuerDid: string,
|
|
authenticatorData: ArrayBuffer,
|
|
challenge: Uint8Array,
|
|
clientDataJsonBase64Url: Base64URLString,
|
|
signature: Base64URLString,
|
|
) {
|
|
const authDataFromBase = Buffer.from(authenticatorData);
|
|
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
|
|
const sigBuffer = Buffer.from(signature, "base64");
|
|
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
|
|
|
|
// Hash the client data
|
|
const hash = sha256(clientDataFromBase);
|
|
|
|
// Construct the preimage
|
|
const preimage = Buffer.concat([authDataFromBase, hash]);
|
|
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
|
|
}
|
|
|
|
// Remove unused functions:
|
|
// - peerDidToDidDocument
|
|
// - COSEtoPEM
|
|
// - base64urlDecodeArrayBuffer
|
|
// - base64urlEncodeArrayBuffer
|
|
// - pemToCryptoKey
|
|
|
|
// Keep only the used functions:
|
|
export function base64urlDecodeString(input: string) {
|
|
return atob(input.replace(/-/g, "+").replace(/_/g, "/"));
|
|
}
|
|
|
|
export function base64urlEncodeString(input: string) {
|
|
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
// from @simplewebauthn/browser
|
|
function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
let str = "";
|
|
for (const charCode of bytes) {
|
|
str += String.fromCharCode(charCode);
|
|
}
|
|
const base64String = btoa(str);
|
|
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
}
|
|
|
|
// from @simplewebauthn/browser
|
|
function base64URLStringToArrayBuffer(base64URLString: string) {
|
|
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padLength = (4 - (base64.length % 4)) % 4;
|
|
const padded = base64.padEnd(base64.length + padLength, "=");
|
|
const binary = atob(padded);
|
|
const buffer = new ArrayBuffer(binary.length);
|
|
const bytes = new Uint8Array(buffer);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return buffer;
|
|
}
|