Files
Shade/packages/shade-sdk/src/approval.ts

793 lines
27 KiB
TypeScript
Raw Normal View History

import type { CryptoProvider } from '@shade/core';
import { ValidationError } from '@shade/core';
/**
* V4.10 cross-host approval routing.
*
* Two pieces, both app-level (no relay changes):
*
* 1. A canonical schema for the V4.9 profile blob. The blob is opaque to
* Shade, but Prism's `cross-host-approval-routing` FR pointed out that
* every Shade app that supports credential-driven device linking will
* end up reinventing the same `hosts[]` / `clients[]` /
* `trustedApproverFingerprints[]` shape. We bless one here so apps can
* interop and so the proxy-approval verify path has a stable place to
* look up trusted-approver public keys.
*
* 2. Build / sign / verify helpers for `approvalNeeded` and
* `linkApproveByProxy` frames. The frames themselves are app-defined
* payloads sent over the existing `Shade.send` / `Shade.receive`
* bilateral E2EE channel Shade just exposes the signing-payload
* canonicalization and Ed25519 plumbing so a fingerprint+signature
* bound to a long-term identity key can be verified on any host that
* has a fresh copy of the user's profile blob.
*
* The proxy-approval signature is *belt-and-suspenders* on top of the
* E2EE that delivered the frame: the E2EE channel already authenticates
* the sender's session, but the signature ties the approval explicitly
* to the approver's long-term identity key. That makes the approval
* verifiable independently of session state useful if a host
* receives a forwarded approval without a prior session, and necessary
* for replay-resistance in the face of a compromised relay that
* reorders bilateral sends.
*
* Headless servers without a GUI use this with the existing X3DH-on-
* first-send behavior of `Shade.send` to ask any trusted-approver
* client (typically a phone) to approve a `linkRequest` from a new
* device. The approver phone biometrically gates a long-term Ed25519
* sign, ships the frame back, and the server runs `verifyProxyApproval`
* against the freshest profile blob before completing pairing.
*/
// ─── Canonical profile-blob schema ─────────────────────────────
/**
* A host: a device that *receives* `linkRequest` frames from new
* devices and runs the pairing handshake. Typically a desktop, server,
* or always-on laptop. Hosts have no special crypto status they're
* just where the user's data lives.
*/
export interface ProfileHostEntry {
/** Shade address (`device:...`). */
address: string;
/** Human-friendly name (e.g. "PC", "Server"). */
name: string;
/** Open enum: `'desktop' | 'server' | 'laptop' | ...`. */
kind: string;
/** Wall-clock ms when this host was added to the profile. */
addedAt: number;
}
/**
* A client: a device that *initiates* link/approval flows and may
* proxy-approve link-requests on behalf of the account when
* `trustedApprover === true`. Typically a phone or tablet.
*
* `identityPublicKey` is the 32-byte Ed25519 long-term identity key.
* `identityFingerprint` is the human-readable safety-number derived
* from it (the same value `Shade.getFingerprintFor(address)` returns).
* Both are stored: the public key is what `verifyProxyApproval` uses
* to check signatures; the fingerprint is what UIs show users for
* out-of-band verification.
*/
export interface ProfileClientEntry {
/** Shade address (`device:...`). */
address: string;
/**
* 32-byte Ed25519 long-term identity public key, lowercase hex
* (64 chars). This is the key that anchors the client's
* prekey-bundle and that proxy-approvals are signed with.
*/
identityPublicKey: string;
/**
* Safety-number-style fingerprint of the identity key, exactly the
* format `computeFingerprint` returns. Stored for fast denormalized
* lookups and so UIs don't have to recompute it.
*/
identityFingerprint: string;
/** Human-friendly name (e.g. "iPhone 15"). */
name: string;
/** Open enum: `'mobile' | 'tablet' | 'browser' | ...`. */
kind: string;
/** Wall-clock ms when this client was added to the profile. */
addedAt: number;
/**
* When true, this client is allowed to proxy-approve `linkRequest`s
* that arrive at any host. Toggled via the workstation's
* Settings Devices UI. Hosts MUST verify against the freshest
* profile blob before honoring an approval (to close the
* revocation TOCTOU window see FR §5).
*/
trustedApprover?: boolean;
}
export interface CanonicalProfileBlob {
/** Schema version. Bump when the shape changes incompatibly. */
version: 1;
hosts: ProfileHostEntry[];
clients: ProfileClientEntry[];
/**
* Denormalized list of `clients[].identityFingerprint` values where
* `trustedApprover === true`. Hosts use this for the fast-path
* "is X allowed to approve?" check; the authoritative source is
* still the per-client `trustedApprover` flag.
*/
trustedApproverFingerprints: string[];
/** Wall-clock ms of the last write that produced this blob. */
updatedAt: number;
/**
* Optional hex-encoded pubkey that wrote this blob. Mirrors the
* profile-storage owner-pubkey but kept in-band so apps that need
* to display "last edited by X" don't have to round-trip the
* relay. Not used for verification the relay's TOFU on
* owner-pubkey is the authoritative auth boundary.
*/
signedBy?: string;
}
const TEXT = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/** Build a fresh empty profile blob (timestamp = `now ?? Date.now()`). */
export function emptyCanonicalProfile(now?: number): CanonicalProfileBlob {
return {
version: 1,
hosts: [],
clients: [],
trustedApproverFingerprints: [],
updatedAt: now ?? Date.now(),
};
}
/**
* Decode a profile-blob plaintext (the `plaintext` of a
* `ProfileGetResult`) into the canonical shape. Throws
* `ValidationError` on malformed JSON or wrong shape.
*
* Forward-compatibility: unknown top-level fields are preserved so a
* device on an older version can round-trip a blob written by a
* newer device without losing data. Unknown fields inside `hosts[]` /
* `clients[]` entries are also preserved.
*/
export function parseCanonicalProfile(
plaintext: Uint8Array | string,
): CanonicalProfileBlob {
const text =
typeof plaintext === 'string' ? plaintext : TEXT_DECODER.decode(plaintext);
let json: unknown;
try {
json = JSON.parse(text);
} catch (err) {
throw new ValidationError(
`profile blob is not valid JSON: ${(err as Error).message}`,
);
}
if (!json || typeof json !== 'object' || Array.isArray(json)) {
throw new ValidationError('profile blob must be a JSON object');
}
const obj = json as Record<string, unknown>;
if (obj.version !== 1) {
throw new ValidationError(
`unsupported profile blob version: ${String(obj.version)}`,
);
}
const hosts = validateArray(obj.hosts, 'hosts', validateHostEntry);
const clients = validateArray(obj.clients, 'clients', validateClientEntry);
const tApp = obj.trustedApproverFingerprints;
const trustedApproverFingerprints = Array.isArray(tApp)
? tApp.map((v, i) => {
if (typeof v !== 'string') {
throw new ValidationError(
`trustedApproverFingerprints[${i}] must be a string`,
);
}
return v;
})
: [];
const updatedAt =
typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt)
? obj.updatedAt
: 0;
const out: CanonicalProfileBlob = {
version: 1,
hosts,
clients,
trustedApproverFingerprints,
updatedAt,
};
if (typeof obj.signedBy === 'string') out.signedBy = obj.signedBy;
return out;
}
/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
export function serializeCanonicalProfile(
blob: CanonicalProfileBlob,
): Uint8Array {
return TEXT.encode(JSON.stringify(blob));
}
function validateArray<T>(
v: unknown,
field: string,
validate: (entry: unknown, index: number, field: string) => T,
): T[] {
if (v === undefined) return [];
if (!Array.isArray(v)) {
throw new ValidationError(`${field} must be an array`);
}
return v.map((entry, i) => validate(entry, i, field));
}
function validateHostEntry(
entry: unknown,
index: number,
field: string,
): ProfileHostEntry {
if (!entry || typeof entry !== 'object') {
throw new ValidationError(`${field}[${index}] must be an object`);
}
const e = entry as Record<string, unknown>;
return {
address: requireString(e.address, `${field}[${index}].address`),
name: requireString(e.name, `${field}[${index}].name`),
kind: requireString(e.kind, `${field}[${index}].kind`),
addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
};
}
function validateClientEntry(
entry: unknown,
index: number,
field: string,
): ProfileClientEntry {
if (!entry || typeof entry !== 'object') {
throw new ValidationError(`${field}[${index}] must be an object`);
}
const e = entry as Record<string, unknown>;
const identityPublicKey = requireString(
e.identityPublicKey,
`${field}[${index}].identityPublicKey`,
);
if (!/^[0-9a-f]{64}$/.test(identityPublicKey)) {
throw new ValidationError(
`${field}[${index}].identityPublicKey must be 64 lowercase hex chars`,
);
}
const out: ProfileClientEntry = {
address: requireString(e.address, `${field}[${index}].address`),
identityPublicKey,
identityFingerprint: requireString(
e.identityFingerprint,
`${field}[${index}].identityFingerprint`,
),
name: requireString(e.name, `${field}[${index}].name`),
kind: requireString(e.kind, `${field}[${index}].kind`),
addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
};
if (typeof e.trustedApprover === 'boolean') {
out.trustedApprover = e.trustedApprover;
}
return out;
}
function requireString(v: unknown, field: string): string {
if (typeof v !== 'string') {
throw new ValidationError(`${field} must be a string`);
}
return v;
}
function requireNumber(v: unknown, field: string): number {
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new ValidationError(`${field} must be a finite number`);
}
return v;
}
// ─── Mutators (immutable; return new blob, never mutate input) ──
/**
* Insert or replace a host entry by address. Any existing host with
* the same address is overwritten. The output's `updatedAt` is set to
* `now ?? Date.now()` so callers don't have to remember to bump it.
*/
export function upsertHost(
blob: CanonicalProfileBlob,
host: ProfileHostEntry,
now?: number,
): CanonicalProfileBlob {
const hosts = blob.hosts.filter((h) => h.address !== host.address);
hosts.push(host);
return { ...blob, hosts, updatedAt: now ?? Date.now() };
}
/** Remove the host with the given address, if any. */
export function removeHost(
blob: CanonicalProfileBlob,
address: string,
now?: number,
): CanonicalProfileBlob {
const hosts = blob.hosts.filter((h) => h.address !== address);
if (hosts.length === blob.hosts.length) return blob;
return { ...blob, hosts, updatedAt: now ?? Date.now() };
}
/**
* Insert or replace a client entry by `identityFingerprint` (the
* stable cryptographic identifier). Address can change without
* losing the trust record e.g. if a phone re-pairs to a new device
* row but keeps its identity key but a new `identityFingerprint`
* is treated as a new client.
*
* Re-derives `trustedApproverFingerprints` from the resulting
* `clients[]` so the denormalized list never drifts.
*/
export function upsertClient(
blob: CanonicalProfileBlob,
client: ProfileClientEntry,
now?: number,
): CanonicalProfileBlob {
const clients = blob.clients.filter(
(c) => c.identityFingerprint !== client.identityFingerprint,
);
clients.push(client);
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/** Remove the client with the given `identityFingerprint`, if any. */
export function removeClient(
blob: CanonicalProfileBlob,
identityFingerprint: string,
now?: number,
): CanonicalProfileBlob {
const clients = blob.clients.filter(
(c) => c.identityFingerprint !== identityFingerprint,
);
if (clients.length === blob.clients.length) return blob;
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/**
* Toggle the `trustedApprover` flag on a client by fingerprint.
* Returns the input unchanged if the fingerprint isn't found.
*/
export function setTrustedApprover(
blob: CanonicalProfileBlob,
identityFingerprint: string,
trusted: boolean,
now?: number,
): CanonicalProfileBlob {
let touched = false;
const clients = blob.clients.map((c) => {
if (c.identityFingerprint !== identityFingerprint) return c;
if ((c.trustedApprover ?? false) === trusted) return c;
touched = true;
const next: ProfileClientEntry = { ...c };
if (trusted) next.trustedApprover = true;
else delete next.trustedApprover;
return next;
});
if (!touched) return blob;
return {
...blob,
clients,
trustedApproverFingerprints: deriveTrustedApprovers(clients),
updatedAt: now ?? Date.now(),
};
}
/**
* True if the given fingerprint resolves to a client whose
* `trustedApprover` flag is set. Cross-checks both `clients[]` and
* the denormalized `trustedApproverFingerprints[]` both must agree
* to count as trusted.
*/
export function isTrustedApprover(
blob: CanonicalProfileBlob,
identityFingerprint: string,
): boolean {
if (!blob.trustedApproverFingerprints.includes(identityFingerprint)) {
return false;
}
const c = findClientByFingerprint(blob, identityFingerprint);
return c?.trustedApprover === true;
}
export function findClientByFingerprint(
blob: CanonicalProfileBlob,
identityFingerprint: string,
): ProfileClientEntry | null {
return (
blob.clients.find((c) => c.identityFingerprint === identityFingerprint) ??
null
);
}
export function findClientByAddress(
blob: CanonicalProfileBlob,
address: string,
): ProfileClientEntry | null {
return blob.clients.find((c) => c.address === address) ?? null;
}
function deriveTrustedApprovers(clients: ProfileClientEntry[]): string[] {
return clients
.filter((c) => c.trustedApprover === true)
.map((c) => c.identityFingerprint);
}
// ─── Approval frames ───────────────────────────────────────────
/**
* Default domain separator for the proxy-approval signing payload.
* Apps with their own canonical name (e.g. Prism) MAY override this
* via the `domain` option, but they must use the SAME value on both
* the signing and verifying side. The frame itself carries the
* domain so a verifier can detect mismatch and reject.
*/
export const DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1';
/** ms-since-epoch defaults: build = 5 minutes from now. */
const DEFAULT_EXPIRES_IN_MS = 5 * 60 * 1000;
export interface ApprovalRequestingDevice {
/** Safety-number fingerprint of the requesting device's identity. */
fingerprint: string;
/** Optional human label (e.g. `"cafe-laptop"`). */
deviceName?: string;
/** Optional `User-Agent`-like hint for display in the approve modal. */
userAgent?: string;
/** Optional best-effort source IP for display (NOT authenticated). */
ipHint?: string;
/** Wall-clock ms when the host received the original linkRequest. */
receivedAt: number;
}
export interface ApprovalRequestFrame {
kind: 'approvalNeeded';
/**
* 128-bit random hex (32 chars) host-generated, used as the
* idempotency key for the approval. The verifier matches the
* `linkApproveByProxy.requestId` against this exact value.
*/
requestId: string;
/** Shade address of the host that received the original linkRequest. */
hostAddress: string;
/** Identity fingerprint of the host (the same value the approver UI shows). */
hostFingerprint: string;
requestingDevice: ApprovalRequestingDevice;
/**
* Wall-clock ms after which the host won't accept a proxy-approval
* for this `requestId`. The approver SHOULD also reject locally if
* the user takes too long to respond.
*/
expiresAt: number;
/** Domain separator the host expects this approval to be signed under. */
domain: string;
}
export interface ProxyApprovalFrame {
kind: 'linkApproveByProxy';
/** Echoed from the matching `ApprovalRequestFrame.requestId`. */
requestId: string;
/** `'approve'` or `'reject'`. The verifier checks against this exactly. */
decision: 'approve' | 'reject';
/**
* Identity fingerprint of the approving client. MUST match an entry
* in the host's profile-blob `clients[]` whose `trustedApprover`
* flag is set.
*/
approverFingerprint: string;
/**
* Ed25519 signature over the canonical signing payload, lowercase
* hex (128 chars). See `canonicalApprovalSigningBytes` for the
* exact byte layout.
*/
signature: string;
/** Domain separator used to produce the signature. */
domain: string;
}
export interface BuildApprovalRequestOptions {
hostAddress: string;
hostFingerprint: string;
requestingDevice: Omit<ApprovalRequestingDevice, 'receivedAt'> & {
receivedAt?: number;
};
/**
* ms TTL after which the host won't honor a proxy-approval for
* this request. Default 5 minutes long enough for the user to
* fish their phone out, short enough to bound replay.
*/
expiresInMs?: number;
/**
* Source of randomness for the `requestId`. Pass the same
* `CryptoProvider` you use elsewhere in the SDK (typically a
* `SubtleCryptoProvider`) it satisfies the `randomBytes` shape.
*/
crypto: { randomBytes(n: number): Uint8Array };
/** Domain separator. Default: `shade-link-approve-v1`. */
domain?: string;
/** Override `Date.now()` (tests). */
now?: () => number;
}
/**
* Build a fresh `approvalNeeded` frame with a 128-bit random
* `requestId`. The host then ships this to each trusted-approver
* client via `Shade.send`. Hosts SHOULD persist `(requestId, expiresAt,
* requestingDevice.fingerprint)` somewhere durable so they can match
* up the eventual `linkApproveByProxy` reply.
*/
export function buildApprovalRequest(
options: BuildApprovalRequestOptions,
): ApprovalRequestFrame {
const now = (options.now ?? Date.now)();
const requestId = bytesToHex(options.crypto.randomBytes(16));
return {
kind: 'approvalNeeded',
requestId,
hostAddress: options.hostAddress,
hostFingerprint: options.hostFingerprint,
requestingDevice: {
fingerprint: options.requestingDevice.fingerprint,
...(options.requestingDevice.deviceName !== undefined
? { deviceName: options.requestingDevice.deviceName }
: {}),
...(options.requestingDevice.userAgent !== undefined
? { userAgent: options.requestingDevice.userAgent }
: {}),
...(options.requestingDevice.ipHint !== undefined
? { ipHint: options.requestingDevice.ipHint }
: {}),
receivedAt: options.requestingDevice.receivedAt ?? now,
},
expiresAt: now + (options.expiresInMs ?? DEFAULT_EXPIRES_IN_MS),
domain: options.domain ?? DEFAULT_APPROVAL_DOMAIN,
};
}
export interface SignProxyApprovalOptions {
/** The frame the host sent. The verifier rebuilds the signing payload from it. */
request: ApprovalRequestFrame;
decision: 'approve' | 'reject';
/**
* The approving client's identity fingerprint. Must match the
* `clients[]` entry the host expects to find as a trusted approver.
*/
approverFingerprint: string;
/**
* 32-byte Ed25519 *seed* `crypto.sign(seed, msg)` works directly
* (the noble convention `@shade/crypto-web` uses).
*/
approverSigningKey: Uint8Array;
/** CryptoProvider for `sign`. */
crypto: Pick<CryptoProvider, 'sign'>;
}
/**
* Build a `linkApproveByProxy` frame signed with the approver's
* long-term Ed25519 identity key. The signing payload is
* domain-separated and binds together every field that protects
* against cross-frame replay (see `canonicalApprovalSigningBytes`).
*/
export async function signProxyApproval(
options: SignProxyApprovalOptions,
): Promise<ProxyApprovalFrame> {
if (options.approverSigningKey.length !== 32) {
throw new ValidationError('approverSigningKey must be 32 bytes (Ed25519 seed)');
}
if (options.decision !== 'approve' && options.decision !== 'reject') {
throw new ValidationError(`decision must be 'approve' or 'reject'`);
}
const payload = canonicalApprovalSigningBytes({
domain: options.request.domain,
requestId: options.request.requestId,
hostFingerprint: options.request.hostFingerprint,
requestingDeviceFingerprint: options.request.requestingDevice.fingerprint,
decision: options.decision,
});
const sig = await options.crypto.sign(options.approverSigningKey, payload);
return {
kind: 'linkApproveByProxy',
requestId: options.request.requestId,
decision: options.decision,
approverFingerprint: options.approverFingerprint,
signature: bytesToHex(sig),
domain: options.request.domain,
};
}
export interface VerifyProxyApprovalOptions {
/** The original `approvalNeeded` frame the host sent (replay-binding). */
request: ApprovalRequestFrame;
/** The `linkApproveByProxy` frame received from the approver. */
approval: ProxyApprovalFrame;
/**
* Profile blob to verify against. The approver must resolve to a
* `clients[]` entry whose `trustedApprover` flag is set.
* Hosts MUST refetch the blob fresh before verifying see FR §5
* for the revocation TOCTOU rationale.
*/
profile: CanonicalProfileBlob;
/** CryptoProvider for `verify`. */
crypto: Pick<CryptoProvider, 'verify'>;
/** Override `Date.now()` (tests). */
now?: () => number;
}
export type VerifyProxyApprovalReason =
| 'request-id-mismatch'
| 'domain-mismatch'
| 'unknown-approver'
| 'not-trusted'
| 'bad-signature'
| 'expired';
export type VerifyProxyApprovalResult =
| { ok: true; approver: ProfileClientEntry }
| { ok: false; reason: VerifyProxyApprovalReason };
/**
* Verify a `linkApproveByProxy` against the originating
* `approvalNeeded` and the host's freshest profile blob. Returns a
* tagged result rather than throwing callers usually want to log
* the reason before deciding what to surface to the user.
*
* Order of checks:
*
* 1. `requestId` must match exactly. Defends against an attacker
* replaying a stale approval against a fresh request.
* 2. `domain` must match exactly. Defends against an approval signed
* under one app's separator being honored by another.
* 3. Approver must resolve to a `clients[]` entry. Identity unknown =
* reject regardless of signature validity.
* 4. Approver's `trustedApprover` flag must be set AND its
* fingerprint must be in `trustedApproverFingerprints[]` (cross-
* check via `isTrustedApprover`).
* 5. Signature must verify against the approver's `identityPublicKey`.
* 6. `expiresAt` must be in the future (gives the host a lower
* bound; the host's own pending-state is the authoritative source).
*/
export async function verifyProxyApproval(
options: VerifyProxyApprovalOptions,
): Promise<VerifyProxyApprovalResult> {
const { request, approval, profile } = options;
if (approval.requestId !== request.requestId) {
return { ok: false, reason: 'request-id-mismatch' };
}
if (approval.domain !== request.domain) {
return { ok: false, reason: 'domain-mismatch' };
}
const approver = findClientByFingerprint(profile, approval.approverFingerprint);
if (!approver) {
return { ok: false, reason: 'unknown-approver' };
}
if (!isTrustedApprover(profile, approval.approverFingerprint)) {
return { ok: false, reason: 'not-trusted' };
}
const now = (options.now ?? Date.now)();
if (now > request.expiresAt) {
return { ok: false, reason: 'expired' };
}
let pubkey: Uint8Array;
let sig: Uint8Array;
try {
pubkey = hexToBytes(approver.identityPublicKey);
sig = hexToBytes(approval.signature);
} catch {
return { ok: false, reason: 'bad-signature' };
}
if (pubkey.length !== 32 || sig.length !== 64) {
return { ok: false, reason: 'bad-signature' };
}
const payload = canonicalApprovalSigningBytes({
domain: approval.domain,
requestId: approval.requestId,
hostFingerprint: request.hostFingerprint,
requestingDeviceFingerprint: request.requestingDevice.fingerprint,
decision: approval.decision,
});
const valid = await options.crypto.verify(pubkey, payload, sig);
if (!valid) {
return { ok: false, reason: 'bad-signature' };
}
return { ok: true, approver };
}
// ─── Canonical signing payload ─────────────────────────────────
export interface ApprovalSigningInput {
domain: string;
requestId: string;
hostFingerprint: string;
requestingDeviceFingerprint: string;
decision: 'approve' | 'reject';
}
/**
* Build the exact bytes that get Ed25519-signed for a proxy-approval.
*
* Format (length-prefixed UTF-8, big-endian u16 lengths):
*
* u16(len(domain)) || domain
* u16(len(requestId)) || requestId
* u16(len(hostFp)) || hostFingerprint
* u16(len(requestFp)) || requestingDeviceFingerprint
* u16(len(decision)) || decision
*
* Length-prefixed rather than delimiter-joined so the encoding is
* unambiguous regardless of what bytes appear in any field. u16
* (max 65535) is plenty: domain < 256 chars by convention,
* fingerprint 71 chars, requestId 32 hex chars, decision 6-7 chars.
*
* Exposed publicly so other Shade implementations (Android Kotlin,
* iOS Swift, etc.) can produce byte-identical signing input from
* test vectors without depending on this TypeScript code.
*/
export function canonicalApprovalSigningBytes(
input: ApprovalSigningInput,
): Uint8Array {
const fields: Uint8Array[] = [
TEXT.encode(input.domain),
TEXT.encode(input.requestId),
TEXT.encode(input.hostFingerprint),
TEXT.encode(input.requestingDeviceFingerprint),
TEXT.encode(input.decision),
];
for (const f of fields) {
if (f.length > 0xffff) {
throw new ValidationError(
`signing field too long: ${f.length} bytes (max 65535)`,
);
}
}
const total = fields.reduce((sum, f) => sum + 2 + f.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const f of fields) {
out[offset] = (f.length >> 8) & 0xff;
out[offset + 1] = f.length & 0xff;
offset += 2;
out.set(f, offset);
offset += f.length;
}
return out;
}
// ─── Hex helpers (kept local so this module has no extra deps) ──
function bytesToHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
return s;
}
function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new ValidationError('hex length must be even');
if (!/^[0-9a-f]*$/.test(hex)) {
throw new ValidationError('hex must be lowercase 0-9a-f');
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}