// many of these are also found in endorser-mobile utility.ts import axios, { AxiosResponse } from "axios"; import { Buffer } from "buffer"; import * as R from "ramda"; import { useClipboard } from "@vueuse/core"; import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import { accountsDBPromise, retrieveSettingsForActiveAccount, updateAccountSettings, updateDefaultSettings, } from "../db/index"; import { Account } from "../db/tables/accounts"; import { Contact } from "../db/tables/contacts"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto"; import * as serverUtil from "../libs/endorserServer"; import { containsHiddenDid, GenericCredWrapper, GenericVerifiableCredential, GiveSummaryRecord, OfferVerifiableCredential, } from "../libs/endorserServer"; import { KeyMeta } from "../libs/crypto/vc"; import { createPeerDid } from "../libs/crypto/vc/didPeer"; import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer"; export interface GiverReceiverInputInfo { did?: string; name?: string; } export enum OnboardPage { Home = "HOME", Discover = "DISCOVER", Create = "CREATE", Contact = "CONTACT", Account = "ACCOUNT", } 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."; export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64"; /* eslint-disable prettier/prettier */ export const UNIT_SHORT: Record = { "BTC": "BTC", "BX": "BX", "ETH": "ETH", "HUR": "Hours", "USD": "US $", }; /* eslint-enable prettier/prettier */ /* eslint-disable prettier/prettier */ export const UNIT_LONG: Record = { "BTC": "Bitcoin", "BX": "Buxbe", "ETH": "Ethereum", "HUR": "hours", "USD": "dollars", }; /* eslint-enable prettier/prettier */ const UNIT_CODES: Record> = { BTC: { name: "Bitcoin", faIcon: "bitcoin-sign", }, HUR: { name: "hours", faIcon: "clock", }, USD: { name: "US Dollars", faIcon: "dollar", }, }; export function iconForUnitCode(unitCode: string) { return UNIT_CODES[unitCode]?.faIcon || "question"; } // from https://stackoverflow.com/a/175787/845494 // ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places // export function isNumeric(str: string): boolean { // This ignore commentary is because typescript complains when you pass a string to isNaN. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return !isNaN(str) && !isNaN(parseFloat(str)); } export function numberOrZero(str: string): number { return isNumeric(str) ? +str : 0; } export const isGlobalUri = (uri: string) => { return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); }; export const isGiveClaimType = (claimType?: string) => { return claimType === "GiveAction"; }; export const isGiveAction = ( veriClaim: GenericCredWrapper, ) => { return isGiveClaimType(veriClaim.claimType); }; export const nameForDid = ( activeDid: string, contacts: Array, did: string, ): string => { if (did === activeDid) { return "you"; } const contact = R.find((con) => con.did == did, contacts); return nameForContact(contact); }; export const nameForContact = ( contact?: Contact, capitalize?: boolean, ): string => { return ( (contact?.name as string) || (capitalize ? "This" : "this") + " unnamed user" ); }; export const doCopyTwoSecRedo = (text: string, fn: () => void) => { fn(); useClipboard() .copy(text) .then(() => setTimeout(fn, 2000)); }; export interface ConfirmerData { confirmerIdList: string[]; confsVisibleToIdList: string[]; numConfsNotVisible: number; } // // This is meant to be a second argument to JSON.stringify to avoid circular references. // // Usage: JSON.stringify(error, getCircularReplacer()) // // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed // function getCircularReplacer() { // const seen = new WeakSet(); // // eslint-disable-next-line @typescript-eslint/no-explicit-any // return (obj: any, key: string, value: any): any => { // if (typeof value === "object" && value !== null) { // if (seen.has(value)) { // return "[circular ref]"; // } // seen.add(value); // } // return value; // }; // } /** * @return only confirmers, excluding the issuer and hidden DIDs */ export async function retrieveConfirmerIdList( apiServer: string, claimId: string, claimIssuerId: string, userDid: string, ): Promise { const confirmUrl = apiServer + "/api/report/issuersWhoClaimedOrConfirmed?claimId=" + encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); const confirmHeaders = await serverUtil.getHeaders(userDid); const response = await axios.get(confirmUrl, { headers: confirmHeaders, }); if (response.status === 200) { const resultList1 = response.data.result || []; //const publicUrls = resultList.publicUrls || []; delete resultList1.publicUrls; // exclude hidden DIDs const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); // exclude the issuer const resultList3 = R.reject( (did: string) => did === claimIssuerId, resultList2, ); const confirmerIdList = resultList3; let numConfsNotVisible = resultList1.length - resultList2.length; if (resultList3.length === resultList2.length) { // the issuer was not in the "visible" list so they must be hidden // so subtract them from the non-visible confirmers count numConfsNotVisible = numConfsNotVisible - 1; } const confsVisibleToIdList = response.data.result.resultVisibleToDids || []; const result: ConfirmerData = { confirmerIdList, confsVisibleToIdList, numConfsNotVisible, }; return result; } else { console.error( "Bad response status of", response.status, "for confirmers:", response, ); return undefined; } } /** * @returns true if the user can confirm the claim * @param veriClaim is expected to have fields: claim, claimType, and issuer */ export function isGiveRecordTheUserCanConfirm( isRegistered: boolean, veriClaim: GenericCredWrapper, activeDid: string, confirmerIdList: string[] = [], ): boolean { return ( isRegistered && isGiveAction(veriClaim) && !confirmerIdList.includes(activeDid) && veriClaim.issuer !== activeDid && !containsHiddenDid(veriClaim.claim) ); } export function notifyWhyCannotConfirm( notifyFun: (notification: NotificationIface, timeout: number) => void, isRegistered: boolean, claimType: string | undefined, giveDetails: GiveSummaryRecord | undefined, activeDid: string, confirmerIdList: string[] = [], ) { if (!isRegistered) { notifyFun( { group: "alert", type: "info", title: "Not Registered", text: "Someone needs to register you before you can confirm.", }, 3000, ); } else if (!isGiveClaimType(claimType)) { notifyFun( { group: "alert", type: "info", title: "Not A Give", text: "This is not a giving action to confirm.", }, 3000, ); } else if (confirmerIdList.includes(activeDid)) { notifyFun( { group: "alert", type: "info", title: "Already Confirmed", text: "You already confirmed this claim.", }, 3000, ); } else if (giveDetails?.issuerDid == activeDid) { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this because you issued this claim.", }, 3000, ); } else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this because some people are hidden.", }, 3000, ); } else { notifyFun( { group: "alert", type: "info", title: "Cannot Confirm", text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.", }, 3000, ); } } export async function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer? reader.onerror = reject; reader.readAsDataURL(blob); }); } export function base64ToBlob(base64DataUrl: string, sliceSize = 512) { // Extract the content type and the Base64 data const [metadata, base64] = base64DataUrl.split(","); const contentTypeMatch = metadata.match(/data:(.*?);base64/); const contentType = contentTypeMatch ? contentTypeMatch[1] : ""; const byteCharacters = atob(base64); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i++) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } return new Blob(byteArrays, { type: contentType }); } /** * @returns the DID of the person who offered, or undefined if hidden * @param veriClaim is expected to have fields: claim and issuer */ export function offerGiverDid( veriClaim: GenericCredWrapper, ): string | undefined { let giver; if ( veriClaim.claim.offeredBy?.identifier && !serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string) ) { giver = veriClaim.claim.offeredBy.identifier; } else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) { giver = veriClaim.issuer; } return giver; } /** * @returns true if the user can fulfill the offer * @param veriClaim is expected to have fields: claim, claimType, and issuer */ export const canFulfillOffer = ( veriClaim: GenericCredWrapper, ) => { return ( veriClaim.claimType === "Offer" && !!offerGiverDid(veriClaim as GenericCredWrapper) ); }; // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" export function findAllVisibleToDids( // eslint-disable-next-line @typescript-eslint/no-explicit-any input: any, humanReadable = false, ): Record> { if (Array.isArray(input)) { const result: Record> = {}; for (let i = 0; i < input.length; i++) { const inside = findAllVisibleToDids(input[i], humanReadable); for (const key in inside) { const pathKey = humanReadable ? "#" + (i + 1) + " " + key : "[" + i + "]" + key; result[pathKey] = inside[key]; } } return result; } else if (input instanceof Object) { // regular map (non-array) object const result: Record> = {}; for (const key in input) { if (key.endsWith("VisibleToDids")) { const newKey = key.slice(0, -"VisibleToDids".length); const pathKey = humanReadable ? newKey : "." + newKey; result[pathKey] = input[key]; } else { const inside = findAllVisibleToDids(input[key], humanReadable); for (const insideKey in inside) { const pathKey = humanReadable ? key + "'s " + insideKey : "." + key + insideKey; result[pathKey] = inside[insideKey]; } } } return result; } else { return {}; } } /** * Test findAllVisibleToDids * pkgx +deno.land sh deno import * as R from 'ramda'; //import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function console.log(R.equals(findAllVisibleToDids(null), {})); console.log(R.equals(findAllVisibleToDids(9), {})); console.log(R.equals(findAllVisibleToDids([]), {})); console.log(R.equals(findAllVisibleToDids({}), {})); console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {})); console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] })); console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] })); * **/ export interface AccountKeyInfo extends Account, KeyMeta {} export const retrieveAccountCount = async (): Promise => { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; return await accountsDB.accounts.count(); }; export const retrieveAccountDids = async (): Promise => { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const allAccounts = await accountsDB.accounts.toArray(); const allDids = allAccounts.map((acc) => acc.did); return allDids; }; // This is provided and recommended when the full key is not necessary so that // future work could separate this info from the sensitive key material. export const retrieveAccountMetadata = async ( activeDid: string, ): Promise => { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; if (account) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { identity, mnemonic, ...metadata } = account; return metadata; } else { return undefined; } }; export const retrieveAllAccountsMetadata = async (): Promise => { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const array = await accountsDB.accounts.toArray(); return array.map((account) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { identity, mnemonic, ...metadata } = account; return metadata; }); }; export const retrieveFullyDecryptedAccount = async ( activeDid: string, ): Promise => { // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; return account; }; // let's try and eliminate this export const retrieveAllFullyDecryptedAccounts = async (): Promise< Array > => { const accountsDB = await accountsDBPromise; const allAccounts = await accountsDB.accounts.toArray(); return allAccounts; }; /** * Generates a new identity, saves it to the database, and sets it as the active identity. * @return {Promise} with the DID of the new identity */ export const generateSaveAndActivateIdentity = async (): Promise => { const mnemonic = generateSeed(); // address is 0x... ETH address, without "did:eth:" const [address, privateHex, publicHex, derivationPath] = deriveAddress(mnemonic); const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const identity = JSON.stringify(newId); // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add({ dateCreated: new Date().toISOString(), derivationPath: derivationPath, did: newId.did, identity: identity, mnemonic: mnemonic, publicKeyHex: newId.keys[0].publicKeyHex, }); await updateDefaultSettings({ activeDid: newId.did }); //console.log("Updated default settings in util"); await updateAccountSettings(newId.did, { isRegistered: false }); return newId.did; }; export const registerAndSavePasskey = async ( keyName: string, ): Promise => { const cred = await registerCredential(keyName); const publicKeyBytes = cred.publicKeyBytes; const did = createPeerDid(publicKeyBytes as Uint8Array); const passkeyCredIdHex = cred.credIdHex as string; const account = { dateCreated: new Date().toISOString(), did, passkeyCredIdHex, publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), }; // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add(account); return account; }; export const registerSaveAndActivatePasskey = async ( keyName: string, ): Promise => { const account = await registerAndSavePasskey(keyName); await updateDefaultSettings({ activeDid: account.did }); await updateAccountSettings(account.did, { isRegistered: false }); return account; }; export const getPasskeyExpirationSeconds = async (): Promise => { const settings = await retrieveSettingsForActiveAccount(); return ( (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * 60 ); }; // These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js export const DAILY_CHECK_TITLE = "DAILY_CHECK"; // This is a special value that tells the service worker to send a direct notification to the device, skipping filters. export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION"; export const sendTestThroughPushServer = async ( subscriptionJSON: PushSubscriptionJSON, skipFilter: boolean, ): Promise => { const settings = await retrieveSettingsForActiveAccount(); let pushUrl: string = DEFAULT_PUSH_SERVER as string; if (settings?.webPushServer) { pushUrl = settings.webPushServer; } const newPayload = { ...subscriptionJSON, // ... overridden with the following // eslint-disable-next-line prettier/prettier message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`, title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push", }; console.log("Sending a test web push message:", newPayload); const payloadStr = JSON.stringify(newPayload); const response = await axios.post( pushUrl + "/web-push/send-test", payloadStr, { headers: { "Content-Type": "application/json", }, }, ); console.log("Got response from web push server:", response); return response; };