cultnet-ts: freeze the Idunn authority and allow one signer per authority
The loaded authority is deeply frozen (bind, health contract, Expected, Activation, identities), so a caller cannot steer what the signer signs; the typed Buffers that could not be frozen are dropped (Expected canonical bytes, Activation public key kept as hex only for the load-time comparison). A second createIdunnRuntimeSigner on the same authority throws, so two signers cannot emit sequence 1 under one runtimeInstanceId. Tests: stateful set pinned against Rust (warming, active, degraded), lease accepted for a route-answered warming, refused for another warming and another target, one signer per authority, frozen authority. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8072314b7c
commit
67b8ae3eda
2 changed files with 104 additions and 9 deletions
|
|
@ -30,6 +30,7 @@ const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "ut
|
|||
const CULTCACHE_STORE_FORMAT = "cultcache.store.v1";
|
||||
const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
const MAX_RECENT_WARMING_PROOFS = 64;
|
||||
const authoritiesWithSigner = new WeakSet<object>();
|
||||
const authorityKeys = new WeakMap<object, { providerPrivateKey: crypto.KeyObject; activationPrivateKey: crypto.KeyObject }>();
|
||||
|
||||
type ExpectedIncarnation = {
|
||||
|
|
@ -51,7 +52,6 @@ type ExpectedIncarnation = {
|
|||
route: { routeId: string; transport: string; stableEndpoint: string; candidateEndpoint: string } | null;
|
||||
capabilities: Array<{ capability: string; schema: string; compatibility: string; minimumCapacity: number }>;
|
||||
dependencies: Array<{ kind: string; capability: string; schema: string; compatibility: string; providerEndpoint: string | null }>;
|
||||
canonicalBytes: Buffer;
|
||||
canonicalSha256: string;
|
||||
};
|
||||
|
||||
|
|
@ -61,18 +61,27 @@ type RuntimeActivation = {
|
|||
runtimeId: string;
|
||||
runtimeInstanceId: string;
|
||||
activationSignerIdentityId: string;
|
||||
activationSignerPublicKey: Buffer;
|
||||
activationSignerPublicKeyHex: string;
|
||||
canonicalSha256: string;
|
||||
};
|
||||
|
||||
export type IdunnRuntimeAuthority = {
|
||||
type DeepReadonly<T> = T extends readonly (infer U)[]
|
||||
? readonly DeepReadonly<U>[]
|
||||
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
|
||||
|
||||
/**
|
||||
* What Idunn admitted for this launch. Deeply frozen at load: bind, health
|
||||
* contract, Expected, Activation and identities are read-only for the life of
|
||||
* the process, so no caller can steer what the signer signs.
|
||||
*/
|
||||
export type IdunnRuntimeAuthority = DeepReadonly<{
|
||||
expected: ExpectedIncarnation;
|
||||
activation: RuntimeActivation;
|
||||
providerSignerIdentityId: string;
|
||||
activationSignerIdentityId: string;
|
||||
boundEndpoint: string;
|
||||
processWriteLeasePath?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type SignedRuntimePresence = {
|
||||
document: CultNetRawDocumentRecord;
|
||||
|
|
@ -152,7 +161,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment(
|
|||
const activationPublicKey = rawEd25519PublicKey(crypto.createPublicKey(activationPrivateKey));
|
||||
const activationSignerIdentityId = sha256Hex(Buffer.concat([ACTIVATION_ID_DOMAIN, activationPublicKey]));
|
||||
if (activationSignerIdentityId !== activationValue.activationSignerIdentityId
|
||||
|| !activationPublicKey.equals(activationValue.activationSignerPublicKey)) {
|
||||
|| activationPublicKey.toString("hex") !== activationValue.activationSignerPublicKeyHex) {
|
||||
throw new Error("Idunn activation credential does not match the root-provisioned Activation witness.");
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +176,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment(
|
|||
throw new Error("Provider identity does not match Idunn Expected.");
|
||||
}
|
||||
|
||||
const authority: IdunnRuntimeAuthority = {
|
||||
const authority: IdunnRuntimeAuthority = deepFreeze({
|
||||
expected: expectedValue,
|
||||
activation: activationValue,
|
||||
providerSignerIdentityId: expectedValue.expectedSignerIdentityId,
|
||||
|
|
@ -176,7 +185,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment(
|
|||
...(process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT]
|
||||
? { processWriteLeasePath: process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
authorityKeys.set(authority, { providerPrivateKey, activationPrivateKey });
|
||||
return authority;
|
||||
}
|
||||
|
|
@ -220,8 +229,15 @@ export function signIdunnRuntimePresence(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One signer per loaded authority: the publisher sequence and the warming proofs
|
||||
* are the signer's, and a second signer would restart the sequence under the
|
||||
* same runtimeInstanceId.
|
||||
*/
|
||||
export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner {
|
||||
const { authority } = options;
|
||||
if (authoritiesWithSigner.has(authority)) throw new Error("This Idunn runtime authority already has a signer.");
|
||||
authoritiesWithSigner.add(authority);
|
||||
const expected = authority.expected;
|
||||
let sequence = 0;
|
||||
let health: RuntimePresenceHealth["state"] = "warming";
|
||||
|
|
@ -291,6 +307,14 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id
|
|||
};
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } {
|
||||
const decoded = decode(fs.readFileSync(filePath));
|
||||
if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) {
|
||||
|
|
@ -360,7 +384,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation {
|
|||
route,
|
||||
capabilities: decodeExpectedCapabilities(values[16]),
|
||||
dependencies: decodeExpectedDependencies(values[17]),
|
||||
canonicalBytes,
|
||||
canonicalSha256: prefixedSha256(canonicalBytes),
|
||||
};
|
||||
}
|
||||
|
|
@ -388,7 +411,7 @@ function decodeActivation(payload: Uint8Array): RuntimeActivation {
|
|||
runtimeId: string(values[2], "Activation runtime id"),
|
||||
runtimeInstanceId: string(values[3], "Activation instance id"),
|
||||
activationSignerIdentityId,
|
||||
activationSignerPublicKey: Buffer.from(publicKey),
|
||||
activationSignerPublicKeyHex: Buffer.from(publicKey).toString("hex"),
|
||||
canonicalSha256: prefixedSha256(canonicalBytes),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,3 +291,75 @@ test("the route answer and the Odin publish report the same state, because the s
|
|||
[1, 3],
|
||||
);
|
||||
});
|
||||
|
||||
// The lease-bound set: Rust issued lease.cc naming presence-warming.bin's digest, and presence-active.bin
|
||||
// and presence-degraded.bin carry that lease's digest.
|
||||
const STATEFUL = "service-stateful" as const;
|
||||
|
||||
function statefulSigner(context: TestContext, lease = "lease.cc") {
|
||||
return signerFor(context, STATEFUL, 0, lease);
|
||||
}
|
||||
|
||||
test("stateful: warming, active and degraded match the Rust-verified vectors byte for byte, active and degraded bound to Rust's lease", (context) => {
|
||||
freezeClock(context);
|
||||
const signer = statefulSigner(context);
|
||||
assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-warming.bin"));
|
||||
signer.reportHealth("active");
|
||||
const active = signer.answerRouteObservation(CHALLENGE);
|
||||
assert.deepEqual(Buffer.from(active.payload), fixtureFile(STATEFUL, "presence-active.bin"));
|
||||
assert.match(String(presenceSlot(active, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)), /^sha256-[0-9a-f]{64}$/);
|
||||
|
||||
const degraded = statefulSigner(context);
|
||||
degraded.answerRouteObservation(CHALLENGE);
|
||||
degraded.reportHealth("degraded");
|
||||
assert.deepEqual(Buffer.from(degraded.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-degraded.bin"));
|
||||
});
|
||||
|
||||
test("a warming answered on the route becomes a lease target: Rust's lease naming it is accepted", (context) => {
|
||||
freezeClock(context);
|
||||
const signer = statefulSigner(context);
|
||||
assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/, "no warming signed yet");
|
||||
signer.answerRouteObservation(CHALLENGE);
|
||||
assert.equal(signer.assertWriteLease(), presenceSlot({ payload: fixtureFile(STATEFUL, "presence-active.bin") }, RUNTIME_PRESENCE_SLOT.writeLeaseSha256));
|
||||
});
|
||||
|
||||
for (const [lease, reason] of [["lease-other-warming.cc", "names another warming digest"], ["lease-other-target.cc", "names another target"]] as const) {
|
||||
test(`a lease that ${reason} is refused, and the signer cannot sign active under it`, (context) => {
|
||||
freezeClock(context);
|
||||
const signer = statefulSigner(context, lease);
|
||||
signer.answerRouteObservation(CHALLENGE);
|
||||
assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/);
|
||||
signer.reportHealth("active");
|
||||
assert.throws(() => signer.answerRouteObservation(CHALLENGE), /does not match the current Expected incarnation/);
|
||||
});
|
||||
}
|
||||
|
||||
test("one signer per loaded authority", (context) => {
|
||||
const authority = openAuthority(context, "web");
|
||||
const capabilities = expectedFacts("web").capabilities;
|
||||
createIdunnRuntimeSigner({ authority, capabilities });
|
||||
assert.throws(() => createIdunnRuntimeSigner({ authority, capabilities }), /already has a signer/);
|
||||
});
|
||||
|
||||
test("the loaded authority is frozen: the bind, contract, Expected, Activation and identities cannot change, and the signer signs the loaded endpoint", (context) => {
|
||||
freezeClock(context);
|
||||
const authority = openAuthority(context, "web");
|
||||
const loaded = expectedFacts("web");
|
||||
const open = authority as unknown as { [key: string]: any };
|
||||
const mutations: Array<[string, () => void]> = [
|
||||
["boundEndpoint", () => { open.boundEndpoint = "http://127.0.0.1:1"; }],
|
||||
["providerSignerIdentityId", () => { open.providerSignerIdentityId = "forged"; }],
|
||||
["activationSignerIdentityId", () => { open.activationSignerIdentityId = "forged"; }],
|
||||
["expected.healthContract", () => { open.expected.healthContract = "forged"; }],
|
||||
["expected.target", () => { open.expected.target = "forged"; }],
|
||||
["expected.canonicalSha256", () => { open.expected.canonicalSha256 = "forged"; }],
|
||||
["expected.route.candidateEndpoint", () => { open.expected.route.candidateEndpoint = "http://127.0.0.1:1"; }],
|
||||
["expected.capabilities[0].minimumCapacity", () => { open.expected.capabilities[0].minimumCapacity = 0; }],
|
||||
["expected.capabilities.push", () => { open.expected.capabilities.push({}); }],
|
||||
["activation.runtimeInstanceId", () => { open.activation.runtimeInstanceId = "forged"; }],
|
||||
["activation.canonicalSha256", () => { open.activation.canonicalSha256 = "forged"; }],
|
||||
];
|
||||
for (const [name, mutate] of mutations) assert.throws(mutate, TypeError, name);
|
||||
const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities });
|
||||
assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue