fix(usage): name the unreadable-key failure, unpin the collector default, align the tab
Review follow-ups on #1304. SIGNING_KEY_UNREADABLE. USAGE_ENCRYPTION_KEY defaults to JWT_SECRET, so rotating JWT_SECRET — the correct response to a suspected compromise — makes the stored Ed25519 key undecryptable. That surfaced as a generic DELIVERY_FAILED which retried forever, and it silently blocks the DELETE packet too: an operator who withdraws has their local state cleared while the collector keeps its copy. decrypt() now tags its own failure and deliver() reports it under its own name, without flagging an identity conflict — an unreadable key is not evidence of a clone. The docs already warned that losing the key breaks deletion signing; they now name the trigger and the error. The collector default is no longer an inline string in the constructor. It is a declared DEFAULT_COLLECTOR_URL, since it is a deployment choice: self-hosters point USAGE_COLLECTOR_URL at their own collector and the UI already derives every link from whatever is configured. schema.cjs is deliberately untouched — it is vendored byte-identical with picpeak-usage, and its $id is a schema identity, not a delivery address. Links in the consent dialog. It named the collector inside prose but never linked it, so an operator deciding whether to opt in could not open the destination or the public schema without retyping a URL. Both are links now, built from the configured collector. UI standards. The tab hand-rolled its surfaces as `<section className="rounded-xl border border-theme …">` and imported Button from a deep path; every other settings tab uses `<Card padding="md">` from the components/common barrel. Converted, with the feedback <form> wrapped rather than replaced so its semantics survive, and headings given the same colour tokens as ImageSecurityTab. The barrel pulls ErrorBoundary -> i18n/config, so the tab's test needed the initReactI18next shim the FaceRecognitionCard test already uses. Not changed: the delete packet reusing the current sequence. The collector handles delete before any sequence check — "possession proof is sufficient for deletion, including when a restored backup has a stale sequence" (picpeak-usage server/collector.js) — so deletion is deliberately sequence-exempt and the client is correct as written. Refs #1110
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,14 @@ const {
|
|||||||
LAYOUTS
|
LAYOUTS
|
||||||
} = require('./protocol.cjs');
|
} = 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 = {
|
const FLAG_MAP = {
|
||||||
crm: 'clients',
|
crm: 'clients',
|
||||||
crm_quotes: 'quotes',
|
crm_quotes: 'quotes',
|
||||||
@@ -66,9 +74,7 @@ class UsageService {
|
|||||||
process.env.USAGE_ENCRYPTION_KEY ||
|
process.env.USAGE_ENCRYPTION_KEY ||
|
||||||
process.env.JWT_SECRET;
|
process.env.JWT_SECRET;
|
||||||
this.endpoint =
|
this.endpoint =
|
||||||
options.endpoint ||
|
options.endpoint || process.env.USAGE_COLLECTOR_URL || DEFAULT_COLLECTOR_URL;
|
||||||
process.env.USAGE_COLLECTOR_URL ||
|
|
||||||
'https://usage.picpeak.app';
|
|
||||||
this.bindingPath =
|
this.bindingPath =
|
||||||
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
|
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
|
||||||
this.encKey = null;
|
this.encKey = null;
|
||||||
@@ -116,6 +122,7 @@ class UsageService {
|
|||||||
.join('.');
|
.join('.');
|
||||||
}
|
}
|
||||||
decrypt(value) {
|
decrypt(value) {
|
||||||
|
try {
|
||||||
const [iv, tag, data] = value
|
const [iv, tag, data] = value
|
||||||
.split('.')
|
.split('.')
|
||||||
.map((v) => Buffer.from(v, 'base64url'));
|
.map((v) => Buffer.from(v, 'base64url'));
|
||||||
@@ -124,6 +131,18 @@ class UsageService {
|
|||||||
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
|
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
|
||||||
'utf8'
|
'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) {
|
async binding(create = false) {
|
||||||
if (create) {
|
if (create) {
|
||||||
@@ -381,7 +400,11 @@ class UsageService {
|
|||||||
'NOT_REGISTERED',
|
'NOT_REGISTERED',
|
||||||
'PACKET_CONFLICT'
|
'PACKET_CONFLICT'
|
||||||
].includes(error.code);
|
].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')
|
await this.db('product_usage_state')
|
||||||
.where({ id: 1 })
|
.where({ id: 1 })
|
||||||
.update({ last_error: code });
|
.update({ last_error: code });
|
||||||
|
|||||||
@@ -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
|
Local development can use an HTTP loopback collector outside production. The
|
||||||
collector URL is never writable through generic settings or request payloads.
|
collector URL is never writable through generic settings or request payloads.
|
||||||
Keep the encryption material stable and protected; losing it makes the old
|
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
|
table, not the generic readable settings. A random mode-0600 file at
|
||||||
`getStoragePath()/usage-instance.key` binds the database to its local storage.
|
`getStoragePath()/usage-instance.key` binds the database to its local storage.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
} from '../../../services/productUsage.service';
|
} from '../../../services/productUsage.service';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => ({
|
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', () => ({
|
vi.mock('../../../components/common/ConfirmDialog', () => ({
|
||||||
useConfirm: () => async () => true
|
useConfirm: () => async () => true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type ProductFeedback
|
type ProductFeedback
|
||||||
} from '../../../services/productUsage.service';
|
} from '../../../services/productUsage.service';
|
||||||
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
||||||
import { Button } from '../../../components/common/Button';
|
import { Button, Card } from '../../../components/common';
|
||||||
|
|
||||||
function ConsentDialog({
|
function ConsentDialog({
|
||||||
close,
|
close,
|
||||||
@@ -48,6 +48,24 @@ function ConsentDialog({
|
|||||||
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
|
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||||||
|
<a
|
||||||
|
className={'text-primary-600 dark:text-primary-400 hover:underline'}
|
||||||
|
href={collector}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.linkCollector')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className={'text-primary-600 dark:text-primary-400 hover:underline'}
|
||||||
|
href={`${collector}/transparency`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.transparency')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<label className="flex items-start gap-2 mb-4">
|
<label className="flex items-start gap-2 mb-4">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -119,8 +137,8 @@ export default function ProductUsageTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 text-theme">
|
<div className="space-y-6 text-theme">
|
||||||
<p>{t('productUsage.purpose')}</p>
|
<p>{t('productUsage.purpose')}</p>
|
||||||
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
|
<Card padding="md" className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t(`productUsage.states.${data.status}`)}
|
{t(`productUsage.states.${data.status}`)}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
|
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
|
||||||
@@ -183,7 +201,7 @@ export default function ProductUsageTab() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
className="underline self-center"
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
||||||
href={`${data.collector_url}/transparency`}
|
href={`${data.collector_url}/transparency`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -191,11 +209,11 @@ export default function ProductUsageTab() {
|
|||||||
{t('productUsage.transparency')}
|
{t('productUsage.transparency')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
{active && (
|
{active && (
|
||||||
<>
|
<>
|
||||||
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
|
<Card padding="md" className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('productUsage.inspect')}
|
{t('productUsage.inspect')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -256,9 +274,10 @@ export default function ProductUsageTab() {
|
|||||||
{JSON.stringify(preview, null, 2)}
|
{JSON.stringify(preview, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
|
<Card padding="md">
|
||||||
<form
|
<form
|
||||||
className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
@@ -285,7 +304,7 @@ export default function ProductUsageTab() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('productUsage.feedbackTitle')}
|
{t('productUsage.feedbackTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{t('productUsage.feedbackDisclosure')}</p>
|
<p>{t('productUsage.feedbackDisclosure')}</p>
|
||||||
@@ -407,6 +426,7 @@ export default function ProductUsageTab() {
|
|||||||
{t('productUsage.sendFeedback')}
|
{t('productUsage.sendFeedback')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{message && <p role="status">{message}</p>}
|
{message && <p role="status">{message}</p>}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"disable": "Deaktivieren & Daten löschen",
|
"disable": "Deaktivieren & Daten löschen",
|
||||||
"retry": "Erneut versuchen / fälligen Bericht senden",
|
"retry": "Erneut versuchen / fälligen Bericht senden",
|
||||||
"transparency": "Öffentliches Schema & Datenschutzhinweise",
|
"transparency": "Öffentliches Schema & Datenschutzhinweise",
|
||||||
|
"linkCollector": "Wohin Berichte gesendet werden",
|
||||||
"hash": "Dein vertraulicher Abfrage-Hash",
|
"hash": "Dein vertraulicher Abfrage-Hash",
|
||||||
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
|
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
|
||||||
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
|
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"disable": "Disable & delete my data",
|
"disable": "Disable & delete my data",
|
||||||
"retry": "Retry / send if due",
|
"retry": "Retry / send if due",
|
||||||
"transparency": "Read the public schema & privacy details",
|
"transparency": "Read the public schema & privacy details",
|
||||||
|
"linkCollector": "Where reports are sent",
|
||||||
"hash": "Your private lookup hash",
|
"hash": "Your private lookup hash",
|
||||||
"lastReport": "Last accepted report: {{date}} (UTC)",
|
"lastReport": "Last accepted report: {{date}} (UTC)",
|
||||||
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
|
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
|
||||||
|
|||||||
Reference in New Issue
Block a user