diff --git a/package-lock.json b/package-lock.json index 73fd2e944..3ca99ad2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "dexie": "^3.2.7", "dexie-export-import": "^4.1.1", "did-jwt": "^7.4.7", + "did-resolver": "^4.1.0", "ethereum-cryptography": "^2.1.3", "ethereumjs-util": "^7.1.5", "jdenticon": "^3.2.0", @@ -10954,7 +10955,8 @@ }, "node_modules/base64url": { "version": "3.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", "optional": true, "engines": { "node": ">=6.0.0" @@ -12422,7 +12424,8 @@ }, "node_modules/did-resolver": { "version": "4.1.0", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", + "integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==" }, "node_modules/didyoumean": { "version": "1.2.2", diff --git a/package.json b/package.json index d5c16fe9e..7f7a4926c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dexie": "^3.2.7", "dexie-export-import": "^4.1.1", "did-jwt": "^7.4.7", + "did-resolver": "^4.1.0", "ethereum-cryptography": "^2.1.3", "ethereumjs-util": "^7.1.5", "jdenticon": "^3.2.0", diff --git a/src/App.vue b/src/App.vue index 880a13423..a18e36c76 100644 --- a/src/App.vue +++ b/src/App.vue @@ -379,6 +379,7 @@ import { Vue, Component } from "vue-facing-decorator"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; import { retrieveSettingsForActiveAccount } from "@/db/index"; import * as libsUtil from "@/libs/util"; +import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util"; interface ServiceWorkerMessage { type: string; @@ -666,20 +667,6 @@ export default class App extends Vue { }); } - private urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding) - .replace(/-/g, "+") - .replace(/_/g, "/"); - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; - } - private subscribeToPush(): Promise { return new Promise((resolve, reject) => { if (!("serviceWorker" in navigator && "PushManager" in window)) { @@ -694,7 +681,7 @@ export default class App extends Vue { return reject(new Error(errorMsg)); } - const applicationServerKey = this.urlBase64ToUint8Array(this.b64); + const applicationServerKey = urlBase64ToUint8Array(this.b64); const options: PushSubscriptionOptions = { userVisibleOnly: true, applicationServerKey: applicationServerKey, diff --git a/src/libs/crypto/vc/did-eth-local-resolver.ts b/src/libs/crypto/vc/did-eth-local-resolver.ts new file mode 100644 index 000000000..c1d628d80 --- /dev/null +++ b/src/libs/crypto/vc/did-eth-local-resolver.ts @@ -0,0 +1,46 @@ +/** + * This did:ethr resolver instructs the did-jwt machinery to use the + * EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the + * signature to recover the DID's public key from a signature. + * + * This effectively hard codes the did:ethr DID resolver to use the address as the public key. + * @param did : string + * @returns {Promise} + * + * Similar code resides in image-api + */ +export const didEthLocalResolver = async (did: string) => { + const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/; + const match = did.match(didRegex); + + if (match) { + const address = match[1]; // Extract eth address: 0x... + const publicKeyHex = address; // Use the address directly as a public key placeholder + + return { + didDocumentMetadata: {}, + didResolutionMetadata: { + contentType: "application/did+ld+json", + }, + didDocument: { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2", + ], + id: did, + verificationMethod: [ + { + id: `${did}#controller`, + type: "EcdsaSec256k1RecoveryMethod2020", + controller: did, + blockchainAccountId: "eip155:1:" + publicKeyHex, + }, + ], + authentication: [`${did}#controller`], + assertionMethod: [`${did}#controller`], + }, + }; + } + + throw new Error(`Unsupported DID format: ${did}`); +}; diff --git a/src/libs/crypto/vc/index.ts b/src/libs/crypto/vc/index.ts index f18949f2c..bc1caabf6 100644 --- a/src/libs/crypto/vc/index.ts +++ b/src/libs/crypto/vc/index.ts @@ -6,14 +6,22 @@ * */ +import { Buffer } from "buffer/"; import * as didJwt from "did-jwt"; +import { JWTVerified } from "did-jwt"; import { JWTDecoded } from "did-jwt/lib/JWT"; +import { Resolver } from "did-resolver"; import { IIdentifier } from "@veramo/core"; import * as u8a from "uint8arrays"; -import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer"; +import { didEthLocalResolver } from "./did-eth-local-resolver"; +import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer"; +import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; +import { urlBase64ToUint8Array } from "./util"; export const ETHR_DID_PREFIX = "did:ethr:"; +export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; +export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD"; /** * Meta info about a key @@ -33,6 +41,8 @@ export interface KeyMeta { passkeyCredIdHex?: string; } +const resolver = new Resolver({ ethr: didEthLocalResolver }); + /** * Tell whether a key is from a passkey * @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey @@ -107,6 +117,78 @@ function bytesToHex(b: Uint8Array): string { return u8a.toString(b, "base16"); } +// We should be calling 'verify' in more places, showing warnings if it fails. export function decodeEndorserJwt(jwt: string): JWTDecoded { return didJwt.decodeJWT(jwt); } + +// return Promise of at least { issuer, payload, verified boolean } +// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer +export async function decodeAndVerifyJwt( + jwt: string, +): Promise> { + const pieces = jwt.split("."); + console.log("WTF decodeAndVerifyJwt", typeof jwt, jwt, pieces); + const header = JSON.parse(base64urlDecodeString(pieces[0])); + const payload = JSON.parse(base64urlDecodeString(pieces[1])); + console.log("WTF decodeAndVerifyJwt after", header, payload); + const issuerDid = payload.iss; + if (!issuerDid) { + return Promise.reject({ + clientError: { + message: `Missing "iss" field in JWT.`, + }, + }); + } + + if (issuerDid.startsWith(ETHR_DID_PREFIX)) { + try { + const verified = await didJwt.verifyJWT(jwt, { resolver }); + return verified; + } catch (e: unknown) { + return Promise.reject({ + clientError: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + message: `JWT failed verification: ` + e.toString(), + code: JWT_VERIFY_FAILED_CODE, + }, + }); + } + } + + if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") { + const verified = await verifyPeerSignature( + Buffer.from(payload), + issuerDid, + urlBase64ToUint8Array(pieces[2]), + ); + if (!verified) { + return Promise.reject({ + clientError: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + message: `JWT failed verification: ` + e.toString(), + code: JWT_VERIFY_FAILED_CODE, + }, + }); + } else { + return { issuer: issuerDid, payload: payload, verified: true }; + } + } + + if (issuerDid.startsWith(PEER_DID_PREFIX)) { + return Promise.reject({ + clientError: { + message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`, + }, + }); + } + + return Promise.reject({ + clientError: { + message: `Unsupported DID method ${issuerDid}`, + code: UNSUPPORTED_DID_METHOD_CODE, + }, + }); +} diff --git a/src/libs/crypto/vc/passkeyDidPeer.ts b/src/libs/crypto/vc/passkeyDidPeer.ts index 5efc372bd..c91ee2c02 100644 --- a/src/libs/crypto/vc/passkeyDidPeer.ts +++ b/src/libs/crypto/vc/passkeyDidPeer.ts @@ -470,8 +470,18 @@ ${pubKeyBuffer.toString("base64")} return pem; } +// tried the base64url library but got an error using their Buffer +export function base64urlDecodeString(input: string) { + return atob(input.replace(/-/g, "+").replace(/_/g, "/")); +} + +// tried the base64url library but got an error using their Buffer +export function base64urlEncodeString(input: string) { + return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars -function base64urlDecode(input: string) { +function base64urlDecodeArrayBuffer(input: string) { input = input.replace(/-/g, "+").replace(/_/g, "/"); const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); const str = atob(input + pad); @@ -483,9 +493,9 @@ function base64urlDecode(input: string) { } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function base64urlEncode(buffer: ArrayBuffer) { +function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) { const str = String.fromCharCode(...new Uint8Array(buffer)); - return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlEncodeString(str); } // from @simplewebauthn/browser diff --git a/src/libs/crypto/vc/util.ts b/src/libs/crypto/vc/util.ts new file mode 100644 index 000000000..2b81e2694 --- /dev/null +++ b/src/libs/crypto/vc/util.ts @@ -0,0 +1,11 @@ +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 59f48e6bb..d01b03330 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -270,6 +270,14 @@ export interface ErrorResult extends ResultWithType { export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; +export interface UserInfo { + name: string; + publicEncKey: string; + registered: boolean; + profileImageUrl?: string; + nextPublicEncKeyHash?: string; +} + // This is used to check for hidden info. // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 const HIDDEN_DID = "did:none:HIDDEN"; @@ -934,17 +942,12 @@ export async function generateEndorserJwtForAccount( isRegistered?: boolean, name?: string, profileImageUrl?: string, + // note that including the next key pushes QR codes to the next resolution smaller + includeNextKeyIfDerived?: boolean, ) { const publicKeyHex = account.publicKeyHex; const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64"); - interface UserInfo { - name: string; - publicEncKey: string; - registered: boolean; - profileImageUrl?: string; - nextPublicEncKeyHash?: string; - } const contactInfo = { iat: Date.now(), iss: account.did, @@ -958,7 +961,7 @@ export async function generateEndorserJwtForAccount( contactInfo.own.profileImageUrl = profileImageUrl; } - if (account?.mnemonic && account?.derivationPath) { + if (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) { const newDerivPath = nextDerivationPath(account.derivationPath as string); const nextPublicHex = deriveAddress( account.mnemonic as string, diff --git a/src/views/ContactImportView.vue b/src/views/ContactImportView.vue index a01a9b8e3..b7ec1ba1d 100644 --- a/src/views/ContactImportView.vue +++ b/src/views/ContactImportView.vue @@ -130,9 +130,7 @@ export default class ContactImportView extends Vue { const importedContacts = ((this.$route as Router).query["contacts"] as string) || "[]"; this.contactsImporting = JSON.parse(importedContacts); - this.contactsSelected = new Array(this.contactsImporting.length).fill( - false, - ); + this.contactsSelected = new Array(this.contactsImporting.length).fill(true); await db.open(); const baseContacts = await db.contacts.toArray(); @@ -158,9 +156,9 @@ export default class ContactImportView extends Vue { if (R.isEmpty(differences)) { this.sameCount++; } - } else { - // automatically import new data - this.contactsSelected[i] = true; + + // don't automatically import previous data + this.contactsSelected[i] = false; } } } diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 00dbf7b84..f45e78515 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -90,8 +90,6 @@