diff --git a/README.md b/README.md index c79de4e..2510962 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run lint ``` # (Let's replace this with a .env.development or .env.staging file.) # The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there. -TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app npm run build +TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app PASSKEYS_ENABLED=yep npm run build ``` * Production diff --git a/src/constants/app.ts b/src/constants/app.ts index a1bcf12..f42f922 100644 --- a/src/constants/app.ts +++ b/src/constants/app.ts @@ -36,6 +36,9 @@ export const DEFAULT_PUSH_SERVER = export const IMAGE_TYPE_PROFILE = "profile"; +export const PASSKEYS_ENABLED = + !!import.meta.env.VITE_PASSKEYS_ENABLED || false; + /** * The possible values for "group" and "type" are in App.vue. * From the notiwind package diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index 169f271..862ed54 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -3,14 +3,13 @@ import { getRandomBytesSync } from "ethereum-cryptography/random"; import { entropyToMnemonic } from "ethereum-cryptography/bip39"; import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { HDNode } from "@ethersproject/hdnode"; -import * as didJwt from "did-jwt"; -import * as u8a from "uint8arrays"; import { - createEndorserJwt, + createEndorserJwtForDid, ENDORSER_JWT_URL_LOCATION, } from "@/libs/endorserServer"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; +import { decodeEndorserJwt } from "@/libs/crypto/vc"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; @@ -95,58 +94,12 @@ export const accessToken = async (did?: string) => { const nowEpoch = Math.floor(Date.now() / 1000); const endEpoch = nowEpoch + 60; // add one minute const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; - return createEndorserJwt(did, tokenPayload); + return createEndorserJwtForDid(did, tokenPayload); } else { return ""; } }; -/** - * Copied out of did-jwt since it's deprecated in that library. - * - * The SimpleSigner returns a configured function for signing data. - * - * @example - * const signer = SimpleSigner(import.meta.env.PRIVATE_KEY) - * signer(data, (err, signature) => { - * ... - * }) - * - * @param {String} hexPrivateKey a hex encoded private key - * @return {Function} a configured signer function - */ -export function SimpleSigner(hexPrivateKey: string): didJwt.Signer { - const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true); - return async (data) => { - const signature = (await signer(data)) as string; - return fromJose(signature); - }; -} - -// from did-jwt/util; see SimpleSigner above -export function fromJose(signature: string): { - r: string; - s: string; - recoveryParam?: number; -} { - const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature); - if (signatureBytes.length < 64 || signatureBytes.length > 65) { - throw new TypeError( - `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, - ); - } - const r = bytesToHex(signatureBytes.slice(0, 32)); - const s = bytesToHex(signatureBytes.slice(32, 64)); - const recoveryParam = - signatureBytes.length === 65 ? signatureBytes[64] : undefined; - return { r, s, recoveryParam }; -} - -// from did-jwt/util; see SimpleSigner above -export function bytesToHex(b: Uint8Array): string { - return u8a.toString(b, "base16"); -} - /** @return results of uportJwtPayload: { iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } } @@ -163,7 +116,7 @@ export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => { } // JWT format: { header, payload, signature, data } - const jwt = didJwt.decodeJWT(jwtText); + const jwt = decodeEndorserJwt(jwtText); return jwt.payload; }; diff --git a/src/libs/crypto/vc/index.ts b/src/libs/crypto/vc/index.ts new file mode 100644 index 0000000..94355ef --- /dev/null +++ b/src/libs/crypto/vc/index.ts @@ -0,0 +1,106 @@ +/** + * Verifiable Credential & DID functions, specifically for EndorserSearch.org tools + */ + +import * as didJwt from "did-jwt"; +import { IIdentifier } from "@veramo/core"; +import * as u8a from "uint8arrays"; + +import { createDidPeerJwt } from "@/libs/crypto/vc/passkeyDidPeer"; +import {JWTDecoded} from "did-jwt/lib/JWT"; + +/** + * Meta info about a key + */ +export interface KeyMeta { + /** + * Decentralized ID for the key + */ + did: string; + /** + * Stringified IIDentifier object from Veramo + */ + identity?: string; + /** + * The Webauthn credential ID in hex, if this is from a passkey + */ + passkeyCredIdHex?: string; +} + +/** + * 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 + */ +export function isFromPasskey(keyMeta?: KeyMeta): boolean { + return !!keyMeta?.passkeyCredIdHex; +} + +export async function createEndorserJwtForKey( + account: KeyMeta, + payload: object, +) { + if (account?.identity) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const identity: IIdentifier = JSON.parse(account.identity!); + const privateKeyHex = identity.keys[0].privateKeyHex; + const signer = await SimpleSigner(privateKeyHex as string); + return didJwt.createJWT(payload, { + issuer: account.did, + signer: signer, + }); + } else if (account?.passkeyCredIdHex) { + return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload); + } else { + throw new Error("No identity data found to sign for DID " + account.did); + } +} + +/** + * Copied out of did-jwt since it's deprecated in that library. + * + * The SimpleSigner returns a configured function for signing data. + * + * @example + * const signer = SimpleSigner(import.meta.env.PRIVATE_KEY) + * signer(data, (err, signature) => { + * ... + * }) + * + * @param {String} hexPrivateKey a hex encoded private key + * @return {Function} a configured signer function + */ +function SimpleSigner(hexPrivateKey: string): didJwt.Signer { + const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true); + return async (data) => { + const signature = (await signer(data)) as string; + return fromJose(signature); + }; +} + +// from did-jwt/util; see SimpleSigner above +function fromJose(signature: string): { + r: string; + s: string; + recoveryParam?: number; +} { + const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature); + if (signatureBytes.length < 64 || signatureBytes.length > 65) { + throw new TypeError( + `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, + ); + } + const r = bytesToHex(signatureBytes.slice(0, 32)); + const s = bytesToHex(signatureBytes.slice(32, 64)); + const recoveryParam = + signatureBytes.length === 65 ? signatureBytes[64] : undefined; + return {r, s, recoveryParam}; +} + +// from did-jwt/util; see SimpleSigner above +function bytesToHex(b: Uint8Array): string { + return u8a.toString(b, "base16"); +} + +export function decodeEndorserJwt(jwt: string): JWTDecoded { + return didJwt.decodeJWT(jwt); +} diff --git a/src/libs/didPeer.ts b/src/libs/crypto/vc/passkeyDidPeer.ts similarity index 99% rename from src/libs/didPeer.ts rename to src/libs/crypto/vc/passkeyDidPeer.ts index acc0fbf..6431123 100644 --- a/src/libs/didPeer.ts +++ b/src/libs/crypto/vc/passkeyDidPeer.ts @@ -21,7 +21,10 @@ import { } from "@simplewebauthn/types"; import { AppString } from "@/constants/app"; -import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers"; +import { + getWebCrypto, + unwrapEC2Signature, +} from "@/libs/crypto/vc/passkeyHelpers"; const PEER_DID_PREFIX = "did:peer:"; const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; diff --git a/src/libs/crypto/passkeyHelpers.ts b/src/libs/crypto/vc/passkeyHelpers.ts similarity index 100% rename from src/libs/crypto/passkeyHelpers.ts rename to src/libs/crypto/vc/passkeyHelpers.ts diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index f05e6de..ff4bbd6 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1,14 +1,13 @@ import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; -import * as didJwt from "did-jwt"; import { LRUCache } from "lru-cache"; import * as R from "ramda"; import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; -import { accessToken, SimpleSigner } from "@/libs/crypto"; +import { accessToken } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; -import { createDidPeerJwt } from "@/libs/didPeer"; import { getAccount, getIdentity } from "@/libs/util"; +import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; // the object in RegisterAction claims @@ -692,7 +691,7 @@ export async function createAndSubmitClaim( }, }; - const vcJwt: string = await createEndorserJwt(issuerDid, vcPayload); + const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); @@ -722,6 +721,14 @@ export async function createAndSubmitClaim( } } +export async function createEndorserJwtForDid( + issuerDid: string, + payload: object, +) { + const account = await getAccount(issuerDid); + return createEndorserJwtForKey(account as KeyMeta, payload); +} + /** * An AcceptAction is when someone accepts some contract or pledge. * @@ -937,25 +944,7 @@ export async function createEndorserJwtVcFromClaim( credentialSubject: claim, }, }; - return createEndorserJwt(issuerDid, vcPayload); -} - -export async function createEndorserJwt(issuerDid: string, payload: object) { - const account = await getAccount(issuerDid); - if (account?.identity) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const identity = JSON.parse(account.identity!); - const privateKeyHex = identity.keys[0].privateKeyHex; - const signer = await SimpleSigner(privateKeyHex); - return didJwt.createJWT(payload, { - issuer: issuerDid, - signer: signer, - }); - } else if (account?.passkeyCredIdHex) { - return createDidPeerJwt(issuerDid, account.passkeyCredIdHex, payload); - } else { - throw new Error("No identity data found to sign for DID " + issuerDid); - } + return createEndorserJwtForDid(issuerDid, vcPayload); } export async function register( @@ -980,7 +969,7 @@ export async function register( }, }; // Create a signature using private key of identity - const vcJwt = await createEndorserJwt(activeDid, vcPayload); + const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload); const url = apiServer + "/api/v2/claim"; const resp = await axios.post(url, { jwtEncoded: vcJwt }); diff --git a/src/libs/util.ts b/src/libs/util.ts index b92f6c7..f7de997 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -11,9 +11,10 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto"; import { GenericCredWrapper, containsHiddenDid } from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer"; -import { createPeerDid, registerCredential } from "@/libs/didPeer"; +import { createPeerDid, registerCredential } from "@/libs/crypto/vc/passkeyDidPeer"; import { Buffer } from "buffer"; +import {KeyMeta} from "@/libs/crypto/vc"; export const PRIVACY_MESSAGE = "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; @@ -196,9 +197,11 @@ export function findAllVisibleToDids( * **/ +export interface AccountKeyInfo extends Account, KeyMeta {} + export const getAccount = async ( activeDid: string, -): Promise => { +): Promise => { await accountsDB.open(); const account = (await accountsDB.accounts .where("did") diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index cbb518b..c14f350 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -84,7 +84,6 @@ import { useClipboard } from "@vueuse/core"; import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; -import { Account } from "@/db/tables/accounts"; import { Contact } from "@/db/tables/contacts"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; import { @@ -93,8 +92,7 @@ import { nextDerivationPath, } from "@/libs/crypto"; import { - CONTACT_URL_PREFIX, - createEndorserJwt, + CONTACT_URL_PREFIX, createEndorserJwtForDid, ENDORSER_JWT_URL_LOCATION, isDid, register, @@ -161,7 +159,7 @@ export default class ContactQRScanShow extends Vue { }, }; - const vcJwt: string = await createEndorserJwt(identity.did, contactInfo); + const vcJwt: string = await createEndorserJwtForDid(identity.did, contactInfo); const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION; this.qrValue = viewPrefix + vcJwt; } diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index ff97ec3..53ac742 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -81,16 +81,29 @@ v-if="!activeDid" class="bg-amber-200 rounded-md text-center px-4 py-3 mb-4" > -

