diff --git a/backend/__tests__/services/usageServiceKeyRotation.test.js b/backend/__tests__/services/usageServiceKeyRotation.test.js new file mode 100644 index 00000000..01a3af1c --- /dev/null +++ b/backend/__tests__/services/usageServiceKeyRotation.test.js @@ -0,0 +1,94 @@ +/** + * The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults to + * JWT_SECRET. Rotating JWT_SECRET — the correct response to a suspected + * compromise — makes that key unreadable. + * + * Before this was named, the failure surfaced as a generic DELIVERY_FAILED + * that retried forever, and it silently blocked the DELETE packet as well: + * an operator who asked to withdraw had their local state cleared while the + * collector kept its copy, with nothing in the UI explaining why. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); + +const SECRET_A = 'a'.repeat(48); +const SECRET_B = 'b'.repeat(48); + +async function bootDb() { + const db = knex({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await db.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + }); + await db('product_usage_state').insert({ id: 1 }); + return db; +} + +describe('usage signing key becomes unreadable after secret rotation', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('names the failure instead of reporting a generic decrypt error', async () => { + db = await bootDb(); + const before = new UsageService(db, { secret: SECRET_A }); + const sealed = before.encrypt('the-signing-key'); + + // Same value, different secret — exactly what rotating JWT_SECRET does. + const after = new UsageService(db, { secret: SECRET_B }); + expect(() => after.decrypt(sealed)).toThrow( + expect.objectContaining({ code: 'SIGNING_KEY_UNREADABLE' }) + ); + }); + + it('still round-trips under the unrotated secret', async () => { + db = await bootDb(); + const service = new UsageService(db, { secret: SECRET_A }); + expect(service.decrypt(service.encrypt('the-signing-key'))).toBe('the-signing-key'); + }); + + it('records SIGNING_KEY_UNREADABLE rather than DELIVERY_FAILED, and does not flag an identity conflict', async () => { + db = await bootDb(); + const sealed = new UsageService(db, { secret: SECRET_A }).encrypt('key'); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + installation_id: 'a'.repeat(64), + public_key: 'p'.repeat(59), + private_key_encrypted: sealed, + sequence: 1, + pending_packet: JSON.stringify({ + action: 'delete', packet_id: 'x', installation_id: 'a'.repeat(64), sequence: 1, + }), + }); + + const service = new UsageService(db, { + secret: SECRET_B, + endpoint: 'http://127.0.0.1:9/', + // A delivery must never be attempted: signing fails first. + fetch: () => { throw new Error('network must not be reached'); }, + }); + await service.deliver(await db('product_usage_state').where({ id: 1 }).first()); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.last_error).toBe('SIGNING_KEY_UNREADABLE'); + // A key we cannot read is not evidence of a cloned installation. + expect(row.status).toBe('active'); + }); +}); diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js index 95fb4fdc..40d949b9 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -20,6 +20,14 @@ const { LAYOUTS } = require('./protocol.cjs'); +// The collector this installation reports to. Declared once rather than +// inline, because it is a deployment choice: self-hosters point +// USAGE_COLLECTOR_URL at their own collector, and the admin UI links to +// whatever is configured rather than to this default. Kept out of the wire +// contract — `schema.cjs` is vendored byte-identical with picpeak-usage and +// its `$id` is a schema identity, not a delivery address. +const DEFAULT_COLLECTOR_URL = 'https://usage.picpeak.app'; + const FLAG_MAP = { crm: 'clients', crm_quotes: 'quotes', @@ -66,9 +74,7 @@ class UsageService { process.env.USAGE_ENCRYPTION_KEY || process.env.JWT_SECRET; this.endpoint = - options.endpoint || - process.env.USAGE_COLLECTOR_URL || - 'https://usage.picpeak.app'; + options.endpoint || process.env.USAGE_COLLECTOR_URL || DEFAULT_COLLECTOR_URL; this.bindingPath = options.bindingPath || path.join(getStoragePath(), 'usage-instance.key'); this.encKey = null; @@ -116,14 +122,27 @@ class UsageService { .join('.'); } decrypt(value) { - const [iv, tag, data] = value - .split('.') - .map((v) => Buffer.from(v, 'base64url')); - const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv); - cipher.setAuthTag(tag); - return Buffer.concat([cipher.update(data), cipher.final()]).toString( - 'utf8' - ); + try { + const [iv, tag, data] = value + .split('.') + .map((v) => Buffer.from(v, 'base64url')); + const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv); + cipher.setAuthTag(tag); + return Buffer.concat([cipher.update(data), cipher.final()]).toString( + 'utf8' + ); + } catch (error) { + // The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults + // to JWT_SECRET — so rotating JWT_SECRET, the correct response to a + // suspected compromise, makes this key unreadable. Without a name of its + // own that surfaced as a generic DELIVERY_FAILED that retried forever, + // and it silently blocks the DELETE packet too: participation could + // never be withdrawn from the collector. Tagged so the operator is told + // what actually happened. + const failure = new Error('Usage signing key cannot be decrypted'); + failure.code = 'SIGNING_KEY_UNREADABLE'; + throw failure; + } } async binding(create = false) { if (create) { @@ -381,7 +400,11 @@ class UsageService { 'NOT_REGISTERED', 'PACKET_CONFLICT' ].includes(error.code); - const code = conflict ? error.code : 'DELIVERY_FAILED'; + const code = conflict + ? error.code + : error.code === 'SIGNING_KEY_UNREADABLE' + ? 'SIGNING_KEY_UNREADABLE' + : 'DELIVERY_FAILED'; await this.db('product_usage_state') .where({ id: 1 }) .update({ last_error: code }); diff --git a/docs/PRODUCT_USAGE.md b/docs/PRODUCT_USAGE.md index 457fb84f..f10d8c07 100644 --- a/docs/PRODUCT_USAGE.md +++ b/docs/PRODUCT_USAGE.md @@ -26,7 +26,12 @@ tracker or CORS policy is required. The collector is the separate Local development can use an HTTP loopback collector outside production. The collector URL is never writable through generic settings or request payloads. Keep the encryption material stable and protected; losing it makes the old -identity unable to sign deletion requests. Keys live in a dedicated database +identity unable to sign deletion requests. Note that `USAGE_ENCRYPTION_KEY` +defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated +`USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports +`SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the +consequence is specific: reports stop and the deletion request can no longer +be signed either. Keys live in a dedicated database table, not the generic readable settings. A random mode-0600 file at `getStoragePath()/usage-instance.key` binds the database to its local storage. diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 9e80b768..3054ddec 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -14,7 +14,11 @@ import { } from '../../../services/productUsage.service'; vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }) + useTranslation: () => ({ t: (key: string) => key }), + // The tab imports from the components/common barrel, which reaches + // ErrorBoundary -> i18n/config, and that calls .use(initReactI18next) at + // import time. Same shim as FaceRecognitionCard.sidecarHealth.test.tsx. + initReactI18next: { type: '3rdParty', init: () => {} } })); vi.mock('../../../components/common/ConfirmDialog', () => ({ useConfirm: () => async () => true diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index b4d42a1c..502c71c1 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -6,7 +6,7 @@ import { type ProductFeedback } from '../../../services/productUsage.service'; import { useConfirm } from '../../../components/common/ConfirmDialog'; -import { Button } from '../../../components/common/Button'; +import { Button, Card } from '../../../components/common'; function ConsentDialog({ close, @@ -48,6 +48,24 @@ function ConsentDialog({

{t(`productUsage.${key}`, { collector })}

))} +
+ + {t('productUsage.linkCollector')} + + + {t('productUsage.transparency')} + +