Browse Source

Merge branch 'master' into passkey-flow

pull/117/head
Trent Larson 5 months ago
parent
commit
ba94bb79ab
  1. 2
      README.md
  2. 2405
      package-lock.json
  3. 12
      package.json
  4. 29
      src/db/tables/accounts.ts
  5. 8
      src/libs/crypto/index.ts
  6. 102
      src/libs/crypto/passkeyHelpers.ts
  7. 570
      src/libs/didPeer.ts
  8. 2
      src/main.ts
  9. 2
      src/views/IdentitySwitcherView.vue
  10. 1
      src/views/StartView.vue
  11. 213
      src/views/TestView.vue

2
README.md

@ -10,7 +10,7 @@ See [project.task.yaml](project.task.yaml) for current priorities.
## Setup ## Setup
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment. We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
``` ```
npm install npm install

2405
package-lock.json

File diff suppressed because it is too large

12
package.json

@ -1,7 +1,6 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.15-beta",
"private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
@ -17,20 +16,25 @@
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/js-yaml": "^4.0.9",
"@types/luxon": "^3.4.2",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",
"@veramo/credential-w3c": "^5.6.0", "@veramo/credential-w3c": "^5.6.0",
"@veramo/data-store": "^5.6.0", "@veramo/data-store": "^5.6.0",
"@veramo/did-manager": "^5.6.0", "@veramo/did-manager": "^5.6.0",
"@veramo/did-provider-ethr": "^5.6.0", "@veramo/did-provider-ethr": "^5.6.0",
"@veramo/did-provider-peer": "^6.0.0",
"@veramo/did-resolver": "^5.6.0", "@veramo/did-resolver": "^5.6.0",
"@veramo/key-manager": "^5.6.0", "@veramo/key-manager": "^5.6.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.1", "dexie-export-import": "^4.1.1",
@ -67,7 +71,9 @@
"web-did-resolver": "^2.0.27" "web-did-resolver": "^2.0.27"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/ramda": "^0.29.11", "@types/ramda": "^0.29.11",
"@types/three": "^0.155.1", "@types/three": "^0.155.1",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",

29
src/db/tables/accounts.ts

@ -3,41 +3,46 @@
*/ */
export type Account = { export type Account = {
/** /**
* Auto-generated ID by Dexie. * Auto-generated ID by Dexie
*/ */
id?: number; id?: number;
/** /**
* The date the account was created. * The date the account was created
*/ */
dateCreated: string; dateCreated: string;
/** /**
* The derivation path for the account. * The derivation path for the account, if this is from a mnemonic
*/ */
derivationPath: string; derivationPath?: string;
/** /**
* Decentralized Identifier (DID) for the account. * Decentralized Identifier (DID) for the account
*/ */
did: string; did: string;
/** /**
* Stringified JSON containing underlying key material. * Stringified JSON containing underlying key material, if generated from a mnemonic
* Based on the IIdentifier type from Veramo. * Based on the IIdentifier type from Veramo
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts} * @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/ */
identity: string; identity?: string;
/** /**
* The public key in hexadecimal format. * The mnemonic phrase for the account, if this is from a mnemonic
*/ */
publicKeyHex: string; mnemonic?: string;
/** /**
* The mnemonic passphrase for the account. * The Webauthn credential ID in hex, if this is from a passkey
*/ */
mnemonic: string; passkeyCredIdHex?: string;
/**
* The public key in hexadecimal format
*/
publicKeyHex: string;
}; };
/** /**

8
src/libs/crypto/index.ts

@ -11,6 +11,8 @@ import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
export const LOCAL_KMS_NAME = "local";
/** /**
* *
* *
@ -31,7 +33,7 @@ export const newIdentifier = (
keys: [ keys: [
{ {
kid: publicHex, kid: publicHex,
kms: "local", kms: LOCAL_KMS_NAME,
meta: { derivationPath: derivationPath }, meta: { derivationPath: derivationPath },
privateKeyHex: privateHex, privateKeyHex: privateHex,
publicKeyHex: publicHex, publicKeyHex: publicHex,
@ -64,6 +66,10 @@ export const deriveAddress = (
return [address, privateHex, publicHex, derivationPath]; return [address, privateHex, publicHex, derivationPath];
}; };
export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes);
};
/** /**
* *
* *

102
src/libs/crypto/passkeyHelpers.ts

@ -0,0 +1,102 @@
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
/**
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
*
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
*/
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]);
return finalSignature;
}
/**
* Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence
* should be removed based on the following logic:
*
* "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0,
* then remove the leading 0x0 byte"
*/
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
/**
* Combine multiple Uint8Arrays into a single Uint8Array
*/
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: unknown = undefined;
export function getWebCrypto() {
/**
* Hello there! If you came here wondering why this method is asynchronous when use of
* `globalThis.crypto` is not, it's to minimize a bunch of refactor related to making this
* synchronous. For example, `generateRegistrationOptions()` and `generateAuthenticationOptions()`
* become synchronous if we make this synchronous (since nothing else in that method is async)
* which represents a breaking API change in this library's core API.
*
* TODO: If it's after February 2025 when you read this then consider whether it still makes sense
* to keep this method asynchronous.
*/
const toResolve = new Promise((resolve, reject) => {
if (webCrypto) {
return resolve(webCrypto);
}
/**
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
* support (and Node v20+)
*/
const _globalThisCrypto = _getWebCryptoInternals.stubThisGlobalThisCrypto();
if (_globalThisCrypto) {
webCrypto = _globalThisCrypto;
return resolve(webCrypto);
}
// We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto());
});
return toResolve;
}
export class MissingWebCrypto extends Error {
constructor() {
const message = "An instance of the Crypto API could not be located";
super(message);
this.name = "MissingWebCrypto";
}
}
// Make it possible to stub return values during testing
export const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: unknown) => {
webCrypto = newCrypto;
},
};

