From e80c5b6e14bcae75afd083c331bd2b1e1f8df29e Mon Sep 17 00:00:00 2001 From: Nicolas Mattia Date: Thu, 7 Dec 2023 15:03:23 +0100 Subject: [PATCH] Pre-fill anchors in VC flow (#2109) * Pre-fill anchors in VC flow This updates the storage to store (a digest of) principals of visited dapps. This allows the VC flow to know (in the general, best case scenario) which anchor (identity number) the user is intending to use. The storage migration includes three things: * Anchors are not written directly as `{ 10000: }` anymore but are nested as `{ anchors: { 10000: } }` to allow for a new top-level field `hasher` which is global to all anchors. * Said new field `hasher` is introduced * Anchors themselves now have a new field, `knownPrincipalDigests`. See storage module documentation for more information on the digests. * Improve principal storage & test * Prune old principals --- .../flows/authorize/postMessageInterface.ts | 10 + .../src/flows/verifiableCredentials/index.ts | 8 +- src/frontend/src/storage/index.test.ts | 150 ++++-- src/frontend/src/storage/index.ts | 445 ++++++++++++++---- .../test-e2e/verifiableCredentials.test.ts | 3 +- src/frontend/src/test-e2e/views.ts | 4 + 6 files changed, 494 insertions(+), 126 deletions(-) diff --git a/src/frontend/src/flows/authorize/postMessageInterface.ts b/src/frontend/src/flows/authorize/postMessageInterface.ts index 91d5a405b5..ce745bafbe 100644 --- a/src/frontend/src/flows/authorize/postMessageInterface.ts +++ b/src/frontend/src/flows/authorize/postMessageInterface.ts @@ -1,5 +1,6 @@ // Types and functions related to the window post message interface used by // applications that want to authenticate the user using Internet Identity +import { setKnownPrincipal } from "$src/storage"; import { LoginData } from "$src/utils/flowResult"; import { unknownToRecord } from "$src/utils/utils"; import { Signature } from "@dfinity/agent"; @@ -179,6 +180,15 @@ export async function authenticationProtocol({ const [userKey, parsed_signed_delegation] = result; + const userPublicKey = Uint8Array.from(userKey); + const principal = Principal.selfAuthenticating(userPublicKey); + + await setKnownPrincipal({ + userNumber: authSuccess.userNumber, + origin: authContext.requestOrigin, + principal, + }); + window.opener.postMessage( { kind: "authorize-client-success", diff --git a/src/frontend/src/flows/verifiableCredentials/index.ts b/src/frontend/src/flows/verifiableCredentials/index.ts index b467c3e624..9dc6ce9064 100644 --- a/src/frontend/src/flows/verifiableCredentials/index.ts +++ b/src/frontend/src/flows/verifiableCredentials/index.ts @@ -8,6 +8,7 @@ import { withLoader } from "$src/components/loader"; import { showMessage } from "$src/components/message"; import { showSpinner } from "$src/components/spinner"; import { fetchDelegation } from "$src/flows/authorize/fetchDelegation"; +import { getAnchorByPrincipal } from "$src/storage"; import { AuthenticatedConnection, Connection } from "$src/utils/iiConnection"; import { Delegation, @@ -98,12 +99,17 @@ const verifyCredentials = async ({ return abortedCredentials({ reason: "auth_failed_issuer" }); } + const userNumber_ = await getAnchorByPrincipal({ + origin: rpOrigin, + principal: givenP_RP, + }); + // Ask user to confirm the verification of credentials const allowed = await allowCredentials({ relyingOrigin: rpOrigin, providerOrigin: issuerOrigin, consentMessage: consentInfo.consent_message, - userNumber: undefined, + userNumber: userNumber_, }); if (allowed.tag === "canceled") { return "aborted"; diff --git a/src/frontend/src/storage/index.test.ts b/src/frontend/src/storage/index.test.ts index 5694cfda87..6086d46b1d 100644 --- a/src/frontend/src/storage/index.test.ts +++ b/src/frontend/src/storage/index.test.ts @@ -1,3 +1,4 @@ +import { Principal } from "@dfinity/principal"; import { nonNullish } from "@dfinity/utils"; import { IDBFactory } from "fake-indexeddb"; import { @@ -6,7 +7,14 @@ import { keys as idbKeys, set as idbSet, } from "idb-keyval"; -import { MAX_SAVED_ANCHORS, getAnchors, setAnchorUsed } from "."; +import { + MAX_SAVED_ANCHORS, + MAX_SAVED_PRINCIPALS, + getAnchorByPrincipal, + getAnchors, + setAnchorUsed, + setKnownPrincipal, +} from "."; beforeAll(() => { // Initialize the IndexedDB global @@ -18,7 +26,7 @@ test("anchors default to nothing", async () => { }); test( - "old userNumber is recovered", + "old userNumber V0 is recovered", withStorage( async () => { expect(await getAnchors()).toStrictEqual([BigInt(123456)]); @@ -76,20 +84,18 @@ test( { localStorage: { before: { userNumber: "123456" }, - after: (storage) => { - const value = storage["anchors"]; - expect(value).toBeDefined(); - const anchors = JSON.parse(value); - expect(anchors).toBeTypeOf("object"); - expect(anchors["123456"]).toBeDefined(); - }, }, indexeddb: { after: (storage) => { + // Written to V3 // eslint-disable-next-line @typescript-eslint/no-explicit-any - const anchors: any = storage["anchors"]; - expect(anchors).toBeTypeOf("object"); - expect(anchors["123456"]).toBeDefined(); + const storageV3: any = storage["ii-storage-v3"]; + expect(storageV3).toBeTypeOf("object"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anchorsV3: any = storageV3["anchors"]; + expect(anchorsV3).toBeTypeOf("object"); + expect(anchorsV3["123456"]).toBeDefined(); }, }, } @@ -117,7 +123,30 @@ test( ); test( - "anchors are also written to localstorage", + "V2 anchors are migrated", + withStorage( + async () => { + expect(await getAnchors()).toContain(BigInt(10000)); + expect(await getAnchors()).toContain(BigInt(10001)); + expect(await getAnchors()).toContain(BigInt(10003)); + }, + { + indexeddb: { + before: { + /* V2 layout */ + anchors: { + "10000": { lastUsedTimestamp: 0 }, + "10001": { lastUsedTimestamp: 0 }, + "10003": { lastUsedTimestamp: 0 }, + }, + }, + }, + } + ) +); + +test( + "anchors are also written to V2", withStorage( async () => { await setAnchorUsed(BigInt(10000)); @@ -125,15 +154,15 @@ test( await setAnchorUsed(BigInt(10003)); }, { - localStorage: { + indexeddb: { after: (storage) => { - const value = storage["anchors"]; - expect(value).toBeDefined(); - const anchors = JSON.parse(value); - expect(anchors).toBeTypeOf("object"); - expect(anchors["10000"]).toBeDefined(); - expect(anchors["10001"]).toBeDefined(); - expect(anchors["10003"]).toBeDefined(); + // Written to V2 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anchorsV2: any = storage["anchors"]; + expect(anchorsV2).toBeTypeOf("object"); + expect(anchorsV2["10000"]).toBeDefined(); + expect(anchorsV2["10001"]).toBeDefined(); + expect(anchorsV2["10003"]).toBeDefined(); }, }, } @@ -162,6 +191,28 @@ test( }) ); +test( + "principal digests are stored", + withStorage(async () => { + const origin = "https://example.com"; + const principal = Principal.fromText("2vxsx-fae"); + await setKnownPrincipal({ + userNumber: BigInt(10000), + origin, + principal, + }); + + const otherOrigin = "https://other.com"; + expect( + await getAnchorByPrincipal({ origin: otherOrigin, principal }) + ).not.toBeDefined(); + + expect(await getAnchorByPrincipal({ origin, principal })).toBe( + BigInt(10000) + ); + }) +); + test( "old anchors are dropped", withStorage(async () => { @@ -180,30 +231,43 @@ test( ); test( - "unknown fields are not dropped", - withStorage( - async () => { - vi.useFakeTimers().setSystemTime(new Date(20)); - await setAnchorUsed(BigInt(10000)); - vi.useRealTimers(); - }, - { - indexeddb: { - before: { - anchors: { - "10000": { lastUsedTimestamp: 10, hello: "world" }, - }, - }, - after: { - anchors: { - "10000": { lastUsedTimestamp: 20, hello: "world" }, - }, - }, - }, + "old principals are dropped", + withStorage(async () => { + const userNumber = BigInt(10000); + const principal = Principal.fromText("2vxsx-fae"); + const oldOrigin = "https://old.com"; + const veryOldOrigin = "https://very.old.com"; + vi.useFakeTimers().setSystemTime(new Date(0)); + await setKnownPrincipal({ userNumber, principal, origin: veryOldOrigin }); + vi.useFakeTimers().setSystemTime(new Date(1)); + await setKnownPrincipal({ userNumber, principal, origin: oldOrigin }); + let date = 2; + vi.useFakeTimers().setSystemTime(new Date(date)); + for (let i = 0; i < MAX_SAVED_PRINCIPALS; i++) { + date++; + vi.useFakeTimers().setSystemTime(new Date(date)); + await setKnownPrincipal({ + userNumber, + principal, + origin: `https://new${i}.com`, + }); } - ) + date++; + vi.useFakeTimers().setSystemTime(new Date(date)); + const newOrigin = "https://new.com"; + await setKnownPrincipal({ userNumber, principal, origin: newOrigin }); + expect( + await getAnchorByPrincipal({ principal, origin: veryOldOrigin }) + ).not.toBeDefined(); + expect( + await getAnchorByPrincipal({ principal, origin: oldOrigin }) + ).not.toBeDefined(); + expect( + await getAnchorByPrincipal({ principal, origin: newOrigin }) + ).toBeDefined(); + vi.useRealTimers(); + }) ); - /** Test storage usage. Storage is cleared after the callback has returned. * If `before` is specified, storage is populated with its content before the test is run. * If `after` is specified, the content of storage are checked against `after` after the diff --git a/src/frontend/src/storage/index.ts b/src/frontend/src/storage/index.ts index 0dfb9d0939..0c83f35c88 100644 --- a/src/frontend/src/storage/index.ts +++ b/src/frontend/src/storage/index.ts @@ -1,24 +1,11 @@ /* Everything related to storage of user flow data, like anchor numbers, last used anchor, etc. */ import { parseUserNumber } from "$src/utils/userNumber"; -import { nonNullish } from "@dfinity/utils"; +import { Principal } from "@dfinity/principal"; +import { isNullish, nonNullish } from "@dfinity/utils"; import { get as idbGet, set as idbSet } from "idb-keyval"; import { z } from "zod"; -/** The Anchor type as stored in storage, including hint of the frequency at which the anchor is used. - * If you change this type, please add a migration */ -type Anchor = z.infer; -const Anchor = z - .object({ - /** Timestamp (mills since epoch) of when anchor was last used */ - lastUsedTimestamp: z.number(), - }) - .passthrough(); /* ensures keys not listed in schema are kept during parse */ - -/** The type of all anchors in storage. */ -type Anchors = z.infer; -const Anchors = z.record(Anchor); - /** We keep as many anchors as possible for two reasons: * - we should design the app to discourage having many many anchors, but shouldn't prevent it * - it's the only place where users may see all their anchors @@ -27,10 +14,16 @@ const Anchors = z.record(Anchor); */ export const MAX_SAVED_ANCHORS = 10; +/** We don't keep an infinite number of principals to not bloat the storage. We do + * keep a significant number however, since (depending on the max TTL for delegation) + * a long time might have elapsed since we stored the principal (auth with RP) and the + * moment the user tries to verify a credential. */ +export const MAX_SAVED_PRINCIPALS = 40; + /** Read saved anchors, sorted */ export const getAnchors = async (): Promise => { - const data = await readAnchors(); - const anchors = Object.keys(data).map((ix) => BigInt(ix)); + const data = await readStorage(); + const anchors = Object.keys(data.anchors).map((ix) => BigInt(ix)); // NOTE: This sort here is only used to ensure users see a stable ordering of anchors. anchors.sort(); @@ -40,30 +33,121 @@ export const getAnchors = async (): Promise => { /** Set the specified anchor as used "just now" */ export const setAnchorUsed = async (userNumber: bigint) => { - await withAnchors((anchors) => { + await withStorage((storage) => { + const ix = userNumber.toString(); + + const anchors = storage.anchors; + const defaultAnchor: Omit = { + knownPrincipals: [], + }; + const oldAnchor = anchors[ix] ?? defaultAnchor; + + // Here we try to be as non-destructive as possible and we keep potentially unknown + // fields + storage.anchors[ix] = { ...oldAnchor, lastUsedTimestamp: nowMillis() }; + return storage; + }); +}; + +/** Look up an anchor by principal. + * In reality the anchor is looked up by principal _digest_, see `computePrincipalDigest` for + * more information. + */ +export const getAnchorByPrincipal = async ({ + origin, + principal, +}: { + origin: string; + principal: Principal; +}): Promise => { + const storage = await readStorage(); + const anchors = storage.anchors; + + const digest = await computePrincipalDigest({ + origin, + principal, + hasher: storage.hasher, + }); + + for (const ix in anchors) { + const anchor: Anchor = anchors[ix]; + + if (anchor.knownPrincipals.some((digest_) => digest_.digest === digest)) { + return BigInt(ix); + } + } + + return; +}; + +/** Set the principal as "known"; i.e. from which the anchor can be "looked up" */ +export const setKnownPrincipal = async ({ + userNumber, + origin, + principal, +}: { + userNumber: bigint; + origin: string; + principal: Principal; +}) => { + await withStorage(async (storage) => { + const defaultAnchor: AnchorV3 = { + knownPrincipals: [], + lastUsedTimestamp: nowMillis(), + }; + const ix = userNumber.toString(); + const anchors = storage.anchors; + const oldAnchor = anchors[ix] ?? defaultAnchor; + + const digest = await computePrincipalDigest({ + origin, + principal, + hasher: storage.hasher, + }); + + const principalData = { + digest, + lastUsedTimestamp: nowMillis(), + }; + + // Remove the principal, if we've encountered it already + const dedupedPrincipals = oldAnchor.knownPrincipals.filter( + (principalData_) => principalData_.digest !== principalData.digest + ); + + // Add the new principal and sort (most recently used is first) + dedupedPrincipals.push(principalData); + dedupedPrincipals.sort((a, b) => b.lastUsedTimestamp - a.lastUsedTimestamp); + + // Only keep the more recent N principals + const prunedPrincipals = dedupedPrincipals.slice(0, MAX_SAVED_PRINCIPALS); // Here we try to be as non-destructive as possible and we keep potentially unknown // fields - anchors[ix] = { ...anchors[ix], lastUsedTimestamp: nowMillis() }; - return anchors; + storage.anchors[ix] = { ...oldAnchor, knownPrincipals: prunedPrincipals }; + return storage; }); }; /** Accessing functions */ // Simply read the storage without updating it -const readAnchors = (): Promise => { - return updateStorage((anchors) => { - return { ret: anchors, updated: false }; +const readStorage = (): Promise => { + return updateStorage((storage) => { + return { ret: storage, updated: false }; }); }; +type Awaitable = T | PromiseLike; + // Read & update the storage -const withAnchors = (op: (anchors: Anchors) => Anchors): Promise => { - return updateStorage((anchors) => { - const newAnchors = op(anchors); - return { ret: newAnchors, updated: true }; +const withStorage = ( + op: (storage: Storage) => Awaitable +): Promise => { + return updateStorage(async (storage) => { + const newStorage = await op(storage); + return { ret: newStorage, updated: true }; }); }; @@ -71,38 +155,48 @@ const withAnchors = (op: (anchors: Anchors) => Anchors): Promise => { * are read correctly (found, migrated, or have a sensible default) and pruned * before being written back (if updated) */ const updateStorage = async ( - op: (anchors: Anchors) => { - ret: Anchors; + op: (storage: Storage) => Awaitable<{ + ret: Storage; updated: boolean /* iff true, anchors are updated in storage */; - } -): Promise => { + }> +): Promise => { let doWrite = false; - const storedAnchors = await readIndexedDB(); + const storedStorage = await readIndexedDB(); - const { ret: migratedAnchors, didMigrate } = nonNullish(storedAnchors) - ? { ret: storedAnchors, didMigrate: false } - : { ret: migrated() ?? {}, didMigrate: true }; + const { ret: migratedStorage, didMigrate } = nonNullish(storedStorage) + ? { ret: storedStorage, didMigrate: false } + : { + ret: (await migrated()) ?? { anchors: {}, hasher: await newHMACKey() }, + didMigrate: true, + }; doWrite ||= didMigrate; - const { ret: updatedAnchors, updated } = op(migratedAnchors); + const { ret: updatedStorage, updated } = await op(migratedStorage); doWrite ||= updated; - const { ret: prunedAnchors, pruned } = pruneAnchors(updatedAnchors); + const { ret: prunedStorage, pruned } = pruneStorage(updatedStorage); doWrite ||= pruned; if (doWrite) { - await writeIndexedDB(prunedAnchors); - // NOTE: we keep local storage up to date in case of rollback - writeLocalStorage(prunedAnchors); + await writeIndexedDB(prunedStorage); + + // NOTE: we keep V2 up to date in case of rollback + await writeIndexedDBV2(prunedStorage.anchors); } - return prunedAnchors; + return prunedStorage; }; /** Migration & Pruning of anchors */ /** Remove most unused anchors until there are MAX_SAVED_ANCHORS left */ + +const pruneStorage = (storage: Storage): { ret: Storage; pruned: boolean } => { + const { ret: prunedAnchors, pruned } = pruneAnchors(storage.anchors); + return { ret: { ...storage, anchors: prunedAnchors }, pruned }; +}; + const pruneAnchors = (anchors: Anchors): { ret: Anchors; pruned: boolean } => { // this is equivalent to while(anchors.length > MAX) // but avoids potential infinite loops if for some reason anchors can't @@ -137,28 +231,99 @@ const mostUnused = (anchors: Anchors): string | undefined => { /** Best effort to migrate potentially old localStorage data to * the latest format */ -const migrated = (): Anchors | undefined => { +const migrated = async (): Promise => { + // Try to read the "v2" (idb.anchors) storage and return that if found + const v2 = await migratedV2(); + if (nonNullish(v2)) { + return v2; + } + // Try to read the "v1" (localStorage.anchors) storage and return that if found - const v1 = migratedV1(); + const v1 = await migratedV1(); if (nonNullish(v1)) { return v1; } // Try to read the "v0" (localStorage.userNumber) storage and return that if found - const v0 = migratedV0(); + const v0 = await migratedV0(); if (nonNullish(v0)) { return v0; } }; -/* Read localStorage data as ls["anchors"] = { "10000": {...}} */ -const migratedV1 = (): Anchors | undefined => { - // NOTE: we do not wipe local storage but keep it around in case of rollback - return readLocalStorage(); +/** Helpers */ + +// Current timestamp, in milliseconds +const nowMillis = (): number => { + return new Date().getTime(); }; -/* Read localStorage data as ls["userNumber"] = "10000" */ -const migratedV0 = (): Anchors | undefined => { +/** + * This computes a digest for a principal. + * Anchors store digests for every principal II generates for them. That way when a user is + * returning to II with a known principal BUT unknown anchor (like is the case in the verifiable + * credentials flow) we can know for sure what anchor they mean to use. + * + * The digest is derived from the actual principal but does not (in theory) allow figuring out + * the actual principal from the digest. This is done to minimize the information leaked if the + * user's browser is compromised: the principals associated with and origin of the visited dapps + * should in principle not be leaked. + * + * The digest is a sha hash of the origin, principal and some secret, i.e. a HMAC digest (with + * non-extractable HMAC key). The origin is hashed in to further reduce the already unlikely + * possibility of collisions. + */ +const computePrincipalDigest = async ({ + origin, + principal: principal_, + hasher, +}: { + origin: string; + principal: Principal; + hasher: CryptoKey; +}): Promise => { + // Create a buffer with origin & principal + const enc = new TextEncoder(); + const principal = principal_.toText(); + const buff = enc.encode( + origin + origin.length.toString() + principal + principal.length.toString() + ); + + // Create the digest + const digestBytes = await crypto.subtle.sign("HMAC", hasher, buff); + const digest = arrayBufferToBase64(digestBytes); + return digest; +}; + +/* Generate HMAC key */ +const newHMACKey = async (): Promise => { + const key = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-512" }, + false /* not extractable */, + ["sign"] /* only used to "sign" (e.g. produce a digest ) */ + ); + + return key; +}; + +/* Read an arraybuffer as a base64 string + * https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string + */ +function arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} + +/** Versions */ + +/** V0, localstorage["userNumber"] = 10000 */ + +const migratedV0 = async (): Promise => { // Nothing to do if no 'userNumber's are stored const userNumberString = localStorage.getItem("userNumber"); if (userNumberString === null) { @@ -175,41 +340,48 @@ const migratedV0 = (): Anchors | undefined => { const ix = userNumber.toString(); - const anchors = { [ix]: { lastUsedTimestamp: nowMillis() } }; + const anchors = { + [ix]: { lastUsedTimestamp: nowMillis(), knownPrincipals: [] }, + }; - return anchors; + const hasher = await newHMACKey(); + return { anchors, hasher }; }; -/** "Low-level" functions to read anchors from and write anchors to IndexedDB */ +/** + * V1, localstorage["anchors"] = { 10000: ... } + * */ -const readIndexedDB = async (): Promise => { - const item: unknown = await idbGet("anchors"); +type AnchorV1 = z.infer; +const AnchorV1 = z.object({ + /** Timestamp (mills since epoch) of when anchor was last used */ + lastUsedTimestamp: z.number(), +}); - if (item === undefined) { - return; - } +/** The type of all anchors in storage. */ +type AnchorsV1 = z.infer; +const AnchorsV1 = z.record(AnchorV1); - // Read the object - const parsed = Anchors.safeParse(item); - if (parsed.success !== true) { - const message = - `could not read saved identities: ignoring malformed IndexedDB data: ` + - parsed.error; - console.warn(message); - return {}; +/* Read localStorage data as ls["anchors"] = { "10000": {...}} */ +const migratedV1 = async (): Promise => { + // NOTE: we do not wipe local storage but keep it around in case of rollback + const anchors: AnchorsV1 | undefined = readLocalStorageV1(); + if (isNullish(anchors)) { + return undefined; } - return parsed.data; -}; + const migratedAnchors: AnchorsV3 = {}; + for (const userNumber in anchors) { + const oldAnchor = anchors[userNumber]; + migratedAnchors[userNumber] = { ...oldAnchor, knownPrincipals: [] }; + } -const writeIndexedDB = async (anchors: Anchors) => { - await idbSet("anchors", anchors); + const hasher = await newHMACKey(); + return { anchors: migratedAnchors, hasher }; }; -/** "Low-level" serialization functions to read and write anchors to local storage */ - // Read localstorage stored anchors -const readLocalStorage = (): Anchors | undefined => { +const readLocalStorageV1 = (): AnchorsV1 | undefined => { const raw = localStorage.getItem("anchors"); // Abort @@ -227,7 +399,7 @@ const readLocalStorage = (): Anchors | undefined => { } // Actually parse the JSON object - const parsed = Anchors.safeParse(item); + const parsed = AnchorsV1.safeParse(item); if (parsed.success !== true) { const message = `could not read saved identities: ignoring malformed localstorage data: ` + @@ -239,14 +411,125 @@ const readLocalStorage = (): Anchors | undefined => { return parsed.data; }; -// Write localstorage stored anchors -const writeLocalStorage = (anchors: Anchors) => { - localStorage.setItem("anchors", JSON.stringify(anchors)); +/** + * V2, indexeddb["anchors"] = { 10000: ... } + * */ + +type AnchorsV2 = z.infer; +type AnchorV2 = z.infer; + +const AnchorV2 = z.object({ + /** Timestamp (mills since epoch) of when anchor was last used */ + lastUsedTimestamp: z.number(), +}); +const AnchorsV2 = z.record(AnchorV2); + +const migratedV2 = async (): Promise => { + const readAnchors = await readIndexedDBV2(); + + if (isNullish(readAnchors)) { + return undefined; + } + + // No known principals + const migratedAnchors: AnchorsV3 = {}; + + for (const userNumber in readAnchors) { + const oldAnchor = readAnchors[userNumber]; + migratedAnchors[userNumber] = { ...oldAnchor, knownPrincipals: [] }; + } + + const hasher = await newHMACKey(); + + return { anchors: migratedAnchors, hasher }; }; -/** Helpers */ +const readIndexedDBV2 = async (): Promise => { + const item: unknown = await idbGet("anchors"); -// Current timestamp, in milliseconds -const nowMillis = (): number => { - return new Date().getTime(); + if (item === undefined) { + return; + } + + // Read the object + const parsed = AnchorsV2.safeParse(item); + if (parsed.success !== true) { + const message = + `could not read saved identities: ignoring malformed IndexedDB data: ` + + parsed.error; + console.warn(message); + return {}; + } + + return parsed.data; +}; + +const writeIndexedDBV2 = async (anchors: AnchorsV2) => { + await idbSet("anchors", anchors); }; + +/** + * V3, indexeddb["ii-storage-v3"] = { anchors: { 20000: ... } } + * */ + +type StorageV3 = z.infer; +type AnchorsV3 = z.infer; +type AnchorV3 = z.infer; +type PrincipalDataV3 = z.infer; + +const IDB_KEY_V3 = "ii-storage-v3"; + +const PrincipalDataV3 = z.object({ + /** The actual digest */ + digest: z.string(), + + /** The last time the user authenticated with the principal */ + lastUsedTimestamp: z.number(), +}); + +const AnchorV3 = z.object({ + /** Timestamp (mills since epoch) of when anchor was last used */ + lastUsedTimestamp: z.number(), + + knownPrincipals: z.array(PrincipalDataV3), +}); +const AnchorsV3 = z.record(AnchorV3); + +/** The type of all anchors in storage. */ +const StorageV3 = z.object({ + anchors: AnchorsV3, + hasher: z.instanceof(CryptoKey), +}); + +const readIndexedDBV3 = async (): Promise => { + const item: unknown = await idbGet(IDB_KEY_V3); + + if (isNullish(item)) { + return; + } + + // Read the object + const parsed = StorageV3.safeParse(item); + if (parsed.success !== true) { + const message = + `could not read saved identities: ignoring malformed IndexedDB data: ` + + parsed.error; + console.warn(message); + return { anchors: {}, hasher: await newHMACKey() }; + } + + return parsed.data; +}; + +const writeIndexedDBV3 = async (storage: Storage) => { + await idbSet(IDB_KEY_V3, storage); +}; + +/* Latest */ + +/* Always points to the latest storage & anchor types */ +type Storage = StorageV3; +type Anchor = AnchorV3; +type Anchors = Storage["anchors"]; +const readIndexedDB = readIndexedDBV3; +const writeIndexedDB = writeIndexedDBV3; diff --git a/src/frontend/src/test-e2e/verifiableCredentials.test.ts b/src/frontend/src/test-e2e/verifiableCredentials.test.ts index 6b148a0422..6a2f785d28 100644 --- a/src/frontend/src/test-e2e/verifiableCredentials.test.ts +++ b/src/frontend/src/test-e2e/verifiableCredentials.test.ts @@ -125,7 +125,8 @@ const getDomain = (url: string) => url.split(".").slice(1).join("."); const vcAllow = new VcAllowView(browser); await vcAllow.waitForDisplay(); - await vcAllow.typeUserNumber(userNumber); + const userNumber_ = await vcAllow.getUserNumber(); + expect(userNumber_).toBe(userNumber); await vcAllow.allow(); await waitToClose(browser); diff --git a/src/frontend/src/test-e2e/views.ts b/src/frontend/src/test-e2e/views.ts index 398b7a78a2..aa611f7323 100644 --- a/src/frontend/src/test-e2e/views.ts +++ b/src/frontend/src/test-e2e/views.ts @@ -579,6 +579,10 @@ export class VcAllowView extends View { await this.browser.$('[data-action="allow"]').click(); } + async getUserNumber(): Promise { + return await this.browser.$('[data-role="anchor-input"]').getValue(); + } + async typeUserNumber(userNumber: string): Promise { await this.browser.$('[data-role="anchor-input"]').waitForDisplayed(); await this.browser.$('[data-role="anchor-input"]').setValue(userNumber);