diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 92113fd..832c456 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -90,7 +90,7 @@ import { Vue, Component, Prop } from "vue-facing-decorator"; import { NotificationIface } from "@/constants/app"; -import { createAndSubmitGive, didInfo } from "@/libs/endorserServer"; +import { createAndSubmitGive, didInfo, serverMessageForUser } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import { Contact } from "@/db/tables/contacts"; @@ -336,7 +336,7 @@ export default class GiftedDialog extends Vue { console.error("Error with give recordation caught:", error); const errorMessage = error.userMessage || - error.response?.data?.error?.message || + serverMessageForUser(error) || "There was an error recording the give."; this.$notify( { diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index 4d54d0c..20aee1f 100644 --- a/src/components/OfferDialog.vue +++ b/src/components/OfferDialog.vue @@ -83,7 +83,7 @@ import { Vue, Component, Prop } from "vue-facing-decorator"; import { NotificationIface } from "@/constants/app"; -import { createAndSubmitOffer } from "@/libs/endorserServer"; +import { createAndSubmitOffer, serverMessageForUser } from "@/libs/endorserServer"; import * as libsUtil from "@/libs/util"; import { retrieveSettingsForActiveAccount } from "@/db/index"; @@ -304,9 +304,9 @@ export default class OfferDialog extends Vue { // eslint-disable-next-line @typescript-eslint/no-explicit-any getOfferCreationErrorMessage(result: any) { return ( + serverMessageForUser(result) || result.error?.userMessage || - result.error?.error || - result.response?.data?.error?.message + result.error?.error ); } } diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index 8260c88..74409ab 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -52,7 +52,7 @@ export const newIdentifier = ( * * * @param {string} mnemonic - * @return {*} {[string, string, string, string]} + * @return {[string, string, string, string]} address, privateHex, publicHex, derivationPath */ export const deriveAddress = ( mnemonic: string, @@ -88,7 +88,8 @@ export const generateSeed = (): string => { /** * Retrieve an access token, or "" if no DID is provided. * - * @return {*} + * @param {string} did + * @return {string} JWT with basic payload */ export const accessToken = async (did?: string) => { if (did) { @@ -147,3 +148,156 @@ export const nextDerivationPath = (origDerivPath: string) => { .join("/"); return newDerivPath; }; + +// Base64 encoding/decoding utilities for browser +function base64ToArrayBuffer(base64: string): Uint8Array { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const binary = String.fromCharCode(...new Uint8Array(buffer)); + return btoa(binary); +} + +const SALT_LENGTH = 16; +const IV_LENGTH = 12; +const KEY_LENGTH = 256; +const ITERATIONS = 100000; + +// Encryption helper function +export async function encryptMessage(message: string, password: string) { + const encoder = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + + // Derive key from password using PBKDF2 + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ); + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['encrypt'] + ); + + // Encrypt the message + const encryptedContent = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encoder.encode(message) + ); + + // Return a JSON structure with base64-encoded components + const result = { + salt: arrayBufferToBase64(salt), + iv: arrayBufferToBase64(iv), + encrypted: arrayBufferToBase64(encryptedContent) + }; + + return btoa(JSON.stringify(result)); +} + +// Decryption helper function +export async function decryptMessage(encryptedJson: string, password: string) { + const decoder = new TextDecoder(); + const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson)); + + // Convert base64 components back to Uint8Arrays + const saltArray = base64ToArrayBuffer(salt); + const ivArray = base64ToArrayBuffer(iv); + const encryptedContent = base64ToArrayBuffer(encrypted); + + // Derive the same key using PBKDF2 with the extracted salt + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ); + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltArray, + iterations: ITERATIONS, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: KEY_LENGTH }, + false, + ['decrypt'] + ); + + // Decrypt the content + const decryptedContent = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: ivArray + }, + key, + encryptedContent + ); + + // Convert the decrypted content back to a string + return decoder.decode(decryptedContent); +} + +// Test function to verify encryption/decryption +export async function testEncryptionDecryption() { + try { + const testMessage = "Hello, this is a test message! 🚀"; + const testPassword = "myTestPassword123"; + + console.log("Original message:", testMessage); + + // Test encryption + console.log("Encrypting..."); + const encrypted = await encryptMessage(testMessage, testPassword); + console.log("Encrypted result:", encrypted); + + // Test decryption + console.log("Decrypting..."); + const decrypted = await decryptMessage(encrypted, testPassword); + console.log("Decrypted result:", decrypted); + + // Verify + const success = testMessage === decrypted; + console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌")); + console.log("Messages match:", success); + + // Test with wrong password + console.log("\nTesting with wrong password..."); + try { + const wrongDecrypted = await decryptMessage(encrypted, "wrongPassword"); + console.log("Should not reach here"); + } catch (error) { + console.log("Correctly failed with wrong password ✅"); + } + + return success; + } catch (error) { + console.error("Test failed with error:", error); + return false; + } +} diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index f116090..0b80972 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -621,41 +621,6 @@ const planCache: LRUCache = new LRUCache({ max: 500, }); -/** - * Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify - * - * @param error - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function errorStringForLog(error: any) { - let stringifiedError = "" + error; - try { - stringifiedError = JSON.stringify(error); - } catch (e) { - // can happen with Dexie, eg: - // TypeError: Converting circular structure to JSON - // --> starting at object with constructor 'DexieError2' - // | property '_promise' -> object with constructor 'DexiePromise' - // --- property '_value' closes the circle - } - let fullError = "" + error + " - JSON: " + stringifiedError; - const errorResponseText = JSON.stringify(error.response); - // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) - if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { - // add error.response stuff - if (R.equals(error?.config, error?.response?.config)) { - // but exclude "config" because it's already in there - const newErrorResponseText = JSON.stringify( - R.omit(["config"] as never[], error.response), - ); - fullError += " - .response w/o same config JSON: " + newErrorResponseText; - } else { - fullError += " - .response JSON: " + errorResponseText; - } - } - return fullError; -} - /** * @param handleId nullable, in which case "undefined" will be returned * @param requesterDid optional, in which case no private info will be returned @@ -710,6 +675,54 @@ export async function setPlanInCache( planCache.set(handleId, planSummary); } +/** + * + * @param error that is thrown from an Endorser server call by Axios + * @returns user-friendly message, or undefined if none found + */ +export function serverMessageForUser(error: any) { + return ( + // this is how most user messages are returned + error?.response?.data?.error?.message + // some are returned as "error" with a string, but those are more for devs and are less helpful to the user + ); +} + +/** + * Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify + * + * @param error + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function errorStringForLog(error: any) { + let stringifiedError = "" + error; + try { + stringifiedError = JSON.stringify(error); + } catch (e) { + // can happen with Dexie, eg: + // TypeError: Converting circular structure to JSON + // --> starting at object with constructor 'DexieError2' + // | property '_promise' -> object with constructor 'DexiePromise' + // --- property '_value' closes the circle + } + let fullError = "" + error + " - JSON: " + stringifiedError; + const errorResponseText = JSON.stringify(error.response); + // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) + if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { + // add error.response stuff + if (R.equals(error?.config, error?.response?.config)) { + // but exclude "config" because it's already in there + const newErrorResponseText = JSON.stringify( + R.omit(["config"] as never[], error.response), + ); + fullError += " - .response w/o same config JSON: " + newErrorResponseText; + } else { + fullError += " - .response JSON: " + errorResponseText; + } + } + return fullError; +} + /** * * @returns { data: Array, hitLimit: boolean true if maximum was hit and there may be more } @@ -1113,7 +1126,7 @@ export async function createAndSubmitClaim( } catch (error: any) { console.error("Error submitting claim:", error); const errorMessage: string = - error.response?.data?.error?.message || + serverMessageForUser(error) || error.message || "Got some error submitting the claim. Check your permissions, network, and error logs."; diff --git a/src/main.ts b/src/main.ts index 758f844..2aa9c6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { faCalendar, faCamera, faCaretDown, + faChair, faCheck, faChevronDown, faChevronLeft, @@ -100,6 +101,7 @@ library.add( faCalendar, faCamera, faCaretDown, + faChair, faCheck, faChevronDown, faChevronLeft, diff --git a/src/router/index.ts b/src/router/index.ts index b8b1be7..4e6e354 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -179,6 +179,11 @@ const routes: Array = [ name: "offer-details", component: () => import("../views/OfferDetailsView.vue"), }, + { + path: '/onboard-meeting', + name: 'onboard-meeting', + component: () => import('../views/OnboardMeetingView.vue'), + }, { path: "/project/:id?", name: "project", diff --git a/src/views/ClaimReportCertificateView.vue b/src/views/ClaimReportCertificateView.vue new file mode 100644 index 0000000..0cfd08c --- /dev/null +++ b/src/views/ClaimReportCertificateView.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 8d553e2..3dab9fe 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -23,27 +23,48 @@
- - - + + + + + + + + - + + + + + + + + +
+ +

+ Onboarding Meeting +

+ + +
+

Current Meeting

+
+

Name: {{ existingMeeting.name }}

+

Expires: {{ formatExpirationTime(existingMeeting.expiresAt) }}

+

Share the the password with the people you want to onboard.

+
+
+ +
+
+ + +
+
+

Delete Meeting?

+

This action cannot be undone. Are you sure you want to delete this meeting?

+
+ + +
+
+
+ + +
+

Create New Meeting

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/src/views/QuickActionBvcEndView.vue b/src/views/QuickActionBvcEndView.vue index e147a9d..1fbc56e 100644 --- a/src/views/QuickActionBvcEndView.vue +++ b/src/views/QuickActionBvcEndView.vue @@ -22,7 +22,7 @@

Confirm

- +
There are no claims yet today for you to confirm. diff --git a/src/views/TestView.vue b/src/views/TestView.vue index 4d25524..ceb8704 100644 --- a/src/views/TestView.vue +++ b/src/views/TestView.vue @@ -35,7 +35,7 @@ 5000, ) " - class="font-bold uppercase bg-slate-900 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2" > Toast @@ -52,7 +52,7 @@ 5000, ) " - class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2" > Info @@ -69,7 +69,7 @@ 5000, ) " - class="font-bold uppercase bg-emerald-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2" > Success @@ -86,7 +86,7 @@ 5000, ) " - class="font-bold uppercase bg-amber-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2" > Warning @@ -103,7 +103,7 @@ 5000, ) " - class="font-bold uppercase bg-rose-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2" > Danger @@ -118,7 +118,7 @@ -1, ) " - class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2" > Notif ON @@ -133,7 +133,7 @@ -1, ) " - class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2" > Notif MUTE @@ -148,7 +148,7 @@ -1, ) " - class="font-bold uppercase bg-slate-600 text-white px-3 py-2 rounded-md mr-2" + class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2" > Notif OFF @@ -184,7 +184,7 @@ Register Passkey @@ -194,13 +194,13 @@ Create JWT @@ -210,19 +210,19 @@ Verify New JWT @@ -230,11 +230,25 @@
Verify New JWT -- requires creation first
+ +
+

