Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"./receipt": {
"import": "./dist/receipt.js",
"types": "./dist/receipt.d.ts"
},
"./compat": {
"import": "./dist/compat.js",
"types": "./dist/compat.d.ts"
}
},
"files": [
Expand All @@ -43,6 +47,7 @@
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"prepare": "npm run build",
"test": "node --test --import tsx/esm test/**/*.test.ts",
"test:build": "npm run build && node --test dist/test/**/*.test.js",
"typecheck": "tsc --noEmit",
Expand Down
201 changes: 201 additions & 0 deletions src/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* @commandlayer/runtime-core — compat.ts
*
* Backward-compatibility shims for runtime/server.mjs.
* These adapters translate between the runtime's envelope format
* (receipt with metadata.proof) and the core v1.1.0 APIs.
*
* The signing protocol is ALWAYS Ed25519(UTF8(canonical)) — raw bytes.
* The "sha256" in function names is a legacy artifact; these functions
* produce v1.1.0-compliant signatures.
*/

import { createHash } from "node:crypto";
import { canonicalize } from "./canonicalize.js";
import { signCanonical, verifyCanonical, CANONICAL_METHOD } from "./crypto.js";

/** Canonical method identifier constant for import by downstream repos. */
export const CANONICAL_ID_SORTED_KEYS_V1 = CANONICAL_METHOD;

// ── Runtime receipt shape (envelope format used by runtime/server.mjs) ────────

export interface RuntimeReceipt {
verb: string;
version?: string;
agent?: string;
timestamp?: string;
metadata?: {
proof?: RuntimeProof;
[key: string]: unknown;
};
[key: string]: unknown;
}

export interface RuntimeProof {
alg: string;
kid: string;
signer_id: string;
canonical: string;
hash_sha256?: string;
signature?: string;
signature_b64?: string;
}

// ── Sign ──────────────────────────────────────────────────────────────────────

export interface SignReceiptCompatOptions {
signer_id: string;
kid: string;
canonical_id?: string;
privateKeyPem: string;
}

export interface SignedRuntimeReceipt extends RuntimeReceipt {
metadata: {
proof: RuntimeProof;
[key: string]: unknown;
};
}

/**
* Sign a runtime-style receipt and embed the proof in metadata.proof.
*
* Signing message: Ed25519(UTF8(canonicalize(receipt_without_proof)))
* The proof block is NOT included in the signed payload.
*
* Returns the receipt with metadata.proof populated.
*/
export function signReceiptEd25519Sha256(
receipt: RuntimeReceipt,
opts: SignReceiptCompatOptions
): SignedRuntimeReceipt {
// Strip any existing proof so it's not included in the signed payload
const { metadata: meta = {}, ...rest } = receipt;
const { proof: _proof, ...metaWithoutProof } = meta;

const payloadToSign: Record<string, unknown> = { ...rest, metadata: metaWithoutProof };
if (Object.keys(metaWithoutProof).length === 0) {
delete payloadToSign.metadata;
}

const canonical = canonicalize(payloadToSign);
const sha256Hex = createHash("sha256").update(canonical, "utf8").digest("hex");
const signature = signCanonical(canonical, opts.privateKeyPem);

const proof: RuntimeProof = {
alg: "ed25519",
kid: opts.kid,
signer_id: opts.signer_id,
canonical: opts.canonical_id ?? CANONICAL_METHOD,
hash_sha256: sha256Hex,
signature_b64: signature,
signature,
};

return {
...rest,
metadata: {
...metaWithoutProof,
proof,
},
} as SignedRuntimeReceipt;
}

// ── Verify ────────────────────────────────────────────────────────────────────

export interface VerifyReceiptCompatOptions {
publicKeyPemOrDer: string;
allowedCanonicals?: string[];
}

export interface VerifyReceiptCompatResult {
ok: boolean;
checks: {
signature_valid: boolean;
hash_matches: boolean;
};
reason?: string;
}

/**
* Verify a runtime-style receipt (with metadata.proof).
*
* Reconstructs the signed payload by stripping metadata.proof,
* then verifies the Ed25519 signature over the canonical bytes.
*
* Also recomputes sha256 and checks hash_sha256 if present (legacy compat).
*/
export function verifyReceiptEd25519Sha256(
receipt: RuntimeReceipt,
opts: VerifyReceiptCompatOptions
): VerifyReceiptCompatResult {
const checks = { signature_valid: false, hash_matches: false };

const proof = receipt?.metadata?.proof;
if (!proof) {
return { ok: false, checks, reason: "Missing metadata.proof" };
}

const sig = proof.signature || proof.signature_b64;
if (!sig) {
return { ok: false, checks, reason: "Missing proof.signature" };
}

const allowedCanonicals = opts.allowedCanonicals ?? [CANONICAL_METHOD];
if (!allowedCanonicals.includes(proof.canonical)) {
return {
ok: false,
checks,
reason: `Unsupported canonicalization method: ${proof.canonical}`,
};
}

// Reconstruct the signed payload (receipt without the proof block)
const { metadata: meta = {}, ...rest } = receipt;
const { proof: _proof, ...metaWithoutProof } = meta;

const payloadToVerify: Record<string, unknown> = { ...rest, metadata: metaWithoutProof };
if (Object.keys(metaWithoutProof).length === 0) {
delete payloadToVerify.metadata;
}

let canonical: string;
try {
canonical = canonicalize(payloadToVerify);
} catch (err) {
return {
ok: false,
checks,
reason: `Canonicalization failed: ${(err as Error).message}`,
};
}

// Verify sha256 hash if present (legacy field, not required for v1.1.0)
if (proof.hash_sha256) {
const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex");
checks.hash_matches = recomputed === proof.hash_sha256;
} else {
checks.hash_matches = true;
}

// Verify signature over raw canonical bytes (v1.1.0 protocol)
try {
checks.signature_valid = verifyCanonical(canonical, sig, opts.publicKeyPemOrDer);
} catch {
return { ok: false, checks, reason: "Signature verification threw an error" };
}

const ok = checks.signature_valid && checks.hash_matches;
return {
ok,
checks,
reason: ok
? undefined
: [
!checks.signature_valid ? "signature invalid" : null,
!checks.hash_matches ? "hash mismatch" : null,
]
.filter(Boolean)
.join(", "),
};
}
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* - ENS resolution
* - Receipt building and verification (v1.1.0)
* - Test vectors
* - Backward-compatibility shims (for runtime/server.mjs)
*/

// Protocol constants
Expand Down Expand Up @@ -59,3 +60,16 @@ export {
type VerifyReceiptResult,
type VerifyReceiptOptions,
} from "./receipt.js";

// Backward-compatibility shims (for runtime/server.mjs)
export {
CANONICAL_ID_SORTED_KEYS_V1,
signReceiptEd25519Sha256,
verifyReceiptEd25519Sha256,
type RuntimeReceipt,
type RuntimeProof,
type SignReceiptCompatOptions,
type SignedRuntimeReceipt,
type VerifyReceiptCompatOptions,
type VerifyReceiptCompatResult,
} from "./compat.js";
138 changes: 138 additions & 0 deletions test/compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
CANONICAL_ID_SORTED_KEYS_V1,
signReceiptEd25519Sha256,
verifyReceiptEd25519Sha256,
} from "../src/compat.js";
import { generateEd25519KeyPair } from "../src/crypto.js";

