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
20 changes: 18 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { runVendorKit } from "./kit-runner";
import { runIssueKey } from "./issue-key";
import { formatArtifactScanReport, resolveAssetInventoryWaivers, scanPublishedArtifact } from "../artifact-scan";
import { runSafeReadCli } from "./read";
import { runVerifyWriteCli } from "./verify-write";

function collectJsonFiles(root: string): string[] {
const stat = statSync(root);
Expand Down Expand Up @@ -68,7 +69,7 @@ function preflightJsonUsageErrors(argv: string[]) {
return false;
}

if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read"].includes(command)) {
if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "artifact-scan", "secure-local-store", "read", "verify-write"].includes(command)) {
return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`);
}

Expand All @@ -82,7 +83,7 @@ function preflightJsonUsageErrors(argv: string[]) {
// command's own flags. Preflighting those against this program's option set
// would reject `contracts read -- todos list --limit 5` for an option that is
// not ours to validate. Commander handles it.
if (command === "read") {
if (command === "read" || command === "verify-write") {
return false;
}

Expand Down Expand Up @@ -509,6 +510,21 @@ export function createContractsProgram() {
process.exitCode = runSafeReadCli(command ?? [], options as never);
});

program
.command("verify-write")
.description(
"Cheaper than rendering a stored body: compare byte length and SHA-256; prevents appended capability content from reaching output"
)
.argument("<target>", "Exact object ID requested from the fetch command")
.argument("[command...]", "The fetch command to run after --; it must return one JSON object")
.requiredOption("--authored <file>", "File containing the exact payload the caller authored")
.option("--id-path <path>", "Dotted path to the fetched object's ID", "id")
.option("--content-path <path>", "Dotted path to the fetched stored content", "body")
.option("-j, --json", "Output metadata-only JSON")
.action((target: string, command: string[], options: Record<string, unknown>) => {
process.exitCode = runVerifyWriteCli(target, command ?? [], options as never);
});

program
.command("issue-key")
.description("Mint an API key (prefix hasna_<app>_): stores the hashed record and prints the secret ONCE")
Expand Down
112 changes: 112 additions & 0 deletions src/cli/verify-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { readFileSync } from "node:fs";
import type { CapturedRead } from "../safe-read";
import { runCaptured } from "../safe-read-exec";
import { verifyFetchedWrite, type VerifyWriteResult } from "../verify-write";

export interface VerifyWriteCliOptions {
authored: string;
idPath?: string;
contentPath?: string;
json?: boolean;
}

interface VerifyWriteCliIo {
log: (line: string) => void;
err: (line: string) => void;
}

const defaultIo: VerifyWriteCliIo = {
log: (line) => console.log(line),
err: (line) => console.error(line)
};

function refusal(code: string, message: string) {
return { ok: false as const, status: "refused" as const, code, message };
}

function writeResult(result: VerifyWriteResult | ReturnType<typeof refusal>, json: boolean, io: VerifyWriteCliIo): number {
if (json) {
io.log(JSON.stringify(result));
} else if (result.status === "match") {
io.log(`MATCH — ${result.message}`);
} else if (result.status === "refused") {
io.err(`REFUSED [${result.code}] — ${result.message}`);
} else if (result.status === "grew") {
io.err(`GREW BY ${result.deltaBytes} BYTES — ${result.message}`);
} else if (result.status === "shrunk") {
io.err(`SHRANK BY ${Math.abs(result.deltaBytes)} BYTES — ${result.message}`);
} else {
io.err(`MISMATCH — ${result.message}`);
}

if (result.status === "match") return 0;
if (result.status === "refused") return 2;
return 1;
}

export function runVerifyWriteCli(
targetId: string,
argv: string[],
options: VerifyWriteCliOptions,
io: VerifyWriteCliIo = defaultIo,
run: (argv: string[]) => CapturedRead = runCaptured
): number {
if (!targetId || !options.authored || argv.length === 0) {
writeResult(
refusal("usage", "target, --authored, and a fetch command after -- are required; stored body NOT rendered"),
Boolean(options.json),
io
);
return 3;
}

let authored: Buffer;
try {
authored = readFileSync(options.authored);
} catch {
return writeResult(
refusal("authored_read_failed", "authored payload could not be read; stored body NOT rendered"),
Boolean(options.json),
io
);
}

let captured: CapturedRead;
try {
captured = run(argv);
} catch {
return writeResult(
refusal("fetch_failed", "fetch command could not be executed; captured output NOT rendered"),
Boolean(options.json),
io
);
}

if (captured.code !== 0) {
return writeResult(
refusal("fetch_failed", "fetch command did not succeed; captured output NOT rendered"),
Boolean(options.json),
io
);
}

let fetched: unknown;
try {
fetched = JSON.parse(captured.stdout);
} catch {
return writeResult(
refusal("fetch_invalid_json", "fetch command did not return one JSON object; captured output NOT rendered"),
Boolean(options.json),
io
);
}

const result = verifyFetchedWrite({
targetId,
authored,
fetched,
idPath: options.idPath ?? "id",
contentPath: options.contentPath ?? "body"
});
return writeResult(result, Boolean(options.json), io);
}
154 changes: 154 additions & 0 deletions src/verify-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { createHash } from "node:crypto";

export type VerifyWriteStatus = "match" | "grew" | "shrunk" | "mismatch" | "refused";

export interface VerifyWriteMatch {
ok: true;
status: "match";
authoredBytes: number;
storedBytes: number;
deltaBytes: 0;
hashesEqual: true;
message: string;
}

export interface VerifyWriteDifference {
ok: false;
status: "grew" | "shrunk" | "mismatch";
authoredBytes: number;
storedBytes: number;
deltaBytes: number;
hashesEqual: false;
message: string;
}

export interface VerifyWriteRefusal {
ok: false;
status: "refused";
code:
| "object_id_missing"
| "object_id_invalid"
| "object_id_mismatch"
| "content_missing"
| "content_invalid";
message: string;
}

export type VerifyWriteResult = VerifyWriteMatch | VerifyWriteDifference | VerifyWriteRefusal;

export interface VerifyFetchedWriteRequest {
targetId: string;
authored: Uint8Array;
fetched: unknown;
idPath?: string;
contentPath?: string;
}

interface PathRead {
found: boolean;
value?: unknown;
}

function readPath(value: unknown, path: string): PathRead {
let current = value;
for (const segment of path.split(".")) {
if (!segment || current === null || typeof current !== "object") {
return { found: false };
}
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
return { found: false };
}
current = (current as Record<string, unknown>)[segment];
}
return { found: true, value: current };
}

function sha256(value: Uint8Array): string {
return createHash("sha256").update(value).digest("hex");
}

function refused(code: VerifyWriteRefusal["code"], message: string): VerifyWriteRefusal {
return { ok: false, status: "refused", code, message };
}

/**
* Compare one fetched object with the caller-authored bytes without returning
* either body or either digest. Object identity is checked before the stored
* content path is accessed.
*/
export function verifyFetchedWrite(request: VerifyFetchedWriteRequest): VerifyWriteResult {
const idRead = readPath(request.fetched, request.idPath ?? "id");
if (!idRead.found) {
return refused("object_id_missing", "fetched object ID was missing; stored body NOT rendered");
}
if (typeof idRead.value !== "string") {
return refused("object_id_invalid", "fetched object ID was not a string; stored body NOT rendered");
}
if (idRead.value !== request.targetId) {
return refused(
"object_id_mismatch",
"fetched object ID did not equal requested ID; stored body NOT rendered"
);
}

const contentRead = readPath(request.fetched, request.contentPath ?? "body");
if (!contentRead.found) {
return refused("content_missing", "stored content field was missing; stored body NOT rendered");
}
if (typeof contentRead.value !== "string") {
return refused("content_invalid", "stored content field was not a string; stored body NOT rendered");
}

const authored = Buffer.from(request.authored);
const stored = Buffer.from(contentRead.value, "utf8");
const authoredBytes = authored.byteLength;
const storedBytes = stored.byteLength;
const deltaBytes = storedBytes - authoredBytes;
const hashesEqual = sha256(authored) === sha256(stored);

if (hashesEqual) {
return {
ok: true,
status: "match",
authoredBytes,
storedBytes,
deltaBytes: 0,
hashesEqual: true,
message: `fetched object ID equals requested ID; ${authoredBytes} bytes; SHA-256 equal; stored body NOT rendered`
};
}

if (deltaBytes > 0) {
return {
ok: false,
status: "grew",
authoredBytes,
storedBytes,
deltaBytes,
hashesEqual: false,
message: `third-party content appended, ${deltaBytes} bytes, NOT rendered`
};
}

if (deltaBytes < 0) {
return {
ok: false,
status: "shrunk",
authoredBytes,
storedBytes,
deltaBytes,
hashesEqual: false,
message: "stored content is shorter, NOT rendered"
};
}

return {
ok: false,
status: "mismatch",
authoredBytes,
storedBytes,
deltaBytes: 0,
hashesEqual: false,
message: "byte length equal but SHA-256 differs; stored body NOT rendered"
};
}
Loading
Loading