Files
ttstd-infinite-canvas/web/src/services/local-runtime-session.ts

464 lines
19 KiB
TypeScript

import { openDB } from "idb";
import { canonicalize } from "json-canonicalize";
const configuredRuntimeEndpoint = typeof import.meta.env === "object" ? import.meta.env.VITE_FRAMEFIELD_LOCAL_RUNTIME_ENDPOINT : undefined;
export const LOCAL_RUNTIME_ENDPOINT = resolveLocalRuntimeEndpoint(configuredRuntimeEndpoint);
const KEY_DATABASE = "framefield-local-runtime";
const KEY_STORE = "browser-keys";
const KEY_RECORD_ID = "default";
export function resolveLocalRuntimeEndpoint(value: string | undefined) {
if (value === undefined) return "http://127.0.0.1:17371";
if (!value || value !== value.trim() || value.includes(",")) {
throw new Error("Local Runtime endpoint must be one exact loopback origin");
}
const url = new URL(value);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash || url.origin !== value) {
throw new Error("Local Runtime endpoint must be one exact loopback origin");
}
return url.origin;
}
export type RuntimeBrowserKeyRecord = {
id: typeof KEY_RECORD_ID;
privateKey: CryptoKey;
publicKeyJwk: JsonWebKey;
keyId: string;
registered: boolean;
};
export type RuntimeBrowserKeyStore = {
load(): Promise<RuntimeBrowserKeyRecord | undefined>;
save(record: RuntimeBrowserKeyRecord): Promise<void>;
clear(): Promise<void>;
};
export type RuntimePublicSession = {
sessionId: string;
keyId: string;
scopes: string[];
expiresAt: string;
};
export type LocalRuntimeConnection = { state: "connected"; session: RuntimePublicSession; runtimeVersion: number } | { state: "origin_not_trusted"; runtimeVersion: number };
type RuntimeInfo = {
runtime: "framefield-local-runtime";
apiVersion: 2;
protocolVersion: "framefield-runtime-session-v1";
runtimeInstanceId: string;
originTrusted: boolean;
};
type RuntimeChallenge = {
state: "challenge";
challengeId: string;
nonce: string;
runtimeInstanceId: string;
expiresAt: string;
keyId: string;
};
type LocalRuntimeSessionClientOptions = {
origin?: string;
keyStore?: RuntimeBrowserKeyStore;
fetch?: typeof fetch;
crypto?: Crypto;
now?: () => number;
};
export class LocalRuntimeClientError extends Error {
constructor(
readonly code: string,
message: string,
readonly status = 0,
) {
super(message);
this.name = "LocalRuntimeClientError";
}
}
export class LocalRuntimeSessionClient {
private readonly origin: string;
private readonly keyStore: RuntimeBrowserKeyStore;
private readonly fetchImpl: typeof fetch;
private readonly cryptoImpl: Crypto;
private readonly now: () => number;
private session?: RuntimePublicSession;
private runtimeInstanceId?: string;
private pending?: RuntimeChallenge;
private connectPromise?: Promise<LocalRuntimeConnection>;
constructor(options: LocalRuntimeSessionClientOptions = {}) {
this.origin = exactOrigin(options.origin ?? globalThis.location?.origin ?? "");
this.keyStore = options.keyStore ?? createIndexedDbRuntimeKeyStore();
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
this.cryptoImpl = options.crypto ?? globalThis.crypto;
this.now = options.now ?? Date.now;
if (!this.cryptoImpl?.subtle || typeof this.cryptoImpl.getRandomValues !== "function") {
throw new LocalRuntimeClientError("webcrypto_unavailable", "浏览器不支持本机安全连接");
}
}
currentSession() {
return this.session ? { ...this.session, scopes: [...this.session.scopes] } : undefined;
}
connect(signal?: AbortSignal) {
if (this.session && Date.parse(this.session.expiresAt) > this.now()) {
return Promise.resolve<LocalRuntimeConnection>({
state: "connected",
session: this.currentSession()!,
runtimeVersion: 2,
});
}
this.connectPromise ??= this.connectOnce(signal).finally(() => {
this.connectPromise = undefined;
});
return this.connectPromise;
}
async request(pathAndQuery: string, init: RequestInit = {}) {
const session = this.session;
if (!session || Date.parse(session.expiresAt) <= this.now()) {
this.session = undefined;
throw new LocalRuntimeClientError("session_required", "本机会话尚未建立", 401);
}
const url = exactRuntimeUrl(pathAndQuery);
const method = String(init.method ?? "GET").toUpperCase();
const bodyBytes = requestBodyBytes(init.body);
const headers = new Headers(init.headers);
if (headers.has("authorization") || headers.has("x-canvas-agent-token")) {
throw new LocalRuntimeClientError("legacy_bearer_rejected", "正常本机请求不能携带旧版凭据");
}
const timestamp = this.now();
const requestNonce = randomBase64Url(this.cryptoImpl, 16);
const lastEventId = headers.get("last-event-id");
const payload = {
protocol: "framefield-runtime-request-v1",
sessionId: session.sessionId,
keyId: session.keyId,
method,
pathAndQuery: `${url.pathname}${url.search}`,
bodySha256: await sha256Base64Url(this.cryptoImpl, bodyBytes),
lastEventId,
origin: this.origin,
endpoint: LOCAL_RUNTIME_ENDPOINT,
runtimeInstanceId: this.runtimeInstanceId!,
requestNonce,
timestamp,
sessionExpiresAt: session.expiresAt,
};
const key = await this.requireKey();
const proof = await signP1363(this.cryptoImpl, key.privateKey, canonicalize(payload));
headers.set("X-Framefield-Runtime-Session", session.sessionId);
headers.set("X-Framefield-Runtime-Timestamp", String(timestamp));
headers.set("X-Framefield-Runtime-Nonce", requestNonce);
headers.set("X-Framefield-Runtime-Proof", proof);
const response = await this.fetchImpl(url, {
...init,
method,
headers,
credentials: "omit",
redirect: "error",
cache: "no-store",
});
if (response.status === 401) this.session = undefined;
return response;
}
revokeLocalSession() {
this.session = undefined;
this.pending = undefined;
}
private async connectOnce(signal?: AbortSignal): Promise<LocalRuntimeConnection> {
const info = await this.readInfo(signal);
if (!info.originTrusted) {
this.session = undefined;
this.pending = undefined;
return { state: "origin_not_trusted", runtimeVersion: info.apiVersion };
}
if (this.runtimeInstanceId && this.runtimeInstanceId !== info.runtimeInstanceId) {
this.session = undefined;
this.pending = undefined;
}
this.runtimeInstanceId = info.runtimeInstanceId;
const key = await this.requireKey();
let challenge = this.pending;
if (!challenge || Date.parse(challenge.expiresAt) <= this.now()) {
challenge = await this.createChallenge(key, signal);
this.pending = challenge;
}
if (challenge.runtimeInstanceId !== info.runtimeInstanceId || challenge.keyId !== key.keyId) {
this.pending = undefined;
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
}
const signature = await signP1363(
this.cryptoImpl,
key.privateKey,
canonicalize({
protocol: "framefield-runtime-session-v1",
challengeId: challenge.challengeId,
nonce: challenge.nonce,
origin: this.origin,
endpoint: LOCAL_RUNTIME_ENDPOINT,
runtimeInstanceId: challenge.runtimeInstanceId,
expiresAt: challenge.expiresAt,
}),
);
const exchange = await this.jsonFetch("/runtime/session/exchange", { challengeId: challenge.challengeId, signature }, signal, true);
if (!exchange.response.ok) throw responseError(exchange.response, exchange.body);
const session = parseSession(exchange.body, key.keyId);
this.pending = undefined;
this.session = session;
if (!key.registered) {
key.registered = true;
await this.keyStore.save(key);
}
return { state: "connected", session: this.currentSession()!, runtimeVersion: info.apiVersion };
}
private async readInfo(signal?: AbortSignal) {
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}/runtime/info`, {
method: "GET",
credentials: "omit",
redirect: "error",
cache: "no-store",
signal,
});
const body = await safeJson(response);
if (!response.ok) throw responseError(response, body);
return parseInfo(body);
}
private async createChallenge(key: RuntimeBrowserKeyRecord, signal?: AbortSignal) {
const payload = key.registered ? { keyId: key.keyId } : { publicKeyJwk: key.publicKeyJwk };
const result = await this.jsonFetch("/runtime/session/challenge", payload, signal, true);
if (result.response.status === 404 && key.registered) {
key.registered = false;
await this.keyStore.save(key);
const retry = await this.jsonFetch("/runtime/session/challenge", { publicKeyJwk: key.publicKeyJwk }, signal, true);
if (!retry.response.ok) throw responseError(retry.response, retry.body);
return parseChallenge(retry.body, key.keyId);
}
if (!result.response.ok) throw responseError(result.response, result.body);
return parseChallenge(result.body, key.keyId);
}
private async jsonFetch(path: string, body: unknown, signal?: AbortSignal, allowError = false) {
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
credentials: "omit",
redirect: "error",
cache: "no-store",
signal,
});
const parsed = await safeJson(response);
if (!allowError && !response.ok) throw responseError(response, parsed);
return { response, body: parsed };
}
private async requireKey() {
const existing = await this.keyStore.load();
if (existing) {
validateKeyRecord(existing);
return existing;
}
const pair = await this.cryptoImpl.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
const publicKeyJwk = await this.cryptoImpl.subtle.exportKey("jwk", pair.publicKey);
validatePublicJwk(publicKeyJwk);
const keyId = await browserKeyId(this.cryptoImpl, publicKeyJwk);
const record: RuntimeBrowserKeyRecord = {
id: KEY_RECORD_ID,
privateKey: pair.privateKey,
publicKeyJwk,
keyId,
registered: false,
};
await this.keyStore.save(record);
return record;
}
}
export function createIndexedDbRuntimeKeyStore(): RuntimeBrowserKeyStore {
const database = openDB(KEY_DATABASE, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains(KEY_STORE)) db.createObjectStore(KEY_STORE);
},
});
return {
async load() {
return (await (await database).get(KEY_STORE, KEY_RECORD_ID)) as RuntimeBrowserKeyRecord | undefined;
},
async save(record) {
await (await database).put(KEY_STORE, record, KEY_RECORD_ID);
},
async clear() {
await (await database).delete(KEY_STORE, KEY_RECORD_ID);
},
};
}
async function browserKeyId(cryptoImpl: Crypto, jwk: JsonWebKey) {
return await sha256Base64Url(
cryptoImpl,
new TextEncoder().encode(
canonicalize({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
}),
),
);
}
async function signP1363(cryptoImpl: Crypto, privateKey: CryptoKey, payload: string) {
const signature = new Uint8Array(await cryptoImpl.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(payload)));
if (signature.byteLength !== 64) {
throw new LocalRuntimeClientError("signature_invalid", "浏览器签名格式不受支持");
}
return base64Url(signature);
}
async function sha256Base64Url(cryptoImpl: Crypto, value: Uint8Array) {
const bytes = new Uint8Array(value.byteLength);
bytes.set(value);
return base64Url(new Uint8Array(await cryptoImpl.subtle.digest("SHA-256", bytes.buffer)));
}
function randomBase64Url(cryptoImpl: Crypto, bytes: number) {
return base64Url(cryptoImpl.getRandomValues(new Uint8Array(bytes)));
}
function base64Url(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function requestBodyBytes(body: BodyInit | null | undefined) {
if (body === undefined || body === null) return new Uint8Array();
if (typeof body === "string") return new TextEncoder().encode(body);
if (body instanceof Uint8Array) return body;
throw new LocalRuntimeClientError("request_body_invalid", "本机请求体格式无效");
}
function exactRuntimeUrl(pathAndQuery: string) {
if (!pathAndQuery.startsWith("/") || pathAndQuery.includes("#")) {
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
}
const url = new URL(pathAndQuery, LOCAL_RUNTIME_ENDPOINT);
if (url.origin !== LOCAL_RUNTIME_ENDPOINT || `${url.pathname}${url.search}` !== pathAndQuery) {
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
}
return url;
}
function exactOrigin(value: string) {
try {
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash || url.origin !== value) throw new Error();
return url.origin;
} catch {
throw new LocalRuntimeClientError("origin_invalid", "当前页面来源无效");
}
}
function validatePublicJwk(jwk: JsonWebKey) {
if (
jwk.kty !== "EC" ||
jwk.crv !== "P-256" ||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.x ?? "") ||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.y ?? "") ||
jwk.d !== undefined ||
jwk.ext !== true ||
!Array.isArray(jwk.key_ops) ||
jwk.key_ops.length !== 1 ||
jwk.key_ops[0] !== "verify"
) {
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器公钥无效");
}
}
function validateKeyRecord(record: RuntimeBrowserKeyRecord) {
validatePublicJwk(record.publicKeyJwk);
if (
record.id !== KEY_RECORD_ID ||
record.privateKey.type !== "private" ||
record.privateKey.extractable ||
record.privateKey.algorithm.name !== "ECDSA" ||
record.privateKey.usages.length !== 1 ||
record.privateKey.usages[0] !== "sign" ||
!/^[A-Za-z0-9_-]{43}$/.test(record.keyId)
) {
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器密钥无效");
}
}
function parseInfo(value: Record<string, unknown>): RuntimeInfo {
assertExactKeys(value, ["runtime", "apiVersion", "protocolVersion", "runtimeInstanceId", "originTrusted"]);
if (
value.runtime !== "framefield-local-runtime" ||
value.apiVersion !== 2 ||
value.protocolVersion !== "framefield-runtime-session-v1" ||
typeof value.runtimeInstanceId !== "string" ||
!/^[A-Za-z0-9_-]{8,160}$/.test(value.runtimeInstanceId) ||
typeof value.originTrusted !== "boolean"
) {
throw new LocalRuntimeClientError("runtime_incompatible", "本机运行时版本不兼容");
}
return value as RuntimeInfo;
}
function parseChallenge(value: Record<string, unknown>, keyId: string): RuntimeChallenge {
assertExactKeys(value, ["state", "challengeId", "nonce", "runtimeInstanceId", "expiresAt", "keyId"]);
if (value.state !== "challenge" || typeof value.challengeId !== "string" || typeof value.nonce !== "string" || typeof value.runtimeInstanceId !== "string" || typeof value.expiresAt !== "string" || value.keyId !== keyId) {
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
}
return value as RuntimeChallenge;
}
function parseSession(value: Record<string, unknown>, keyId: string): RuntimePublicSession {
assertExactKeys(value, ["sessionId", "keyId", "scopes", "expiresAt"]);
if (typeof value.sessionId !== "string" || value.sessionId.length < 16 || value.keyId !== keyId || !Array.isArray(value.scopes) || value.scopes.some((scope) => typeof scope !== "string") || typeof value.expiresAt !== "string") {
throw new LocalRuntimeClientError("session_invalid", "本机会话响应无效");
}
return value as RuntimePublicSession;
}
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]) {
const allowed = new Set(keys);
if (Object.keys(value).some((key) => !allowed.has(key))) {
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效");
}
}
async function safeJson(response: Response) {
try {
const value = (await response.json()) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
return value as Record<string, unknown>;
} catch {
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", response.status);
}
}
function responseError(response: Response, body: Record<string, unknown>) {
const code = typeof body.code === "string" && body.code in PUBLIC_RUNTIME_ERROR_MESSAGES ? body.code : "runtime_request_failed";
return new LocalRuntimeClientError(code, PUBLIC_RUNTIME_ERROR_MESSAGES[code], response.status);
}
const PUBLIC_RUNTIME_ERROR_MESSAGES: Record<string, string> = {
challenge_invalid: "本机会话挑战无效",
invalid_public_key: "浏览器公钥无效",
origin_not_trusted: "当前页面来源未获本机授权",
rate_limited: "本机连接请求过多,请稍后重试",
registration_not_found: "浏览器密钥尚未注册",
runtime_internal_error: "本机运行时请求失败",
runtime_request_failed: "本机运行时请求失败",
};