570
src/libs/didPeer.ts

@ -0,0 +1,570 @@
import asn1 from "asn1-ber";
import { Buffer } from "buffer/";
import { decode as cborDecode } from "cbor-x";
import { bytesToMultibase, JWTPayload, multibaseToBytes } from "did-jwt";
import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js";
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/types";
import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers";
const PEER_DID_PREFIX = "did:peer:";
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
rpName: "Time Safari",
rpID: window.location.hostname,
userName: passkeyName || "Time Safari 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) {
console.log("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 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",
"p256-pub",
);
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId;
}
function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length));
}
export class PeerSetup {
public authenticatorData?: ArrayBuffer;
public challenge?: Uint8Array;
public clientDataJsonBase64Url?: Base64URLString;
public signature?: Base64URLString;
public async createJwtSimplewebauthn(
issuerDid: string,
payload: object,
credIdHex: string,
) {
const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer,
);
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
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,
iat: Math.floor(Date.now() / 1000),
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,
) {
const fullPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
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",
},
],
challenge: this.challenge.buffer,
rpID: window.location.hostname,
userVerification: "preferred",
},
};
const credential = await navigator.credentials.get(options);
// console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.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,
iat: Math.floor(Date.now() / 1000),
iss: issuerDid,
};
const dataInJwtString = JSON.stringify(dataInJwt);
const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature)
.toString("base64")
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature;
return jwt;
}
// 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;
// };
// }
}
// 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);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
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;
}
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]);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const WebCrypto = await getWebCrypto();
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"],
);
const verified = await WebCrypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSigBuffer,
preimage,
);
return verified;
}
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 from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is 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" },
};
}
// convert COSE public key to PEM format
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
function base64urlDecode(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
function base64urlEncode(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer) {
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;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes);
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: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]),
]);
return { publicKeyJwk, publicKeyBuffer };
}
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

2
src/main.ts

