[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:
2026-10-19 02:34:02 +01:00
parent a78c4cea14
commit fc66b8e088
4 changed files with 255 additions and 0 deletions
+77
View File
@@ -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;
}
+28
View File
@@ -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)}`;
}