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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,4 @@ MIT

### Verb conventions

Use `cl.wrap("verify", handler)` for normal SDK usage. For discovery/catalog metadata, advertise the canonical capability name `clas.trust-verification.verify`. The SDK also accepts fully-qualified trust capability inputs in `wrap(...)` and normalizes emitted receipt `verb` to the short form.
Use `cl.wrap("verify", handler)` for normal SDK usage. For discovery/catalog metadata, advertise the canonical capability name `clas.trust-verification.verify`. The SDK also accepts fully-qualified trust capability inputs in `wrap(...)` and normalizes emitted receipt `verb` to the short form and emits canonical metadata proof envelopes.
4 changes: 2 additions & 2 deletions examples/full-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ process.stdout.write("Signed receipt\n");
process.stdout.write(`${JSON.stringify(result.receipt, null, 2)}\n`);
process.stdout.write(`receipt.signer: ${result.receipt.signer}\n`);
process.stdout.write(`receipt.verb: ${result.receipt.verb}\n`);
process.stdout.write(`receipt.proof.kid: ${result.receipt.proof.kid}\n`);
process.stdout.write(`receipt.proof.signer_id: ${result.receipt.proof.signer_id}\n\n`);
process.stdout.write(`receipt.proof.signature.kid: ${result.receipt.proof.signature.kid}\n`);
process.stdout.write(`receipt.signer: ${result.receipt.signer}\n\n`);

const statusOf = (value: unknown): string => {
if (!value || typeof value !== "object") {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1"
"ajv-formats": "^3.0.1",
"@commandlayer/runtime-core": "^1.2.0"
}
}
43 changes: 15 additions & 28 deletions src/receipt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { canonicalize, type JsonValue } from "./canonicalize.js";
import { importEd25519PrivateKeyFromPem, signEd25519Base64 } from "./crypto.js";
import {
signCommandLayerReceipt,
type CommandLayerCanonicalization,
type CommandLayerReceipt,
type SignCommandLayerReceiptParams,
} from "@commandlayer/runtime-core";
import type { JsonValue } from "./canonicalize.js";

export interface ReceiptInput {
version: "1.0.0";
Expand All @@ -18,15 +23,7 @@ export interface ReceiptInput {
};
}

export interface Receipt extends ReceiptInput {
proof: {
canonical: string;
alg: "ed25519";
signature: string;
kid: string;
signer_id: string;
};
}
export type Receipt = CommandLayerReceipt<ReceiptInput>;

