[feat](client): generate a cryptographic per-install device id
The device id is the subscriber-only pseudonymous handle, used as the join idempotency key and to address this device in the delivery ledger. Use SecureRandom through the native bridge, validate it against the service limits, and keep hardware identifiers out of it entirely. Device labels describe the device role, never a slice of the id, and the subscriber list renders the wire id/created/lastSeenAt fields.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
DEVICE_ID_MAX_BYTES,
|
||||
DEVICE_ID_PREFIX,
|
||||
DEVICE_ID_RANDOM_CHARS,
|
||||
DEVICE_ID_STORAGE_KEY,
|
||||
DeviceIdUnavailableError,
|
||||
isValidDeviceId,
|
||||
newDeviceId,
|
||||
} from '../src/lib/deviceId';
|
||||
import { ANDROID_PHONE_LABEL, buildDeviceLabel } from '../src/lib/deviceLabel';
|
||||
|
||||
function fixedSource(value: string) {
|
||||
return jest.fn(async (_chars: number) => value);
|
||||
}
|
||||
|
||||
describe('device id', () => {
|
||||
it('is a dev_-prefixed hex token of the expected size', async () => {
|
||||
const hex = 'a1b2c3d4e5f60718293a4b5c';
|
||||
const id = await newDeviceId(fixedSource(hex));
|
||||
expect(id).toBe(`${DEVICE_ID_PREFIX}${hex}`);
|
||||
expect(id).toHaveLength(DEVICE_ID_PREFIX.length + DEVICE_ID_RANDOM_CHARS);
|
||||
expect(id).toMatch(/^dev_[0-9a-f]{24}$/);
|
||||
});
|
||||
|
||||
it('keeps the `dev_` prefix and stores under a stable key', () => {
|
||||
expect(DEVICE_ID_PREFIX).toBe('dev_');
|
||||
expect(DEVICE_ID_STORAGE_KEY).toBe('alarm.deviceId.v1');
|
||||
});
|
||||
|
||||
it('refuses to build an id when the random source is unavailable', async () => {
|
||||
await expect(newDeviceId(async () => null)).rejects.toBeInstanceOf(DeviceIdUnavailableError);
|
||||
// The real bridge has no native module in a JS-only test run, so the default
|
||||
// source must fail loudly instead of falling back to Math.random().
|
||||
await expect(newDeviceId()).rejects.toBeInstanceOf(DeviceIdUnavailableError);
|
||||
});
|
||||
|
||||
it('rejects sources that are not the requested size or not hex', async () => {
|
||||
await expect(newDeviceId(fixedSource('abc'))).rejects.toBeInstanceOf(DeviceIdUnavailableError);
|
||||
await expect(
|
||||
newDeviceId(fixedSource('zzzzzzzzzzzzzzzzzzzzzzzz')),
|
||||
).rejects.toBeInstanceOf(DeviceIdUnavailableError);
|
||||
});
|
||||
|
||||
it('satisfies the service rule: 1..128 bytes, no control characters', async () => {
|
||||
const id = await newDeviceId(fixedSource('0123456789abcdef01234567'));
|
||||
expect(id.length).toBeGreaterThanOrEqual(1);
|
||||
expect(id.length).toBeLessThanOrEqual(DEVICE_ID_MAX_BYTES);
|
||||
// Mirrors Alarm/src/actions/errors.go:190-205.
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const code = id.charCodeAt(i);
|
||||
expect(code).toBeGreaterThanOrEqual(0x20);
|
||||
expect(code).not.toBe(0x7f);
|
||||
}
|
||||
expect(isValidDeviceId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('validates exactly like the service', () => {
|
||||
expect(isValidDeviceId('dev_abc123')).toBe(true);
|
||||
expect(isValidDeviceId(`dev_${'a'.repeat(DEVICE_ID_MAX_BYTES - 4)}`)).toBe(true);
|
||||
expect(isValidDeviceId(`dev_${'a'.repeat(DEVICE_ID_MAX_BYTES - 3)}`)).toBe(false);
|
||||
expect(isValidDeviceId('')).toBe(false);
|
||||
expect(isValidDeviceId(' ')).toBe(false);
|
||||
expect(isValidDeviceId('bad\u0000id')).toBe(false);
|
||||
expect(isValidDeviceId('bad\u001fid')).toBe(false);
|
||||
expect(isValidDeviceId('bad\u007fid')).toBe(false);
|
||||
expect(isValidDeviceId(42 as unknown)).toBe(false);
|
||||
expect(isValidDeviceId(null as unknown)).toBe(false);
|
||||
});
|
||||
|
||||
it('contains no hardware, model or install-time basis', async () => {
|
||||
// A generated id is exactly the prefix plus random hex: nothing from the
|
||||
// environment can leak in, because nothing from the environment is read.
|
||||
const id = await newDeviceId(fixedSource('deadbeefdeadbeefdeadbeef'));
|
||||
expect(id).toBe('dev_deadbeefdeadbeefdeadbeef');
|
||||
expect(id).not.toMatch(/android|sdk|serial|emulator|install/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device label', () => {
|
||||
it('names the device role, not the identity', async () => {
|
||||
const id = await newDeviceId(fixedSource('0123456789abcdef01234567'));
|
||||
const label = buildDeviceLabel();
|
||||
expect(label).toBe(ANDROID_PHONE_LABEL);
|
||||
expect(label).not.toContain(DEVICE_ID_PREFIX);
|
||||
for (let i = 0; i <= id.length - 4; i++) {
|
||||
expect(label).not.toContain(id.slice(i, i + 4));
|
||||
}
|
||||
});
|
||||
|
||||
it('can carry a per-alert ordinal for disambiguation', () => {
|
||||
expect(buildDeviceLabel(2)).toBe('Android phone 2');
|
||||
expect(buildDeviceLabel(0)).toBe('Android phone');
|
||||
expect(buildDeviceLabel(Number.NaN)).toBe('Android phone');
|
||||
});
|
||||
|
||||
it('never derives the label from a hex token', () => {
|
||||
const label = buildDeviceLabel();
|
||||
expect(label).toBe('Android phone');
|
||||
expect(label).not.toMatch(/[0-9a-f]{4}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export {};
|
||||
|
||||
/**
|
||||
* Subscriber rows must never depend on a stable device identifier.
|
||||
*
|
||||
* The service stopped serialising `deviceId` (privacy ruling); the admin still
|
||||
* has to tell two phones apart well enough to revoke the right one, without any
|
||||
* token fragment or identifier. This is a source contract because the row is
|
||||
* JSX with no headless renderer in this project.
|
||||
*/
|
||||
declare const __dirname: string;
|
||||
const fs: { readFileSync(p: string, enc: string): string } = jest.requireActual('fs');
|
||||
const path: { join(...parts: string[]): string } = jest.requireActual('path');
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'screens', 'AlertDetailScreen.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('subscriber row discriminator', () => {
|
||||
it('does not read deviceId or the removed joined field', () => {
|
||||
expect(source).not.toMatch(/sub\.deviceId/);
|
||||
expect(source).not.toMatch(/sub\.joined\b/);
|
||||
});
|
||||
|
||||
it('uses the subscription id as the revocation handle', () => {
|
||||
expect(source).toMatch(/key=\{sub\.id\}/);
|
||||
expect(source).toMatch(/subscriptionId: sub\.id/);
|
||||
});
|
||||
|
||||
it('only reloads after a successful revoke and surfaces failures', () => {
|
||||
const pressAt = source.indexOf('onPress={async () => {');
|
||||
expect(pressAt).toBeGreaterThan(-1);
|
||||
const handler = source.slice(pressAt, pressAt + 700);
|
||||
// reloadSubs() lives in the try, after the awaited removal, so the row
|
||||
// cannot vanish optimistically when the service rejects the request.
|
||||
expect(handler.indexOf('await api.removeSubscription')).toBeLessThan(
|
||||
handler.indexOf('reloadSubs()'),
|
||||
);
|
||||
expect(handler).toContain('setError(');
|
||||
});
|
||||
|
||||
it('names a device by role label + server order, never a token fragment', () => {
|
||||
const row = source.slice(source.indexOf('const deviceName'), source.indexOf('const deviceName') + 900);
|
||||
expect(row).toMatch(/index \+ 1/);
|
||||
expect(row).toMatch(/joined \{joinedAgo\}/);
|
||||
expect(row).not.toMatch(/token/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user