diff --git a/packages/dappfence/src/core/constants.js b/packages/dappfence/src/core/constants.js index 3bbbfd0a..56f4cbb3 100644 --- a/packages/dappfence/src/core/constants.js +++ b/packages/dappfence/src/core/constants.js @@ -67,6 +67,18 @@ export const VERIFICATION_STATUS = Object.freeze({ /** @typedef {typeof VERIFICATION_STATUS[keyof typeof VERIFICATION_STATUS]} VerificationStatus */ +/** + * The subset of status descriptions that actually reach the warning page. + * `recordSecurityViolation` short-circuits non-violation verdicts before + * recording a block, so MATCH / SKIPPED / REWRITE / CSP_PROTECTED / + * REDIRECT never appear on stored blocks. Dispatch tables that only + * handle violating cases key off this type. + * Hand-maintained because `verdict()` returns `isViolation: boolean` (not + * a literal), so tsc can't filter the enum by that flag automatically — + * if a new violating status is added to VERIFICATION_STATUS, extend here. + * @typedef {'FILE_UNTRUSTED' | 'DENIED_BY_RULE' | 'ERROR' | 'MANIFEST_UNTRUSTED'} ViolatingStatusName + */ + /** * @typedef {'NULL_RESPONSE' * | 'BODY_UNREADABLE' diff --git a/packages/dappfence/src/sw/__tests__/api-handler.test.js b/packages/dappfence/src/sw/__tests__/api-handler.test.js index 87d6fe97..29dd3325 100644 --- a/packages/dappfence/src/sw/__tests__/api-handler.test.js +++ b/packages/dappfence/src/sw/__tests__/api-handler.test.js @@ -31,6 +31,10 @@ function createMockAppStore() { manifest: { files: { '/app.js': 'hash1' } }, }), }, + activeIdentityStore: { + getActiveIdentity: vi.fn().mockResolvedValue(undefined), + updateActiveIdentity: vi.fn().mockResolvedValue(undefined), + }, verificationResultsStore: { get: vi.fn().mockResolvedValue([{ file: '/app.js', status: 'match' }]), }, @@ -60,7 +64,14 @@ describe('createApiHandler', () => { beforeEach(() => { appStore = createMockAppStore(); onSecurityViolation = vi.fn(); - handler = createApiHandler({ onSecurityViolation, appStore }); + handler = createApiHandler({ + onSecurityViolation, + appStore, + config: { + manifestSignatureType: 'ethereum-personal-sign', + manifestSignatureIdentity: '0xtest', + }, + }); }); describe('authentication', () => { @@ -263,7 +274,9 @@ describe('createApiHandler', () => { const { activeBlocks } = decodeInlinedConfig(await res.text()); expect(activeBlocks[0].expectedHashes).toEqual([]); - expect(activeBlocks[0].actualHash).toBe('N/A'); + // actualHash is null when unset; the template renders 'Not available' + // only for views that opt into the actual row. + expect(activeBlocks[0].actualHash).toBeNull(); expect(activeBlocks[0].occurrenceCount).toBe(1); }); diff --git a/packages/dappfence/src/sw/__tests__/manifest-loader.test.js b/packages/dappfence/src/sw/__tests__/manifest-loader.test.js index fcd826f9..8ebe443a 100644 --- a/packages/dappfence/src/sw/__tests__/manifest-loader.test.js +++ b/packages/dappfence/src/sw/__tests__/manifest-loader.test.js @@ -37,13 +37,17 @@ function makeSwContext({ }; } -function makeAppStore({ addLatestResult = MANIFEST_INFO } = {}) { +function makeAppStore({ addLatestResult = MANIFEST_INFO, activeIdentity } = {}) { return { trustedManifestStore: { addLatest: vi.fn(() => Promise.resolve(addLatestResult)), getLatest: vi.fn(() => Promise.resolve(undefined)), getAll: vi.fn(() => Promise.resolve([])), }, + activeIdentityStore: { + getActiveIdentity: vi.fn(() => Promise.resolve(activeIdentity)), + updateActiveIdentity: vi.fn((identity) => Promise.resolve(identity)), + }, }; } @@ -172,7 +176,8 @@ describe('fetchAndStoreManifest', () => { expect(result.status).toBe(VERIFICATION_STATUS.MATCH); expect(result.appVersion).toBe('v-ok'); expect(appStore.trustedManifestStore.addLatest).toHaveBeenCalledWith( - normalizeManifestData(VALID_PAYLOAD) + normalizeManifestData(VALID_PAYLOAD), + { signatureType: 'secp256k1', identity: '0xABCDEF' } ); }); }); diff --git a/packages/dappfence/src/sw/__tests__/manifest-store.test.js b/packages/dappfence/src/sw/__tests__/manifest-store.test.js index ab3fcf98..e1d2d112 100644 --- a/packages/dappfence/src/sw/__tests__/manifest-store.test.js +++ b/packages/dappfence/src/sw/__tests__/manifest-store.test.js @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createManifestStore } from '../storage/manifest-store.js'; +const MOCK_SIGNER = { signatureType: 'ethereum-personal-sign', identity: '0xmocksigner' }; + function createInMemoryStorage() { const store = new Map(); return { @@ -34,23 +36,29 @@ describe('createManifestStore', () => { }); it('addLatest synthesizes a deterministic appVersion from manifest content', async () => { - const { appVersion } = await storage.trustedManifestStore.addLatest({ - files: { '/a.js': 'h' }, - }); + const { appVersion } = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'h' } }, + MOCK_SIGNER + ); expect(appVersion).toMatch(/^manifest-[A-Za-z0-9+/]{16}$/); - const dup = await storage.trustedManifestStore.addLatest({ - files: { '/a.js': 'h' }, - }); + const dup = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'h' } }, + MOCK_SIGNER + ); expect(dup.appVersion).toBe(appVersion); - const other = await storage.trustedManifestStore.addLatest({ - files: { '/b.js': 'h2' }, - }); + const other = await storage.trustedManifestStore.addLatest( + { files: { '/b.js': 'h2' } }, + MOCK_SIGNER + ); expect(other.appVersion).not.toBe(appVersion); }); it('addLatest stores a manifest retrievable by appVersion and via getLatest', async () => { const manifestData = { files: { '/app.js': 'abc123', '/style.css': 'def456' } }; - const { appVersion } = await storage.trustedManifestStore.addLatest(manifestData); + const { appVersion } = await storage.trustedManifestStore.addLatest( + manifestData, + MOCK_SIGNER + ); expect(await storage.trustedManifestStore.get(appVersion)).toEqual(manifestData); expect(await storage.trustedManifestStore.getLatest()).toEqual( @@ -68,29 +76,85 @@ describe('createManifestStore', () => { metadata: { extensions: ['.js', '.wasm'] }, customField: { future: true }, }; - const { appVersion } = await storage.trustedManifestStore.addLatest(manifestData); + const { appVersion } = await storage.trustedManifestStore.addLatest( + manifestData, + MOCK_SIGNER + ); expect(await storage.trustedManifestStore.get(appVersion)).toEqual(manifestData); expect((await storage.trustedManifestStore.getLatest()).manifest).toEqual(manifestData); }); it('getLatest returns the most recently added manifest', async () => { - await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } }); - const second = await storage.trustedManifestStore.addLatest({ - files: { '/b.js': 'y' }, - }); + await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } }, MOCK_SIGNER); + const second = await storage.trustedManifestStore.addLatest( + { files: { '/b.js': 'y' } }, + MOCK_SIGNER + ); const latest = await storage.trustedManifestStore.getLatest(); expect(latest.appVersion).toBe(second.appVersion); expect(latest.manifest).toEqual({ files: { '/b.js': 'y' } }); }); + describe('revokeManifests flag', () => { + it('replaces the entire list with just the new entry when flag is true', async () => { + await storage.trustedManifestStore.addLatest( + { files: { '/old1.js': 'a' } }, + MOCK_SIGNER + ); + await storage.trustedManifestStore.addLatest( + { files: { '/old2.js': 'b' } }, + MOCK_SIGNER + ); + expect(await storage.trustedManifestStore.getAll()).toHaveLength(2); + + const { appVersion } = await storage.trustedManifestStore.addLatest( + { files: { '/new.js': 'c' }, revokeManifests: true }, + MOCK_SIGNER + ); + const all = await storage.trustedManifestStore.getAll(); + expect(all).toHaveLength(1); + expect(all[0].appVersion).toBe(appVersion); + expect(all[0].manifest).toEqual({ + files: { '/new.js': 'c' }, + revokeManifests: true, + }); + }); + + it('still prepends normally when flag is absent or falsy', async () => { + await storage.trustedManifestStore.addLatest( + { files: { '/old.js': 'a' } }, + MOCK_SIGNER + ); + await storage.trustedManifestStore.addLatest( + { files: { '/new.js': 'b' }, revokeManifests: false }, + MOCK_SIGNER + ); + expect(await storage.trustedManifestStore.getAll()).toHaveLength(2); + }); + + it('updates the in-memory cache after a revoke', async () => { + await storage.trustedManifestStore.addLatest( + { files: { '/old.js': 'a' } }, + MOCK_SIGNER + ); + await storage.trustedManifestStore.addLatest( + { files: { '/new.js': 'b' }, revokeManifests: true }, + MOCK_SIGNER + ); + const latest = await storage.trustedManifestStore.getLatest(); + expect(latest.manifest.files).toEqual({ '/new.js': 'b' }); + }); + }); + it('addLatest caps entries at 20 (safety bound)', async () => { const versions = []; for (let i = 1; i <= 25; i++) { - const { appVersion } = await storage.trustedManifestStore.addLatest({ - files: { [`/f${i}.js`]: `h${i}` }, - }); + const { appVersion } = await storage.trustedManifestStore.addLatest( + { files: { [`/f${i}.js`]: `h${i}` } }, + MOCK_SIGNER + ); versions.push(appVersion); } const all = await storage.trustedManifestStore.getAll(); @@ -103,11 +167,15 @@ describe('createManifestStore', () => { }); it('re-adding an existing manifest dedups and promotes it to the front', async () => { - const a = await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'x' } }); - await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'y' } }); - const aAgain = await storage.trustedManifestStore.addLatest({ - files: { '/a.js': 'x' }, - }); + const a = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'x' } }, + MOCK_SIGNER + ); + await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'y' } }, MOCK_SIGNER); + const aAgain = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'x' } }, + MOCK_SIGNER + ); expect(aAgain.appVersion).toBe(a.appVersion); expect((await storage.trustedManifestStore.getLatest()).appVersion).toBe(a.appVersion); @@ -119,15 +187,17 @@ describe('createManifestStore', () => { it('prunes entries older than 24h on next addLatest', async () => { vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - const stale = await storage.trustedManifestStore.addLatest({ - files: { '/old.js': 'gone' }, - }); + const stale = await storage.trustedManifestStore.addLatest( + { files: { '/old.js': 'gone' } }, + MOCK_SIGNER + ); expect(await storage.trustedManifestStore.get(stale.appVersion)).toBeDefined(); vi.setSystemTime(new Date('2026-01-02T00:00:01Z')); - const fresh = await storage.trustedManifestStore.addLatest({ - files: { '/new.js': 'kept' }, - }); + const fresh = await storage.trustedManifestStore.addLatest( + { files: { '/new.js': 'kept' } }, + MOCK_SIGNER + ); expect(await storage.trustedManifestStore.get(stale.appVersion)).toBeUndefined(); expect(await storage.trustedManifestStore.get(fresh.appVersion)).toBeDefined(); @@ -135,20 +205,33 @@ describe('createManifestStore', () => { it('does not prune entries younger than 24h', async () => { vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - const first = await storage.trustedManifestStore.addLatest({ - files: { '/a.js': 'ha' }, - }); + const first = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'ha' } }, + MOCK_SIGNER + ); vi.setSystemTime(new Date('2026-01-01T23:59:59Z')); - await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'hb' } }); + await storage.trustedManifestStore.addLatest( + { files: { '/b.js': 'hb' } }, + MOCK_SIGNER + ); expect(await storage.trustedManifestStore.get(first.appVersion)).toBeDefined(); }); }); it('getAll returns entries newest-first', async () => { - const a = await storage.trustedManifestStore.addLatest({ files: { '/a.js': 'ha' } }); - const b = await storage.trustedManifestStore.addLatest({ files: { '/b.js': 'hb' } }); - const c = await storage.trustedManifestStore.addLatest({ files: { '/c.js': 'hc' } }); + const a = await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'ha' } }, + MOCK_SIGNER + ); + const b = await storage.trustedManifestStore.addLatest( + { files: { '/b.js': 'hb' } }, + MOCK_SIGNER + ); + const c = await storage.trustedManifestStore.addLatest( + { files: { '/c.js': 'hc' } }, + MOCK_SIGNER + ); const all = await storage.trustedManifestStore.getAll(); expect(all.map((e) => e.appVersion)).toEqual([ @@ -161,6 +244,68 @@ describe('createManifestStore', () => { it('getAll returns an empty array when nothing is stored', async () => { expect(await storage.trustedManifestStore.getAll()).toEqual([]); }); + + describe('updateActiveIdentity', () => { + it('writes the supplied identity to the active-identity key', async () => { + await storage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xnewsigner', + }); + const anchor = await storage.activeIdentityStore.getActiveIdentity(); + expect(anchor.signatureType).toBe('ethereum-personal-sign'); + expect(anchor.identity).toBe('0xnewsigner'); + expect(typeof anchor.enrolledAt).toBe('number'); + }); + + it('overwrites a prior anchor set by addLatest', async () => { + await storage.trustedManifestStore.addLatest( + { files: { '/a.js': 'x' } }, + { signatureType: 'ethereum-personal-sign', identity: '0xoldsigner' } + ); + await storage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xnewsigner', + }); + const anchor = await storage.activeIdentityStore.getActiveIdentity(); + expect(anchor.identity).toBe('0xnewsigner'); + }); + + it('keeps the in-memory cache in sync so getActiveIdentity does not re-read IDB', async () => { + // Prime the cache with the first identity. + await storage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xfirst', + }); + await storage.activeIdentityStore.getActiveIdentity(); + + // Wrap the underlying get to detect post-update IDB reads. + const backend = createInMemoryStorage(); + const freshStorage = createManifestStore(backend); + await freshStorage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xcached', + }); + const spy = vi.spyOn(backend, 'get'); + const anchor = await freshStorage.activeIdentityStore.getActiveIdentity(); + expect(anchor.identity).toBe('0xcached'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('is idempotent — rewriting the same identity leaves the anchor identical in shape', async () => { + const first = await storage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xsame', + }); + const second = await storage.activeIdentityStore.updateActiveIdentity({ + signatureType: 'ethereum-personal-sign', + identity: '0xsame', + }); + expect(second.signatureType).toBe(first.signatureType); + expect(second.identity).toBe(first.identity); + // enrolledAt is refreshed each call — that's expected, not a bug. + expect(typeof second.enrolledAt).toBe('number'); + }); + }); }); describe('verificationResults', () => { diff --git a/packages/dappfence/src/sw/__tests__/response.test.js b/packages/dappfence/src/sw/__tests__/response.test.js index e4b336ae..189edc81 100644 --- a/packages/dappfence/src/sw/__tests__/response.test.js +++ b/packages/dappfence/src/sw/__tests__/response.test.js @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import securityWarningHtml from '../../templates/security-warning.html?raw'; -import { createBlockResponse, createRedirectResponse, injectResponseHeaders } from '../response.js'; +import { + createBlockResponse, + createRedirectResponse, + createSecurityPageResponse, + injectResponseHeaders, +} from '../response.js'; // `isFeatureEnabled` reads the Vite-injected `__FEATURES__` define, which // isn't populated in the vitest runtime — stub it so `response.js`'s @@ -63,6 +68,218 @@ describe('security-warning template', () => { it('contains the + // Pull the encoded blob back out and decode it so we can assert on the + // view fields the template will actually see at runtime. + const CONFIG_PATTERN = /JSON\.parse\(decodeURIComponent\("([^"]+)"\)\)/; + async function extractConfig(response) { + const text = await response.text(); + const match = CONFIG_PATTERN.exec(text); + expect(match, 'rendered HTML must contain the DAPPFENCE_CONFIG script').not.toBeNull(); + return JSON.parse(decodeURIComponent(match[1])); + } + + function block(overrides) { + return { + status: 'FILE_UNTRUSTED', + reason: 'HASH_MISMATCH', + assetType: 'asset', + fileKey: '/app.js', + url: 'https://example.com/app.js', + timestamp: '2026-10-07T12:00:00.000Z', + ...overrides, + }; + } + + it('resolves view labels for a FILE_UNTRUSTED / HASH_MISMATCH', async () => { + const res = createSecurityPageResponse('tok', [ + block({ expectedHashes: ['sha256-aaa'], actualHash: 'sha256-bbb' }), + ]); + const cfg = await extractConfig(res); + const [b] = cfg.activeBlocks; + expect(b.view.title).toBe('File content tampered'); + expect(b.view.expectedLabel).toBe('Expected hash'); + expect(b.view.actualLabel).toBe('Actual hash'); + expect(b.expectedHashes).toEqual(['sha256-aaa']); + expect(b.actualHash).toBe('sha256-bbb'); + }); + + it('preserves multiple expectedHashes as an array', async () => { + const res = createSecurityPageResponse('tok', [ + block({ expectedHashes: ['sha256-a', 'sha256-b'], actualHash: 'sha256-c' }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].expectedHashes).toEqual(['sha256-a', 'sha256-b']); + }); + + it('resolves view for FILE_UNTRUSTED / NOT_IN_MANIFEST without an expected label', async () => { + const res = createSecurityPageResponse('tok', [ + block({ reason: 'NOT_IN_MANIFEST', actualHash: 'sha256-xyz' }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('File not listed in trusted manifest'); + expect(cfg.activeBlocks[0].view.expectedLabel).toBeUndefined(); + expect(cfg.activeBlocks[0].view.actualLabel).toBe('Actual hash'); + }); + + it('falls back on unknown FILE_UNTRUSTED reason', async () => { + const res = createSecurityPageResponse('tok', [block({ reason: 'FUTURE_REASON' })]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('File untrusted (FUTURE_REASON)'); + }); + + it('resolves view for DENIED_BY_RULE with no hash labels', async () => { + const res = createSecurityPageResponse('tok', [block({ status: 'DENIED_BY_RULE' })]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('File blocked by security rule'); + expect(cfg.activeBlocks[0].view.expectedLabel).toBeUndefined(); + expect(cfg.activeBlocks[0].view.actualLabel).toBeUndefined(); + }); + + it('resolves view for ERROR by reason', async () => { + const res = createSecurityPageResponse('tok', [ + block({ status: 'ERROR', reason: 'FETCH_BAD_STATUS', httpStatus: 500 }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('Verification failed: non-OK HTTP status'); + }); + + it('falls back on unknown ERROR reason', async () => { + const res = createSecurityPageResponse('tok', [ + block({ status: 'ERROR', reason: 'FUTURE_REASON' }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('Verification error (FUTURE_REASON)'); + }); + + it('resolves MANIFEST_UNTRUSTED / SIGNER_CHANGED to the signer-rotation view', async () => { + const res = createSecurityPageResponse('tok', [ + block({ + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + reason: 'SIGNER_CHANGED', + fileKey: '/manifest.json', + expectedHashes: ['fulcio-oidc:andres@coinspect.com'], + actualHash: 'fulcio-oidc:other@coinspect.com', + }), + ]); + const cfg = await extractConfig(res); + const [b] = cfg.activeBlocks; + expect(b.view.title).toBe('Manifest signer changed since pinning'); + expect(b.view.expectedLabel).toBe('Pinned signer'); + expect(b.view.actualLabel).toBe('Current signer'); + expect(b.expectedHashes).toEqual(['fulcio-oidc:andres@coinspect.com']); + expect(b.actualHash).toBe('fulcio-oidc:other@coinspect.com'); + expect(b.reason).toBe('SIGNER_CHANGED'); + }); + + it('resolves MANIFEST_UNTRUSTED / SIGNATURE_MISMATCH to the recovered-signer view', async () => { + const res = createSecurityPageResponse('tok', [ + block({ + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + reason: 'SIGNATURE_MISMATCH', + }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe( + 'Manifest signature identity does not match pinned signer' + ); + expect(cfg.activeBlocks[0].view.actualLabel).toBe('Recovered signer'); + }); + + it.each([ + ['UNSUPPORTED_SIGNATURE', 'Manifest signature uses an unsupported algorithm'], + ['SIGNATURE_ERROR', 'Manifest signature verification threw an error'], + ['MANIFEST_PARSE_ERROR', 'Manifest could not be parsed'], + ['MANIFEST_FETCH_BAD_STATUS', 'Manifest fetch returned a non-OK status'], + ['MANIFEST_FETCH_EXCEPTION', 'Manifest fetch threw an exception'], + ['CONFIG_ERROR', 'DappFence manifest is misconfigured'], + ])('resolves MANIFEST_UNTRUSTED / %s title', async (reason, title) => { + const res = createSecurityPageResponse('tok', [ + block({ status: 'MANIFEST_UNTRUSTED', assetType: 'manifest', reason }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe(title); + }); + + it('falls back on unknown MANIFEST_UNTRUSTED reason', async () => { + const res = createSecurityPageResponse('tok', [ + block({ status: 'MANIFEST_UNTRUSTED', assetType: 'manifest', reason: 'FUTURE' }), + ]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('Manifest cannot be trusted (FUTURE)'); + }); + + it('falls back on unknown status', async () => { + const res = createSecurityPageResponse('tok', [block({ status: 'NEW_STATUS' })]); + const cfg = await extractConfig(res); + expect(cfg.activeBlocks[0].view.title).toBe('Security violation (NEW_STATUS)'); + }); + + it('uses the file-tamper summary when all blocks are asset-level', async () => { + const res = createSecurityPageResponse('tok', [block()]); + const cfg = await extractConfig(res); + expect(cfg.summary.subtitle).toBe('Potentially Malicious Content Blocked'); + expect(cfg.summary.message).toMatch(/content on this page has been modified/i); + }); + + it('switches to manifest-trust summary when any block is manifest-level', async () => { + const res = createSecurityPageResponse('tok', [ + block(), + block({ + status: 'MANIFEST_UNTRUSTED', + assetType: 'manifest', + reason: 'SIGNER_CHANGED', + }), + ]); + const cfg = await extractConfig(res); + expect(cfg.summary.subtitle).toBe('Manifest Trust Issue'); + expect(cfg.summary.message).toMatch(/authenticity of this site's manifest/i); + }); + + it('formats the timestamp into the enriched record', async () => { + const res = createSecurityPageResponse('tok', [block()]); + const cfg = await extractConfig(res); + expect(typeof cfg.activeBlocks[0].formattedTimestamp).toBe('string'); + expect(cfg.activeBlocks[0].formattedTimestamp.length).toBeGreaterThan(0); + }); + + it('passes the apiToken through to the config', async () => { + const res = createSecurityPageResponse('secret-token', []); + const cfg = await extractConfig(res); + expect(cfg.apiToken).toBe('secret-token'); + }); }); describe('createBlockResponse edge cases', () => { diff --git a/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js b/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js new file mode 100644 index 00000000..20a59bcc --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js @@ -0,0 +1,184 @@ +/** + * Signer revocation — escalation layer. + * + * Policy: rotating the anchored identity does NOT revoke the previous signer's + * manifests. The escalation search in createVerifier walks the full manifest + * history and accepts the first MATCH, so a file vouched for only by the + * previous signer keeps verifying after a rotation. That is intended: a + * legitimate rotation must not retroactively break the old signer's files. + * + * Revocation is explicit: the new signer publishes a manifest with + * `revokeManifests: true`, `addLatest` replaces the history with that single + * entry, and from then on escalation has nothing from the old signer to fall + * back to. This is how a leaked key is remediated. + * + * The store is real (in-memory backend), and `getManifestHistory` is wired to + * `trustedManifestStore.getAll` exactly like manifest-loader does, so these + * tests exercise the flag end to end rather than a hand-built history array. + * Responses and hashes are real too — the body the verifier hashes and the hash + * the manifest declares come from the same bytes. + * + * Store-layer coverage lives in signer-revocation-store.test.js. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + createVerifier, + basicVerifyAgainstManifest, + securityVerifyAgainstManifest, +} from '../manifest/security-verifier.js'; +import { normalizeManifestData } from '../manifest/manifest-loader.js'; +import { isRequestAllowed } from '../manifest/rules.js'; +import { createManifestStore } from '../storage/manifest-store.js'; +import { calculateHash } from '../../core/crypto.js'; +import { VERIFICATION_STATUS } from '../../core/constants.js'; + +const LOCATION_HREF = 'https://example.com/sw.js'; + +const ANCHORED = { signatureType: 'noble-secp256k1-recovered-eth', identity: '0xNEW' }; +const RETIRED = { signatureType: 'noble-secp256k1-recovered-eth', identity: '0xLEAKED' }; + +const EVIL_BODY = 'window.drainWallet()'; +const LEGIT_BODY = 'console.log("ok")'; + +const hashOf = (body) => calculateHash(new TextEncoder().encode(body)); +const EVIL_HASH = await hashOf(EVIL_BODY); +const LEGIT_HASH = await hashOf(LEGIT_BODY); + +const STRATEGIES = [ + { + name: 'basic', + strategy: { verifyAgainstManifest: basicVerifyAgainstManifest, isAllowed: () => false }, + }, + { + name: 'security', + strategy: { + verifyAgainstManifest: securityVerifyAgainstManifest, + isAllowed: isRequestAllowed, + }, + }, +]; + +function createInMemoryStorage() { + const store = new Map(); + return { + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + delete: async (key) => store.delete(key), + withTx: async (fn) => + fn({ + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + }), + }; +} + +function scriptRequest(path) { + return { + method: 'GET', + mode: 'same-origin', + destination: 'script', + url: `https://example.com${path}`, + }; +} + +function scriptResponse(body) { + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/javascript' }, + }); +} + +/** + * Plays out a rotation away from a leaked key: + * 1. The attacker, holding RETIRED, gets a manifest listing /evil.js stored. + * 2. The user accepts the rotation to ANCHORED. + * 3. The new owner publishes a manifest that lists only /app.js — with or + * without `revokeManifests`, depending on the test. + */ +async function rotateAwayFromLeakedKey({ revokeManifests }) { + const { trustedManifestStore, activeIdentityStore } = + createManifestStore(createInMemoryStorage()); + + await trustedManifestStore.addLatest( + normalizeManifestData({ files: { '/evil.js': [EVIL_HASH] }, mode: 'protected' }), + RETIRED + ); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + await trustedManifestStore.addLatest( + normalizeManifestData({ + files: { '/app.js': [LEGIT_HASH] }, + mode: 'protected', + revokeManifests, + }), + ANCHORED + ); + + return trustedManifestStore; +} + +function makeVerify(trustedManifestStore, strategy) { + const manifestLoader = { + // The network serves the same manifest the new owner already stored, + // so the last escalation step adds nothing beyond what history holds. + fetchAndStoreManifest: vi.fn(async () => ({ + status: VERIFICATION_STATUS.MATCH, + ...(await trustedManifestStore.getLatest()), + })), + storeManifestFromResponse: vi.fn(), + getManifestHistory: trustedManifestStore.getAll, + }; + const { verifyResponse } = createVerifier( + { + swContext: { + getLocationHref: () => LOCATION_HREF, + matchAllClients: vi.fn(() => Promise.resolve([{ id: 'client-1' }])), + }, + appStore: { + verificationResultsStore: { add: vi.fn(() => Promise.resolve()) }, + }, + config: { manifestUrl: 'https://example.com/integrity-manifest.json' }, + }, + manifestLoader, + strategy + ); + return async (req, response) => + verifyResponse(req, response, 'client-1', await trustedManifestStore.getLatest()); +} + +describe.each(STRATEGIES)('escalation across a signer rotation — $name', ({ strategy }) => { + describe('without revokeManifests', () => { + it('still accepts a file only the previous signer vouched for', async () => { + const store = await rotateAwayFromLeakedKey({ revokeManifests: false }); + const verify = makeVerify(store, strategy); + + const result = await verify(scriptRequest('/evil.js'), scriptResponse(EVIL_BODY)); + + // The latest (ANCHORED) manifest doesn't list /evil.js, so the search + // falls back to history and finds the RETIRED entry. By design. + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + }); + }); + + describe('with revokeManifests', () => { + it('rejects a file only the previous signer vouched for', async () => { + const store = await rotateAwayFromLeakedKey({ revokeManifests: true }); + const verify = makeVerify(store, strategy); + + const result = await verify(scriptRequest('/evil.js'), scriptResponse(EVIL_BODY)); + + // History was replaced by the revoking manifest, so there is no + // RETIRED entry left for the search to fall back to. + expect(result.status).not.toBe(VERIFICATION_STATUS.MATCH); + }); + + it('still accepts a file the new signer vouched for', async () => { + const store = await rotateAwayFromLeakedKey({ revokeManifests: true }); + const verify = makeVerify(store, strategy); + + const result = await verify(scriptRequest('/app.js'), scriptResponse(LEGIT_BODY)); + + // Control: proves the rejection above isn't the harness rejecting everything. + expect(result.status).toBe(VERIFICATION_STATUS.MATCH); + }); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js b/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js new file mode 100644 index 00000000..e45b815f --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js @@ -0,0 +1,127 @@ +/** + * Signer revocation — store layer. + * + * Policy: rotating the anchored identity does NOT revoke the previous signer's + * manifests. The old signer was trusted when those manifests were stored, and a + * legitimate rotation must not retroactively break files they vouched for. The + * only thing that removes old entries is a manifest carrying + * `revokeManifests: true`, which makes `addLatest` replace the whole list + * instead of prepending. + * + * For a leaked key, that means the new owner signs their first post-rotation + * manifest with `revokeManifests: true`; storing it drops everything the leaked + * key produced. + * + * `updateActiveIdentity` stands in for the user accepting a rotation. + * + * The escalation search that consumes these reads is covered in + * signer-revocation-escalation.test.js. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createManifestStore } from '../storage/manifest-store.js'; + +const ANCHORED = { signatureType: 'noble-secp256k1-recovered-eth', identity: '0xNEW' }; +const RETIRED = { signatureType: 'noble-secp256k1-recovered-eth', identity: '0xLEAKED' }; + +function createInMemoryStorage() { + const store = new Map(); + return { + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + delete: async (key) => store.delete(key), + withTx: async (fn) => + fn({ + get: async (key) => store.get(key), + set: async (key, value) => store.set(key, value), + }), + }; +} + +describe('trustedManifestStore — retired signers', () => { + let trustedManifestStore; + let activeIdentityStore; + + beforeEach(() => { + ({ trustedManifestStore, activeIdentityStore } = + createManifestStore(createInMemoryStorage())); + }); + + describe('rotation alone keeps the previous signer trusted', () => { + it('getAll returns entries from both signers, newest first', async () => { + await trustedManifestStore.addLatest({ files: { '/old.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + await trustedManifestStore.addLatest({ files: { '/app.js': ['h2'] } }, ANCHORED); + + const all = await trustedManifestStore.getAll(); + + expect(all.map((m) => m.signerIdentity)).toEqual([ANCHORED.identity, RETIRED.identity]); + }); + + it('getLatest returns the previous signer’s entry until the new signer stores one', async () => { + await trustedManifestStore.addLatest({ files: { '/old.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + + const latest = await trustedManifestStore.getLatest(); + + expect(latest.signerIdentity).toBe(RETIRED.identity); + }); + + it('get(appVersion) still resolves the previous signer’s manifest', async () => { + const { appVersion } = await trustedManifestStore.addLatest( + { files: { '/old.js': ['h'] } }, + RETIRED + ); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + + expect(await trustedManifestStore.get(appVersion)).toEqual({ + files: { '/old.js': ['h'] }, + }); + }); + }); + + describe('revokeManifests from the new signer drops the previous signer', () => { + it('getAll keeps only the revoking entry', async () => { + await trustedManifestStore.addLatest({ files: { '/old.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + await trustedManifestStore.addLatest( + { files: { '/app.js': ['h2'] }, revokeManifests: true }, + ANCHORED + ); + + const all = await trustedManifestStore.getAll(); + + expect(all.map((m) => m.signerIdentity)).toEqual([ANCHORED.identity]); + }); + + it('get(appVersion) no longer resolves the previous signer’s manifest', async () => { + const { appVersion } = await trustedManifestStore.addLatest( + { files: { '/old.js': ['h'] } }, + RETIRED + ); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + await trustedManifestStore.addLatest( + { files: { '/app.js': ['h2'] }, revokeManifests: true }, + ANCHORED + ); + + expect(await trustedManifestStore.get(appVersion)).toBeUndefined(); + }); + + it('later non-revoking manifests do not bring the previous signer back', async () => { + await trustedManifestStore.addLatest({ files: { '/old.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + await trustedManifestStore.addLatest( + { files: { '/app.js': ['h2'] }, revokeManifests: true }, + ANCHORED + ); + await trustedManifestStore.addLatest({ files: { '/app.js': ['h3'] } }, ANCHORED); + + const all = await trustedManifestStore.getAll(); + + expect(all.map((m) => m.signerIdentity)).toEqual([ + ANCHORED.identity, + ANCHORED.identity, + ]); + }); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js b/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js new file mode 100644 index 00000000..be06717d --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js @@ -0,0 +1,137 @@ +/** + * Site-unblock must only move the signer anchor when a rotation is what the + * user is accepting. + * + * `handleSiteUnblock` writes `config.manifestSignatureIdentity` into the + * active-identity anchor before clearing blocks, unconditionally — it never + * reads the active blocks, so it cannot tell "accept this signer rotation" + * apart from "dismiss this hash-mismatch warning". + * + * Two reasons that matters: + * + * 1. Scope. The endpoint is named, documented and surfaced in the UI as + * "clear the active blocks". Permanently re-rooting manifest trust is a + * different action, and the warning page's confirm dialogs describe only + * the first one. + * + * 2. Verification. The loader was deliberately changed so the anchor can only + * ever hold an identity that produced a valid manifest signature (see + * "verify the signature before setting the activeIdentity"). This path + * bypasses that: it takes an identity straight from the bootstrap config + * and persists it without any signature having been checked. If that + * identity never signs a storable manifest, the client is left anchored to + * a signer it can never satisfy. + * + * STATUS: the two "does not re-anchor" tests FAIL against the current + * implementation. They describe the intended behavior. The SIGNER_CHANGED + * tests pass today and must keep passing — accepting a real rotation is the + * reason this write exists at all. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createApiHandler } from '../api-handler.js'; +import { API } from '../../core/constants.js'; + +// response.js reads isFeatureEnabled('auto_confirm_site_lock') at module scope, +// and __FEATURES__ is a build-time define that does not exist under vitest. +// vi.hoisted runs before the import graph is evaluated, so the real +// isFeatureEnabled works instead of being stubbed. +vi.hoisted(() => { + globalThis.__FEATURES__ = {}; +}); + +const TOKEN = 'test-token-123'; +const ANCHORED = { signatureType: 'noble-secp256k1-recovered-eth', identity: '0xANCHORED' }; +const CONFIG_IDENTITY = '0xFROM_BOOTSTRAP_HTML'; + +/** A stored block record, shaped as recordSecurityBlock persists it. */ +const block = (status, reason) => ({ status, reason, fileKey: '/app.js', active: true }); + +function makeAppStore(activeBlocks) { + return { + apiTokenStore: { getApiToken: vi.fn().mockResolvedValue(TOKEN) }, + activeBlocksStore: { + isBlocked: vi.fn().mockResolvedValue(true), + getActiveBlocks: vi.fn().mockResolvedValue(activeBlocks), + getAllBlocks: vi.fn().mockResolvedValue(activeBlocks), + clearBlockCondition: vi.fn().mockResolvedValue(undefined), + }, + trustedManifestStore: { getLatest: vi.fn().mockResolvedValue(undefined) }, + activeIdentityStore: { + getActiveIdentity: vi.fn().mockResolvedValue(ANCHORED), + updateActiveIdentity: vi.fn().mockResolvedValue(undefined), + }, + verificationResultsStore: { get: vi.fn().mockResolvedValue([]) }, + }; +} + +function unblockRequest() { + return new Request(`https://example.com${API.SITE_UNBLOCK}`, { + method: 'POST', + headers: { 'X-DappFence-Token': TOKEN }, + }); +} + +describe('site-unblock and the signer anchor', () => { + let appStore; + let handler; + + const arrange = (activeBlocks) => { + appStore = makeAppStore(activeBlocks); + handler = createApiHandler({ + onSecurityViolation: vi.fn(), + appStore, + config: { + manifestSignatureType: 'noble-secp256k1-recovered-eth', + manifestSignatureIdentity: CONFIG_IDENTITY, + }, + }); + }; + + beforeEach(() => vi.clearAllMocks()); + + describe('dismissing a block that is not a signer rotation', () => { + it('clears the blocks but does not re-anchor on FILE_UNTRUSTED', async () => { + arrange([block('FILE_UNTRUSTED', 'HASH_MISMATCH')]); + + const res = await handler(API.SITE_UNBLOCK, unblockRequest()); + + expect(res.status).toBe(200); + expect(appStore.activeBlocksStore.clearBlockCondition).toHaveBeenCalled(); + expect(appStore.activeIdentityStore.updateActiveIdentity).not.toHaveBeenCalled(); + }); + + it('does not re-anchor on a manifest block whose reason is not SIGNER_CHANGED', async () => { + arrange([block('MANIFEST_UNTRUSTED', 'SIGNATURE_MISMATCH')]); + + await handler(API.SITE_UNBLOCK, unblockRequest()); + + expect(appStore.activeIdentityStore.updateActiveIdentity).not.toHaveBeenCalled(); + }); + }); + + describe('accepting a signer rotation', () => { + it('re-anchors to the bootstrap identity on SIGNER_CHANGED', async () => { + arrange([block('MANIFEST_UNTRUSTED', 'SIGNER_CHANGED')]); + + const res = await handler(API.SITE_UNBLOCK, unblockRequest()); + + expect(res.status).toBe(200); + expect(appStore.activeIdentityStore.updateActiveIdentity).toHaveBeenCalledWith({ + signatureType: 'noble-secp256k1-recovered-eth', + identity: CONFIG_IDENTITY, + }); + expect(appStore.activeBlocksStore.clearBlockCondition).toHaveBeenCalled(); + }); + + it('re-anchors when a rotation is blocked alongside unrelated violations', async () => { + arrange([ + block('FILE_UNTRUSTED', 'HASH_MISMATCH'), + block('MANIFEST_UNTRUSTED', 'SIGNER_CHANGED'), + ]); + + await handler(API.SITE_UNBLOCK, unblockRequest()); + + expect(appStore.activeIdentityStore.updateActiveIdentity).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/dappfence/src/sw/__tests__/storage-index.test.js b/packages/dappfence/src/sw/__tests__/storage-index.test.js index 7016f97d..f09a3afc 100644 --- a/packages/dappfence/src/sw/__tests__/storage-index.test.js +++ b/packages/dappfence/src/sw/__tests__/storage-index.test.js @@ -34,9 +34,10 @@ describe('createAppStore', () => { it('manifest stores are functional', async () => { const appStore = createAppStore(createInMemoryDatabase()); - const { appVersion } = await appStore.trustedManifestStore.addLatest({ - files: { '/a.js': 'h' }, - }); + const { appVersion } = await appStore.trustedManifestStore.addLatest( + { files: { '/a.js': 'h' } }, + { signatureType: 'ethereum-personal-sign', identity: '0xmocksigner' } + ); const latest = await appStore.trustedManifestStore.getLatest(); expect(latest.appVersion).toBe(appVersion); }); diff --git a/packages/dappfence/src/sw/api-handler.js b/packages/dappfence/src/sw/api-handler.js index 4e604eb5..5d49e9f1 100644 --- a/packages/dappfence/src/sw/api-handler.js +++ b/packages/dappfence/src/sw/api-handler.js @@ -12,10 +12,16 @@ const logger = createLogger(); * @param {object} deps * @param {function} deps.onSecurityViolation - called to broadcast the active block condition * @param {object} deps.appStore + * @param {object} deps.config */ -export function createApiHandler({ onSecurityViolation, appStore }) { - const { apiTokenStore, activeBlocksStore, trustedManifestStore, verificationResultsStore } = - appStore; +export function createApiHandler({ onSecurityViolation, appStore, config }) { + const { + apiTokenStore, + activeBlocksStore, + trustedManifestStore, + activeIdentityStore, + verificationResultsStore, + } = appStore; async function validateApiToken(request) { const token = await apiTokenStore.getApiToken(); @@ -76,8 +82,10 @@ export function createApiHandler({ onSecurityViolation, appStore }) { } async function handleSiteUnblock(_request) { - // Errors bubble to the outer catch and become a plain-text 500, matching - // how the rest of this handler reports unexpected failures. + await activeIdentityStore.updateActiveIdentity({ + signatureType: config.manifestSignatureType, + identity: config.manifestSignatureIdentity, + }); await activeBlocksStore.clearBlockCondition(); logger.log('Site unblocked successfully'); return new Response( diff --git a/packages/dappfence/src/sw/manifest/manifest-loader.js b/packages/dappfence/src/sw/manifest/manifest-loader.js index bb0b6adc..07a30b21 100644 --- a/packages/dappfence/src/sw/manifest/manifest-loader.js +++ b/packages/dappfence/src/sw/manifest/manifest-loader.js @@ -173,7 +173,7 @@ export const normalizeManifestData = (manifestData) => { * @param {object} deps.config - Must include manifestUrl, manifestSignatureType, manifestSignatureIdentity */ export const createManifestLoader = ({ swContext, appStore, config }) => { - const { trustedManifestStore } = appStore; + const { trustedManifestStore, activeIdentityStore } = appStore; const singleFlight = createSingleFlight(); const { manifestUrl, manifestSignatureType, manifestSignatureIdentity } = config; const manifestFileKey = manifestUrl @@ -196,6 +196,7 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { const storeManifestFromResponse = async (response) => { try { const json = await response.json(); + const signatureResult = verifyManifestSignature( manifestSignatureType, manifestSignatureIdentity, @@ -204,8 +205,32 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { if (signatureResult.status.isViolation) { return manifestResult(signatureResult); } + + let activeIdentity = await activeIdentityStore.getActiveIdentity(); + if (!activeIdentity) { + // The first time we accept the identity + activeIdentity = await activeIdentityStore.updateActiveIdentity({ + signatureType: manifestSignatureType, + identity: manifestSignatureIdentity, + }); + } + if ( + activeIdentity.signatureType !== manifestSignatureType || + activeIdentity.identity !== manifestSignatureIdentity + ) { + logger.error( + `Signer change: anchored ${activeIdentity.signatureType}:${activeIdentity.identity} but config declares ${manifestSignatureType}:${manifestSignatureIdentity}` + ); + return manifestResult({ + status: VERIFICATION_STATUS.MANIFEST_UNTRUSTED, + reason: 'SIGNER_CHANGED', + expectedHashes: [`${activeIdentity.signatureType}:${activeIdentity.identity}`], + actualHash: `${manifestSignatureType}:${manifestSignatureIdentity}`, + }); + } const { appVersion, manifest } = await trustedManifestStore.addLatest( - normalizeManifestData(signatureResult.payload) + normalizeManifestData(signatureResult.payload), + activeIdentity ); logger.log( `Loaded manifest, app version: ${appVersion.substring(0, 12)}... (${Object.keys(manifest.files).length} files)` diff --git a/packages/dappfence/src/sw/response.js b/packages/dappfence/src/sw/response.js index d758fc1c..3282dd69 100644 --- a/packages/dappfence/src/sw/response.js +++ b/packages/dappfence/src/sw/response.js @@ -2,7 +2,7 @@ import securityWarningHtml from '../templates/security-warning.html?raw'; import securityWarningCss from '../templates/security-warning.css?raw'; import { isFeatureEnabled } from '../core/utils.js'; -import { API } from '../core/constants.js'; +import { API, ASSET_TYPE } from '../core/constants.js'; // CSS and the build-time feature flag are static across all renders — fold // them into the template once at a module load instead of repeating the work @@ -24,6 +24,119 @@ const { index: configIndex, 0: configMatch } = CONFIG_SCRIPT_PATTERN.exec(BASE_H const HTML_PREFIX = BASE_HTML.slice(0, configIndex); const HTML_SUFFIX = BASE_HTML.slice(configIndex + configMatch.length); +/** + * Per-status / per-reason view copy for the warning page. Mirrors the + * STATUS_LOG dispatch in sw/storage/index.js (view layer here, log layer + * there). A view entry resolves to `{ title, expectedLabel?, actualLabel? }`. + * When `expectedLabel` / `actualLabel` are omitted, the template omits the + * corresponding row — not every reason has hashes or identities to show + * (e.g. CONFIG_ERROR, DENIED_BY_RULE). + * + * @typedef {{ title: string, expectedLabel?: string, actualLabel?: string }} BlockView + */ + +const HASH_LABELS = { expectedLabel: 'Expected hash', actualLabel: 'Actual hash' }; +const SIGNER_LABELS = { expectedLabel: 'Pinned signer', actualLabel: 'Current signer' }; + +/** @type {Record} */ +const MANIFEST_UNTRUSTED_VIEWS = { + SIGNER_CHANGED: { title: 'Manifest signer changed since pinning', ...SIGNER_LABELS }, + SIGNATURE_MISMATCH: { + title: 'Manifest signature identity does not match pinned signer', + expectedLabel: 'Pinned signer', + actualLabel: 'Recovered signer', + }, + UNSUPPORTED_SIGNATURE: { title: 'Manifest signature uses an unsupported algorithm' }, + SIGNATURE_ERROR: { title: 'Manifest signature verification threw an error' }, + MANIFEST_PARSE_ERROR: { title: 'Manifest could not be parsed' }, + MANIFEST_FETCH_BAD_STATUS: { title: 'Manifest fetch returned a non-OK status' }, + MANIFEST_FETCH_EXCEPTION: { title: 'Manifest fetch threw an exception' }, + CONFIG_ERROR: { title: 'DappFence manifest is misconfigured' }, +}; + +/** @type {Record} */ +const ERROR_VIEWS = { + NULL_RESPONSE: { title: 'Verification failed: no response received' }, + BODY_UNREADABLE: { title: 'Verification failed: response body unreadable' }, + FETCH_BAD_STATUS: { title: 'Verification failed: non-OK HTTP status' }, + FETCH_EXCEPTION: { title: 'Verification failed: fetch threw' }, + NO_MANIFEST_AVAILABLE: { title: 'Verification failed: no trusted manifest available' }, +}; + +/** @type {Record} */ +const FILE_UNTRUSTED_VIEWS = { + HASH_MISMATCH: { title: 'File content tampered', ...HASH_LABELS }, + NOT_IN_MANIFEST: { + title: 'File not listed in trusted manifest', + actualLabel: 'Actual hash', + }, +}; + +/** + * Dispatch keyed by the status *description* on stored block records. + * `Record` enforces full coverage of every + * violating verdict and rejects any key that isn't a current member — + * same stale-key guard STATUS_LOG uses, scoped to just the subset of + * verdicts that produce blocks. Non-violation verdicts never reach the + * warning page (`recordSecurityViolation` short-circuits before + * `recordSecurityBlock`), so they're intentionally outside the key set. + * @type {Record BlockView>} + */ +const BLOCK_VIEW = { + FILE_UNTRUSTED: (b) => + FILE_UNTRUSTED_VIEWS[b.reason] ?? { + title: `File untrusted (${b.reason ?? 'unspecified'})`, + }, + DENIED_BY_RULE: () => ({ title: 'File blocked by security rule' }), + ERROR: (b) => + ERROR_VIEWS[b.reason] ?? { title: `Verification error (${b.reason ?? 'unspecified'})` }, + MANIFEST_UNTRUSTED: (b) => + MANIFEST_UNTRUSTED_VIEWS[b.reason] ?? { + title: `Manifest cannot be trusted (${b.reason ?? 'unspecified'})`, + }, +}; + +const defaultView = (b) => ({ title: `Security violation (${b.status ?? 'unknown'})` }); + +/** + * Top-of-page summary. Manifest-level blocks get distinct copy because the + * default "content modified" message is misleading when the failure is + * signer-rotation or a signature issue — nothing in the page itself has + * necessarily been tampered with, the trust anchor failed. + */ +function derivePageSummary(blocks) { + const hasManifestBlock = blocks.some((b) => b.assetType === ASSET_TYPE.MANIFEST); + if (hasManifestBlock) { + return { + subtitle: 'Manifest Trust Issue', + message: + "DappFence could not establish the authenticity of this site's manifest. " + + 'This can indicate a signer change, a signature mismatch, or a manifest ' + + 'configuration problem. Loading the site may expose you to untrusted content.', + }; + } + return { + subtitle: 'Potentially Malicious Content Blocked', + message: + 'DappFence has detected that content on this page has been modified and may be unsafe. ' + + 'This protection prevents potentially malicious code from running in your browser.', + }; +} + +function enrichActiveBlocks(blocks) { + return blocks.map((block) => { + const view = (BLOCK_VIEW[block.status] ?? defaultView)(block); + return { + ...block, + view, + expectedHashes: block.expectedHashes || [], + actualHash: block.actualHash || null, + occurrenceCount: block.occurrenceCount || 1, + formattedTimestamp: new Date(block.timestamp).toLocaleString(), + }; + }); +} + /** * Emit the `` block for @@ -37,16 +150,6 @@ function renderConfigScript(config) { return ``; } -function enrichActiveBlocks(blocks) { - return blocks.map((block) => ({ - ...block, - expectedHashes: block.expectedHashes || [], - actualHash: block.actualHash || 'N/A', - occurrenceCount: block.occurrenceCount || 1, - formattedTimestamp: new Date(block.timestamp).toLocaleString(), - })); -} - /** * Creates a Response object with the specified parameters */ @@ -178,9 +281,11 @@ export function createRedirectResponse(location) { * @param {Array} activeBlocks */ export function createSecurityPageResponse(apiToken, activeBlocks) { + const enriched = enrichActiveBlocks(activeBlocks); const configScript = renderConfigScript({ apiToken, - activeBlocks: enrichActiveBlocks(activeBlocks), + activeBlocks: enriched, + summary: derivePageSummary(enriched), autoConfirmSiteLock: AUTO_CONFIRM_SITE_LOCK, }); const html = HTML_PREFIX + configScript + HTML_SUFFIX; diff --git a/packages/dappfence/src/sw/services.js b/packages/dappfence/src/sw/services.js index 4419cbc0..ac49e8df 100644 --- a/packages/dappfence/src/sw/services.js +++ b/packages/dappfence/src/sw/services.js @@ -47,6 +47,7 @@ export function createServices(swGlobal) { const core = { swContext, appStore, + config, manifestService, onSecurityViolation: messageBroker.broadcastSecurityViolation, }; @@ -59,7 +60,6 @@ export function createServices(swGlobal) { fetchHandler: createSecurityFetchHandler({ ...core, handleApiEndpoint }), installHandler: createInstallHandler({ ...core, - config, onInstallDone: hookService.installEventDone, }), activateHandler: createActivateHandler(core), diff --git a/packages/dappfence/src/sw/storage/manifest-store.js b/packages/dappfence/src/sw/storage/manifest-store.js index 578bfc39..5022ae6e 100644 --- a/packages/dappfence/src/sw/storage/manifest-store.js +++ b/packages/dappfence/src/sw/storage/manifest-store.js @@ -11,6 +11,7 @@ import { calculateHash } from '../../core/crypto.js'; // Trusted Manifest System constants const TRUSTED_MANIFEST_KEY = 'trusted-manifest'; const VERIFICATION_RESULTS_KEY = 'verification-results'; +const ACTIVE_IDENTITY_KEY = 'active-identity'; // Trusted-manifest priority queue: newest-first. // Primary cleanup: entries older than MAX_AGE_MS are pruned on each addLatest. @@ -58,18 +59,27 @@ export function createManifestStore(database) { }; const trustedManifestStore = { - async addLatest(manifest) { + async addLatest(manifest, signer) { const appVersion = await createSyntheticAppVersion(manifest); - // Read-modify-write under a single transaction so concurrent - // addLatest calls can't clobber each other's updates. let newList; await database.withTx(async (tx) => { - const list = (await tx.get(TRUSTED_MANIFEST_KEY)) || []; const now = Date.now(); - const deduped = list.filter((m) => m.appVersion !== appVersion); - deduped.unshift({ appVersion, manifest, storedAt: now }); - const pruned = deduped.filter((m) => now - m.storedAt < MAX_AGE_MS); - newList = pruned.slice(0, MAX_MANIFESTS); + const newEntry = { + appVersion, + manifest, + storedAt: now, + signatureType: signer.signatureType, + signerIdentity: signer.identity, + }; + if (manifest.revokeManifests) { + newList = [newEntry]; + } else { + const list = (await tx.get(TRUSTED_MANIFEST_KEY)) || []; + const deduped = list.filter((m) => m.appVersion !== appVersion); + deduped.unshift(newEntry); + const pruned = deduped.filter((m) => now - m.storedAt < MAX_AGE_MS); + newList = pruned.slice(0, MAX_MANIFESTS); + } await tx.set(TRUSTED_MANIFEST_KEY, newList); }); cachedList = newList; @@ -95,9 +105,28 @@ export function createManifestStore(database) { }, }; - /** - * Verification Results database Operations - */ + let cachedActiveIdentity = null; + + const activeIdentityStore = { + async getActiveIdentity() { + if (cachedActiveIdentity === null) { + cachedActiveIdentity = (await database.get(ACTIVE_IDENTITY_KEY)) || undefined; + } + return cachedActiveIdentity; + }, + + async updateActiveIdentity({ signatureType, identity }) { + const newIdentity = { + signatureType, + identity, + enrolledAt: Date.now(), + }; + await database.set(ACTIVE_IDENTITY_KEY, newIdentity); + cachedActiveIdentity = newIdentity; + return newIdentity; + }, + }; + const verificationResultsStore = { async get(appVersion) { const allResults = (await database.get(VERIFICATION_RESULTS_KEY)) || {}; @@ -137,6 +166,7 @@ export function createManifestStore(database) { return { trustedManifestStore, + activeIdentityStore, verificationResultsStore, }; } diff --git a/packages/dappfence/src/templates/security-warning.html b/packages/dappfence/src/templates/security-warning.html index 4d90ac55..4ea0c927 100644 --- a/packages/dappfence/src/templates/security-warning.html +++ b/packages/dappfence/src/templates/security-warning.html @@ -18,8 +18,10 @@

Security Warning

-

Potentially Malicious Content Blocked

-

+

+ Potentially Malicious Content Blocked +

+

DappFence has detected that content on this page has been modified and may be unsafe. This protection prevents potentially malicious code from running in your browser. @@ -56,6 +58,7 @@

Active Blocks0

const { apiToken = null, activeBlocks = [], + summary = null, autoConfirmSiteLock = false, } = DAPPFENCE_CONFIG; @@ -65,6 +68,7 @@

Active Blocks0

document.getElementById('security-timestamp').textContent = 'Blocked at ' + new Date().toLocaleString(); + applySummary(summary); renderBlocks(activeBlocks); // Site unblock button — clears ALL active blocks at once @@ -139,6 +143,18 @@

Active Blocks0

} }); + function applySummary(summary) { + if (!summary) { + return; + } + if (summary.subtitle) { + document.getElementById('security-subtitle').textContent = summary.subtitle; + } + if (summary.message) { + document.getElementById('security-message').textContent = summary.message; + } + } + function renderBlocks(blocks) { // Only show the Active Blocks section when we actually have // blocks to show. In the normal flow there's always ≥1 @@ -161,32 +177,57 @@

Active Blocks0

card.open = true; } - const summary = document.createElement('summary'); - summary.className = 'block-summary'; + const view = block.view || {}; + const summaryEl = document.createElement('summary'); + summaryEl.className = 'block-summary'; + const heading = document.createElement('div'); + heading.className = 'block-heading'; + if (view.title) { + const title = document.createElement('span'); + title.className = 'block-title'; + title.textContent = view.title; + heading.appendChild(title); + } const name = document.createElement('span'); name.className = 'block-name'; name.textContent = block.fileKey || 'Unknown'; name.title = block.fileKey || 'Unknown'; - summary.appendChild(name); + heading.appendChild(name); + summaryEl.appendChild(heading); const count = block.occurrenceCount || 1; const badge = document.createElement('span'); badge.className = 'occurrence-badge'; badge.textContent = `${count}×`; badge.title = `${count} occurrence${count > 1 ? 's' : ''}`; - summary.appendChild(badge); + summaryEl.appendChild(badge); - card.appendChild(summary); + card.appendChild(summaryEl); const body = document.createElement('div'); body.className = 'block-body'; - body.appendChild( - infoRow('Expected hash', block.expectedHash || 'Not available', 'hash') - ); - body.appendChild( - infoRow('Actual hash', block.actualHash || 'Not available', 'hash') - ); + if (view.expectedLabel) { + body.appendChild( + infoRow( + view.expectedLabel, + formatList(block.expectedHashes), + 'hash' + ) + ); + } + if (view.actualLabel) { + body.appendChild( + infoRow( + view.actualLabel, + block.actualHash || 'Not available', + 'hash' + ) + ); + } + if (block.reason) { + body.appendChild(infoRow('Reason', block.reason)); + } body.appendChild( infoRow('First seen', block.formattedTimestamp || 'Unknown') ); @@ -196,6 +237,13 @@

Active Blocks0

}); } + function formatList(values) { + if (!Array.isArray(values) || values.length === 0) { + return 'Not available'; + } + return values.join(', '); + } + function infoRow(label, value, extraValueClass) { const row = document.createElement('div'); row.className = 'info-row'; diff --git a/packages/test-app/src/build-config.js b/packages/test-app/src/build-config.js index 38926ef4..9ce3dc54 100644 --- a/packages/test-app/src/build-config.js +++ b/packages/test-app/src/build-config.js @@ -24,10 +24,11 @@ const directories = { templateDir: path.resolve(ROOT_DIR, 'template'), }; -// This key is used for TESTING only -const secretKey = hexToBytes('46c88fcabce00eced90f15ceb9325fd879e44b43c623b174416a219a6103e05d'); -const publicKey = getPublicKey(secretKey); -const keys = { publicKey, secretKey }; +// These keys are used for TESTING only +const secretKeyA = hexToBytes('46c88fcabce00eced90f15ceb9325fd879e44b43c623b174416a219a6103e05d'); +const secretKeyB = hexToBytes('a1b2c3d4e5f6071829304a5b6c7d8e9f0011223344556677889900aabbccddee'); +const keyPairA = { publicKey: getPublicKey(secretKeyA), secretKey: secretKeyA }; +const keyPairB = { publicKey: getPublicKey(secretKeyB), secretKey: secretKeyB }; const defaultManifest = { mode: MODE.PROTECTED, @@ -62,6 +63,7 @@ const defaultManifest = { '/csp-test-denied': [], }, }, + keyPair: keyPairA, }; const simpleAppPages = { @@ -82,6 +84,14 @@ const simpleAppPages = { template: 'simple-app.html', manifest: 'csp-report-only-manifest.json', }, + 'signer-b.html': { + template: 'simple-app.html', + manifest: 'integrity-manifest-signer-b.json', + }, + 'revoke.html': { + template: 'simple-app.html', + manifest: 'integrity-manifest-revoke.json', + }, }; const simpleAppBase = { @@ -108,6 +118,8 @@ const simpleAppBase = { reportSample: true, }, }, + 'integrity-manifest-signer-b.json': { ...defaultManifest, keyPair: keyPairB }, + 'integrity-manifest-revoke.json': { ...defaultManifest, revokeManifests: true }, }, pages: simpleAppPages, }; @@ -130,6 +142,7 @@ const BUILD_CONFIGURATIONS = { description: 'Tampering Security Test', manifests: { 'tampering-test-manifest.json': { + keyPair: keyPairA, pathRules: [{ type: 'directory-index' }], }, }, @@ -143,14 +156,10 @@ const BUILD_CONFIGURATIONS = { 'reporting-test': { ...simpleAppBase, templateFlags: { USE_SW_REGISTER: true, USE_APP: false }, - manifests: { - 'integrity-manifest.json': { ...defaultManifest, mode: MODE.REPORTING }, - 'no-not-found-manifest.json': { - ...defaultManifest, - pathRules: [{ type: 'directory-index' }], - mode: MODE.REPORTING, - }, - }, + manifests: Object.entries(simpleAppBase.manifests).reduce((acc, [k, m]) => { + acc[k] = { ...m, mode: MODE.REPORTING }; + return acc; + }, {}), }, }; @@ -167,4 +176,4 @@ for (const env in DAPPFENCE_PACKAGES) { }; } } -export { OUT_DIR, BUILD_TARGETS, keys, EXTERNAL_ASSETS }; +export { OUT_DIR, BUILD_TARGETS, EXTERNAL_ASSETS }; diff --git a/packages/test-app/src/build.js b/packages/test-app/src/build.js index 0224f33d..068d2596 100644 --- a/packages/test-app/src/build.js +++ b/packages/test-app/src/build.js @@ -18,7 +18,7 @@ import { bytesToHex, keccak256, } from '@dappfence/manifest-tools/crypto'; -import { BUILD_TARGETS, OUT_DIR, keys, EXTERNAL_ASSETS } from './build-config.js'; +import { BUILD_TARGETS, OUT_DIR, EXTERNAL_ASSETS } from './build-config.js'; let log = console.log; @@ -52,8 +52,18 @@ function render(inputPath, outFile, signatureData, args) { log(` Render: ${outputPath}`); } -function renderPages(target, outDir, signatureData, version) { +function renderPages(target, outDir, { personalSign }, version) { for (const [output, page] of Object.entries(target.pages || {})) { + const manifestConfig = target.manifests?.[page.manifest]; + if (!manifestConfig?.keyPair) { + throw new Error( + `Page "${output}" references manifest "${page.manifest}" with no signing keyPair` + ); + } + const signatureData = { + identity: ethereumAddress(manifestConfig.keyPair.publicKey), + type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth', + }; const pageDir = path.dirname(path.join(outDir, output)); if (!fs.existsSync(pageDir)) fs.mkdirSync(pageDir, { recursive: true }); render(path.join(target.templateDir, page.template), output, signatureData, { @@ -120,8 +130,12 @@ function hashOutputFiles(outDir) { return files; } -async function writeSignedManifest(manifests, outDir, { personalSign, signatureData }) { - for (const { manifestFile, data } of manifests) { +async function writeSignedManifest(manifests, outDir, { personalSign }) { + for (const { manifestFile, data, keyPair } of manifests) { + const signatureData = { + identity: ethereumAddress(keyPair.publicKey), + type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth', + }; let signedManifest; if (personalSign) { const msg = new TextEncoder('utf-8').encode(JSON.stringify(data, null, 2)); @@ -148,18 +162,21 @@ async function writeSignedManifest(manifests, outDir, { personalSign, signatureD } signedManifest = { pay: data, sig: signature }; } else { - const { pay, sig } = signManifest(data, keys); + const { pay, sig } = signManifest(data, keyPair); signedManifest = { pay, sig }; } const outPath = path.join(outDir, manifestFile); fs.writeFileSync(outPath, JSON.stringify(signedManifest, null, 2)); - log(`Manifest written to: ${outPath}`); + log(`Manifest ${manifestFile} signed by ${signatureData.identity}, written to: ${outPath}`); } } async function buildManifestsData(target, sharedFiles, { targetName, version, outDir }) { const results = []; for (const [manifestFile, manifest] of Object.entries(target.manifests || {})) { + if (!manifest.keyPair) { + throw new Error(`Manifest "${manifestFile}" has no signing keyPair declared`); + } const additionalFileHashes = {}; for (const [manifestKey, relPaths] of Object.entries(manifest.additionalFiles || {})) { const hashes = relPaths.map((p) => calculateFileHash(path.join(target.assetDir, p))); @@ -201,11 +218,12 @@ async function buildManifestsData(target, sharedFiles, { targetName, version, ou pathRules: manifest.pathRules || [], contentRules: manifest.contentRules || null, ...(csp ? { csp } : {}), + ...(manifest.revokeManifests ? { revokeManifests: true } : {}), metadata: { buildTime: new Date().toISOString(), version, target: targetName }, }; log(`Manifest ${manifestFile}: ${Object.keys(data.files).length} assets`); - results.push({ manifestFile, data }); + results.push({ manifestFile, data, keyPair: manifest.keyPair }); } return results; } @@ -216,21 +234,15 @@ async function buildTarget(targetName, target, { personalSign = false }, version if (fs.existsSync(outDir)) fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true }); - const signatureData = { - identity: ethereumAddress(keys.publicKey), - type: personalSign ? 'personal-sign-alt' : 'noble-secp256k1-recovered-eth', - }; - log(`Manifest signer: ${signatureData.identity}`); - copySourceFiles(target, outDir, version); - renderPages(target, outDir, signatureData, version); + renderPages(target, outDir, { personalSign }, version); const sharedFiles = hashOutputFiles(outDir); const manifests = await buildManifestsData(target, sharedFiles, { targetName, version, outDir, }); - await writeSignedManifest(manifests, outDir, { personalSign, signatureData }); + await writeSignedManifest(manifests, outDir, { personalSign }); } // CLI diff --git a/packages/test-app/test/common/sw-identity-rotation.spec.ts b/packages/test-app/test/common/sw-identity-rotation.spec.ts new file mode 100644 index 00000000..05541ced --- /dev/null +++ b/packages/test-app/test/common/sw-identity-rotation.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '../sw-fixtures'; +import type { Page } from '@playwright/test'; + +async function readIDB(page: Page, key: string): Promise { + return page.evaluate((k) => { + return new Promise((resolve, reject) => { + const req = indexedDB.open('AppSecurity', 1); + req.onerror = () => reject(req.error); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction(['data'], 'readonly'); + const getReq = tx.objectStore('data').get(k); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }; + }); + }, key) as Promise; +} + +type BlockRecord = { + status: string; + reason?: string; + expectedHashes?: string[]; + actualHash?: string; +}; + +async function readActiveBlocks(page: Page): Promise { + const ids = (await readIDB(page, 'active-block-ids')) ?? []; + const blocks = (await readIDB>(page, 'blocks')) ?? {}; + return ids.map((id) => blocks[id]).filter(Boolean); +} + +async function waitForAnchoredIdentity(page: Page, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const anchor = await readIDB<{ identity?: string }>(page, 'active-identity'); + if (anchor?.identity) { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('timed out waiting for active-identity to be anchored'); +} + +async function waitForActiveBlock(page: Page, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const blocks = await readActiveBlocks(page); + if (blocks.length > 0) { + return blocks; + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('timed out waiting for an active block'); +} + +test('SIGNER_CHANGED when navigating from signer A to signer B', async ({ page, swHelper }) => { + await page.goto('/'); + await expect(page).toHaveTitle('DappFence - Manifest Mode Example'); + await swHelper.waitForServiceWorkerActivation(); + await waitForAnchoredIdentity(page); + + await page.goto('/signer-b.html'); + const [block] = await waitForActiveBlock(page); + expect(block.status).toBe('MANIFEST_UNTRUSTED'); + expect(block.reason).toBe('SIGNER_CHANGED'); + await page.reload(); + await page.waitForURL(/.*\/sw-api\/security-warning/); +}); + +test('Remove Site Lock accepts rotation and lets signer B load normally', async ({ + page, + swHelper, +}) => { + await page.goto('/'); + await expect(page).toHaveTitle('DappFence - Manifest Mode Example'); + await swHelper.waitForServiceWorkerActivation(); + await waitForAnchoredIdentity(page); + + await page.goto('/signer-b.html').catch(() => {}); + await waitForActiveBlock(page); + + page.on('dialog', async (dialog) => { + await dialog.accept(); + }); + const unblock = page.getByRole('button', { name: 'Remove Site Lock' }); + await unblock.waitFor({ state: 'visible', timeout: 10000 }); + await unblock.click(); + + await page.goto('/signer-b.html').catch(() => {}); + await expect(page).toHaveTitle('DappFence - Manifest Mode Example'); +}); + +test('revokeManifests flag wipes stored manifest history', async ({ page, swHelper }) => { + await page.goto('/'); + await expect(page).toHaveTitle('DappFence - Manifest Mode Example'); + await swHelper.waitForServiceWorkerActivation(); + await waitForAnchoredIdentity(page); + + const before = (await readIDB(page, 'trusted-manifest')) ?? []; + expect(before.length).toBeGreaterThanOrEqual(1); + + await page.goto('/revoke.html'); + await expect(page).toHaveTitle('DappFence - Manifest Mode Example'); + await swHelper.waitForServiceWorkerActivation(); + + const after = (await readIDB(page, 'trusted-manifest')) ?? []; + expect(after).toHaveLength(1); +});