Add runtime auth tokens and canonical field keys

This commit is contained in:
Timothy J. Aveni
2026-07-12 14:32:49 -07:00
parent 2eed097994
commit e0d8da526c
10 changed files with 385 additions and 29 deletions
+50
View File
@@ -512,6 +512,22 @@ identity:
field operation key = object_id + field_name + canonical_param_bytes
```
Canonical parameter bytes are semantic identity, not display formatting. Two
parameter objects with the same protobuf/JSON value but different map key order
must produce the same key. V0 can canonicalize by lowering the Camino `Value`
map to normalized JSON with sorted object keys, then encoding that canonical
string. Later, when typed protobuf field APIs move into Camino, the preferred
form should be deterministic protobuf bytes for the operation input message.
This key should eventually be reused by:
- `GetField` and `WatchField` dedupe
- package-backed watch demand leases
- derived/adaptor cache entries
- provenance/debug dependency records
- cycle and fanout accounting
- parameterized UI props subscriptions
`protoc` validates message types used by `rpc` input/output, which avoids
stringly `input_type`/`output_type` metadata. Custom options still contain a
string service name because protobuf options do not have a native
@@ -1198,6 +1214,40 @@ Track operation provenance early:
This helps debugging, replay, migrations, future permissions, and agent-written
programs.
### Package Runtime Identity
Package code must not self-assert actor identity by sending arbitrary `actor`
fields or package headers to Camino. Orch owns runtime activation and should mint
short-lived capability tokens for package runtime processes. Package runtime
helpers attach those tokens when calling Camino. Camino verifies the token with a
shared secret or future stronger mechanism and derives package provenance from
the signed claims.
V0 token claims are intentionally small:
```text
package_namespace
package_name
descriptor_version
runtime_key
issued_at
```
The token proves only "this request came through a runtime process spawned for
this package descriptor." It is not yet a user permission model and does not
solve package sandboxing. A compromised package can still use its own package
token, but it cannot claim to be a different package without orch's signing
secret.
Later this should grow into:
- invocation/watch correlation IDs
- exact function ref and schema slot provenance
- expiry and rotation
- per-object/field capability scope where useful
- Camino-side rejection for package writes that lack a valid runtime token
- audit records that distinguish user intent from package-side effects
## Mounted/External State
Some fields may represent external or mounted state:
+10 -4
View File
@@ -136,10 +136,12 @@ Camino replaces "packages with state contexts" as the conceptual center.
Minecraft-server example can still spawn multiple world/server processes, but
that is package-owned behavior rather than orch's default cardinality.
- Package code should not assert provenance by sending user/actor fields or
identity headers to Camino. Today unauthenticated local writes record
`local-rpc`. Later, orch should issue package runtime capability tokens and own
invocation provenance; Camino should verify that token rather than trusting
package-supplied identity metadata.
unsigned identity headers to Camino. Orch owns runtime activation and mints
package runtime capability tokens; package runtime helpers attach those tokens
to Camino RPCs. Camino verifies signed token claims when
`CAMINO_RUNTIME_AUTH_SECRET` is configured and records package actor
provenance such as `package:quixos.todo/todo-runtime`. Unauthenticated local
writes still record `local-rpc` for v0 dev ergonomics.
- Each Camino class should have a real proto schema file. TypeScript object
bindings are runtime/client sugar generated from schema, not the schema source
of truth.
@@ -188,6 +190,10 @@ Decided direction, not fully implemented:
- Parameter sets must be serializable and canonicalizable so
`(object_id, field_name, canonical_param_bytes)` can key reads, watches,
dedupe, future caches, and provenance.
- Current v0 helper canonicalizes Camino `Value` parameter maps by lowering to
normalized JSON with sorted object keys and base64url-encoding that canonical
string inside `fieldOperationKey`. This is a placeholder for deterministic
protobuf input bytes once `GetField`/`WatchField` become real Camino RPCs.
- `google.protobuf.Empty` is the singleton parameter set for unparameterized
custom field operations.
- Signals/channels are event surfaces. They support subscription but do not
@@ -17,6 +17,7 @@ import {
import type { FunctionRef } from "./gen/quixos/refs_pb.js";
import type { FunctionResolution } from "./package-descriptors.js";
import type { Value } from "./gen/camino/api_pb.js";
import { signRuntimeToken } from "./runtime-auth.js";
const execFile = promisify(childProcess.execFile);
@@ -201,12 +202,27 @@ export const createPackageRuntimeManager = () => {
const startRuntime = async (resolution: FunctionResolution) => {
const { sourcePath, serverPath } = await buildRuntimeServer(resolution);
const port = await allocatePort();
const runtimeKey = packageKey(resolution);
const runtimeTokenSecret = process.env.CAMINO_RUNTIME_AUTH_SECRET;
const runtimeToken = runtimeTokenSecret
? signRuntimeToken(
{
packageNamespace: resolution.descriptor.packageNamespace,
packageName: resolution.descriptor.packageName,
descriptorVersion: resolution.descriptor.descriptorVersion,
runtimeKey,
issuedAt: Date.now(),
},
runtimeTokenSecret,
)
: "";
const child = childProcess.spawn(serverPath, [], {
cwd: sourcePath,
env: {
...process.env,
QUIXOS_RUNTIME_HOST: "127.0.0.1",
QUIXOS_RUNTIME_PORT: String(port),
CAMINO_RUNTIME_AUTH_TOKEN: runtimeToken,
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -218,7 +234,7 @@ export const createPackageRuntimeManager = () => {
});
const runtime: RuntimeProcess = {
key: packageKey(resolution),
key: runtimeKey,
child,
packageNamespace: resolution.descriptor.packageNamespace,
packageName: resolution.descriptor.packageName,
@@ -0,0 +1,23 @@
import crypto from "node:crypto";
type RuntimeTokenClaims = {
packageNamespace: string;
packageName: string;
descriptorVersion: number;
runtimeKey: string;
issuedAt: number;
};
const tokenPrefix = "qrt1";
const hmac = (secret: string, payload: string) =>
crypto.createHmac("sha256", secret).update(payload).digest("base64url");
export const signRuntimeToken = (
claims: RuntimeTokenClaims,
secret: string,
) => {
const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url");
return `${tokenPrefix}.${encodedClaims}.${hmac(secret, encodedClaims)}`;
};
+36 -13
View File
@@ -106,6 +106,7 @@ type CapturedSetField = {
fieldName: string;
value: unknown;
actor: string;
runtimeToken?: string;
};
const projectObjectId = "obj:0198d5d2-2c89-7ddc-8fd0-5c32cebe2c00";
@@ -200,14 +201,18 @@ const withFakeCaminoServer = async <T>(
});
},
async setField(request) {
async setField(request, context) {
const value = request.value;
assert.ok(value);
const runtimeToken = context.requestHeader.get(
"x-camino-runtime-token",
);
setFields.push({
objectId: request.objectId,
fieldName: request.fieldName,
value: protoValueToJs(value),
actor: "local-rpc",
...(runtimeToken ? { runtimeToken } : {}),
});
return {
object: fakeObject(request.objectId, "quixos.todo:Task:1:", {
@@ -367,9 +372,12 @@ test("InvokeFunction builds and runs package runtime from descriptor", async ()
"../packages/todo-runtime/descriptor.quixos-package.txtpb",
);
const registry = loadPackageDescriptorRegistry(descriptorPath);
const previousRuntimeSecret = process.env.CAMINO_RUNTIME_AUTH_SECRET;
process.env.CAMINO_RUNTIME_AUTH_SECRET = "test-runtime-secret";
await withFakeCaminoServer(async (_caminoUrl, setFields) =>
withPackageRuntimeServer(async (baseUrl) => {
try {
await withFakeCaminoServer(async (_caminoUrl, setFields) =>
withPackageRuntimeServer(async (baseUrl) => {
const client = createClient(
OrchestratorRuntime,
createConnectTransport({ baseUrl, httpVersion: "1.1" }),
@@ -410,16 +418,31 @@ test("InvokeFunction builds and runs package runtime from descriptor", async ()
status: "DONE",
objectId,
});
assert.deepEqual(setFields, [
{
objectId,
fieldName: "status",
value: "DONE",
actor: "local-rpc",
},
]);
}, registry),
);
assert.equal(setFields.length, 1);
assert.deepEqual(
{
objectId: setFields[0]?.objectId,
fieldName: setFields[0]?.fieldName,
value: setFields[0]?.value,
actor: setFields[0]?.actor,
},
{
objectId,
fieldName: "status",
value: "DONE",
actor: "local-rpc",
},
);
assert.match(setFields[0]?.runtimeToken ?? "", /^qrt1\./);
}, registry),
);
} finally {
if (previousRuntimeSecret === undefined) {
delete process.env.CAMINO_RUNTIME_AUTH_SECRET;
} else {
process.env.CAMINO_RUNTIME_AUTH_SECRET = previousRuntimeSecret;
}
}
});
test("InvokeFunction builds and runs Project task summary resolver", async () => {
+20 -9
View File
@@ -14,6 +14,7 @@ import {
opToProto,
} from "./rpc-convert.js";
import { compileCaminoSchema } from "./schema-compiler.js";
import { verifyRuntimeToken } from "./runtime-auth.js";
type OpRecord = Awaited<ReturnType<CaminoDb["listOps"]>>[number];
@@ -90,7 +91,17 @@ const handle = async <T>(fn: () => Promise<T>) => {
}
};
const inferActor = () => "local-rpc";
const inferActor = (headers: Headers) => {
const token = headers.get("x-camino-runtime-token");
const secret = process.env.CAMINO_RUNTIME_AUTH_SECRET;
if (token && secret) {
const claims = verifyRuntimeToken(token, secret);
if (claims) {
return `package:${claims.packageNamespace}/${claims.packageName}`;
}
}
return "local-rpc";
};
export const createCaminoConnectRoutes =
(db: CaminoDb) => (router: ConnectRouter) => {
@@ -113,13 +124,13 @@ export const createCaminoConnectRoutes =
})),
})),
createObject: (request) =>
createObject: (request, context) =>
handle(async () => ({
object: objectToProto(
await db.createObject({
classId: request.classId,
fields: protoFieldsToJs(request.fields),
actor: inferActor(),
actor: inferActor(context.requestHeader),
}),
),
})),
@@ -129,19 +140,19 @@ export const createCaminoConnectRoutes =
object: objectToProto(await db.getObject(request.objectId)),
})),
setField: (request) =>
setField: (request, context) =>
handle(async () => ({
object: objectToProto(
await db.setField({
objectId: request.objectId,
fieldName: request.fieldName,
value: protoValueToJs(request.value),
actor: inferActor(),
actor: inferActor(context.requestHeader),
}),
),
})),
addEdge: (request) =>
addEdge: (request, context) =>
handle(async () => ({
edge: edgeToProto(
await db.addEdge({
@@ -150,7 +161,7 @@ export const createCaminoConnectRoutes =
toObjectId: request.toObjectId,
fields: protoFieldsToJs(request.fields),
ordinal: request.ordinal,
actor: inferActor(),
actor: inferActor(context.requestHeader),
}),
),
})),
@@ -160,11 +171,11 @@ export const createCaminoConnectRoutes =
edges: (await db.listEdges(request.objectId)).map(edgeToProto),
})),
removeEdge: (request) =>
removeEdge: (request, context) =>
handle(async () => {
const removed = await db.removeEdge(
request.edgeId,
inferActor(),
inferActor(context.requestHeader),
);
return {
edgeId: removed.id,
@@ -0,0 +1,43 @@
import type { Value } from "./gen/camino/api_pb.js";
import { protoValueToJs } from "./rpc-convert.js";
const normalize = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(normalize);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => [key, normalize(entryValue)]),
);
};
export const canonicalFieldParams = (
params: Record<string, Value> | undefined,
) =>
JSON.stringify(
normalize(
Object.fromEntries(
Object.entries(params ?? {}).map(([key, value]) => [
key,
protoValueToJs(value),
]),
),
),
);
export const fieldOperationKey = (params: {
objectId: string;
fieldName: string;
input?: Record<string, Value>;
}) =>
[
params.objectId,
params.fieldName,
Buffer.from(canonicalFieldParams(params.input)).toString("base64url"),
].join(":");
@@ -0,0 +1,54 @@
import crypto from "node:crypto";
export type RuntimeTokenClaims = {
packageNamespace: string;
packageName: string;
descriptorVersion: number;
runtimeKey: string;
issuedAt: number;
};
const tokenPrefix = "qrt1";
const hmac = (secret: string, payload: string) =>
crypto.createHmac("sha256", secret).update(payload).digest("base64url");
const isRuntimeTokenClaims = (value: unknown): value is RuntimeTokenClaims => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const claims = value as Partial<RuntimeTokenClaims>;
return (
typeof claims.packageNamespace === "string" &&
typeof claims.packageName === "string" &&
typeof claims.descriptorVersion === "number" &&
Number.isInteger(claims.descriptorVersion) &&
typeof claims.runtimeKey === "string" &&
typeof claims.issuedAt === "number" &&
Number.isInteger(claims.issuedAt)
);
};
export const verifyRuntimeToken = (
token: string,
secret: string,
): RuntimeTokenClaims | undefined => {
const [prefix, encodedClaims, signature] = token.split(".");
if (prefix !== tokenPrefix || !encodedClaims || !signature) {
return undefined;
}
const expected = hmac(secret, encodedClaims);
const expectedBytes = Buffer.from(expected);
const signatureBytes = Buffer.from(signature);
if (
expectedBytes.length !== signatureBytes.length ||
!crypto.timingSafeEqual(expectedBytes, signatureBytes)
) {
return undefined;
}
const parsed = JSON.parse(
Buffer.from(encodedClaims, "base64url").toString("utf8"),
) as unknown;
return isRuntimeTokenClaims(parsed) ? parsed : undefined;
};
+79 -1
View File
@@ -1,14 +1,16 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { createClient } from "@connectrpc/connect";
import { createClient, type Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";
import { connectNodeAdapter } from "@connectrpc/connect-node";
import { createCaminoConnectRoutes } from "../src/connect.js";
import { createCaminoDb } from "../src/db.js";
import { fieldOperationKey } from "../src/field-params.js";
import { CaminoService } from "../src/gen/camino/api_pb.js";
import { FunctionCapabilityKind } from "../src/gen/quixos/package_pb.js";
import {
@@ -44,6 +46,24 @@ const writeTempSchema = async (source: string) => {
return file;
};
const signRuntimeToken = (
claims: {
packageNamespace: string;
packageName: string;
descriptorVersion: number;
runtimeKey: string;
issuedAt: number;
},
secret: string,
) => {
const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url");
const signature = crypto
.createHmac("sha256", secret)
.update(encodedClaims)
.digest("base64url");
return `qrt1.${encodedClaims}.${signature}`;
};
const nextWithTimeout = async <T>(
iterator: AsyncIterator<T>,
label: string,
@@ -113,6 +133,26 @@ test("schema compiler lowers the todo fixture", async () => {
assert.equal(displayLabelWatchStop.function.operation, "watchStop");
});
test("field operation keys canonicalize parameter order", () => {
const left = fieldOperationKey({
objectId: "obj:0198d5d2-2c89-7ddc-8fd0-5c32cebe2c00",
fieldName: "filtered_list",
input: jsObjectToProtoFields({
status: "OPEN",
filters: { tag: "backend", priority: "HIGH" },
}),
});
const right = fieldOperationKey({
objectId: "obj:0198d5d2-2c89-7ddc-8fd0-5c32cebe2c00",
fieldName: "filtered_list",
input: jsObjectToProtoFields({
filters: { priority: "HIGH", tag: "backend" },
status: "OPEN",
}),
});
assert.equal(left, right);
});
test("schema validation resolves todo function refs through descriptors", async () => {
const compiled = await compileCaminoSchema(taskSchemaPath);
const descriptor = parsePackageDescriptorFile(todoDescriptorPath);
@@ -342,6 +382,7 @@ test("database stores objects, fields, edges, and not-found edge lookups", async
test("Connect RPC exposes the Camino object flow", async () => {
await withTestPostgres(async ({ databaseUrl }) => {
const db = await createCaminoDb(databaseUrl);
const previousRuntimeSecret = process.env.CAMINO_RUNTIME_AUTH_SECRET;
const server = http.createServer(
connectNodeAdapter({
routes: createCaminoConnectRoutes(db),
@@ -458,7 +499,44 @@ test("Connect RPC exposes the Camino object flow", async () => {
const listed = await client.listEdges({ objectId: projectObject.id });
assert.equal(listed.edges.length, 1);
const runtimeSecret = "test-runtime-secret";
process.env.CAMINO_RUNTIME_AUTH_SECRET = runtimeSecret;
const runtimeToken = signRuntimeToken(
{
packageNamespace: "quixos.todo",
packageName: "todo-runtime",
descriptorVersion: 1,
runtimeKey: "test-runtime",
issuedAt: Date.now(),
},
runtimeSecret,
);
const runtimeTokenInterceptor: Interceptor = (next) => async (request) => {
request.header.set("x-camino-runtime-token", runtimeToken);
return await next(request);
};
const packageClient = createClient(
CaminoService,
createConnectTransport({
baseUrl: `http://127.0.0.1:${address.port}`,
httpVersion: "1.1",
interceptors: [runtimeTokenInterceptor],
}),
);
await packageClient.setField({
objectId: taskObject.id,
fieldName: "status",
value: jsToProtoValue("DONE"),
});
const packageOps = await client.listOps({ objectId: taskObject.id });
assert.equal(packageOps.ops.at(-1)?.actor, "package:quixos.todo/todo-runtime");
} finally {
if (previousRuntimeSecret === undefined) {
delete process.env.CAMINO_RUNTIME_AUTH_SECRET;
} else {
process.env.CAMINO_RUNTIME_AUTH_SECRET = previousRuntimeSecret;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}).catch(() => undefined);
@@ -6,6 +6,7 @@ import {
createClient,
type Client,
type HandlerContext,
type Interceptor,
} from "@connectrpc/connect";
import {
connectNodeAdapter,
@@ -569,6 +570,46 @@ export const protoFieldsToJs = (
]),
);
const normalizeForCanonicalJson = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(normalizeForCanonicalJson);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => [key, normalizeForCanonicalJson(entryValue)]),
);
};
export const canonicalFieldParams = (
params: Record<string, Value> | undefined,
) =>
JSON.stringify(
normalizeForCanonicalJson(
Object.fromEntries(
Object.entries(params ?? {}).map(([key, value]) => [
key,
protoValueToJs(value),
]),
),
),
);
export const fieldOperationKey = (params: {
objectId: string;
fieldName: string;
input?: Record<string, Value>;
}) =>
[
params.objectId,
params.fieldName,
Buffer.from(canonicalFieldParams(params.input)).toString("base64url"),
].join(":");
const derivedDependencyToProto = (dependency: DerivedDependency) =>
create(DerivedDependencySchema, {
kind: dependency.kind,
@@ -679,9 +720,20 @@ export const serveQuixosPackageRuntime = <
const host = process.env.QUIXOS_RUNTIME_HOST ?? "127.0.0.1";
const port = Number(process.env.QUIXOS_RUNTIME_PORT ?? "0");
const caminoUrl = process.env.CAMINO_URL ?? "http://127.0.0.1:7310";
const runtimeToken = process.env.CAMINO_RUNTIME_AUTH_TOKEN ?? "";
const runtimeTokenInterceptor: Interceptor = (next) => async (request) => {
if (runtimeToken) {
request.header.set("x-camino-runtime-token", runtimeToken);
}
return await next(request);
};
const camino = createClient(
CaminoService,
createConnectTransport({ baseUrl: caminoUrl, httpVersion: "1.1" }),
createConnectTransport({
baseUrl: caminoUrl,
httpVersion: "1.1",
interceptors: runtimeToken ? [runtimeTokenInterceptor] : [],
}),
);
const trackingCamino = createDependencyTrackingCaminoClient(camino);