You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
375 lines
12 KiB
375 lines
12 KiB
import asn1 from "asn1-ber";
|
|
import { Buffer } from "buffer/";
|
|
import { decode as cborDecode } from "cbor-x";
|
|
import { createJWS, JWTPayload, verifyJWT } from "did-jwt";
|
|
import { DIDResolutionResult, Resolver } from "did-resolver";
|
|
import { bytesToMultibase } from "@veramo/utils";
|
|
import { startAuthentication } from "@simplewebauthn/browser";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
VerifyRegistrationResponseOpts,
|
|
} from "@simplewebauthn/server";
|
|
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
|
|
import {
|
|
Base64URLString,
|
|
PublicKeyCredentialRequestOptionsJSON,
|
|
} from "@simplewebauthn/types";
|
|
|
|
export interface JWK {
|
|
kty: string;
|
|
crv: string;
|
|
x: string;
|
|
y: string;
|
|
}
|
|
export interface PublicKeyCredential {
|
|
rawId: Uint8Array;
|
|
jwt: JWK;
|
|
}
|
|
|
|
function toBase64Url(anything: Uint8Array) {
|
|
return Buffer.from(anything)
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
}
|
|
|
|
export async function registerCredential(
|
|
userId: Uint8Array,
|
|
challenge: Uint8Array,
|
|
) {
|
|
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
|
challenge: challenge,
|
|
rp: {
|
|
name: "Time Safari",
|
|
id: window.location.hostname,
|
|
},
|
|
user: {
|
|
id: userId,
|
|
name: "Current-User",
|
|
displayName: "Current User",
|
|
},
|
|
pubKeyCredParams: [
|
|
{
|
|
type: "public-key",
|
|
alg: -7, // ES256 algorithm
|
|
},
|
|
],
|
|
authenticatorSelection: {
|
|
authenticatorAttachment: "platform",
|
|
userVerification: "preferred",
|
|
},
|
|
timeout: 60000,
|
|
attestation: "direct",
|
|
};
|
|
|
|
const credential = await navigator.credentials.create({
|
|
publicKey: publicKeyOptions,
|
|
});
|
|
console.log("credential", credential);
|
|
console.log(credential?.id, " is the new ID base64-url-encoded");
|
|
console.log(toBase64Url(credential?.rawId), " is the base64 rawId");
|
|
const attestationResponse = credential?.response;
|
|
const verfInput: VerifyRegistrationResponseOpts = {
|
|
response: {
|
|
id: credential?.id as string,
|
|
rawId: credential?.id as string, //Buffer.from(credential?.rawId).toString("base64"),
|
|
response: {
|
|
attestationObject: toBase64Url(attestationResponse?.attestationObject),
|
|
clientDataJSON: toBase64Url(attestationResponse?.clientDataJSON),
|
|
},
|
|
clientExtensionResults: {},
|
|
type: "public-key",
|
|
},
|
|
expectedChallenge: toBase64Url(challenge),
|
|
expectedOrigin: window.location.origin,
|
|
};
|
|
console.log("verfInput", verfInput);
|
|
const verification = await verifyRegistrationResponse(verfInput);
|
|
console.log("verification", verification);
|
|
|
|
// Parse the attestation response to get the public key
|
|
const clientDataJSON = attestationResponse.clientDataJSON;
|
|
console.log("clientDataJSON raw", clientDataJSON);
|
|
console.log(
|
|
"clientDataJSON dec",
|
|
new TextDecoder("utf-8").decode(clientDataJSON),
|
|
);
|
|
const attestationObject = cborDecode(
|
|
new Uint8Array(attestationResponse.attestationObject),
|
|
);
|
|
console.log("attestationObject", attestationObject);
|
|
|
|
const jwkObj = cborDecode(
|
|
verification.registrationInfo?.credentialPublicKey as Uint8Array,
|
|
);
|
|
console.log("jwkObj from verification", jwkObj);
|
|
if (
|
|
jwkObj[1] != 2 || // kty "EC"
|
|
jwkObj[3] != -7 || // alg "ES256"
|
|
jwkObj[-1] != 1 || // crv "P-256"
|
|
jwkObj[-2].length != 32 || // x
|
|
jwkObj[-3].length != 32 // y
|
|
) {
|
|
throw new Error("Unable to extract key.");
|
|
}
|
|
const publicKeyJwk = {
|
|
alg: "ES256",
|
|
crv: "P-256",
|
|
kty: "EC",
|
|
x: toBase64Url(jwkObj[-2]),
|
|
y: toBase64Url(jwkObj[-3]),
|
|
};
|
|
const publicKeyBytes = Buffer.concat([
|
|
Buffer.from(jwkObj[-2]),
|
|
Buffer.from(jwkObj[-3]),
|
|
]);
|
|
|
|
//const publicKeyBytes = extractPublicKeyCose(attestationObject.authData);
|
|
//const publicKeyJwk = extractPublicKeyJwk(attestationObject.authData);
|
|
|
|
return {
|
|
authData: attestationObject.authData,
|
|
credId: credential?.id,
|
|
rawId: credential?.rawId,
|
|
publicKeyJwk,
|
|
publicKeyBytes,
|
|
};
|
|
}
|
|
|
|
// parse authData
|
|
// here's one: https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
|
|
// from https://chatgpt.com/c/0ce72fda-bc5d-42ff-a748-6022f6e39fa0
|
|
// from https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
|
|
|
|
export function createPeerDid(publicKeyBytes: Uint8Array) {
|
|
// https://github.com/decentralized-identity/veramo/blob/next/packages/did-provider-peer/src/peer-did-provider.ts#L67
|
|
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
|
|
const methodSpecificId = bytesToMultibase(
|
|
publicKeyBytes,
|
|
"base58btc",
|
|
"secp256k1-pub",
|
|
);
|
|
return "did:peer:0" + methodSpecificId;
|
|
}
|
|
|
|
export class PeerSetup {
|
|
public authenticatorData?: ArrayBuffer;
|
|
public clientDataJsonDecoded?: object;
|
|
|
|
public async createJwt(
|
|
payload: object,
|
|
issuerDid: string,
|
|
credentialId: string,
|
|
) {
|
|
const signer = await this.webAuthnES256KSigner(credentialId);
|
|
|
|
// from createJWT in did-jwt/src/JWT.ts
|
|
const header: JWTPayload = { typ: "JWT", alg: "ES256K" };
|
|
const timestamps: Partial<JWTPayload> = {
|
|
iat: Math.floor(Date.now() / 1000),
|
|
exp: undefined,
|
|
};
|
|
const fullPayload = { ...timestamps, ...payload, iss: issuerDid };
|
|
|
|
const jwt = createJWS(fullPayload, signer, header);
|
|
return jwt;
|
|
}
|
|
|
|
async webAuthnES256KSigner(credentialID: string) {
|
|
return async (data: string | Uint8Array) => {
|
|
const signature = await this.generateWebAuthnSignature(
|
|
data,
|
|
credentialID,
|
|
);
|
|
|
|
// 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("signature inside signer", signature);
|
|
console.log("buffer signature inside signer", signatureBuffer);
|
|
// Decode the DER-encoded signature to extract R and S values
|
|
const reader = new asn1.BerReader(signatureBuffer);
|
|
console.log("after reader");
|
|
reader.readSequence();
|
|
console.log("after read sequence");
|
|
const r = reader.readString(asn1.Ber.Integer, true);
|
|
console.log("after r");
|
|
const s = reader.readString(asn1.Ber.Integer, true);
|
|
console.log("after r & s");
|
|
|
|
// Ensure R and S are 32 bytes each
|
|
const rBuffer = Buffer.from(r);
|
|
const sBuffer = Buffer.from(s);
|
|
console.log("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(
|
|
"combinedSignature",
|
|
combinedSignature.length,
|
|
combinedSignature,
|
|
);
|
|
|
|
const combSig64 = combinedSignature.toString("base64");
|
|
console.log("combSig64", combSig64);
|
|
const combSig64Url = combSig64
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
console.log("combSig64Url", combSig64Url);
|
|
return combSig64Url;
|
|
};
|
|
}
|
|
|
|
async generateWebAuthnSignature(
|
|
dataToSign: string | Uint8Array, // from Signer interface
|
|
credentialId: string,
|
|
) {
|
|
if (dataToSign instanceof Uint8Array) {
|
|
dataToSign = new TextDecoder("utf-8").decode(dataToSign as Uint8Array);
|
|
}
|
|
|
|
console.log("credentialId", credentialId);
|
|
const options = {
|
|
challenge: new TextEncoder().encode(dataToSign).buffer,
|
|
//allowCredentials: [{ id: credentialId, type: "public-key" }],
|
|
userVerification: "preferred",
|
|
};
|
|
|
|
const assertion = await navigator.credentials.get({ publicKey: options });
|
|
console.log("assertion", assertion);
|
|
|
|
const authenticatorAssertionResponse = assertion?.response;
|
|
this.clientDataJsonDecoded = JSON.parse(
|
|
new TextDecoder("utf-8").decode(
|
|
authenticatorAssertionResponse.clientDataJSON,
|
|
),
|
|
);
|
|
console.log("clientDataJSON decoded", this.clientDataJsonDecoded);
|
|
this.authenticatorData = authenticatorAssertionResponse.authenticatorData;
|
|
console.log("authenticator data", this.authenticatorData);
|
|
return authenticatorAssertionResponse.signature;
|
|
}
|
|
}
|
|
|
|
export async function verifyJwt(
|
|
jwt: string,
|
|
credId: Base64URLString,
|
|
rawId: Uint8Array,
|
|
authenticatorData: ArrayBuffer,
|
|
clientDataJSON: object,
|
|
publicKey: Uint8Array,
|
|
signature: Base64URLString,
|
|
) {
|
|
const options: PublicKeyCredentialRequestOptionsJSON =
|
|
await generateAuthenticationOptions({
|
|
rpID: window.location.hostname,
|
|
// Require users to use a previously-registered authenticator
|
|
// allowCredentials: userPasskeys.map(passkey => ({
|
|
// id: passkey.id,
|
|
// transports: passkey.transports,
|
|
// })),
|
|
});
|
|
console.log("authentication options", options);
|
|
|
|
const clientAuth = await startAuthentication(options);
|
|
console.log("clientAuth", clientAuth);
|
|
|
|
const verfOpts: VerifyAuthenticationResponseOpts = {
|
|
response: clientAuth,
|
|
authenticator: {
|
|
credentialID: credId,
|
|
credentialPublicKey: publicKey,
|
|
counter: 0,
|
|
},
|
|
expectedChallenge: () => true, // options.challenge doesn't work
|
|
expectedOrigin: window.location.origin,
|
|
expectedRPID: window.location.hostname,
|
|
};
|
|
console.log("verfOpts", verfOpts);
|
|
const verificationFromClient = await verifyAuthenticationResponse(verfOpts);
|
|
console.log("client auth verification", verificationFromClient);
|
|
|
|
const authData = toBase64Url(Buffer.from(authenticatorData));
|
|
const bufferizedJson = toBase64Url(
|
|
new TextEncoder().encode(JSON.stringify(clientDataJSON)),
|
|
);
|
|
const authOpts: VerifyAuthenticationResponseOpts = {
|
|
response: {
|
|
id: credId,
|
|
rawId: toBase64Url(rawId),
|
|
response: {
|
|
authenticatorData: authData,
|
|
clientDataJSON: bufferizedJson,
|
|
signature: signature,
|
|
},
|
|
clientExtensionResults: {},
|
|
type: "public-key",
|
|
},
|
|
expectedChallenge: () => true, // options.challenge doesn't work
|
|
expectedOrigin: window.location.origin,
|
|
expectedRPID: window.location.hostname,
|
|
authenticator: {
|
|
credentialID: credId,
|
|
credentialPublicKey: publicKey,
|
|
counter: 0,
|
|
},
|
|
};
|
|
const verification = await verifyAuthenticationResponse(authOpts);
|
|
console.log("auth verification", verification);
|
|
|
|
const decoded = verifyJWT(jwt, {
|
|
resolver: new Resolver({ peer: peerDidToDidDocument }),
|
|
});
|
|
return decoded;
|
|
}
|
|
|
|
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
|
|
if (!did.startsWith("did:peer:0z")) {
|
|
throw new Error(
|
|
"This only verifies a peer DID, method 0, encoded base58btc.",
|
|
);
|
|
}
|
|
// this is basically hard-coded based on the results from the @aviarytech/did-peer resolver
|
|
const id = did.split(":")[2];
|
|
const multibase = id.slice(1);
|
|
const encnumbasis = multibase.slice(1);
|
|
const didDocument = {
|
|
"@context": [
|
|
"https://www.w3.org/ns/did/v1",
|
|
"https://w3id.org/security/suites/jws-2020/v1",
|
|
],
|
|
assertionMethod: [did + "#" + encnumbasis],
|
|
authentication: [did + "#" + encnumbasis],
|
|
capabilityDelegation: [did + "#" + encnumbasis],
|
|
capabilityInvocation: [did + "#" + encnumbasis],
|
|
id: did,
|
|
keyAgreement: undefined,
|
|
service: undefined,
|
|
verificationMethod: [
|
|
{
|
|
controller: did,
|
|
id: did + "#" + encnumbasis,
|
|
publicKeyMultibase: multibase,
|
|
type: "EcdsaSecp256k1VerificationKey2019",
|
|
},
|
|
],
|
|
};
|
|
return {
|
|
didDocument,
|
|
didDocumentMetadata: {},
|
|
didResolutionMetadata: { contentType: "application/did+ld+json" },
|
|
};
|
|
}
|
|
|