diff --git a/src/core/canonical/hash.ts b/src/core/canonical/hash.ts new file mode 100644 index 0000000..3ec0c67 --- /dev/null +++ b/src/core/canonical/hash.ts @@ -0,0 +1,12 @@ +/** + * Canonical hashing (spec §8.6) — Node side of ./jcs.ts. Split out so the + * pure canonicalizer stays importable by the web editor (no node:crypto). + */ + +import { createHash } from "node:crypto"; +import { canonicalize } from "./jcs.js"; + +/** SHA-256 of the canonical UTF-8 bytes, lowercase hex (used for policyHash). */ +export function canonicalSha256Hex(value: unknown): string { + return createHash("sha256").update(Buffer.from(canonicalize(value), "utf8")).digest("hex"); +} diff --git a/src/core/canonical/jcs.ts b/src/core/canonical/jcs.ts index 04f2f4c..500e4b9 100644 --- a/src/core/canonical/jcs.ts +++ b/src/core/canonical/jcs.ts @@ -11,9 +11,12 @@ * * Any canonicalization divergence produces a hash mismatch, hence a refusal * (fail closed) — never a tolerance. + * + * PURE module (no Node APIs): it is imported by the web editor too, so the + * daemon and the companion canonicalize with the SAME function — parity by + * construction, not by hand-kept copies (#45). Hashing lives in ./hash.ts. */ -import { createHash } from "node:crypto"; import { CanonicalizationError } from "../errors.js"; export function canonicalize(value: unknown): string { @@ -48,8 +51,3 @@ export function canonicalize(value: unknown): string { throw new CanonicalizationError(`unsupported type: ${typeof value}`); } } - -/** SHA-256 of the canonical UTF-8 bytes, lowercase hex (used for policyHash). */ -export function canonicalSha256Hex(value: unknown): string { - return createHash("sha256").update(Buffer.from(canonicalize(value), "utf8")).digest("hex"); -} diff --git a/src/core/policy/schema.ts b/src/core/policy/schema.ts index d9caaf5..4089b2f 100644 --- a/src/core/policy/schema.ts +++ b/src/core/policy/schema.ts @@ -9,6 +9,12 @@ import { Ajv, type ValidateFunction } from "ajv"; import { ValidationError } from "../errors.js"; import { parseAsset } from "../asset.js"; +import { + CHAIN_ID_PATTERN, + MATCH_PATH_PATTERN, + RULE_ID_PATTERN, + SELECT_FIELD_PATTERN, +} from "./vocabulary.js"; export type MatchOperator = | { lte: string } @@ -78,10 +84,6 @@ export interface Policy { maxActionsPerTransaction?: number; } -/** Match paths are a closed vocabulary — unknown paths are schema errors. */ -const MATCH_PATH_PATTERN = - "^(contract|action|authorization\\.(actor|permission)|data\\.[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64}){0,4})$"; - const matchValueSchema = { oneOf: [ { type: "string", minLength: 1, maxLength: 256 }, @@ -146,7 +148,7 @@ const policyJsonSchema = { required: ["name", "chainId"], properties: { name: { type: "string", minLength: 1, maxLength: 32 }, - chainId: { type: "string", pattern: "^[0-9a-f]{64}$" }, + chainId: { type: "string", pattern: CHAIN_ID_PATTERN }, }, }, rules: { @@ -157,7 +159,7 @@ const policyJsonSchema = { additionalProperties: false, required: ["id", "effect", "match"], properties: { - id: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,63}$" }, + id: { type: "string", pattern: RULE_ID_PATTERN }, effect: { enum: ["allow", "deny"] }, match: { type: "object", @@ -201,7 +203,7 @@ const policyJsonSchema = { key: { type: "string", minLength: 1, maxLength: 64 }, }, }, - select: { type: "string", pattern: "^[a-zA-Z0-9_]{1,64}$" }, + select: { type: "string", pattern: SELECT_FIELD_PATTERN }, op: { enum: ["contains", "eq"] }, value: { type: "string", minLength: 1, maxLength: 256 }, }, diff --git a/src/core/policy/vocabulary.ts b/src/core/policy/vocabulary.ts new file mode 100644 index 0000000..195bace --- /dev/null +++ b/src/core/policy/vocabulary.ts @@ -0,0 +1,31 @@ +/** + * The policy schema's closed vocabularies (spec §8) — string patterns shared + * VERBATIM between the daemon's validator (schema.ts) and the web editor's + * compiler, so the editor can never produce a path the daemon rejects as + * schema_invalid, and neither side keeps a hand-copied regex (#45). + * + * PURE module: no imports, no Node APIs — safe for the browser bundle. + * + * Note: `authorization.actor|permission` and the 64-hex chain id are the XPR + * dialect's vocabulary; the PolicyDialect extraction (#45 phase A) moves them + * behind the chain module. Keeping them here keeps daemon/web in lockstep in + * the meantime. + */ + +/** Match paths are a closed vocabulary — unknown paths are schema errors. */ +export const MATCH_PATH_PATTERN = + "^(contract|action|authorization\\.(actor|permission)|data\\.[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64}){0,4})$"; + +/** Provider `select` field names (spec §8.4). */ +export const SELECT_FIELD_PATTERN = "^[a-zA-Z0-9_]{1,64}$"; + +/** Rule ids. */ +export const RULE_ID_PATTERN = "^[a-z0-9][a-z0-9-]{0,63}$"; + +/** Chain ids (Antelope 64-hex today — see the dialect note above). */ +export const CHAIN_ID_PATTERN = "^[0-9a-f]{64}$"; + +export const MATCH_PATH_RE = new RegExp(MATCH_PATH_PATTERN); +export const SELECT_FIELD_RE = new RegExp(SELECT_FIELD_PATTERN); +export const RULE_ID_RE = new RegExp(RULE_ID_PATTERN); +export const CHAIN_ID_RE = new RegExp(CHAIN_ID_PATTERN); diff --git a/src/onboarding/flow.ts b/src/onboarding/flow.ts index 19ee61f..af3a373 100644 --- a/src/onboarding/flow.ts +++ b/src/onboarding/flow.ts @@ -19,7 +19,8 @@ * testable without a live chain. */ -import { canonicalize, canonicalSha256Hex } from "../core/canonical/jcs.js"; +import { canonicalize } from "../core/canonical/jcs.js"; +import { canonicalSha256Hex } from "../core/canonical/hash.js"; import { emptyPolicy } from "../core/policy/schema.js"; import { SignBoxError } from "../core/errors.js"; import type { ChainContext, ExportPolicy } from "../core/types.js"; diff --git a/test/jcs.test.ts b/test/jcs.test.ts index 97a3dbd..2fd22dc 100644 --- a/test/jcs.test.ts +++ b/test/jcs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { canonicalize, canonicalSha256Hex } from "../src/core/canonical/jcs.js"; +import { canonicalize } from "../src/core/canonical/jcs.js"; +import { canonicalSha256Hex } from "../src/core/canonical/hash.js"; import { CanonicalizationError } from "../src/core/errors.js"; describe("RFC 8785 canonicalization", () => { diff --git a/test/vocabulary.test.ts b/test/vocabulary.test.ts new file mode 100644 index 0000000..fe5f8f7 --- /dev/null +++ b/test/vocabulary.test.ts @@ -0,0 +1,45 @@ +/** + * Shared policy vocabulary (#45 C.1) — the SAME patterns drive the daemon's + * validator and the web editor's compiler. These tests pin that the exported + * regexes agree with validatePolicy's actual accept/reject behavior, so a + * drift between the two can't reappear silently. + */ + +import { describe, expect, it } from "vitest"; +import { MATCH_PATH_RE, RULE_ID_RE, SELECT_FIELD_RE } from "../src/core/policy/vocabulary.js"; +import { validatePolicy } from "../src/core/policy/schema.js"; + +function policyWithMatchKey(key: string) { + return { + schemaVersion: 1, + default: "deny", + chain: { name: "XPR", chainId: "a".repeat(64) }, + rules: [{ id: "r1", effect: "allow", match: { [key]: "x" } }], + }; +} + +const ACCEPTED = ["contract", "action", "authorization.actor", "authorization.permission", "data.to", "data.quantity.amount"]; +const REJECTED = ["authorization.foo", "data", "memo", "data..to", "data.to!", "authorization"]; + +describe("policy vocabulary — regex ↔ validator parity", () => { + it("accepts exactly what the validator accepts (match paths)", () => { + for (const key of ACCEPTED) { + expect(MATCH_PATH_RE.test(key), key).toBe(true); + expect(() => validatePolicy(policyWithMatchKey(key)), key).not.toThrow(); + } + }); + + it("rejects exactly what the validator rejects (match paths)", () => { + for (const key of REJECTED) { + expect(MATCH_PATH_RE.test(key), key).toBe(false); + expect(() => validatePolicy(policyWithMatchKey(key)), key).toThrow(); + } + }); + + it("rule ids and select fields follow the shared patterns", () => { + expect(RULE_ID_RE.test("allow-small-tips")).toBe(true); + expect(RULE_ID_RE.test("Bad_Id")).toBe(false); + expect(SELECT_FIELD_RE.test("producers")).toBe(true); + expect(SELECT_FIELD_RE.test("nested.field")).toBe(false); + }); +}); diff --git a/web/src/editor/compile.ts b/web/src/editor/compile.ts index c70c206..b015259 100644 --- a/web/src/editor/compile.ts +++ b/web/src/editor/compile.ts @@ -31,11 +31,10 @@ export interface CompileResult { warnings: string[]; } -// The daemon's closed vocabularies (schema.ts) — validate custom paths here so -// the editor never pushes a policy the daemon would reject as schema_invalid. -const MATCH_PATH_RE = - /^(contract|action|authorization\.(actor|permission)|data\.[a-zA-Z0-9_]{1,64}(\.[a-zA-Z0-9_]{1,64}){0,4})$/; -const SELECT_FIELD_RE = /^[a-zA-Z0-9_]{1,64}$/; +// The daemon's closed vocabularies — imported from the SAME module the +// daemon's validator uses (src/core/policy/vocabulary.ts), so the editor can +// never produce a path the daemon rejects as schema_invalid (#45). +import { MATCH_PATH_RE, SELECT_FIELD_RE } from "@sbx-core/policy/vocabulary"; function listOf(s: string): string[] { return s.split(",").map((x) => x.trim()).filter(Boolean); @@ -188,15 +187,9 @@ export function compilePolicy(nodes: GraphNode[], wires: Wire[], chainId: string }; } -/** Canonical-ish stringify (sorted keys) for the prototype policyhash. */ -export function canonicalize(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; - if (value !== null && typeof value === "object") { - const obj = value as Record; - return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(",")}}`; - } - return JSON.stringify(value); -} +// THE daemon's canonicalizer (RFC 8785 JCS), same source file — the pushed +// policyjson is byte-identical to what verifyStoredPolicy re-canonicalizes. +export { canonicalize } from "@sbx-core/canonical/jcs"; export async function sha256Hex(text: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); diff --git a/web/tsconfig.json b/web/tsconfig.json index a5ff4e6..8db2a58 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -12,8 +12,9 @@ "noEmit": true, "jsx": "react-jsx", "strict": true, + "paths": { "@sbx-core/*": ["../src/core/*"] }, "noUnusedLocals": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "../src/core/canonical/jcs.ts", "../src/core/policy/vocabulary.ts", "../src/core/errors.ts"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 599a87b..404f091 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,15 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; + +// The daemon's PURE core modules (canonicalize, policy vocabulary) are +// imported directly by the editor — same source, parity by construction (#45). +const coreDir = fileURLToPath(new URL("../src/core", import.meta.url)); // @proton/link expects a few Node globals in the browser; provide them. export default defineConfig({ plugins: [react()], define: { global: "globalThis" }, - server: { port: 5173 }, - resolve: { alias: { buffer: "buffer" } }, + server: { port: 5173, fs: { allow: [".", "../src/core"] } }, + resolve: { alias: { buffer: "buffer", "@sbx-core": coreDir } }, });