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
12 changes: 12 additions & 0 deletions src/core/canonical/hash.ts
Original file line number Diff line number Diff line change
@@ -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");
}
10 changes: 4 additions & 6 deletions src/core/canonical/jcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
16 changes: 9 additions & 7 deletions src/core/policy/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
Expand Down Expand Up @@ -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 },
},
Expand Down
31 changes: 31 additions & 0 deletions src/core/policy/vocabulary.ts
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion src/onboarding/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion test/jcs.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
45 changes: 45 additions & 0 deletions test/vocabulary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 7 additions & 14 deletions web/src/editor/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown>;
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<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
Expand Down
3 changes: 2 additions & 1 deletion web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
9 changes: 7 additions & 2 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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 } },
});
Loading