export function canonicalPayloadFromReceiptInput(receipt: ReceiptInput) {
return {
Expand All @@ -44,23 +41,13 @@ export function canonicalPayloadFromReceiptInput(receipt: ReceiptInput) {
export async function createReceipt(params: {
keyId: string;
privateKeyPem: string;
canonicalization: string;
canonicalization: CommandLayerCanonicalization;
input: ReceiptInput;
}): Promise<Receipt> {
const canonicalPayload = canonicalPayloadFromReceiptInput(params.input);
const canonical = canonicalize(canonicalPayload);

const privateKey = await importEd25519PrivateKeyFromPem(params.privateKeyPem);
const sig = await signEd25519Base64(privateKey, canonical);

return {
...params.input,
proof: {
canonical: params.canonicalization,
alg: "ed25519",
signature: sig,
kid: params.keyId,
signer_id: params.input.signer,
},
};
return signCommandLayerReceipt({
receipt: params.input,
privateKeyPem: params.privateKeyPem,
kid: params.keyId,
canonicalization: params.canonicalization,
} as SignCommandLayerReceiptParams<ReceiptInput>);
}
67 changes: 46 additions & 21 deletions src/schemas.trust-receipt-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@
"properties": {
"status": {
"type": "string",
"enum": ["ok", "error"]
"enum": [
"ok",
"error"
]
},
"duration_ms": {
"type": "integer",
Expand All @@ -81,32 +84,54 @@
"type": "object",
"additionalProperties": false,
"required": [
"canonical",
"alg",
"signature",
"kid",
"signer_id"
"canonicalization",
"hash",
"signature"
],
"properties": {
"canonical": {
"canonicalization": {
"const": "json.sorted_keys.v1"
},
"alg": {
"type": "string",
"enum": ["ed25519"]
"hash": {
"type": "object",
"additionalProperties": false,
"required": [
"alg",
"value"
],
"properties": {
"alg": {
"const": "SHA-256"
},
"value": {
"type": "string",
"minLength": 16,
"pattern": "^[A-Za-z0-9+/=]+$"
}
}
},
"signature": {
"type": "string",
"minLength": 16,
"pattern": "^[A-Za-z0-9+/=]+$"
},
"kid": {
"type": "string",
"minLength": 1
},
"signer_id": {
"type": "string",
"minLength": 1
"type": "object",
"additionalProperties": false,
"required": [
"alg",
"value",
"kid"
],
"properties": {
"alg": {
"const": "Ed25519"
},
"value": {
"type": "string",
"minLength": 16,
"pattern": "^[A-Za-z0-9+/=]+$"
},
"kid": {
"type": "string",
"minLength": 1
}
}
}
}
}
Expand Down
45 changes: 15 additions & 30 deletions test/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import { verifyCommandLayerReceipt } from "@commandlayer/runtime-core";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

import { CommandLayer } from "../src/index.js";
import { validateTrustReceipt } from "../src/index.js";
import { canonicalize } from "../src/canonicalize.js";
import { canonicalPayloadFromReceiptInput } from "../src/receipt.js";

function toPem(pkcs8: ArrayBuffer): string {
const b64 = Buffer.from(pkcs8).toString("base64");
Expand Down Expand Up @@ -40,10 +39,12 @@ test("wrapping an action creates a receipt with required fields", async () => {
assert.equal(result.receipt.version, "1.0.0");
assert.equal(result.receipt.family, "trust-verification");
assert.equal(result.receipt.verb, "verify");
assert.equal(result.receipt.proof.alg, "ed25519");
assert.ok(result.receipt.proof.signature.length > 0);
assert.equal(result.receipt.proof.kid, "vC4WbcNoq2znSCiQ");
assert.equal(result.receipt.proof.signer_id, "verifyagent.eth");
assert.equal(result.receipt.proof.canonicalization, "json.sorted_keys.v1");
assert.equal(result.receipt.proof.hash.alg, "SHA-256");
assert.equal(result.receipt.proof.signature.alg, "Ed25519");
assert.ok(result.receipt.proof.signature.value.length > 0);
assert.equal(result.receipt.proof.signature.kid, "vC4WbcNoq2znSCiQ");
assert.equal((result.receipt as Record<string, unknown>).signature_b64, undefined);
assert.ok(result.receipt.execution.started_at);
assert.ok(result.receipt.execution.completed_at);
});
Expand Down Expand Up @@ -77,7 +78,7 @@ test("wrap rejects an unrecognized verb before running the wrapped function", as
);
});

test("signature is verifiable over raw canonical payload bytes", async () => {
test("emitted receipt verifies with runtime-core and tampering is invalid", async () => {
const { pem, publicKey } = await generateKeyPair();

const cl = new CommandLayer({
Expand All @@ -91,28 +92,12 @@ test("signature is verifiable over raw canonical payload bytes", async () => {
run: async () => ({ y: 2 }),
});

const canonicalPayload = canonicalPayloadFromReceiptInput(receipt);
assert.equal("proof" in canonicalPayload, false, "proof must not be in canonical payload");
const verification = await verifyCommandLayerReceipt({ receipt });
assert.equal(verification.status, "VALID");

const canonical = canonicalize(canonicalPayload);
const sig = Buffer.from(receipt.proof.signature, "base64");

const ok = await webcrypto.subtle.verify(
"Ed25519",
publicKey,
sig,
new TextEncoder().encode(canonical),
);
assert.equal(ok, true, "signature must verify over raw canonical payload bytes");

const tamperedPayload = { ...canonicalPayload, output: { y: 99 } };
const tamperedOk = await webcrypto.subtle.verify(
"Ed25519",
publicKey,
sig,
new TextEncoder().encode(canonicalize(tamperedPayload)),
);
assert.equal(tamperedOk, false, "tampered payload must not verify");
const tampered = { ...receipt, output: { y: 99 } };
const tamperedVerification = await verifyCommandLayerReceipt({ receipt: tampered });
assert.equal(tamperedVerification.status, "INVALID");
});

test("wrap returns signed error receipt when wrapped agent throws", async () => {
Expand All @@ -131,8 +116,8 @@ test("wrap returns signed error receipt when wrapped agent throws", async () =>

assert.equal(result.receipt.execution.status, "error");
assert.match(result.receipt.execution.error ?? "", /simulated failure/);
assert.ok(result.receipt.proof.signature);
assert.equal(result.receipt.proof.alg, "ed25519");
assert.ok(result.receipt.proof.signature.value);
assert.equal(result.receipt.proof.signature.alg, "Ed25519");
});

test("error receipt is also schema-valid", async () => {
Expand Down
8 changes: 4 additions & 4 deletions test/trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ test("invalid receipt fails", async () => {
input: makeValidTrustRequest(),
});

const result = validateTrustReceipt({ ...receipt, proof: { ...receipt.proof, alg: "rsa" } });
const result = validateTrustReceipt({ ...receipt, proof: { ...receipt.proof, signature: { ...receipt.proof.signature, alg: "RSA" } } });
assert.equal(result.ok, false);
assert.ok(result.errors.length > 0);
});
Expand All @@ -91,7 +91,7 @@ test("assert variants throw", async () => {
});

assert.throws(
() => assertValidTrustReceipt({ ...receipt, proof: { ...receipt.proof, signature: "nope" } }),
() => assertValidTrustReceipt({ ...receipt, proof: { ...receipt.proof, signature: { alg: "Ed25519", value: "nope", kid: "kid-1" } } }),
/Invalid CLAS Trust Verification v1 receipt/,
);
});
Expand Down Expand Up @@ -120,7 +120,7 @@ test("missing receipt proof fields fails", async () => {

const invalid = {
...receipt,
proof: { canonical: receipt.proof.canonical },
proof: { canonicalization: receipt.proof.canonicalization },
};

const result = validateTrustReceipt(invalid);
Expand All @@ -138,7 +138,7 @@ test("invalid canonicalization value fails", async () => {

const result = validateTrustReceipt({
...receipt,
proof: { ...receipt.proof, canonical: "json.unsorted.v1" },
proof: { ...receipt.proof, canonicalization: "json.unsorted.v1" },
});

assert.equal(result.ok, false);
Expand Down
Loading