diff --git a/__tests__/deviceId.test.ts b/__tests__/deviceId.test.ts new file mode 100644 index 0000000..47526c8 --- /dev/null +++ b/__tests__/deviceId.test.ts @@ -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}/); + }); +}); diff --git a/__tests__/subscriberRow.test.ts b/__tests__/subscriberRow.test.ts new file mode 100644 index 0000000..55b4a7b --- /dev/null +++ b/__tests__/subscriberRow.test.ts @@ -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); + }); +}); diff --git a/src/lib/deviceId.ts b/src/lib/deviceId.ts new file mode 100644 index 0000000..308d2f3 --- /dev/null +++ b/src/lib/deviceId.ts @@ -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; + +/** 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 { + 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; +} diff --git a/src/lib/deviceLabel.ts b/src/lib/deviceLabel.ts new file mode 100644 index 0000000..a0cf852 --- /dev/null +++ b/src/lib/deviceLabel.ts @@ -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)}`; +}