[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}/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user