SkateSpots/src/lib/cordn/deviceLink.ts

81 lines
2.2 KiB
TypeScript

/**
* Two-device handoff payload.
*
* The NEW device publishes a key package to the coordinator and shows this
* compact code as a QR. An EXISTING member scans it, consumes the key package
* by ref, and the coordinator stores the Welcome for the new device. The
* private key never leaves the new device, and the code is short enough for a
* normal QR.
*/
export interface CordnDeviceLink {
gid: string;
kpRef: string;
pubkey: string;
coordinatorPubkey: string;
relays: string[];
}
const PREFIX = 'cordn-device:';
const HEX64 = /^[0-9a-f]{64}$/;
function b64urlEncode(value: string): string {
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function b64urlDecode(value: string): string {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
return atob(padded + pad);
}
export function encodeDeviceLink(link: CordnDeviceLink): string {
const json = JSON.stringify({
g: link.gid,
k: link.kpRef,
p: link.pubkey,
c: link.coordinatorPubkey,
r: link.relays,
});
return `${PREFIX}${b64urlEncode(json)}`;
}
export function decodeDeviceLink(raw: string | undefined | null): CordnDeviceLink | null {
if (!raw || !raw.startsWith(PREFIX)) return null;
try {
const parsed = JSON.parse(b64urlDecode(raw.slice(PREFIX.length))) as {
g?: unknown;
k?: unknown;
p?: unknown;
c?: unknown;
r?: unknown;
};
if (
typeof parsed.g !== 'string' ||
!parsed.g ||
typeof parsed.k !== 'string' ||
!parsed.k ||
typeof parsed.p !== 'string' ||
!HEX64.test(parsed.p) ||
typeof parsed.c !== 'string' ||
!HEX64.test(parsed.c) ||
!Array.isArray(parsed.r) ||
parsed.r.length === 0
) {
return null;
}
const relays = parsed.r.filter(
(relay): relay is string =>
typeof relay === 'string' && /^wss?:\/\/[^\s]+$/i.test(relay),
);
if (relays.length === 0) return null;
return {
gid: parsed.g,
kpRef: parsed.k,
pubkey: parsed.p,
coordinatorPubkey: parsed.c,
relays,
};
} catch {
return null;
}
}