247 lines
9.9 KiB
TypeScript
247 lines
9.9 KiB
TypeScript
const MAX_PAIRING_PAYLOAD_LENGTH = 16_384;
|
|
|
|
// A pairing candidate is one way to reach the host's HTTP API. `type`
|
|
// discriminates the transport:
|
|
// - lan / tunnel: reach `url` directly (health-check, then redeem over fetch).
|
|
// - relay: no reachable URL — open the E2EE relay tunnel to `serverId` via
|
|
// `relayUrl`, trusting `hostEncPubJwk`, then redeem over the tunnel.
|
|
// The one-time pairing `secret` (payload level) is the single auth credential,
|
|
// redeemed over whichever transport connects first. Relay carries no embedded
|
|
// bearer token — that is the v1 sin this format replaces.
|
|
export type PairingDirectCandidate = {
|
|
type: 'lan' | 'tunnel';
|
|
url: string;
|
|
priority?: number;
|
|
};
|
|
|
|
export type PairingRelayCandidate = {
|
|
type: 'relay';
|
|
relayUrl: string;
|
|
serverId: string;
|
|
hostEncPubJwk: JsonWebKey;
|
|
// One-time relay-infrastructure authorization. Reserved: the v1 relay worker
|
|
// ignores it (E2EE + the pairing secret are the actual gates). Plumbed for
|
|
// future relay-side per-device/traffic control. Never persisted.
|
|
grant?: string;
|
|
priority?: number;
|
|
};
|
|
|
|
export type PairingEndpointCandidate = PairingDirectCandidate | PairingRelayCandidate;
|
|
|
|
export type PairingConnectionPayload = {
|
|
v: 2;
|
|
pairingId: string;
|
|
secret: string;
|
|
label?: string;
|
|
fingerprint?: string;
|
|
expiresAt?: string;
|
|
candidates: PairingEndpointCandidate[];
|
|
};
|
|
|
|
const globalWithBuffer = globalThis as typeof globalThis & {
|
|
Buffer?: {
|
|
from: (value: string, encoding?: string) => { toString: (encoding: string) => string };
|
|
};
|
|
};
|
|
|
|
const base64UrlEncode = (value: string): string => {
|
|
if (globalWithBuffer.Buffer) {
|
|
return globalWithBuffer.Buffer.from(value, 'utf8').toString('base64url');
|
|
}
|
|
const bytes = new TextEncoder().encode(value);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
binary += String.fromCharCode(...bytes.slice(i, i + 0x8000));
|
|
}
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
};
|
|
|
|
const base64UrlDecode = (value: string): string | null => {
|
|
try {
|
|
if (globalWithBuffer.Buffer) {
|
|
return globalWithBuffer.Buffer.from(value, 'base64url').toString('utf8');
|
|
}
|
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
|
|
const binary = atob(padded);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
return new TextDecoder().decode(bytes);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const normalizeHttpUrl = (value: unknown): string | null => {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
parsed.hash = '';
|
|
return parsed.toString().replace(/\/+$/g, '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Relay endpoints are WebSocket URLs and keep their path (e.g. `/ws`, `/tunnel`),
|
|
// so only the fragment is stripped — never the trailing path segment.
|
|
const normalizeWsUrl = (value: unknown): string | null => {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return null;
|
|
parsed.hash = '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
|
|
|
|
// EC P-256 public JWK (the relay E2EE trust anchor). Strict: only the four
|
|
// public-key members are retained; a private `d` or any other member is dropped.
|
|
const normalizeEcPublicJwk = (value: unknown): JsonWebKey | null => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
const jwk = value as Record<string, unknown>;
|
|
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
|
|
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
|
|
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
|
};
|
|
|
|
const normalizePriority = (value: unknown): number | undefined =>
|
|
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
|
|
const normalizePairingCandidate = (value: unknown): PairingEndpointCandidate | null => {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const record = value as Record<string, unknown>;
|
|
const priority = normalizePriority(record.priority);
|
|
|
|
if (record.type === 'lan' || record.type === 'tunnel') {
|
|
const url = normalizeHttpUrl(record.url);
|
|
if (!url) return null;
|
|
return priority === undefined ? { type: record.type, url } : { type: record.type, url, priority };
|
|
}
|
|
|
|
if (record.type === 'relay') {
|
|
const relayUrl = normalizeWsUrl(record.relayUrl);
|
|
if (!relayUrl) return null;
|
|
const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : '';
|
|
if (!serverId) return null;
|
|
const hostEncPubJwk = normalizeEcPublicJwk(record.hostEncPubJwk);
|
|
if (!hostEncPubJwk) return null;
|
|
const grant = typeof record.grant === 'string' && record.grant.trim() ? record.grant.trim() : undefined;
|
|
return {
|
|
type: 'relay',
|
|
relayUrl,
|
|
serverId,
|
|
hostEncPubJwk,
|
|
...(grant ? { grant } : {}),
|
|
...(priority === undefined ? {} : { priority }),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const normalizePairingPayload = (value: unknown): PairingConnectionPayload | null => {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (record.v !== 2) return null;
|
|
const pairingId = typeof record.pairingId === 'string' ? record.pairingId.trim() : '';
|
|
const secret = typeof record.secret === 'string' ? record.secret.trim() : '';
|
|
if (!pairingId || !secret) return null;
|
|
const candidates = Array.isArray(record.candidates)
|
|
? record.candidates.map(normalizePairingCandidate).filter((candidate): candidate is PairingEndpointCandidate => Boolean(candidate))
|
|
: [];
|
|
if (candidates.length === 0) return null;
|
|
const expiresAt = typeof record.expiresAt === 'string' && record.expiresAt.trim() ? record.expiresAt.trim() : undefined;
|
|
if (expiresAt) {
|
|
const expiresTime = Date.parse(expiresAt);
|
|
if (!Number.isFinite(expiresTime) || expiresTime <= Date.now()) return null;
|
|
}
|
|
const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : undefined;
|
|
const fingerprint = typeof record.fingerprint === 'string' && record.fingerprint.trim() ? record.fingerprint.trim() : undefined;
|
|
return {
|
|
v: 2,
|
|
pairingId,
|
|
secret,
|
|
...(label ? { label } : {}),
|
|
...(fingerprint ? { fingerprint } : {}),
|
|
...(expiresAt ? { expiresAt } : {}),
|
|
candidates,
|
|
};
|
|
};
|
|
|
|
export const buildPairingConnectionPayload = (input: Omit<PairingConnectionPayload, 'v'>): PairingConnectionPayload => ({
|
|
v: 2,
|
|
pairingId: input.pairingId.trim(),
|
|
secret: input.secret.trim(),
|
|
...(input.label?.trim() ? { label: input.label.trim() } : {}),
|
|
...(input.fingerprint?.trim() ? { fingerprint: input.fingerprint.trim() } : {}),
|
|
...(input.expiresAt?.trim() ? { expiresAt: input.expiresAt.trim() } : {}),
|
|
candidates: input.candidates,
|
|
});
|
|
|
|
export const encodePairingConnectionPayload = (payload: PairingConnectionPayload): string => {
|
|
const normalized = normalizePairingPayload(payload);
|
|
if (!normalized) throw new Error('Invalid pairing connection payload');
|
|
const params = new URLSearchParams();
|
|
params.set('v', '2');
|
|
params.set('p', base64UrlEncode(JSON.stringify(normalized)));
|
|
return `openchamber://connect?${params.toString()}`;
|
|
};
|
|
|
|
export const parsePairingConnectionPayload = (value: string): PairingConnectionPayload | null => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
try {
|
|
const url = new URL(trimmed);
|
|
if (url.protocol !== 'openchamber:' || url.hostname !== 'connect') return null;
|
|
if (url.searchParams.get('v') !== '2') return null;
|
|
const encoded = url.searchParams.get('p') || '';
|
|
if (!encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
const decoded = base64UrlDecode(encoded);
|
|
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
return normalizePairingPayload(JSON.parse(decoded) as unknown);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// URL-string-only sibling of parsePairingConnectionPayload. Old Android WebViews
|
|
// (e.g. WebView 114) mis-parse non-special schemes: `new URL('openchamber://connect?...')`
|
|
// yields hostname "" and pathname "//connect", so the URL-based parser above rejects a
|
|
// perfectly valid pairing link. This parser never touches the URL/URLSearchParams APIs —
|
|
// it matches the head with a regex and reads `v`/`p` straight off the query string.
|
|
// Used by the Android QR-scan path after the standard parse fails; keeps every existing
|
|
// validation (version, payload length, base64url, candidate normalization).
|
|
export const parsePairingConnectionPayloadString = (value: string): PairingConnectionPayload | null => {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
const question = trimmed.indexOf('?');
|
|
if (question === -1 || !/^openchamber:\/\/connect\/?$/i.test(trimmed.slice(0, question))) return null;
|
|
let version: string | null = null;
|
|
let encoded: string | null = null;
|
|
for (const part of trimmed.slice(question + 1).split('&')) {
|
|
const eq = part.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = part.slice(0, eq);
|
|
const value_ = part.slice(eq + 1);
|
|
if (key === 'v') version = value_;
|
|
else if (key === 'p') encoded = value_;
|
|
}
|
|
if (version !== '2' || !encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
const decoded = base64UrlDecode(encoded);
|
|
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
|
try {
|
|
return normalizePairingPayload(JSON.parse(decoded) as unknown);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|