387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import crypto, { type KeyObject } from "node:crypto";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
LOCAL_RUNTIME_ENDPOINT,
|
|
canonicalRuntimeJson,
|
|
createRuntimeRequestPayload,
|
|
sha256Base64Url,
|
|
} from "../src/local-runtime-contract.js";
|
|
import {
|
|
LocalRuntimeSessionError,
|
|
LocalRuntimeSessionManager,
|
|
runtimeBrowserKeyId,
|
|
type RuntimeBrowserRegistration,
|
|
} from "../src/local-runtime-session.js";
|
|
|
|
const origin = "http://127.0.0.1:3001";
|
|
const now = Date.parse("2026-08-10T00:00:00.000Z");
|
|
|
|
test("Local Runtime canonical JSON follows the RFC 8785 primitive vector", () => {
|
|
assert.equal(
|
|
canonicalRuntimeJson({ z: "line\nfeed", a: [3, true, null], n: 1e-7 }),
|
|
'{"a":[3,true,null],"n":1e-7,"z":"line\\nfeed"}',
|
|
);
|
|
});
|
|
|
|
test("Local Runtime derives RFC 7638 key ids from only public P-256 coordinates", () => {
|
|
const key = browserKey();
|
|
const withMetadata = { ...key.publicJwk, kid: "ignored", alg: "ES256" };
|
|
|
|
assert.equal(
|
|
runtimeBrowserKeyId(key.publicJwk),
|
|
sha256Base64Url(canonicalRuntimeJson({
|
|
crv: "P-256",
|
|
kty: "EC",
|
|
x: key.publicJwk.x,
|
|
y: key.publicJwk.y,
|
|
})),
|
|
);
|
|
assert.throws(
|
|
() => runtimeBrowserKeyId(withMetadata as JsonWebKey),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "invalid_public_key",
|
|
);
|
|
});
|
|
|
|
test("an exact trusted origin silently registers a browser key only after its signed challenge proof", () => {
|
|
const key = browserKey();
|
|
const registrations: RuntimeBrowserRegistration[] = [];
|
|
const manager = runtimeManager(registrations);
|
|
|
|
const first = manager.createChallenge(origin, { publicKeyJwk: key.publicJwk });
|
|
const second = manager.createChallenge(origin, { publicKeyJwk: key.publicJwk });
|
|
assert.equal(first.state, "challenge");
|
|
assert.deepEqual(second, first);
|
|
assert.equal(first.expiresAt, new Date(now + 60_000).toISOString());
|
|
assert.equal(JSON.stringify(first).includes("pair"), false);
|
|
assert.equal(registrations.length, 0);
|
|
|
|
const session = manager.exchange(origin, {
|
|
challengeId: first.challengeId,
|
|
signature: signSessionChallenge(key.privateKey, first),
|
|
});
|
|
assert.equal(session.keyId, runtimeBrowserKeyId(key.publicJwk));
|
|
assert.equal(registrations.length, 1);
|
|
assert.equal(registrations[0].origin, origin);
|
|
});
|
|
|
|
test("the default trusted-origin session authorizes Dreamina catalog reads and explicit generation routes", () => {
|
|
const key = browserKey();
|
|
const manager = runtimeManager([registration(key.publicJwk)]);
|
|
const session = exchangeRegistered(manager, key);
|
|
|
|
assert.equal(session.scopes.includes("dreamina:models"), true);
|
|
assert.equal(session.scopes.includes("dreamina:generate"), true);
|
|
assert.doesNotThrow(() => manager.verifyRequest(signedRequest(key.privateKey, session, {
|
|
method: "GET",
|
|
pathAndQuery: "/dreamina/models",
|
|
body: Buffer.alloc(0),
|
|
scope: "dreamina:models",
|
|
})));
|
|
assert.doesNotThrow(() => manager.verifyRequest(signedRequest(key.privateKey, session, {
|
|
method: "POST",
|
|
pathAndQuery: "/dreamina/generate",
|
|
body: Buffer.from("{}"),
|
|
scope: "dreamina:generate",
|
|
})));
|
|
});
|
|
|
|
test("untrusted or missing origins cannot silently register a browser key", () => {
|
|
const key = browserKey();
|
|
assert.throws(
|
|
() => runtimeManager().createChallenge("https://hostile.example", { publicKeyJwk: key.publicJwk }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "origin_not_trusted",
|
|
);
|
|
assert.throws(
|
|
() => runtimeManager().createChallenge("", { publicKeyJwk: key.publicJwk }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "origin_invalid",
|
|
);
|
|
});
|
|
|
|
test("an invalid challenge proof never persists a trusted browser key", () => {
|
|
const key = browserKey();
|
|
const wrongKey = browserKey();
|
|
const registrations: RuntimeBrowserRegistration[] = [];
|
|
let persisted = 0;
|
|
const manager = new LocalRuntimeSessionManager({
|
|
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
|
runtimeInstanceId: "runtime-instance-fixture",
|
|
trustedOrigins: [origin],
|
|
registrations,
|
|
persistRegistrations: () => { persisted += 1; },
|
|
now: () => now,
|
|
});
|
|
const challenge = manager.createChallenge(origin, { publicKeyJwk: key.publicJwk });
|
|
|
|
assert.throws(
|
|
() => manager.exchange(origin, {
|
|
challengeId: challenge.challengeId,
|
|
signature: signSessionChallenge(wrongKey.privateKey, challenge),
|
|
}),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "challenge_proof_invalid",
|
|
);
|
|
assert.equal(registrations.length, 0);
|
|
assert.equal(persisted, 0);
|
|
});
|
|
|
|
test("missing registrations use the neutral browser-key diagnostic", () => {
|
|
const key = browserKey();
|
|
assert.throws(
|
|
() => runtimeManager().createChallenge(origin, { keyId: runtimeBrowserKeyId(key.publicJwk) }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError
|
|
&& error.code === "registration_not_found"
|
|
&& error.message === "浏览器密钥尚未注册",
|
|
);
|
|
});
|
|
|
|
test("registered browser receives a sixty-second standard challenge", () => {
|
|
const key = browserKey();
|
|
const registrations = [registration(key.publicJwk)];
|
|
const manager = runtimeManager(registrations);
|
|
|
|
const challenge = manager.createChallenge(origin, { keyId: registrations[0].keyId });
|
|
|
|
assert.equal(challenge.state, "challenge");
|
|
assert.equal(challenge.expiresAt, new Date(now + 60_000).toISOString());
|
|
assert.deepEqual(Object.keys(challenge).sort(), ["challengeId", "expiresAt", "keyId", "nonce", "runtimeInstanceId", "state"]);
|
|
});
|
|
|
|
test("valid request proof succeeds exactly once and route substitution is rejected", () => {
|
|
const key = browserKey();
|
|
const manager = runtimeManager([registration(key.publicJwk)]);
|
|
const session = exchangeRegistered(manager, key);
|
|
const input = signedRequest(key.privateKey, session, {
|
|
method: "GET",
|
|
pathAndQuery: "/dreamina/status",
|
|
body: Buffer.alloc(0),
|
|
scope: "dreamina:status",
|
|
});
|
|
|
|
const verified = manager.verifyRequest(input);
|
|
assert.equal(verified.sessionId, session.sessionId);
|
|
assert.throws(
|
|
() => manager.verifyRequest(input),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "request_replayed",
|
|
);
|
|
assert.throws(
|
|
() => manager.verifyRequest({ ...signedRequest(key.privateKey, session, {
|
|
method: "GET",
|
|
pathAndQuery: "/dreamina/status",
|
|
body: Buffer.alloc(0),
|
|
scope: "dreamina:status",
|
|
}), pathAndQuery: "/dreamina/logout" }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "request_proof_invalid",
|
|
);
|
|
});
|
|
|
|
test("scope mismatch, stale timestamp, and body substitution fail closed", () => {
|
|
const key = browserKey();
|
|
const manager = runtimeManager([registration(key.publicJwk)], ["dreamina:login"]);
|
|
const session = exchangeRegistered(manager, key);
|
|
const valid = signedRequest(key.privateKey, session, {
|
|
method: "POST",
|
|
pathAndQuery: "/dreamina/login",
|
|
body: Buffer.from("{}"),
|
|
scope: "dreamina:login",
|
|
});
|
|
|
|
assert.throws(
|
|
() => manager.verifyRequest({ ...valid, scope: "canvas:connect" }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "scope_denied",
|
|
);
|
|
assert.throws(
|
|
() => manager.verifyRequest({ ...valid, timestamp: now - 31_000 }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "request_stale",
|
|
);
|
|
assert.throws(
|
|
() => manager.verifyRequest({ ...valid, body: Buffer.from('{"changed":true}') }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "request_proof_invalid",
|
|
);
|
|
});
|
|
|
|
test("session and registration revoke remove authorization without accepting another key id", () => {
|
|
const key = browserKey();
|
|
const registrations = [registration(key.publicJwk)];
|
|
const manager = runtimeManager(registrations);
|
|
const session = exchangeRegistered(manager, key);
|
|
|
|
manager.revokeSession(session.sessionId);
|
|
assert.throws(
|
|
() => manager.verifyRequest(signedRequest(key.privateKey, session, {
|
|
method: "GET",
|
|
pathAndQuery: "/dreamina/status",
|
|
body: Buffer.alloc(0),
|
|
scope: "dreamina:status",
|
|
})),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "session_invalid",
|
|
);
|
|
|
|
const next = exchangeRegistered(manager, key);
|
|
manager.revokeRegistration(next.sessionId);
|
|
assert.equal(registrations.length, 0);
|
|
assert.throws(
|
|
() => manager.createChallenge(origin, { keyId: next.keyId }),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "registration_not_found",
|
|
);
|
|
});
|
|
|
|
test("session absolute TTL schedules revocation without waiting for another request", () => {
|
|
const key = browserKey();
|
|
const registrations = [registration(key.publicJwk)];
|
|
const revoked: string[] = [];
|
|
let expiry: (() => void) | undefined;
|
|
let cleared = 0;
|
|
const options = {
|
|
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
|
runtimeInstanceId: "runtime-instance-fixture",
|
|
trustedOrigins: [origin],
|
|
registrations,
|
|
now: () => now,
|
|
onSessionRevoked: (sessionId: string) => revoked.push(sessionId),
|
|
timers: {
|
|
setTimeout(callback: () => void, delayMs: number) {
|
|
assert.equal(delayMs, 10 * 60_000);
|
|
expiry = callback;
|
|
return "expiry-fixture";
|
|
},
|
|
clearTimeout(handle: unknown) {
|
|
assert.equal(handle, "expiry-fixture");
|
|
cleared += 1;
|
|
},
|
|
},
|
|
} as ConstructorParameters<typeof LocalRuntimeSessionManager>[0];
|
|
const manager = new LocalRuntimeSessionManager(options);
|
|
const session = exchangeRegistered(manager, key);
|
|
|
|
assert.equal(typeof expiry, "function");
|
|
expiry?.();
|
|
|
|
assert.deepEqual(revoked, [session.sessionId]);
|
|
assert.equal(cleared, 1);
|
|
assert.throws(
|
|
() => manager.verifyRequest(signedRequest(key.privateKey, session, {
|
|
method: "GET",
|
|
pathAndQuery: "/dreamina/status",
|
|
body: Buffer.alloc(0),
|
|
scope: "dreamina:status",
|
|
})),
|
|
(error: unknown) => error instanceof LocalRuntimeSessionError && error.code === "session_invalid",
|
|
);
|
|
manager.dispose();
|
|
});
|
|
|
|
function runtimeManager(
|
|
registrations: RuntimeBrowserRegistration[] = [],
|
|
scopes?: ConstructorParameters<typeof LocalRuntimeSessionManager>[0]["scopes"],
|
|
) {
|
|
return new LocalRuntimeSessionManager({
|
|
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
|
runtimeInstanceId: "runtime-instance-fixture",
|
|
trustedOrigins: [origin],
|
|
registrations,
|
|
now: () => now,
|
|
scopes,
|
|
});
|
|
}
|
|
|
|
function browserKey() {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
const exported = publicKey.export({ format: "jwk" });
|
|
const publicJwk: JsonWebKey = {
|
|
kty: exported.kty,
|
|
crv: exported.crv,
|
|
x: exported.x,
|
|
y: exported.y,
|
|
ext: true,
|
|
key_ops: ["verify"],
|
|
};
|
|
return { publicJwk, privateKey };
|
|
}
|
|
|
|
function registration(publicKeyJwk: JsonWebKey): RuntimeBrowserRegistration {
|
|
const keyId = runtimeBrowserKeyId(publicKeyJwk);
|
|
return {
|
|
keyId,
|
|
origin,
|
|
publicKeyJwk,
|
|
fingerprint: keyId,
|
|
createdAt: new Date(now - 1_000).toISOString(),
|
|
};
|
|
}
|
|
|
|
function exchangeRegistered(manager: LocalRuntimeSessionManager, key: ReturnType<typeof browserKey>) {
|
|
const challenge = manager.createChallenge(origin, { keyId: runtimeBrowserKeyId(key.publicJwk) });
|
|
return manager.exchange(origin, {
|
|
challengeId: challenge.challengeId,
|
|
signature: signSessionChallenge(key.privateKey, challenge),
|
|
});
|
|
}
|
|
|
|
function signSessionChallenge(
|
|
privateKey: KeyObject,
|
|
challenge: {
|
|
challengeId: string;
|
|
nonce: string;
|
|
runtimeInstanceId: string;
|
|
expiresAt: string;
|
|
},
|
|
) {
|
|
const payload = canonicalRuntimeJson({
|
|
protocol: "framefield-runtime-session-v1",
|
|
challengeId: challenge.challengeId,
|
|
nonce: challenge.nonce,
|
|
origin,
|
|
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
|
runtimeInstanceId: challenge.runtimeInstanceId,
|
|
expiresAt: challenge.expiresAt,
|
|
});
|
|
return crypto.sign("sha256", Buffer.from(payload), {
|
|
key: privateKey,
|
|
dsaEncoding: "ieee-p1363",
|
|
}).toString("base64url");
|
|
}
|
|
|
|
function signedRequest(
|
|
privateKey: KeyObject,
|
|
session: { sessionId: string; keyId: string; expiresAt: string },
|
|
request: {
|
|
method: string;
|
|
pathAndQuery: string;
|
|
body: Buffer;
|
|
scope: string;
|
|
},
|
|
) {
|
|
const requestNonce = crypto.randomBytes(16).toString("base64url");
|
|
const payload = createRuntimeRequestPayload({
|
|
sessionId: session.sessionId,
|
|
keyId: session.keyId,
|
|
method: request.method,
|
|
pathAndQuery: request.pathAndQuery,
|
|
bodySha256: sha256Base64Url(request.body),
|
|
lastEventId: null,
|
|
origin,
|
|
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
|
runtimeInstanceId: "runtime-instance-fixture",
|
|
requestNonce,
|
|
timestamp: now,
|
|
sessionExpiresAt: session.expiresAt,
|
|
});
|
|
const proof = crypto.sign("sha256", Buffer.from(canonicalRuntimeJson(payload)), {
|
|
key: privateKey,
|
|
dsaEncoding: "ieee-p1363",
|
|
}).toString("base64url");
|
|
return {
|
|
sessionId: session.sessionId,
|
|
origin,
|
|
method: request.method,
|
|
pathAndQuery: request.pathAndQuery,
|
|
body: request.body,
|
|
lastEventId: null,
|
|
requestNonce,
|
|
timestamp: now,
|
|
proof,
|
|
scope: request.scope,
|
|
};
|
|
}
|