From a37eeb30c7575b5d712b3e281b700470407828fb Mon Sep 17 00:00:00 2001 From: adji Date: Wed, 7 Oct 2026 17:16:24 -0300 Subject: [PATCH 1/6] feat(core): TOFU signer-identity anchor with change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the manifest signer on first install (TOFU) and treats a subsequent disagreement between the bootstrap config and the stored anchor as a manifest-level violation. storage/manifest-store.js: - New IDB key 'active-identity' holding { signatureType, identity, enrolledAt }. - addLatest(manifest, signer) now writes the signer pair onto each trusted- manifest entry (signatureType, signerIdentity) and, atomically in the same tx, seeds active-identity on first write — can't leave the manifest and anchor out of sync on a crash. - New getActiveIdentity() with a cache mirror. manifest-loader.js: - storeManifestFromResponse now resolves the anchor before verifying. Three paths: 1. No anchor (first install) → trust config.identity, verify, store manifest + seed anchor atomically. 2. Anchor present AND matches config → verify against the anchor (equivalent to current behavior). 3. Anchor present AND disagrees with config → return MANIFEST_UNTRUSTED + reason='SIGNER_CHANGED' without fetching or verifying. Bootstrap-level identity swap is a rotation signal the user has to accept explicitly. - verifyManifestSignature receives the anchor identity (not the config), so a manifest signed by a different key than the pinned anchor also fails even when the config appears to agree. --- packages/dappfence/src/core/constants.js | 12 + .../src/sw/__tests__/api-handler.test.js | 17 +- .../src/sw/__tests__/manifest-loader.test.js | 9 +- .../src/sw/__tests__/manifest-store.test.js | 217 ++++++++++++++--- .../src/sw/__tests__/response.test.js | 219 +++++++++++++++++- .../src/sw/__tests__/storage-index.test.js | 7 +- packages/dappfence/src/sw/api-handler.js | 18 +- .../src/sw/manifest/manifest-loader.js | 29 ++- packages/dappfence/src/sw/response.js | 129 ++++++++++- packages/dappfence/src/sw/services.js | 2 +- .../src/sw/storage/manifest-store.js | 52 ++++- .../src/templates/security-warning.html | 74 ++++-- packages/test-app/src/build-config.js | 35 +-- packages/test-app/src/build.js | 42 ++-- .../test/common/sw-identity-rotation.spec.ts | 109 +++++++++ 15 files changed, 855 insertions(+), 116 deletions(-) create mode 100644 packages/test-app/test/common/sw-identity-rotation.spec.ts diff --git a/packages/dappfence/src/core/constants.js b/packages/dappfence/src/core/constants.js index 55a99155..b16ffd93 100644 --- a/packages/dappfence/src/core/constants.js +++ b/packages/dappfence/src/core/constants.js @@ -69,6 +69,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__/storage-index.test.js b/packages/dappfence/src/sw/__tests__/storage-index.test.js index 5ece8965..4ca986e0 100644 --- a/packages/dappfence/src/sw/__tests__/storage-index.test.js +++ b/packages/dappfence/src/sw/__tests__/storage-index.test.js @@ -30,9 +30,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..533aa69f 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,30 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { const storeManifestFromResponse = async (response) => { try { const json = await response.json(); + + 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 signatureResult = verifyManifestSignature( manifestSignatureType, manifestSignatureIdentity, @@ -205,7 +229,8 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { return manifestResult(signatureResult); } 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 a649a839..dc703673 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, MSG } from '../core/constants.js'; +import { API, ASSET_TYPE, MSG } 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, reportEventsType: MSG.REPORT_EVENTS, reportEventsIntervalMs: __DEV__ ? 15_000 : 300_000, diff --git a/packages/dappfence/src/sw/services.js b/packages/dappfence/src/sw/services.js index a95a7bae..abe8e3a1 100644 --- a/packages/dappfence/src/sw/services.js +++ b/packages/dappfence/src/sw/services.js @@ -49,6 +49,7 @@ export function createServices(swGlobal) { const core = { swContext, appStore, + config, manifestService, securityProcessor, onSecurityViolation: messageBroker.broadcastSecurityViolation, @@ -62,7 +63,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 44d345ee..5a299845 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)) || {}; @@ -139,6 +168,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 6b14f974..7836ac32 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, reportEventsType = null, reportEventsIntervalMs = 0, @@ -67,6 +70,7 @@