@ -162,7 +162,7 @@ function setupGlobalErrorHandler(app: VueApp) {
info: string, info: string,
) => { ) => {
console.error( console.error(
"Global Error Handler. Info:", "Ouch! Global Error Handler. Info:",
info, info,
"Error:", "Error:",
err, err,

2
src/views/IdentitySwitcherView.vue

@ -113,7 +113,7 @@ export default class IdentitySwitcherView extends Vue {
const accounts = await accountsDB.accounts.toArray(); const accounts = await accountsDB.accounts.toArray();
for (let n = 0; n < accounts.length; n++) { for (let n = 0; n < accounts.length; n++) {
try { try {
const did = JSON.parse(accounts[n].identity)["did"]; const did = accounts[n]["did"];
this.otherIdentities.push({ did: did }); this.otherIdentities.push({ did: did });
if (did && this.activeDid === did) { if (did && this.activeDid === did) {
this.activeDidInIdentities = true; this.activeDidInIdentities = true;

1
src/views/StartView.vue

@ -63,6 +63,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { accountsDB } from "@/db/index"; import { accountsDB } from "@/db/index";
@Component({ @Component({

213
src/views/TestView.vue

@ -21,8 +21,8 @@
</h1> </h1>
</div> </div>
<div class="mb-8"> <div>
<h2 class="text-xl font-bold mb-4">Notiwind Alert Test Suite</h2> <h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button <button
@click=" @click="
@ -154,8 +154,8 @@
</button> </button>
</div> </div>
<div> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">Share Image</h2> <h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target". Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" /> <input type="file" @change="uploadFile" />
<router-link <router-link
@ -169,22 +169,130 @@
Go to Shared Page Go to Shared Page
</router-link> </router-link>
</div> </div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Passkeys</h2>
See console for results.
<br/>
Active DID: {{ activeDid }}
{{ credIdHex ? "has passkey ID" : "has no passkey ID" }}
<div>
Register
<button
@click="register()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
</div>
<div>
Create
<button
@click="createJwtSimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Navigator
</button>
</div>
<div v-if="jwt">
Verify
<button
@click="verifySimplewebauthn()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
p256 - broken
</button>
</div>
<button
@click="verifyMyJwt()"
class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Verify Mine
</button>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Buffer } from "buffer/";
import { Base64URLString } from "@simplewebauthn/types";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import {
createPeerDid,
PeerSetup,
registerCredential,
verifyJwtP256,
verifyJwtSimplewebauthn,
verifyJwtWebCrypto,
} from "@/libs/didPeer";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
const inputFileNameRef = ref<Blob>(); const inputFileNameRef = ref<Blob>();
const TEST_PAYLOAD = {
vc: {
credentialSubject: {
"@context": "https://schema.org",
"@type": "GiveAction",
description: "pizza",
},
},
};
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class Help extends Vue { export default class Help extends Vue {
// for file import
fileName?: string; fileName?: string;
// for passkeys
credIdHex?: string;
activeDid?: string;
jwt?: string;
peerSetup?: PeerSetup;
userName?: string;
async mounted() {
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = (settings?.activeDid as string) || "";
this.userName = settings?.firstName as string;
await accountsDB.open();
const account: { identity?: string } | undefined = await accountsDB.accounts
.where("did")
.equals(this.activeDid)
.first();
if (this.activeDid) {
if (account) {
this.credIdHex = account.passkeyCredIdHex as string;
} else {
alert("No account found for DID " + this.activeDid);
}
}
}
async uploadFile(event: Event) { async uploadFile(event: Event) {
inputFileNameRef.value = event.target.files[0]; inputFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File // https://developer.mozilla.org/en-US/docs/Web/API/File
@ -214,5 +322,100 @@ export default class Help extends Vue {
showFileNextStep() { showFileNextStep() {
return !!inputFileNameRef.value; return !!inputFileNameRef.value;
} }
public async register() {
const cred = await registerCredential(this.userName);
const publicKeyBytes = cred.publicKeyBytes;
this.activeDid = createPeerDid(publicKeyBytes as Uint8Array);
this.credIdHex = cred.credIdHex as string;
await accountsDB.open();
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
did: this.activeDid,
passkeyCredIdHex: this.credIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
});``
}
public async createJwtSimplewebauthn() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtSimplewebauthn(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("simple jwt4url", this.jwt);
}
public async createJwtNavigator() {
this.peerSetup = new PeerSetup();
this.jwt = await this.peerSetup.createJwtNavigator(
this.activeDid as string,
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("lower jwt4url", this.jwt);
}
public async verifyP256() {
const decoded = await verifyJwtP256(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifySimplewebauthn() {
const decoded = await verifyJwtSimplewebauthn(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyWebCrypto() {
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
this.peerSetup.authenticatorData as ArrayBuffer,
this.peerSetup.challenge as Uint8Array,
this.peerSetup.clientDataJsonBase64Url as Base64URLString,
this.peerSetup.signature as Base64URLString,
);
console.log("decoded", decoded);
}
public async verifyMyJwt() {
const jwt =
"eyJ0eXAiOiJKV0FOVCIsImFsZyI6IkVTMjU2In0.eyJBdXRoZW50aWNhdGlvbkRhdGFCNjRVUkwiOiJTWllONVlnT2pHaDBOQmNQWkhaZ1c0X2tycm1paGpMSG1Wenp1b01kbDJNRkFBQUFBQSIsIkNsaWVudERhdGFKU09OQjY0VVJMIjoiZXlKMGVYQmxJam9pZDJWaVlYVjBhRzR1WjJWMElpd2lZMmhoYkd4bGJtZGxJam9pWlhsS01sbDVTVFpsZVVwcVkyMVdhMXBYTlRCaFYwWnpWVE5XYVdGdFZtcGtRMGsyWlhsS1FWa3lPWFZrUjFZMFpFTkpOa2x0YURCa1NFSjZUMms0ZG1NeVRtOWFWekZvVEcwNWVWcDVTWE5KYTBJd1pWaENiRWxxYjJsU01td3lXbFZHYW1SSGJIWmlhVWx6U1cxU2JHTXlUbmxoV0VJd1lWYzVkVWxxYjJsalIydzJaVzFGYVdaWU1ITkpiV3hvWkVOSk5rMVVZM2hQUkZVMFRtcHJOVTFEZDJsaFdFNTZTV3B2YVZwSGJHdFBia0pzV2xoSk5rMUljRXhVVlZweFpHeFdibGRZU2s1TlYyaFpaREJTYW1GV2JFbGhWVVUxVkZob1dXUkZjRkZYUnpWVFZFVndNbU5YT1U1VWEwWk1ZakJTVFZkRWJIZFRNREZZVkVkSmVsWnJVbnBhTTFab1RWaEJlV1ZzWTNobFJtaFRZekp3WVZVeFVrOWpNbG95VkZjMVQyVlZNVlJPTWxKRFRrZHpNMVJyUm05U2JtUk5UVE5DV1ZGdVNrTlhSMlExVjFWdk5XTnRhMmxtVVNJc0ltOXlhV2RwYmlJNkltaDBkSEE2THk5c2IyTmhiR2h2YzNRNk9EQTRNQ0lzSW1OeWIzTnpUM0pwWjJsdUlqcG1ZV3h6WlgwIiwiaWF0IjoxNzE4NTg2OTkyLCJpc3MiOiJkaWQ6cGVlcjowektNRmp2VWdZck0xaFh3RGNpWUhpQTlNeFh0SlBYblJMSnZxb01OQUtvRExYOXBLTVdMYjNWRHNndWExcDJ6VzF4WFJzalpTVE5zZnZNbk55TVM3ZEI0azdOQWhGd0wzcFhCckJYZ3lZSjlyaSJ9.MEUCIQDJyCTbMPIFnuBoW3FYnlgtDEIHZ2OrkCEvqVnHU7kJDQIgVxjBjfW1TwQfcSOYwK8Z7AdCWGJlyxtLEsrnPif7caE";
const pieces = jwt.split(".");
console.log("pieces", typeof pieces[1], pieces);
const payload = JSON.parse(Buffer.from(pieces[1], "base64").toString());
const authData = Buffer.from(payload["AuthenticationDataB64URL"], "base64");
const clientJSON = Buffer.from(
payload["ClientDataJSONB64URL"],
"base64",
).toString();
const clientData = JSON.parse(clientJSON);
const challenge = clientData.challenge;
const signatureB64URL = pieces[2];
const decoded = await verifyJwtWebCrypto(
this.credIdHex as Base64URLString,
this.activeDid as string,
authData,
challenge,
payload["ClientDataJSONB64URL"],
signatureB64URL,
);
console.log("decoded", decoded);
}
} }
</script> </script>

Loading…
Cancel
Save