describe("CANONICAL_ID_SORTED_KEYS_V1", () => {
test("equals json.sorted_keys.v1", () => {
assert.equal(CANONICAL_ID_SORTED_KEYS_V1, "json.sorted_keys.v1");
});
});

describe("signReceiptEd25519Sha256 / verifyReceiptEd25519Sha256", () => {
const kp = generateEd25519KeyPair();

const baseReceipt = {
verb: "test",
version: "1.1.0",
agent: "test.commandlayer.eth",
timestamp: "2026-05-13T00:00:00.000Z",
metadata: { session_id: "abc123" },
};

test("signs and verifies a receipt", () => {
const signed = signReceiptEd25519Sha256(baseReceipt, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

assert.ok(signed.metadata?.proof, "should have metadata.proof");
const proof = signed.metadata.proof;
assert.equal(proof.alg, "ed25519");
assert.equal(proof.kid, "testKid");
assert.equal(proof.signer_id, "test.commandlayer.eth");
assert.equal(proof.canonical, "json.sorted_keys.v1");
assert.ok(proof.signature, "should have signature");
assert.ok(proof.signature_b64, "should have signature_b64");
assert.equal(proof.signature, proof.signature_b64, "signature and signature_b64 should match");
assert.ok(proof.hash_sha256, "should have hash_sha256");

const result = verifyReceiptEd25519Sha256(signed, {
publicKeyPemOrDer: kp.publicKeyPem,
});

assert.ok(result.ok, `verify should succeed: ${result.reason}`);
assert.ok(result.checks.signature_valid);
assert.ok(result.checks.hash_matches);
});

test("verify fails with wrong public key", () => {
const signed = signReceiptEd25519Sha256(baseReceipt, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

const wrongKp = generateEd25519KeyPair();
const result = verifyReceiptEd25519Sha256(signed, {
publicKeyPemOrDer: wrongKp.publicKeyPem,
});

assert.ok(!result.ok, "verify should fail with wrong key");
assert.ok(!result.checks.signature_valid);
});

test("verify fails if receipt tampered after signing", () => {
const signed = signReceiptEd25519Sha256(baseReceipt, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

const tampered = { ...signed, verb: "tampered" };
const result = verifyReceiptEd25519Sha256(tampered, {
publicKeyPemOrDer: kp.publicKeyPem,
});

assert.ok(!result.ok, "verify should fail for tampered receipt");
});

test("verify fails with missing proof", () => {
const result = verifyReceiptEd25519Sha256(baseReceipt, {
publicKeyPemOrDer: kp.publicKeyPem,
});
assert.ok(!result.ok);
assert.match(result.reason ?? "", /Missing metadata\.proof/);
});

test("proof block excluded from signed payload — signing is idempotent", () => {
const signed = signReceiptEd25519Sha256(baseReceipt, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

// Re-signing an already-signed receipt should produce the same signature
const signed2 = signReceiptEd25519Sha256(signed, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

assert.equal(
signed.metadata.proof.signature,
signed2.metadata.proof.signature,
"proof block must not be included in signed payload"
);

const r2 = verifyReceiptEd25519Sha256(signed2, { publicKeyPemOrDer: kp.publicKeyPem });
assert.ok(r2.ok);
});

test("supports signature via signature_b64 field", () => {
const signed = signReceiptEd25519Sha256(baseReceipt, {
signer_id: "test.commandlayer.eth",
kid: "testKid",
privateKeyPem: kp.privateKeyPem,
});

// Simulate a legacy receipt that only has signature_b64
const legacy = {
...signed,
metadata: {
...signed.metadata,
proof: { ...signed.metadata.proof, signature: undefined },
},
};

const result = verifyReceiptEd25519Sha256(legacy as typeof signed, {
publicKeyPemOrDer: kp.publicKeyPem,
});
assert.ok(result.ok, `should verify via signature_b64: ${result.reason}`);
});
});
Loading