[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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomToken } from '../services/alarmNative';
|
||||
|
||||
/**
|
||||
* The per-install pseudonymous device id.
|
||||
*
|
||||
* Design rules (all of them load-bearing):
|
||||
* - **Pure randomness.** No hardware identifier, no model name, no install
|
||||
* time, no counter. Android's `ANDROID_ID`, `Settings.Secure`, serials and
|
||||
* telephony ids are deliberately never touched: this value is the user's
|
||||
* only identity on the alarm service, and it must not be a device
|
||||
* fingerprint.
|
||||
* - **Cryptographically random.** `Math.random()` is a PRNG and its output is
|
||||
* predictable from enough samples; a predictable id would let someone guess
|
||||
* or enumerate subscriptions. The id therefore comes from
|
||||
* `java.security.SecureRandom` through the native bridge.
|
||||
* - **Stable across launches.** Persisted in AsyncStorage and reused.
|
||||
* - **Service-compatible.** The service trims, rejects empty, rejects more
|
||||
* than 128 bytes and rejects control characters
|
||||
* (`Alarm/src/actions/errors.go:190-205`); [isValidDeviceId] mirrors that so
|
||||
* a bad id is caught here instead of as a confusing 400 on join.
|
||||
*/
|
||||
|
||||
export const DEVICE_ID_STORAGE_KEY = 'alarm.deviceId.v1';
|
||||
export const DEVICE_ID_PREFIX = 'dev_';
|
||||
/** 24 hex chars = 96 bits of entropy; well inside the service's 128-byte cap. */
|
||||
export const DEVICE_ID_RANDOM_CHARS = 24;
|
||||
export const DEVICE_ID_MAX_BYTES = 128;
|
||||
|
||||
export type TokenSource = (chars: number) => Promise<string | null>;
|
||||
|
||||
/** The OS gave us no cryptographic source: refuse rather than degrade. */
|
||||
export class DeviceIdUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('This device could not create a secure identity. Re-open the app and try again.');
|
||||
this.name = 'DeviceIdUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a device id from a random-token source. Split out so the format can be
|
||||
* tested without a native module.
|
||||
*/
|
||||
export async function newDeviceId(source: TokenSource = randomToken): Promise<string> {
|
||||
const hex = await source(DEVICE_ID_RANDOM_CHARS);
|
||||
if (!hex || !/^[0-9a-f]+$/i.test(hex) || hex.length !== DEVICE_ID_RANDOM_CHARS) {
|
||||
throw new DeviceIdUnavailableError();
|
||||
}
|
||||
const id = `${DEVICE_ID_PREFIX}${hex.toLowerCase()}`;
|
||||
if (!isValidDeviceId(id)) throw new DeviceIdUnavailableError();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Byte length of a JS string as UTF-8, matching Go's `len(string)`. */
|
||||
function utf8Length(value: string): number {
|
||||
let bytes = 0;
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the service's `validateDeviceID`: trimmed, non-empty, at most 128
|
||||
* bytes, no control characters (`< 0x20` or `0x7f`).
|
||||
*/
|
||||
export function isValidDeviceId(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const id = value.trim();
|
||||
if (id.length === 0) return false;
|
||||
if (utf8Length(id) > DEVICE_ID_MAX_BYTES) return false;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const code = id.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The label this device publishes to the alert admin.
|
||||
*
|
||||
* It identifies the **device role** ("a phone", so the owner can tell it apart
|
||||
* from a tablet or a second phone), never the identity. It must not contain any
|
||||
* part of the device id: the id is the subscriber's only pseudonymous handle,
|
||||
* and the admin does not need a slice of it to pick the right device to remove.
|
||||
*
|
||||
* Disambiguation for a user with several phones comes from the subscription
|
||||
* list itself (platform + join time, and the per-alert ordinal when one is
|
||||
* known) — not from a secret.
|
||||
*/
|
||||
|
||||
export const ANDROID_PHONE_LABEL = 'Android phone';
|
||||
|
||||
/**
|
||||
* `ordinal` is the device's position in the alert's subscription list when the
|
||||
* caller knows it (1-based). At join time the client cannot know it reliably —
|
||||
* `listSubscriptions` is an owner-only action and the server assigns order — so
|
||||
* the scanner passes nothing and the alert detail screen provides the joined
|
||||
* timestamp instead.
|
||||
*/
|
||||
export function buildDeviceLabel(ordinal?: number): string {
|
||||
if (ordinal === undefined || !Number.isFinite(ordinal) || ordinal < 1) {
|
||||
return ANDROID_PHONE_LABEL;
|
||||
}
|
||||
return `${ANDROID_PHONE_LABEL} ${Math.floor(ordinal)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user