- To recognize giving, have someone register you: -

-
- - - - - - +
+

+ Choose how to see info from your contacts or share contributions: +

+
+ + + Give me all the options. + +
+
+
+

+ To recognize giving or collaborate, have someone register you: +

void; AppString = AppString; + PASSKEYS_ENABLED = PASSKEYS_ENABLED; activeDid = ""; allContacts: Array = []; diff --git a/src/views/TestView.vue b/src/views/TestView.vue index f8fc272..f717436 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -246,14 +246,15 @@ import { Component, Vue } from "vue-facing-decorator"; import QuickNav from "@/components/QuickNav.vue"; import { AppString, NotificationIface } from "@/constants/app"; import { accountsDB, db } from "@/db/index"; +import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; +import * as vcLib from "@/libs/crypto/vc"; import { PeerSetup, verifyJwtP256, verifyJwtSimplewebauthn, verifyJwtWebCrypto, -} from "@/libs/didPeer"; -import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { registerAndSavePasskey } from "@/libs/util"; +} from "@/libs/crypto/vc/passkeyDidPeer"; +import {AccountKeyInfo, getAccount, registerAndSavePasskey} from "@/libs/util"; const inputFileNameRef = ref(); @@ -360,6 +361,13 @@ export default class Help extends Vue { } public async createJwtSimplewebauthn() { + const account: AccountKeyInfo | undefined = await getAccount( + this.activeDid || "", + ); + if (!vcLib.isFromPasskey(account)) { + alert(`The DID ${this.activeDid} is not passkey-enabled.`); + return; + } this.peerSetup = new PeerSetup(); this.jwt = await this.peerSetup.createJwtSimplewebauthn( this.activeDid as string, @@ -370,6 +378,13 @@ export default class Help extends Vue { } public async createJwtNavigator() { + const account: AccountKeyInfo | undefined = await getAccount( + this.activeDid || "", + ); + if (!vcLib.isFromPasskey(account)) { + alert(`The DID ${this.activeDid} is not passkey-enabled.`); + return; + } this.peerSetup = new PeerSetup(); this.jwt = await this.peerSetup.createJwtNavigator( this.activeDid as string,