e40bc474bc
Probing the live collector to settle the delete-sequence question turned up something else: usage.picpeak.app answers a valid usage.v2 registration with INVALID_PACKET while the identical v1 flow is accepted. It does not speak v2 yet — which the deployment notes already require, but the consequence of getting that order wrong was worse than "reports do not send". Opting in to v2 against a v1-only collector left the installation stuck. Registration was refused, so nothing existed at the collector at all; the row sat in activation_pending, disable moved it to deletion_pending, retry was futile forever, and enable refused because the row was not `disabled`. The abandon hatch added earlier did not apply: it was gated on SIGNING_KEY_UNREADABLE. So the most harmless possible failure — nothing registered anywhere — was the one an operator could not clear. The gate is now the property that actually matters: a participation the collector has provably never accepted (sequence 0, no receipt) with a failing delivery can be discarded, from activation_pending as well as deletion_pending. Its receipt records `never-registered` rather than an unconfirmed deletion, because nothing remote exists to be unsure about. A participation the collector *did* accept keeps the old narrow gate and its explicit warning — clearing local state while the collector still holds reports must stay a deliberate, warned-about act. A collector that rejects a registration or a deletion outright now reports SCHEMA_NOT_ACCEPTED instead of DELIVERY_FAILED, and the settings page says the collector does not accept this report version yet. Retrying cannot fix that, and sending the operator to look for a network fault they do not have was wrong. Verified end to end against the live collector: v2 opt-in reports SCHEMA_NOT_ACCEPTED, the exit is offered immediately, the receipt says never-registered, and joining again on v1 registers, reports and withdraws with a collector-confirmed deletion.
360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
/**
|
|
* 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 { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
|
|
|
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.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
|
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.text('privacy_receipts');
|
|
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);
|
|
t.integer('attempts').notNullable().defaultTo(0);
|
|
t.bigInteger('next_attempt_at').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');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Naming the failure told the operator what happened but left them nowhere to
|
|
* go: the delete packet can never be signed, so the row stays in
|
|
* deletion_pending forever, and enable() refuses because it is not `disabled`.
|
|
* An operator who rotated the secret precisely because it was compromised
|
|
* cannot restore it, so without an exit the feature is bricked.
|
|
*/
|
|
describe('abandoning a withdrawal that can never be signed', () => {
|
|
let db;
|
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
|
|
|
const stuck = async () => {
|
|
db = await bootDb();
|
|
await db.schema.createTable('product_usage_markers', (t) => {
|
|
t.string('feature', 60).primary();
|
|
});
|
|
await db('product_usage_markers').insert({ feature: 'crm' });
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
status: 'deletion_pending',
|
|
installation_id: 'a'.repeat(64),
|
|
public_key: 'p'.repeat(59),
|
|
private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'),
|
|
sequence: 4,
|
|
last_error: 'SIGNING_KEY_UNREADABLE',
|
|
});
|
|
return new UsageService(db, {
|
|
secret: SECRET_B,
|
|
endpoint: 'https://usage.example.test',
|
|
bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`,
|
|
fetch: () => { throw new Error('network must not be reached'); },
|
|
});
|
|
};
|
|
|
|
it('clears the local identity and records the deletion as unconfirmed', async () => {
|
|
const service = await stuck();
|
|
const status = await service.abandon();
|
|
|
|
expect(status.status).toBe('disabled');
|
|
expect(status.installation_id).toBeNull();
|
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
|
expect(row.private_key_encrypted).toBeNull();
|
|
expect(row.public_key).toBeNull();
|
|
expect(row.last_error).toBeNull();
|
|
expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 });
|
|
|
|
// The receipt must not claim a deletion the collector never confirmed.
|
|
const receipt = JSON.parse(row.privacy_receipts).last_abandonment;
|
|
expect(receipt.status).toBe('collector-unconfirmed');
|
|
expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE');
|
|
expect(receipt.installation_id).toBe('a'.repeat(64));
|
|
});
|
|
|
|
it('lets the operator rejoin afterwards', async () => {
|
|
const service = await stuck();
|
|
await service.abandon();
|
|
expect((await service.state()).status).toBe('disabled');
|
|
});
|
|
|
|
it('refuses on a withdrawal that is merely undelivered', async () => {
|
|
const service = await stuck();
|
|
await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' });
|
|
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
|
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
|
});
|
|
|
|
it('refuses while participation is active', async () => {
|
|
const service = await stuck();
|
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
|
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
|
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Every failed delivery used to be retried on the next admin request, and
|
|
* /activity is open to any authenticated admin while the settings ticker fires
|
|
* it every five minutes per open tab. A permanently rejected packet therefore
|
|
* produced one collector request per admin action, indefinitely.
|
|
*/
|
|
describe('delivery backoff', () => {
|
|
let db;
|
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
|
|
|
// A real identity and a schema-valid packet, so the failure happens where
|
|
// this test claims it does — at the network — rather than in signPacket.
|
|
const activeWithPendingPacket = async (fetchImpl, now) => {
|
|
db = await bootDb();
|
|
await db.schema.createTable('product_usage_markers', (t) => {
|
|
t.string('feature', 60).primary();
|
|
});
|
|
const service = new UsageService(db, {
|
|
secret: SECRET_A,
|
|
endpoint: 'https://usage.example.test',
|
|
now: () => now(),
|
|
fetch: fetchImpl,
|
|
});
|
|
const identity = generateIdentity();
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
status: 'active',
|
|
consent_version: 'usage-consent.v2',
|
|
installation_id: identity.installation_id,
|
|
public_key: identity.public_key,
|
|
private_key_encrypted: service.encrypt(identity.private_key),
|
|
sequence: 1,
|
|
pending_packet: JSON.stringify(
|
|
makePacket(identity, 'session', 2, {}, 'usage.v2')
|
|
),
|
|
});
|
|
return service;
|
|
};
|
|
|
|
it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => {
|
|
let clock = 1_000_000;
|
|
let calls = 0;
|
|
const service = await activeWithPendingPacket(() => {
|
|
calls += 1;
|
|
throw new Error('collector unreachable');
|
|
}, () => clock);
|
|
|
|
await service.tick();
|
|
expect(calls).toBe(1);
|
|
const paced = await service.state();
|
|
expect(Number(paced.attempts)).toBe(1);
|
|
expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock);
|
|
|
|
// The unattended callers — /activity and the settings ticker — wait.
|
|
await service.tick();
|
|
await service.tick();
|
|
expect(calls).toBe(1);
|
|
|
|
// The operator pressing Retry does not.
|
|
await service.tick({ force: true });
|
|
expect(calls).toBe(2);
|
|
expect(Number((await service.state()).attempts)).toBe(2);
|
|
|
|
// Once the window passes, the automatic sender tries again on its own.
|
|
clock = Number((await service.state()).next_attempt_at) + 1;
|
|
await service.tick();
|
|
expect(calls).toBe(3);
|
|
});
|
|
|
|
it('grows the wait with consecutive failures and caps it at an hour', () => {
|
|
const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' });
|
|
expect(service.backoffMs(1)).toBe(2 * 60000);
|
|
expect(service.backoffMs(3)).toBe(8 * 60000);
|
|
expect(service.backoffMs(20)).toBe(60 * 60000);
|
|
});
|
|
|
|
it('clears the pacing once a packet is accepted', async () => {
|
|
const clock = 1_000_000;
|
|
const service = await activeWithPendingPacket(async () => {
|
|
throw new Error('collector unreachable');
|
|
}, () => clock);
|
|
await service.tick();
|
|
expect(Number((await service.state()).attempts)).toBe(1);
|
|
|
|
await service.clearDeliveryBackoff();
|
|
const cleared = await service.state();
|
|
expect(Number(cleared.attempts)).toBe(0);
|
|
expect(Number(cleared.next_attempt_at)).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The same dead end, reached the ordinary way. If an installation opts in to
|
|
* usage.v2 while the collector still only speaks usage.v1 — the deployment
|
|
* order the docs warn about — the registration is rejected outright. Nothing
|
|
* exists at the collector, and yet the operator could not clear the tab:
|
|
* disable moved to deletion_pending, retry was futile, enable refused, and the
|
|
* abandon hatch was gated on SIGNING_KEY_UNREADABLE, which this is not.
|
|
*
|
|
* Verified against the live collector before this was written: a valid v2
|
|
* register is answered with INVALID_PACKET while the identical v1 flow is
|
|
* accepted.
|
|
*/
|
|
describe('a participation the collector never accepted', () => {
|
|
let db;
|
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
|
|
|
const rejectingCollector = async (status) => {
|
|
db = await bootDb();
|
|
await db.schema.createTable('product_usage_markers', (t) => {
|
|
t.string('feature', 60).primary();
|
|
});
|
|
const identity = generateIdentity();
|
|
const service = new UsageService(db, {
|
|
secret: SECRET_A,
|
|
endpoint: 'https://usage.example.test',
|
|
bindingPath: `${require('os').tmpdir()}/usage-unreg-${Date.now()}-${Math.random()}.key`,
|
|
fetch: async () => ({
|
|
ok: false,
|
|
status: 400,
|
|
headers: { get: () => null },
|
|
body: (async function* () { yield Buffer.from(JSON.stringify({ error: 'INVALID_PACKET' })); })(),
|
|
}),
|
|
});
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
status,
|
|
consent_version: 'usage-consent.v2',
|
|
installation_id: identity.installation_id,
|
|
public_key: identity.public_key,
|
|
private_key_encrypted: service.encrypt(identity.private_key),
|
|
sequence: 0,
|
|
pending_packet: JSON.stringify(
|
|
makePacket(identity, status === 'deletion_pending' ? 'delete' : 'register', 0,
|
|
status === 'deletion_pending' ? {} : { consent_version: 'usage-consent.v2' }, 'usage.v2')
|
|
),
|
|
});
|
|
return service;
|
|
};
|
|
|
|
it('names the rejection instead of blaming the network', async () => {
|
|
const service = await rejectingCollector('activation_pending');
|
|
await service.tick({ force: true });
|
|
expect((await service.state()).last_error).toBe('SCHEMA_NOT_ACCEPTED');
|
|
});
|
|
|
|
it('offers the exit straight from activation_pending', async () => {
|
|
const service = await rejectingCollector('activation_pending');
|
|
await service.tick({ force: true });
|
|
const status = await service.status();
|
|
expect(status.can_abandon).toBe(true);
|
|
expect(status.abandon_never_registered).toBe(true);
|
|
|
|
await service.abandon();
|
|
const after = await service.state();
|
|
expect(after.status).toBe('disabled');
|
|
expect(after.installation_id).toBeNull();
|
|
// Provably nothing remote, so the receipt must not hedge.
|
|
expect(JSON.parse(after.privacy_receipts).last_abandonment.status)
|
|
.toBe('never-registered');
|
|
});
|
|
|
|
it('offers it from deletion_pending too, once the withdrawal is also undeliverable', async () => {
|
|
const service = await rejectingCollector('deletion_pending');
|
|
await service.tick({ force: true });
|
|
expect((await service.status()).can_abandon).toBe(true);
|
|
await service.abandon();
|
|
expect((await service.state()).status).toBe('disabled');
|
|
});
|
|
|
|
it('never offers it while a registered participation could still be deleted remotely', async () => {
|
|
const service = await rejectingCollector('deletion_pending');
|
|
// Something WAS accepted once: the collector may still hold reports, so
|
|
// clearing local state silently would be a lie.
|
|
await db('product_usage_state').where({ id: 1 }).update({
|
|
sequence: 3,
|
|
last_receipt: JSON.stringify({ status: 'accepted' }),
|
|
last_error: 'DELIVERY_FAILED',
|
|
});
|
|
expect((await service.status()).can_abandon).toBe(false);
|
|
await expect(service.abandon()).rejects.toThrow(/cannot be completed/);
|
|
});
|
|
|
|
it('does not offer it before a delivery has actually failed', async () => {
|
|
const service = await rejectingCollector('activation_pending');
|
|
expect((await service.status()).can_abandon).toBe(false);
|
|
});
|
|
});
|