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);