feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
533
canvas-agent/src/local-runtime-session.ts
Normal file
533
canvas-agent/src/local-runtime-session.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import crypto, { type JsonWebKey as NodeJsonWebKey } from "node:crypto";
|
||||
|
||||
import {
|
||||
canonicalRuntimeJson,
|
||||
createRuntimeRequestPayload,
|
||||
sha256Base64Url,
|
||||
type LocalRuntimeScope,
|
||||
type RuntimeSessionChallengePayload,
|
||||
} from "./local-runtime-contract.js";
|
||||
|
||||
const REGISTERED_CHALLENGE_TTL_MS = 60_000;
|
||||
const SESSION_TTL_MS = 10 * 60_000;
|
||||
const REQUEST_CLOCK_SKEW_MS = 30_000;
|
||||
const MAX_SESSION_NONCES = 2_048;
|
||||
const MAX_PENDING_CHALLENGES = 64;
|
||||
|
||||
const DEFAULT_SCOPES: readonly LocalRuntimeScope[] = [
|
||||
"runtime:status",
|
||||
"runtime:revoke",
|
||||
"canvas:connect",
|
||||
"dreamina:status",
|
||||
"dreamina:login",
|
||||
"dreamina:logout",
|
||||
"dreamina:run",
|
||||
"dreamina:models",
|
||||
"dreamina:generate",
|
||||
];
|
||||
|
||||
export type RuntimeBrowserRegistration = {
|
||||
keyId: string;
|
||||
origin: string;
|
||||
publicKeyJwk: JsonWebKey;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RuntimePublicSession = {
|
||||
sessionId: string;
|
||||
keyId: string;
|
||||
scopes: readonly LocalRuntimeScope[];
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type RuntimeChallengeResponse = {
|
||||
state: "challenge";
|
||||
challengeId: string;
|
||||
nonce: string;
|
||||
runtimeInstanceId: string;
|
||||
expiresAt: string;
|
||||
keyId: string;
|
||||
};
|
||||
|
||||
export type RuntimeRequestVerification = {
|
||||
sessionId: string;
|
||||
origin: string;
|
||||
method: string;
|
||||
pathAndQuery: string;
|
||||
body: Uint8Array;
|
||||
lastEventId: string | null;
|
||||
requestNonce: string;
|
||||
timestamp: number;
|
||||
proof: string;
|
||||
scope: LocalRuntimeScope;
|
||||
};
|
||||
|
||||
type PendingChallenge = {
|
||||
challengeId: string;
|
||||
nonce: string;
|
||||
origin: string;
|
||||
keyId: string;
|
||||
publicKeyJwk: JsonWebKey;
|
||||
fingerprint: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type RuntimeSessionRecord = RuntimePublicSession & {
|
||||
origin: string;
|
||||
expiresAtMs: number;
|
||||
usedNonces: Set<string>;
|
||||
};
|
||||
|
||||
export type LocalRuntimeSessionManagerOptions = {
|
||||
endpoint: string;
|
||||
runtimeInstanceId?: string;
|
||||
trustedOrigins: readonly string[];
|
||||
registrations: RuntimeBrowserRegistration[];
|
||||
persistRegistrations?: (registrations: readonly RuntimeBrowserRegistration[]) => void;
|
||||
now?: () => number;
|
||||
scopes?: readonly LocalRuntimeScope[];
|
||||
onSessionRevoked?: (sessionId: string) => void;
|
||||
timers?: {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
};
|
||||
};
|
||||
|
||||
export class LocalRuntimeSessionError extends Error {
|
||||
constructor(readonly code: string, message = code, readonly statusCode = 403) {
|
||||
super(message);
|
||||
this.name = "LocalRuntimeSessionError";
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalRuntimeSessionManager {
|
||||
readonly runtimeInstanceId: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly trustedOrigins: Set<string>;
|
||||
private readonly registrations: RuntimeBrowserRegistration[];
|
||||
private readonly persistRegistrations: (registrations: readonly RuntimeBrowserRegistration[]) => void;
|
||||
private readonly now: () => number;
|
||||
private readonly scopes: readonly LocalRuntimeScope[];
|
||||
private readonly onSessionRevoked?: (sessionId: string) => void;
|
||||
private readonly timers: NonNullable<LocalRuntimeSessionManagerOptions["timers"]>;
|
||||
private readonly challenges = new Map<string, PendingChallenge>();
|
||||
private readonly sessions = new Map<string, RuntimeSessionRecord>();
|
||||
private readonly sessionExpiryTimers = new Map<string, unknown>();
|
||||
|
||||
constructor(options: LocalRuntimeSessionManagerOptions) {
|
||||
this.endpoint = exactEndpoint(options.endpoint);
|
||||
this.runtimeInstanceId = options.runtimeInstanceId ?? randomBase64Url(18);
|
||||
this.trustedOrigins = new Set(options.trustedOrigins.map(exactOrigin));
|
||||
this.registrations = options.registrations;
|
||||
this.persistRegistrations = options.persistRegistrations ?? (() => undefined);
|
||||
this.now = options.now ?? Date.now;
|
||||
this.scopes = options.scopes ?? DEFAULT_SCOPES;
|
||||
this.onSessionRevoked = options.onSessionRevoked;
|
||||
this.timers = options.timers ?? {
|
||||
setTimeout(callback, delayMs) {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
timer.unref();
|
||||
return timer;
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
clearTimeout(handle as NodeJS.Timeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
isTrustedOrigin(origin: string) {
|
||||
try {
|
||||
return this.trustedOrigins.has(exactOrigin(origin));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
createChallenge(
|
||||
originValue: string,
|
||||
input: { publicKeyJwk: JsonWebKey; keyId?: never } | { keyId: string; publicKeyJwk?: never },
|
||||
): RuntimeChallengeResponse {
|
||||
const origin = this.requireTrustedOrigin(originValue);
|
||||
this.pruneExpired();
|
||||
if ("publicKeyJwk" in input && input.publicKeyJwk) {
|
||||
const publicKeyJwk = validatePublicJwk(input.publicKeyJwk);
|
||||
const keyId = runtimeBrowserKeyId(publicKeyJwk);
|
||||
const registered = this.findRegistration(origin, keyId);
|
||||
if (registered) return this.createRegisteredChallenge(registered);
|
||||
const existing = [...this.challenges.values()].find((challenge) => (
|
||||
challenge.origin === origin
|
||||
&& challenge.keyId === keyId
|
||||
&& challenge.expiresAt > this.now()
|
||||
));
|
||||
if (existing) return this.publicChallenge(existing);
|
||||
return this.createSignedChallenge(origin, keyId, publicKeyJwk, keyId);
|
||||
}
|
||||
if (!("keyId" in input) || typeof input.keyId !== "string") {
|
||||
throw new LocalRuntimeSessionError("challenge_invalid", "会话挑战请求无效", 400);
|
||||
}
|
||||
const registration = this.findRegistration(origin, input.keyId);
|
||||
if (!registration) {
|
||||
throw new LocalRuntimeSessionError("registration_not_found", "浏览器密钥尚未注册", 404);
|
||||
}
|
||||
return this.createRegisteredChallenge(registration);
|
||||
}
|
||||
|
||||
exchange(originValue: string, input: { challengeId: string; signature: string }): RuntimePublicSession {
|
||||
const origin = this.requireTrustedOrigin(originValue);
|
||||
const challenge = this.requireChallenge(input.challengeId);
|
||||
if (challenge.origin !== origin) {
|
||||
this.challenges.delete(challenge.challengeId);
|
||||
throw new LocalRuntimeSessionError("challenge_invalid", "会话挑战无效", 403);
|
||||
}
|
||||
const payload = sessionChallengePayload(
|
||||
challenge,
|
||||
this.endpoint,
|
||||
this.runtimeInstanceId,
|
||||
);
|
||||
if (!verifyP1363(challenge.publicKeyJwk, canonicalRuntimeJson(payload), input.signature)) {
|
||||
this.challenges.delete(challenge.challengeId);
|
||||
throw new LocalRuntimeSessionError("challenge_proof_invalid", "会话签名无效", 403);
|
||||
}
|
||||
this.challenges.delete(challenge.challengeId);
|
||||
if (!this.findRegistration(challenge.origin, challenge.keyId)) {
|
||||
this.registrations.push({
|
||||
keyId: challenge.keyId,
|
||||
origin: challenge.origin,
|
||||
publicKeyJwk: challenge.publicKeyJwk,
|
||||
fingerprint: challenge.fingerprint,
|
||||
createdAt: new Date(this.now()).toISOString(),
|
||||
});
|
||||
this.persistRegistrations(this.registrations);
|
||||
}
|
||||
return this.createSession(challenge.origin, challenge.keyId);
|
||||
}
|
||||
|
||||
verifyRequest(input: RuntimeRequestVerification): RuntimePublicSession {
|
||||
const session = this.sessions.get(input.sessionId);
|
||||
if (!session || session.expiresAtMs <= this.now()) {
|
||||
if (session) this.removeSession(session.sessionId);
|
||||
throw new LocalRuntimeSessionError("session_invalid", "本机会话无效", 401);
|
||||
}
|
||||
const origin = this.requireTrustedOrigin(input.origin);
|
||||
if (session.origin !== origin) {
|
||||
throw new LocalRuntimeSessionError("session_invalid", "本机会话来源不匹配", 401);
|
||||
}
|
||||
if (!Number.isSafeInteger(input.timestamp)
|
||||
|| Math.abs(this.now() - input.timestamp) > REQUEST_CLOCK_SKEW_MS) {
|
||||
throw new LocalRuntimeSessionError("request_stale", "请求时间已失效", 401);
|
||||
}
|
||||
if (!session.scopes.includes(input.scope)) {
|
||||
throw new LocalRuntimeSessionError("scope_denied", "本机会话权限不足", 403);
|
||||
}
|
||||
if (!validNonce(input.requestNonce)) {
|
||||
throw new LocalRuntimeSessionError("request_nonce_invalid", "请求随机数无效", 400);
|
||||
}
|
||||
if (session.usedNonces.has(input.requestNonce)) {
|
||||
throw new LocalRuntimeSessionError("request_replayed", "请求已使用", 409);
|
||||
}
|
||||
if (session.usedNonces.size >= MAX_SESSION_NONCES) {
|
||||
throw new LocalRuntimeSessionError("rate_limited", "本机会话请求过多", 429);
|
||||
}
|
||||
const registration = this.findRegistration(origin, session.keyId);
|
||||
if (!registration) {
|
||||
this.removeSession(session.sessionId);
|
||||
throw new LocalRuntimeSessionError("session_invalid", "本机浏览器授权已撤销", 401);
|
||||
}
|
||||
const payload = createRuntimeRequestPayload({
|
||||
sessionId: session.sessionId,
|
||||
keyId: session.keyId,
|
||||
method: input.method.toUpperCase(),
|
||||
pathAndQuery: input.pathAndQuery,
|
||||
bodySha256: sha256Base64Url(input.body),
|
||||
lastEventId: input.lastEventId,
|
||||
origin,
|
||||
endpoint: this.endpoint,
|
||||
runtimeInstanceId: this.runtimeInstanceId,
|
||||
requestNonce: input.requestNonce,
|
||||
timestamp: input.timestamp,
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
});
|
||||
if (!verifyP1363(registration.publicKeyJwk, canonicalRuntimeJson(payload), input.proof)) {
|
||||
throw new LocalRuntimeSessionError("request_proof_invalid", "请求签名无效", 401);
|
||||
}
|
||||
// Signature verification precedes the atomic replay mark, so failed substitutions
|
||||
// cannot consume a valid nonce while concurrent identical proofs cannot both pass.
|
||||
if (session.usedNonces.has(input.requestNonce)) {
|
||||
throw new LocalRuntimeSessionError("request_replayed", "请求已使用", 409);
|
||||
}
|
||||
session.usedNonces.add(input.requestNonce);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
revokeSession(sessionId: string) {
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
throw new LocalRuntimeSessionError("session_invalid", "本机会话无效", 401);
|
||||
}
|
||||
this.removeSession(sessionId);
|
||||
}
|
||||
|
||||
revokeRegistration(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new LocalRuntimeSessionError("session_invalid", "本机会话无效", 401);
|
||||
}
|
||||
const keyId = session.keyId;
|
||||
const origin = session.origin;
|
||||
for (let index = this.registrations.length - 1; index >= 0; index -= 1) {
|
||||
const item = this.registrations[index];
|
||||
if (item.keyId === keyId && item.origin === origin) this.registrations.splice(index, 1);
|
||||
}
|
||||
for (const [challengeId, challenge] of this.challenges) {
|
||||
if (challenge.keyId === keyId && challenge.origin === origin) {
|
||||
this.challenges.delete(challengeId);
|
||||
}
|
||||
}
|
||||
for (const [activeSessionId, activeSession] of this.sessions) {
|
||||
if (activeSession.keyId === keyId && activeSession.origin === origin) {
|
||||
this.removeSession(activeSessionId);
|
||||
}
|
||||
}
|
||||
this.persistRegistrations(this.registrations);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const sessionId of [...this.sessions.keys()]) this.removeSession(sessionId);
|
||||
this.challenges.clear();
|
||||
}
|
||||
|
||||
private createRegisteredChallenge(registration: RuntimeBrowserRegistration): RuntimeChallengeResponse {
|
||||
return this.createSignedChallenge(
|
||||
registration.origin,
|
||||
registration.keyId,
|
||||
validatePublicJwk(registration.publicKeyJwk),
|
||||
registration.fingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
private createSignedChallenge(
|
||||
origin: string,
|
||||
keyId: string,
|
||||
publicKeyJwk: JsonWebKey,
|
||||
fingerprint: string,
|
||||
): RuntimeChallengeResponse {
|
||||
if (this.challenges.size >= MAX_PENDING_CHALLENGES) {
|
||||
throw new LocalRuntimeSessionError("rate_limited", "会话挑战过多", 429);
|
||||
}
|
||||
const challenge: PendingChallenge = {
|
||||
challengeId: randomBase64Url(18),
|
||||
nonce: randomBase64Url(32),
|
||||
origin,
|
||||
keyId,
|
||||
publicKeyJwk,
|
||||
fingerprint,
|
||||
expiresAt: this.now() + REGISTERED_CHALLENGE_TTL_MS,
|
||||
};
|
||||
this.challenges.set(challenge.challengeId, challenge);
|
||||
return this.publicChallenge(challenge);
|
||||
}
|
||||
|
||||
private publicChallenge(challenge: PendingChallenge): RuntimeChallengeResponse {
|
||||
return {
|
||||
state: "challenge",
|
||||
challengeId: challenge.challengeId,
|
||||
nonce: challenge.nonce,
|
||||
runtimeInstanceId: this.runtimeInstanceId,
|
||||
expiresAt: new Date(challenge.expiresAt).toISOString(),
|
||||
keyId: challenge.keyId,
|
||||
};
|
||||
}
|
||||
|
||||
private createSession(origin: string, keyId: string): RuntimePublicSession {
|
||||
const expiresAtMs = this.now() + SESSION_TTL_MS;
|
||||
const session: RuntimeSessionRecord = {
|
||||
sessionId: randomBase64Url(24),
|
||||
keyId,
|
||||
origin,
|
||||
scopes: [...this.scopes],
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
expiresAtMs,
|
||||
usedNonces: new Set(),
|
||||
};
|
||||
this.sessions.set(session.sessionId, session);
|
||||
this.sessionExpiryTimers.set(
|
||||
session.sessionId,
|
||||
this.timers.setTimeout(() => this.removeSession(session.sessionId), SESSION_TTL_MS),
|
||||
);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
private requireTrustedOrigin(value: string) {
|
||||
const origin = exactOrigin(value);
|
||||
if (!this.trustedOrigins.has(origin)) {
|
||||
throw new LocalRuntimeSessionError("origin_not_trusted", "来源未获本机授权", 403);
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
private requireChallenge(challengeId: string) {
|
||||
const challenge = this.challenges.get(challengeId);
|
||||
if (!challenge || challenge.expiresAt <= this.now()) {
|
||||
if (challenge) this.challenges.delete(challengeId);
|
||||
throw new LocalRuntimeSessionError("challenge_invalid", "会话挑战已失效", 404);
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
|
||||
private findRegistration(origin: string, keyId: string) {
|
||||
return this.registrations.find((registration) => (
|
||||
registration.origin === origin && registration.keyId === keyId
|
||||
));
|
||||
}
|
||||
|
||||
private removeSession(sessionId: string) {
|
||||
if (!this.sessions.delete(sessionId)) return;
|
||||
if (this.sessionExpiryTimers.has(sessionId)) {
|
||||
const timer = this.sessionExpiryTimers.get(sessionId);
|
||||
this.sessionExpiryTimers.delete(sessionId);
|
||||
this.timers.clearTimeout(timer);
|
||||
}
|
||||
this.onSessionRevoked?.(sessionId);
|
||||
}
|
||||
|
||||
private pruneExpired() {
|
||||
const current = this.now();
|
||||
for (const [challengeId, challenge] of this.challenges) {
|
||||
if (challenge.expiresAt <= current) this.challenges.delete(challengeId);
|
||||
}
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (session.expiresAtMs <= current) this.removeSession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runtimeBrowserKeyId(value: JsonWebKey) {
|
||||
const jwk = validatePublicJwk(value);
|
||||
return sha256Base64Url(canonicalRuntimeJson({
|
||||
crv: jwk.crv,
|
||||
kty: jwk.kty,
|
||||
x: jwk.x,
|
||||
y: jwk.y,
|
||||
}));
|
||||
}
|
||||
|
||||
function validatePublicJwk(value: JsonWebKey) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new LocalRuntimeSessionError("invalid_public_key", "浏览器公钥无效", 400);
|
||||
}
|
||||
const allowed = new Set(["kty", "crv", "x", "y", "ext", "key_ops"]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))
|
||||
|| value.kty !== "EC"
|
||||
|| value.crv !== "P-256"
|
||||
|| typeof value.x !== "string"
|
||||
|| typeof value.y !== "string"
|
||||
|| !/^[A-Za-z0-9_-]{43}$/.test(value.x)
|
||||
|| !/^[A-Za-z0-9_-]{43}$/.test(value.y)
|
||||
|| value.d !== undefined
|
||||
|| value.ext !== true
|
||||
|| !Array.isArray(value.key_ops)
|
||||
|| value.key_ops.length !== 1
|
||||
|| value.key_ops[0] !== "verify") {
|
||||
throw new LocalRuntimeSessionError("invalid_public_key", "浏览器公钥无效", 400);
|
||||
}
|
||||
try {
|
||||
crypto.createPublicKey({ key: value as NodeJsonWebKey, format: "jwk" });
|
||||
} catch {
|
||||
throw new LocalRuntimeSessionError("invalid_public_key", "浏览器公钥无效", 400);
|
||||
}
|
||||
return {
|
||||
kty: value.kty,
|
||||
crv: value.crv,
|
||||
x: value.x,
|
||||
y: value.y,
|
||||
ext: true,
|
||||
key_ops: ["verify"],
|
||||
} satisfies JsonWebKey;
|
||||
}
|
||||
|
||||
function sessionChallengePayload(
|
||||
challenge: PendingChallenge,
|
||||
endpoint: string,
|
||||
runtimeInstanceId: string,
|
||||
): RuntimeSessionChallengePayload {
|
||||
return {
|
||||
protocol: "framefield-runtime-session-v1",
|
||||
challengeId: challenge.challengeId,
|
||||
nonce: challenge.nonce,
|
||||
origin: challenge.origin,
|
||||
endpoint,
|
||||
runtimeInstanceId,
|
||||
expiresAt: new Date(challenge.expiresAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function verifyP1363(publicKeyJwk: JsonWebKey, payload: string, signatureValue: string) {
|
||||
if (!/^[A-Za-z0-9_-]{86}$/.test(signatureValue)) return false;
|
||||
const signature = Buffer.from(signatureValue, "base64url");
|
||||
if (signature.byteLength !== 64) return false;
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey({
|
||||
key: publicKeyJwk as NodeJsonWebKey,
|
||||
format: "jwk",
|
||||
});
|
||||
return crypto.verify("sha256", Buffer.from(payload), {
|
||||
key: publicKey,
|
||||
dsaEncoding: "ieee-p1363",
|
||||
}, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publicSession(session: RuntimeSessionRecord): RuntimePublicSession {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
keyId: session.keyId,
|
||||
scopes: [...session.scopes],
|
||||
expiresAt: session.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function randomBase64Url(bytes: number) {
|
||||
return crypto.randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
function validNonce(value: string) {
|
||||
if (!/^[A-Za-z0-9_-]{22,}$/.test(value)) return false;
|
||||
try {
|
||||
return Buffer.from(value, "base64url").byteLength >= 16;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function exactOrigin(value: string) {
|
||||
if (typeof value !== "string" || value.includes(",") || value === "null") {
|
||||
throw new LocalRuntimeSessionError("origin_invalid", "来源无效", 403);
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new LocalRuntimeSessionError("origin_invalid", "来源无效", 403);
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| url.pathname !== "/"
|
||||
|| url.search
|
||||
|| url.hash
|
||||
|| url.origin !== value) {
|
||||
throw new LocalRuntimeSessionError("origin_invalid", "来源无效", 403);
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function exactEndpoint(value: string) {
|
||||
const origin = exactOrigin(value);
|
||||
const url = new URL(origin);
|
||||
if (url.hostname !== "127.0.0.1") {
|
||||
throw new Error("Local Runtime endpoint must use 127.0.0.1");
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
Reference in New Issue
Block a user