Active Blocks0

document.getElementById('security-timestamp').textContent = 'Blocked at ' + new Date().toLocaleString(); + applySummary(summary); renderBlocks(activeBlocks); startReportEventsPing(); @@ -172,6 +176,18 @@

Active Blocks0

schedule(); } + 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 @@ -194,32 +210,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') ); @@ -229,6 +270,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); +}); From 4e0d3afd45b44765acc16f113ac73124bb781456 Mon Sep 17 00:00:00 2001 From: adji Date: Tue, 20 Oct 2026 07:52:37 -0300 Subject: [PATCH 2/6] fix: verify the signature before setting the activeIdentity --- .../src/sw/manifest/manifest-loader.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/dappfence/src/sw/manifest/manifest-loader.js b/packages/dappfence/src/sw/manifest/manifest-loader.js index 533aa69f..07a30b21 100644 --- a/packages/dappfence/src/sw/manifest/manifest-loader.js +++ b/packages/dappfence/src/sw/manifest/manifest-loader.js @@ -197,6 +197,15 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { try { const json = await response.json(); + const signatureResult = verifyManifestSignature( + manifestSignatureType, + manifestSignatureIdentity, + json + ); + if (signatureResult.status.isViolation) { + return manifestResult(signatureResult); + } + let activeIdentity = await activeIdentityStore.getActiveIdentity(); if (!activeIdentity) { // The first time we accept the identity @@ -219,15 +228,6 @@ export const createManifestLoader = ({ swContext, appStore, config }) => { actualHash: `${manifestSignatureType}:${manifestSignatureIdentity}`, }); } - - const signatureResult = verifyManifestSignature( - manifestSignatureType, - manifestSignatureIdentity, - json - ); - if (signatureResult.status.isViolation) { - return manifestResult(signatureResult); - } const { appVersion, manifest } = await trustedManifestStore.addLatest( normalizeManifestData(signatureResult.payload), activeIdentity From 8fa9e52ae36d8d1334a58e0e54f689def4e6559a Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 20 Oct 2026 13:20:16 +0000 Subject: [PATCH 3/6] REVIEW: Test, add revocation tests --- .../signer-revocation-escalation.test.js | 140 ++++++++++++++++++ .../__tests__/signer-revocation-store.test.js | 83 +++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js create mode 100644 packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js 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..0df5c1fa --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js @@ -0,0 +1,140 @@ +/** + * Signer revocation — escalation layer. + * + * `addLatest` stamps every stored entry with { signatureType, signerIdentity } + * (storage/manifest-store.js), but no reader consults them. The escalation + * search in createVerifier walks the whole manifest history and accepts the + * first MATCH, so a file vouched for only by a retired key still verifies after + * the anchor has rotated away from it. Rotating the anchor is the standard + * response to a leaked signing key; it has to stop honoring that key's + * manifests or it remediates nothing. + * + * Real Response objects and real hashing throughout — the body the verifier + * hashes and the hash the manifest declares come from the same bytes, so + * nothing here depends on a stubbed digest. + * + * Store-layer coverage lives in signer-revocation-store.test.js. + * + * STATUS: the two "rejects" tests FAIL against the current implementation. + * They describe the intended behavior, not the shipped behavior. The two + * "still accepts" tests pass and exist to prove the harness is not simply + * rejecting everything. + */ +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 { 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 entry = (appVersion, files, signer) => ({ + appVersion, + manifest: normalizeManifestData({ files, mode: 'protected' }), + storedAt: Date.now(), + signatureType: signer.signatureType, + signerIdentity: signer.identity, +}); + +// What the attacker got signed while they held the leaked key. +const EVIL_ENTRY = entry('v-evil', { '/evil.js': [EVIL_HASH] }, RETIRED); + +// The post-rotation manifest. Does not list /evil.js at all. +const CURRENT_ENTRY = entry('v-new', { '/app.js': [LEGIT_HASH] }, ANCHORED); + +const STRATEGIES = [ + { + name: 'basic', + strategy: { verifyAgainstManifest: basicVerifyAgainstManifest, isAllowed: () => false }, + }, + { + name: 'security', + strategy: { + verifyAgainstManifest: securityVerifyAgainstManifest, + isAllowed: isRequestAllowed, + }, + }, +]; + +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' }, + }); +} + +describe.each(STRATEGIES)( + 'escalation must not fall back to a retired signer — $name', + ({ strategy }) => { + function makeVerifier() { + const manifestLoader = { + // After a rotation the bootstrap config disagrees with the + // anchor, so a fresh fetch cannot produce a usable manifest. + fetchAndStoreManifest: vi.fn(() => + Promise.resolve({ + status: VERIFICATION_STATUS.MANIFEST_UNTRUSTED, + reason: 'SIGNER_CHANGED', + }) + ), + storeManifestFromResponse: vi.fn(), + getManifestHistory: vi.fn(() => Promise.resolve([CURRENT_ENTRY, EVIL_ENTRY])), + }; + 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 (req, response) => verifyResponse(req, response, 'client-1', CURRENT_ENTRY); + } + + it('rejects a file that only the retired signer vouched for', async () => { + const verify = makeVerifier(); + + const result = await verify(scriptRequest('/evil.js'), scriptResponse(EVIL_BODY)); + + // Today: MATCH — the escalation loop reaches EVIL_ENTRY and accepts it. + expect(result.status).not.toBe(VERIFICATION_STATUS.MATCH); + }); + + it('still accepts a file the anchored signer vouched for', async () => { + const verify = makeVerifier(); + + const result = await verify(scriptRequest('/app.js'), scriptResponse(LEGIT_BODY)); + + 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..a92053bb --- /dev/null +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js @@ -0,0 +1,83 @@ +/** + * Signer revocation — store layer. + * + * `addLatest` stamps every entry with { signatureType, signerIdentity } + * (storage/manifest-store.js), but no reader consults them, so manifests + * produced by a key that is no longer the anchored identity stay trusted. + * Rotating the anchor is the standard response to a leaked signing key; it has + * to stop honoring that key's manifests or it remediates nothing. + * + * These tests cover the three reads the escalation search consumes, plus the + * requirement that filtering stays non-destructive. The search that consumes + * them is covered in signer-revocation-escalation.test.js. + * + * STATUS: these FAIL against the current implementation. They describe the + * intended behavior, not the shipped behavior. + */ +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())); + }); + + it('getAll omits manifests signed by anyone but the anchored identity', async () => { + await trustedManifestStore.addLatest({ files: { '/evil.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]); + }); + + it('getLatest does not stand in a retired signer when the anchor has stored nothing', async () => { + await trustedManifestStore.addLatest({ files: { '/evil.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + + expect(await trustedManifestStore.getLatest()).toBeUndefined(); + }); + + it('get(appVersion) does not resolve a retired signer’s manifest', async () => { + const { appVersion } = await trustedManifestStore.addLatest( + { files: { '/evil.js': ['h'] } }, + RETIRED + ); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + + expect(await trustedManifestStore.get(appVersion)).toBeUndefined(); + }); + + it('keeps serving manifests once the anchor is rotated back to their signer', async () => { + await trustedManifestStore.addLatest({ files: { '/a.js': ['h'] } }, RETIRED); + await activeIdentityStore.updateActiveIdentity(ANCHORED); + expect(await trustedManifestStore.getAll()).toHaveLength(0); + + // Filtering must be non-destructive — the entries are ignored while a + // different identity is anchored, not deleted. + await activeIdentityStore.updateActiveIdentity(RETIRED); + expect(await trustedManifestStore.getAll()).toHaveLength(1); + }); +}); From 05bdb7d3e094da064ea43afba719f051ec613558 Mon Sep 17 00:00:00 2001 From: adji Date: Tue, 20 Oct 2026 13:06:35 -0300 Subject: [PATCH 4/6] test(core): align signer-revocation tests with revokeManifests policy Rotation keeps the previous signer's manifests trusted; only a revokeManifests manifest from the new signer clears them. Escalation tests now run against a real in-memory store wired as manifest-loader does, covering both the by-design fallback and the flag-driven rejection. --- .../signer-revocation-escalation.test.js | 182 +++++++++++------- .../__tests__/signer-revocation-store.test.js | 118 ++++++++---- 2 files changed, 194 insertions(+), 106 deletions(-) diff --git a/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js b/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js index 0df5c1fa..20a59bcc 100644 --- a/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-escalation.test.js @@ -1,24 +1,24 @@ /** * Signer revocation — escalation layer. * - * `addLatest` stamps every stored entry with { signatureType, signerIdentity } - * (storage/manifest-store.js), but no reader consults them. The escalation - * search in createVerifier walks the whole manifest history and accepts the - * first MATCH, so a file vouched for only by a retired key still verifies after - * the anchor has rotated away from it. Rotating the anchor is the standard - * response to a leaked signing key; it has to stop honoring that key's - * manifests or it remediates nothing. + * 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. * - * Real Response objects and real hashing throughout — the body the verifier - * hashes and the hash the manifest declares come from the same bytes, so - * nothing here depends on a stubbed digest. + * 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. * - * Store-layer coverage lives in signer-revocation-store.test.js. + * 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. * - * STATUS: the two "rejects" tests FAIL against the current implementation. - * They describe the intended behavior, not the shipped behavior. The two - * "still accepts" tests pass and exist to prove the harness is not simply - * rejecting everything. + * Store-layer coverage lives in signer-revocation-store.test.js. */ import { describe, it, expect, vi } from 'vitest'; import { @@ -28,6 +28,7 @@ import { } 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'; @@ -43,20 +44,6 @@ const hashOf = (body) => calculateHash(new TextEncoder().encode(body)); const EVIL_HASH = await hashOf(EVIL_BODY); const LEGIT_HASH = await hashOf(LEGIT_BODY); -const entry = (appVersion, files, signer) => ({ - appVersion, - manifest: normalizeManifestData({ files, mode: 'protected' }), - storedAt: Date.now(), - signatureType: signer.signatureType, - signerIdentity: signer.identity, -}); - -// What the attacker got signed while they held the leaked key. -const EVIL_ENTRY = entry('v-evil', { '/evil.js': [EVIL_HASH] }, RETIRED); - -// The post-rotation manifest. Does not list /evil.js at all. -const CURRENT_ENTRY = entry('v-new', { '/app.js': [LEGIT_HASH] }, ANCHORED); - const STRATEGIES = [ { name: 'basic', @@ -71,6 +58,20 @@ const STRATEGIES = [ }, ]; +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', @@ -87,54 +88,97 @@ function scriptResponse(body) { }); } -describe.each(STRATEGIES)( - 'escalation must not fall back to a retired signer — $name', - ({ strategy }) => { - function makeVerifier() { - const manifestLoader = { - // After a rotation the bootstrap config disagrees with the - // anchor, so a fresh fetch cannot produce a usable manifest. - fetchAndStoreManifest: vi.fn(() => - Promise.resolve({ - status: VERIFICATION_STATUS.MANIFEST_UNTRUSTED, - reason: 'SIGNER_CHANGED', - }) - ), - storeManifestFromResponse: vi.fn(), - getManifestHistory: vi.fn(() => Promise.resolve([CURRENT_ENTRY, EVIL_ENTRY])), - }; - 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 (req, response) => verifyResponse(req, response, 'client-1', CURRENT_ENTRY); - } - - it('rejects a file that only the retired signer vouched for', async () => { - const verify = makeVerifier(); +/** + * 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)); - // Today: MATCH — the escalation loop reaches EVIL_ENTRY and accepts it. + // 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 anchored signer vouched for', async () => { - const verify = makeVerifier(); + 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 index a92053bb..e45b815f 100644 --- a/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js +++ b/packages/dappfence/src/sw/__tests__/signer-revocation-store.test.js @@ -1,18 +1,21 @@ /** * Signer revocation — store layer. * - * `addLatest` stamps every entry with { signatureType, signerIdentity } - * (storage/manifest-store.js), but no reader consults them, so manifests - * produced by a key that is no longer the anchored identity stay trusted. - * Rotating the anchor is the standard response to a leaked signing key; it has - * to stop honoring that key's manifests or it remediates nothing. + * 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. * - * These tests cover the three reads the escalation search consumes, plus the - * requirement that filtering stays non-destructive. The search that consumes - * them is covered in signer-revocation-escalation.test.js. + * 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. * - * STATUS: these FAIL against the current implementation. They describe the - * intended behavior, not the shipped behavior. + * `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'; @@ -43,41 +46,82 @@ describe('trustedManifestStore — retired signers', () => { createManifestStore(createInMemoryStorage())); }); - it('getAll omits manifests signed by anyone but the anchored identity', async () => { - await trustedManifestStore.addLatest({ files: { '/evil.js': ['h'] } }, RETIRED); - await activeIdentityStore.updateActiveIdentity(ANCHORED); - await trustedManifestStore.addLatest({ files: { '/app.js': ['h2'] } }, ANCHORED); + 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(); + const all = await trustedManifestStore.getAll(); - expect(all.map((m) => m.signerIdentity)).toEqual([ANCHORED.identity]); - }); + expect(all.map((m) => m.signerIdentity)).toEqual([ANCHORED.identity, RETIRED.identity]); + }); - it('getLatest does not stand in a retired signer when the anchor has stored nothing', async () => { - await trustedManifestStore.addLatest({ files: { '/evil.js': ['h'] } }, RETIRED); - await activeIdentityStore.updateActiveIdentity(ANCHORED); + 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); - expect(await trustedManifestStore.getLatest()).toBeUndefined(); - }); + const latest = await trustedManifestStore.getLatest(); - it('get(appVersion) does not resolve a retired signer’s manifest', async () => { - const { appVersion } = await trustedManifestStore.addLatest( - { files: { '/evil.js': ['h'] } }, - RETIRED - ); - await activeIdentityStore.updateActiveIdentity(ANCHORED); + expect(latest.signerIdentity).toBe(RETIRED.identity); + }); - expect(await trustedManifestStore.get(appVersion)).toBeUndefined(); + 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'] }, + }); + }); }); - it('keeps serving manifests once the anchor is rotated back to their signer', async () => { - await trustedManifestStore.addLatest({ files: { '/a.js': ['h'] } }, RETIRED); - await activeIdentityStore.updateActiveIdentity(ANCHORED); - expect(await trustedManifestStore.getAll()).toHaveLength(0); + 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(); - // Filtering must be non-destructive — the entries are ignored while a - // different identity is anchored, not deleted. - await activeIdentityStore.updateActiveIdentity(RETIRED); - expect(await trustedManifestStore.getAll()).toHaveLength(1); + expect(all.map((m) => m.signerIdentity)).toEqual([ + ANCHORED.identity, + ANCHORED.identity, + ]); + }); }); }); From 3a245f07920345da30f28aea2eb4b09ffbbd9e54 Mon Sep 17 00:00:00 2001 From: emi Date: Wed, 21 Oct 2026 17:29:56 +0000 Subject: [PATCH 5/6] REVIEW: test(core): cover site-unblock rewriting the signer anchor unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSiteUnblock writes config.manifestSignatureIdentity into the active-identity anchor and then clears the blocks. The write is unconditional: the handler never reads getActiveBlocks, so it cannot distinguish "accept this signer rotation" from "dismiss this hash-mismatch warning". Two problems follow. Scope. The endpoint is named and surfaced in the UI as "remove the site lock", and both confirm dialogs in security-warning.html describe only clearing blocks — the button at :78 and the Ctrl+Shift+R interceptor at :138, which routes into the same unlockSite(). Neither mentions that the key allowed to sign this site's manifests is being changed permanently. The pinned/current signer rows exist, but in a collapsed card body, not at the decision point, and the hard-reload path never renders them. Verification. b73cad2 moved verifyManifestSignature above the anchor seeding so the anchor can only hold an identity that produced a valid signature. This path bypasses that invariant: it persists an identity read straight from the bootstrap HTML with no signature checked. A client that accepts a rotation to an identity which never signs a storable manifest is left anchored to a signer it can never satisfy, with no route back through the UI. The test varies the only input that should govern the decision and holds the rest fixed. All four cases produce the same write today: FILE_UNTRUSTED / HASH_MISMATCH -> re-anchors (should not) MANIFEST_UNTRUSTED / SIGNATURE_MISMATCH -> re-anchors (should not) MANIFEST_UNTRUSTED / SIGNER_CHANGED -> re-anchors (correct) hash mismatch + SIGNER_CHANGED -> re-anchors once (correct) The first two FAIL against the current implementation and describe the intended behavior. The last two pass today and must keep passing: the write exists so a legitimate rotation is not an unrecoverable brick, so the fix cannot be to drop it. The mixed-block case pins the predicate — a rotation usually lands alongside hash mismatches from the same deploy, so `some()` is required and `every()` or a length check would regress it. Gating the write on an active SIGNER_CHANGED block turns all four green with no other suite changes (verified locally: 536 passed). Not covered here: the dialog wording, which lives in the template and needs DOM or e2e coverage, and the downstream effect of anchoring an unverified identity, which depends on store state this test does not model. No vi.mock — real api-handler, response.js and templates. __FEATURES__ is seeded via vi.hoisted because response.js evaluates isFeatureEnabled at module scope and that define does not exist under vitest. Co-Authored-By: Claude Opus 5 (1M context) --- .../sw/__tests__/site-unblock-anchor.test.js | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js 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(); + }); + }); +}); From a331f28a2329a2ef54e3982fb06eada97d989ddf Mon Sep 17 00:00:00 2001 From: adji Date: Wed, 21 Oct 2026 19:16:43 -0300 Subject: [PATCH 6/6] fix(sw): only re-anchor signer on site-unblock when a rotation is blocked The warning page UI is intentionally minimal, "Remove Site Lock" clears every active block in one gesture, so if a SIGNER_CHANGED block is in the set the user is implicitly accepting the new identity along with it. Previously handleSiteUnblock wrote config.manifestSignatureIdentity to the trust anchor unconditionally, which also re-rooted trust on unrelated dismissals (hash mismatch, etc.) and bypassed the loader's verify-before-anchor invariant. Guard the write so it only runs when the active blocks include a SIGNER_CHANGED reason. Turns the two previously-red site-unblock-anchor.test.js cases green and adds coverage for the empty-blocks case and clearBlockCondition parity on the non-rotation path. --- .../src/sw/__tests__/site-unblock-anchor.test.js | 10 ++++++++++ packages/dappfence/src/sw/api-handler.js | 15 ++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js b/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js index be06717d..d4b9614b 100644 --- a/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js +++ b/packages/dappfence/src/sw/__tests__/site-unblock-anchor.test.js @@ -105,6 +105,16 @@ describe('site-unblock and the signer anchor', () => { await handler(API.SITE_UNBLOCK, unblockRequest()); + expect(appStore.activeBlocksStore.clearBlockCondition).toHaveBeenCalled(); + expect(appStore.activeIdentityStore.updateActiveIdentity).not.toHaveBeenCalled(); + }); + + it('does not re-anchor when there are no active blocks', async () => { + arrange([]); + + await handler(API.SITE_UNBLOCK, unblockRequest()); + + expect(appStore.activeBlocksStore.clearBlockCondition).toHaveBeenCalled(); expect(appStore.activeIdentityStore.updateActiveIdentity).not.toHaveBeenCalled(); }); }); diff --git a/packages/dappfence/src/sw/api-handler.js b/packages/dappfence/src/sw/api-handler.js index 5d49e9f1..de9b1f5b 100644 --- a/packages/dappfence/src/sw/api-handler.js +++ b/packages/dappfence/src/sw/api-handler.js @@ -82,12 +82,17 @@ export function createApiHandler({ onSecurityViolation, appStore, config }) { } async function handleSiteUnblock(_request) { - await activeIdentityStore.updateActiveIdentity({ - signatureType: config.manifestSignatureType, - identity: config.manifestSignatureIdentity, - }); + const activeBlocks = await activeBlocksStore.getActiveBlocks(); + // UI clears all blocks at once; accept the new identity only if a rotation is in the set. + const acceptingRotation = activeBlocks.some((b) => b.reason === 'SIGNER_CHANGED'); + if (acceptingRotation) { + await activeIdentityStore.updateActiveIdentity({ + signatureType: config.manifestSignatureType, + identity: config.manifestSignatureIdentity, + }); + } await activeBlocksStore.clearBlockCondition(); - logger.log('Site unblocked successfully'); + logger.log(`Site unblocked successfully (acceptingRotation=${acceptingRotation})`); return new Response( JSON.stringify({ success: true,