diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 183ac97..30274be 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -287,11 +287,10 @@ export default class GiftedDialog extends Vue { unitCode: string = "HUR", ) { try { - const identity = await libsUtil.getIdentity(this.activeDid); const result = await createAndSubmitGive( this.axios, this.apiServer, - identity, + this.activeDid, giverDid, this.receiver?.did as string, description, diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index e048888..1498c06 100644 --- a/src/components/OfferDialog.vue +++ b/src/components/OfferDialog.vue @@ -223,11 +223,10 @@ export default class OfferDialog extends Vue { } try { - const identity = await libsUtil.getIdentity(this.activeDid); const result = await createAndSubmitOffer( this.axios, this.apiServer, - identity, + this.activeDid, description, amount, unitCode, diff --git a/src/components/PhotoDialog.vue b/src/components/PhotoDialog.vue index b28f82d..fe323cb 100644 --- a/src/components/PhotoDialog.vue +++ b/src/components/PhotoDialog.vue @@ -126,7 +126,6 @@ import { Component, Vue } from "vue-facing-decorator"; import VuePictureCropper, { cropper } from "vue-picture-cropper"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; -import { getIdentity } from "@/libs/util"; import { db } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { accessToken } from "@/libs/crypto"; @@ -348,8 +347,7 @@ export default class PhotoDialog extends Vue { this.blob = (await cropper?.getBlob()) || undefined; } - const identifier = await getIdentity(this.activeDid); - const token = await accessToken(identifier); + const token = await accessToken(this.activeDid); const headers = { Authorization: "Bearer " + token, }; diff --git a/src/components/World/components/objects/landmarks.js b/src/components/World/components/objects/landmarks.js index b8163a2..eb0f8f9 100644 --- a/src/components/World/components/objects/landmarks.js +++ b/src/components/World/components/objects/landmarks.js @@ -1,12 +1,11 @@ import axios from "axios"; -import * as R from "ramda"; import * as THREE from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader"; import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import * as TWEEN from "@tweenjs/tween.js"; -import { accountsDB, db } from "@/db"; +import { db } from "@/db"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -import { accessToken } from "@/libs/crypto"; +import { getHeaders } from "@/libs/endorserServer"; const ANIMATION_DURATION_SECS = 10; const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/"; @@ -19,17 +18,7 @@ export async function loadLandmarks(vue, world, scene, loop) { const settings = await db.settings.get(MASTER_SETTINGS_KEY); const activeDid = settings?.activeDid || ""; const apiServer = settings?.apiServer; - await accountsDB.open(); - const accounts = await accountsDB.accounts.toArray(); - const account = R.find((acc) => acc.did === activeDid, accounts); - const headers = { - "Content-Type": "application/json", - }; - const identity = JSON.parse(account?.identity || "null"); - if (identity) { - const token = await accessToken(identity); - headers["Authorization"] = "Bearer " + token; - } + const headers = await getHeaders(activeDid); const url = apiServer + "/api/v2/report/claims?claimType=GiveAction"; const resp = await axios.get(url, { headers: headers }); diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index 71c67fe..169f271 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -6,7 +6,10 @@ import { HDNode } from "@ethersproject/hdnode"; import * as didJwt from "did-jwt"; import * as u8a from "uint8arrays"; -import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer"; +import { + createEndorserJwt, + ENDORSER_JWT_URL_LOCATION, +} from "@/libs/endorserServer"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; @@ -85,32 +88,17 @@ export const generateSeed = (): string => { /** * Retreive an access token * - * @param {IIdentifier} identifier * @return {*} */ -export const accessToken = async (identifier: IIdentifier) => { - const did: string = identifier.did; - const privateKeyHex: string = identifier.keys[0].privateKeyHex as string; - - const signer = SimpleSigner(privateKeyHex); - - const nowEpoch = Math.floor(Date.now() / 1000); - const endEpoch = nowEpoch + 60; // add one minute - - const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; - const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R - const jwt: string = await didJwt.createJWT(tokenPayload, { - alg, - issuer: did, - signer, - }); - return jwt; -}; - -export const sign = async (privateKeyHex: string) => { - const signer = SimpleSigner(privateKeyHex); - - return signer; +export const accessToken = async (did?: string) => { + if (did) { + 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); + } else { + return ""; + } }; /** diff --git a/src/libs/crypto/passkeyHelpers.ts b/src/libs/crypto/passkeyHelpers.ts index 5e3daab..59f6b98 100644 --- a/src/libs/crypto/passkeyHelpers.ts +++ b/src/libs/crypto/passkeyHelpers.ts @@ -88,7 +88,7 @@ export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> { ); return toResolve; } -export class MissingWebCrypto extends Error { +class MissingWebCrypto extends Error { constructor() { const message = "An instance of the Crypto API could not be located"; super(message); @@ -96,7 +96,7 @@ export class MissingWebCrypto extends Error { } } // Make it possible to stub return values during testing -export const _getWebCryptoInternals = { +const _getWebCryptoInternals = { stubThisGlobalThisCrypto: () => globalThis.crypto, // Make it possible to reset the `webCrypto` at the top of the file setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => { diff --git a/src/libs/didPeer.ts b/src/libs/didPeer.ts index 74c8b19..acc0fbf 100644 --- a/src/libs/didPeer.ts +++ b/src/libs/didPeer.ts @@ -20,6 +20,7 @@ import { PublicKeyCredentialRequestOptionsJSON, } from "@simplewebauthn/types"; +import { AppString } from "@/constants/app"; import { getWebCrypto, unwrapEC2Signature } from "@/libs/crypto/passkeyHelpers"; const PEER_DID_PREFIX = "did:peer:"; @@ -42,9 +43,9 @@ function arrayToBase64Url(anything: Uint8Array) { export async function registerCredential(passkeyName?: string) { const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({ - rpName: "Time Safari", + rpName: AppString.APP_NAME, rpID: window.location.hostname, - userName: passkeyName || "Time Safari User", + userName: passkeyName || AppString.APP_NAME + " User", // Don't prompt users for additional information about the authenticator // (Recommended for smoother UX) attestationType: "none", @@ -116,13 +117,17 @@ export class PeerSetup { issuerDid: string, payload: object, credIdHex: string, + expMinutes: number = 1, ) { const credentialId = arrayBufferToBase64URLString( Buffer.from(credIdHex, "hex").buffer, ); + const issuedAt = Math.floor(Date.now() / 1000); + const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now const fullPayload = { ...payload, - iat: Math.floor(Date.now() / 1000), + exp: expiryTime, + iat: issuedAt, iss: issuerDid, }; this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); @@ -158,7 +163,8 @@ export class PeerSetup { const dataInJwt = { AuthenticationDataB64URL: authenticatorDataBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url, - iat: Math.floor(Date.now() / 1000), + exp: expiryTime, + iat: issuedAt, iss: issuerDid, }; const dataInJwtString = JSON.stringify(dataInJwt); @@ -177,10 +183,14 @@ export class PeerSetup { issuerDid: string, payload: object, credIdHex: string, + expMinutes: number = 1, ) { + const issuedAt = Math.floor(Date.now() / 1000); + const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now const fullPayload = { ...payload, - iat: Math.floor(Date.now() / 1000), + exp: expiryTime, + iat: issuedAt, iss: issuerDid, }; const dataToSignString = JSON.stringify(fullPayload); @@ -226,7 +236,8 @@ export class PeerSetup { const dataInJwt = { AuthenticationDataB64URL: authenticatorDataBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url, - iat: Math.floor(Date.now() / 1000), + exp: expiryTime, + iat: issuedAt, iss: issuerDid, }; const dataInJwtString = JSON.stringify(dataInJwt); @@ -307,6 +318,16 @@ export class PeerSetup { // } } +export async function createDidPeerJwt( + did: string, + credIdHex: string, + payload: object, +): Promise { + const peerSetup = new PeerSetup(); + const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex); + return jwt; +} + // I'd love to use this but it doesn't verify. // Requires: // npm install @noble/curves @@ -379,6 +400,7 @@ export async function verifyJwtSimplewebauthn( return verification.verified; } +// similar code is in endorser-ch util-crypto.ts verifyPeerSignature export async function verifyJwtWebCrypto( credId: Base64URLString, issuerDid: string, diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 835fe5d..f05e6de 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1,19 +1,14 @@ -import { - Axios, - AxiosRequestConfig, - AxiosResponse, - RawAxiosRequestHeaders, -} from "axios"; +import { Axios, AxiosRequestConfig, AxiosResponse } from "axios"; import * as didJwt from "did-jwt"; import { LRUCache } from "lru-cache"; import * as R from "ramda"; -import { IIdentifier } from "@veramo/core"; import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; import { accessToken, SimpleSigner } from "@/libs/crypto"; import { NonsensitiveDexie } from "@/db/index"; -import { getIdentity } from "@/libs/util"; +import { createDidPeerJwt } from "@/libs/didPeer"; +import { getAccount, getIdentity } from "@/libs/util"; export const SCHEMA_ORG_CONTEXT = "https://schema.org"; // the object in RegisterAction claims @@ -160,7 +155,7 @@ export interface OfferVerifiableCredential { // Note that previous VCs may have additional fields. // https://endorser.ch/doc/html/transactions.html#id7 export interface PlanVerifiableCredential { - "@context": SCHEMA_ORG_CONTEXT; + "@context": "https://schema.org"; "@type": "PlanAction"; name: string; agent?: { identifier: string }; @@ -453,28 +448,30 @@ export function didInfo( return didInfoForContact(did, activeDid, contact, allMyDids).displayName; } -async function getHeaders(identity: IIdentifier | null) { - const headers: RawAxiosRequestHeaders = { +export async function getHeaders(did?: string) { + const headers: { "Content-Type": string; Authorization?: string } = { "Content-Type": "application/json", }; - if (identity) { - const token = await accessToken(identity); + if (did) { + const token = await accessToken(did); headers["Authorization"] = "Bearer " + token; + } else { + // it's often OK to request without auth; we assume necessary checks are done earlier } return headers; } /** * @param handleId nullable, in which case "undefined" will be returned - * @param identity nullable, in which case no private info will be returned + * @param requesterDid optional, in which case no private info will be returned * @param axios * @param apiServer */ export async function getPlanFromCache( handleId: string | null, - identity: IIdentifier | null, axios: Axios, apiServer: string, + requesterDid?: string, ): Promise { if (!handleId) { return undefined; @@ -485,7 +482,7 @@ export async function getPlanFromCache( apiServer + "/api/v2/report/plans?handleId=" + encodeURIComponent(handleId); - const headers = await getHeaders(identity); + const headers = await getHeaders(requesterDid); try { const resp = await axios.get(url, { headers }); if (resp.status === 200 && resp.data?.data?.length > 0) { @@ -518,6 +515,9 @@ export async function setPlanInCache( planCache.set(handleId, planSummary); } +/** + * Construct GiveAction VC for submission to server + */ export function constructGive( fromDid?: string | null, toDid?: string, @@ -572,7 +572,7 @@ export function constructGive( export async function createAndSubmitGive( axios: Axios, apiServer: string, - identity: IIdentifier, + issuerDid: string, fromDid?: string | null, toDid?: string, description?: string, @@ -596,7 +596,7 @@ export async function createAndSubmitGive( ); return createAndSubmitClaim( vcClaim as GenericCredWrapper, - identity, + issuerDid, apiServer, axios, ); @@ -614,7 +614,7 @@ export async function createAndSubmitGive( export async function createAndSubmitOffer( axios: Axios, apiServer: string, - identity: IIdentifier, + issuerDid: string, description?: string, amount?: number, unitCode?: string, @@ -625,7 +625,7 @@ export async function createAndSubmitOffer( const vcClaim: OfferVerifiableCredential = { "@context": SCHEMA_ORG_CONTEXT, "@type": "Offer", - offeredBy: { identifier: identity.did }, + offeredBy: { identifier: issuerDid }, validThrough: expirationDate || undefined, }; if (amount) { @@ -649,7 +649,7 @@ export async function createAndSubmitOffer( } return createAndSubmitClaim( vcClaim as GenericCredWrapper, - identity, + issuerDid, apiServer, axios, ); @@ -657,7 +657,7 @@ export async function createAndSubmitOffer( // similar logic is found in endorser-mobile export const createAndSubmitConfirmation = async ( - identifier: IIdentifier, + issuerDid: string, claim: GenericVerifiableCredential, lastClaimId: string, // used to set the lastClaimId handleId: string | undefined, @@ -674,12 +674,12 @@ export const createAndSubmitConfirmation = async ( "@type": "AgreeAction", object: goodClaim, }; - return createAndSubmitClaim(confirmationClaim, identifier, apiServer, axios); + return createAndSubmitClaim(confirmationClaim, issuerDid, apiServer, axios); }; export async function createAndSubmitClaim( vcClaim: GenericVerifiableCredential, - identity: IIdentifier, + issuerDid: string, apiServer: string, axios: Axios, ): Promise { @@ -692,34 +692,15 @@ export async function createAndSubmitClaim( }, }; - // Create a signature using private key of identity - const firstKey = identity.keys[0]; - const privateKeyHex = firstKey?.privateKeyHex; - - if (!privateKeyHex) { - throw { - error: "No private key", - message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`, - }; - } - - const signer = await SimpleSigner(privateKeyHex); - - // Create a JWT for the request - const vcJwt: string = await didJwt.createJWT(vcPayload, { - issuer: identity.did, - signer, - }); + const vcJwt: string = await createEndorserJwt(issuerDid, vcPayload); // Make the xhr request payload const payload = JSON.stringify({ jwtEncoded: vcJwt }); const url = `${apiServer}/api/v2/claim`; - const token = await accessToken(identity); const response = await axios.post(url, payload, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, }); @@ -944,18 +925,49 @@ export const bvcMeetingJoinClaim = (did: string, startTime: string) => { }; }; +export async function createEndorserJwtVcFromClaim( + issuerDid: string, + claim: object, +) { + // Make a payload for the claim + const vcPayload = { + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + 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); + } +} + export async function register( activeDid: string, apiServer: string, axios: Axios, contact: Contact, ) { - const identity = await getIdentity(activeDid); - const vcClaim: RegisterVerifiableCredential = { "@context": SCHEMA_ORG_CONTEXT, "@type": "RegisterAction", - agent: { identifier: identity.did }, + agent: { identifier: activeDid }, object: SERVICE_ID, participant: { identifier: contact.did }, }; @@ -968,26 +980,10 @@ export async function register( }, }; // Create a signature using private key of identity - if (identity.keys[0].privateKeyHex == null) { - return { error: "Private key not found." }; - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const privateKeyHex: string = identity.keys[0].privateKeyHex!; - const signer = await SimpleSigner(privateKeyHex); - const alg = undefined; - // Create a JWT for the request - const vcJwt: string = await didJwt.createJWT(vcPayload, { - alg: alg, - issuer: identity.did, - signer: signer, - }); - - // Make the xhr request payload - const payload = JSON.stringify({ jwtEncoded: vcJwt }); - const url = apiServer + "/api/v2/claim"; - const headers = await getHeaders(identity); + const vcJwt = await createEndorserJwt(activeDid, vcPayload); - const resp = await axios.post(url, payload, { headers }); + const url = apiServer + "/api/v2/claim"; + const resp = await axios.post(url, { jwtEncoded: vcJwt }); if (resp.data?.success?.handleId) { return { success: true }; } else if (resp.data?.success?.embeddedRecordError) { @@ -1017,7 +1013,7 @@ export async function setVisibilityUtil( const url = apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe"); const identity = await getIdentity(activeDid); - const headers = await getHeaders(identity); + const headers = await getHeaders(identity.did); const payload = JSON.stringify({ did: contact.did }); try { @@ -1046,16 +1042,16 @@ export async function setVisibilityUtil( * * @param apiServer endorser server URL string * @param axios Axios instance - * @param {IIdentifier} identity - The identity object to check rate limits for. + * @param {string} issuerDid - The DID for which to check rate limits. * @returns {Promise} The Axios response object. */ export async function fetchEndorserRateLimits( apiServer: string, axios: Axios, - identity: IIdentifier, + issuerDid: string, ) { const url = `${apiServer}/api/report/rateLimits`; - const headers = await getHeaders(identity); + const headers = await getHeaders(issuerDid); return await axios.get(url, { headers } as AxiosRequestConfig); } @@ -1064,15 +1060,11 @@ export async function fetchEndorserRateLimits( * * @param apiServer image server URL string * @param axios Axios instance - * @param {IIdentifier} identity - The identity object to check rate limits for. + * @param {string} issuerDid - The DID for which to check rate limits. * @returns {Promise} The Axios response object. */ -export async function fetchImageRateLimits( - apiServer: string, - axios: Axios, - identity: IIdentifier, -) { +export async function fetchImageRateLimits(axios: Axios, issuerDid: string) { const url = DEFAULT_IMAGE_API_SERVER + "/image-limits"; - const headers = await getHeaders(identity); + const headers = await getHeaders(issuerDid); return await axios.get(url, { headers } as AxiosRequestConfig); } diff --git a/src/libs/util.ts b/src/libs/util.ts index cdecf31..b92f6c7 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -11,9 +11,12 @@ 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 { Buffer } from "buffer"; export const PRIVACY_MESSAGE = - "The data you send be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to those you allow."; + "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."; /* eslint-disable prettier/prettier */ export const UNIT_SHORT: Record = { @@ -193,12 +196,19 @@ export function findAllVisibleToDids( * **/ -export const getIdentity = async (activeDid: string): Promise => { +export const getAccount = async ( + activeDid: string, +): Promise => { await accountsDB.open(); const account = (await accountsDB.accounts .where("did") .equals(activeDid) .first()) as Account; + return account; +}; + +export const getIdentity = async (activeDid: string): Promise => { + const account = await getAccount(activeDid); const identity = JSON.parse(account?.identity || "null"); if (!identity) { @@ -239,6 +249,38 @@ export const generateSaveAndActivateIdentity = async (): Promise => { 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"), + }; + await accountsDB.open(); + await accountsDB.accounts.add(account); + return account; +}; + +export const registerSaveAndActivatePasskey = async ( + keyName: string, +): Promise => { + const account = await registerAndSavePasskey(keyName); + + await db.open(); + await db.settings.update(MASTER_SETTINGS_KEY, { + activeDid: account.did, + }); + + return account; +}; + export const sendTestThroughPushServer = async ( subscriptionJSON: PushSubscriptionJSON, skipFilter: boolean, diff --git a/src/test/index.ts b/src/test/index.ts index 1c2f5f4..5b9d376 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -6,6 +6,9 @@ import { SERVICE_ID } from "../libs/endorserServer"; import { deriveAddress, newIdentifier } from "../libs/crypto"; import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; +/** + * Get User #0 to sign & submit a RegisterAction for the user's activeDid. + */ export async function testServerRegisterUser() { const testUser0Mnem = "seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control"; diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 98fbf5b..d32267e 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -359,6 +359,7 @@
Derivation Path
{{ derivationPath }} @@ -375,6 +376,12 @@ Copied
+
+ (none) +
@@ -646,13 +653,16 @@