Encryption & Decryption

+ See console for more output. +
+ + Result: {{ encryptionTestResult }} +
+
@@ -248,6 +262,7 @@ import { Router } from "vue-router"; import QuickNav from "@/components/QuickNav.vue"; import { AppString, NotificationIface } from "@/constants/app"; import { db, retrieveSettingsForActiveAccount } from "@/db/index"; +import * as cryptoLib from "@/libs/crypto"; import * as vcLib from "@/libs/crypto/vc"; import { PeerSetup, @@ -279,6 +294,9 @@ const TEST_PAYLOAD = { export default class Help extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; + // for encryption/decryption + encryptionTestResult?: boolean; + // for file import fileName?: string; @@ -289,6 +307,8 @@ export default class Help extends Vue { peerSetup?: PeerSetup; userName?: string; + cryptoLib = cryptoLib; + async mounted() { const settings = await retrieveSettingsForActiveAccount(); this.activeDid = settings.activeDid || ""; @@ -363,6 +383,10 @@ export default class Help extends Vue { this.credIdHex = account.passkeyCredIdHex; } + public async testEncryptionDecryption() { + this.encryptionTestResult = await cryptoLib.testEncryptionDecryption(); + } + public async createJwtSimplewebauthn() { const account: AccountKeyInfo | undefined = await retrieveAccountMetadata( this.activeDid || "",