imbas-verify 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/key.d.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The installation key.
3
+ *
4
+ * Every signed record Imbas produces is signed by one key pair that belongs to this
5
+ * installation and nowhere else. That is a deliberate limit, not a shortcoming:
6
+ * Sigstore's keyless flow publishes the signer's identity to a public transparency
7
+ * log, and an operator running Imbas on their own machine should not have to announce
8
+ * to the world that they approved a change. So the key is local, the public half is
9
+ * the only thing that ever leaves, and what a signature proves is bounded by that —
10
+ * see `proves` / `does_not_prove` in the predicate.
11
+ *
12
+ * The private key is written once, with the tightest permission the OS offers, and is
13
+ * never read into a response, a log line, a provenance record or an export. Nothing
14
+ * in this module hands it out: the loader in `signing.ts` returns a handle whose only
15
+ * outward key material is `publicPem()`. The private PEM lives in a closure, so a
16
+ * caller cannot find it by spreading the object or serialising it.
17
+ *
18
+ * This file is compiled into the published verifier, so it holds only what *checking* a
19
+ * signature needs. Key creation and signing live in `signing.ts`, which the verifier does
20
+ * not compile — the tool that checks the evidence must not be able to manufacture it.
21
+ */
22
+ import { type KeyObject } from "node:crypto";
23
+ /** Where the installation key lives inside a data dir. */
24
+ export declare function keyDir(dataDir: string): string;
25
+ export declare function privateKeyPath(dataDir: string): string;
26
+ export declare function publicKeyPath(dataDir: string): string;
27
+ /** sha256 of arbitrary bytes, hex. The DER export is bytes, so this is not `ids.sha256`. */
28
+ export declare function sha256Bytes(bytes: Buffer): string;
29
+ /**
30
+ * The keyid: sha256 of the DER SubjectPublicKeyInfo, hex.
31
+ *
32
+ * Over the DER rather than the PEM on purpose. PEM is base64 of the DER with line
33
+ * breaks and a header, so two encoders can produce different PEM text for the same
34
+ * key — and a keyid that changed with the formatting would be useless as an
35
+ * identifier of the key. The DER is the key; the PEM is a way of writing it down.
36
+ */
37
+ export declare function keyIdOf(publicKey: KeyObject): string;
38
+ /** Reads a keyid from a PEM public key without creating anything. */
39
+ export declare function keyIdOfPem(publicPem: string): string;
40
+ /**
41
+ * Reads a keyid, or null when the text is not a public key at all.
42
+ *
43
+ * Verifying is often the first thing anyone runs on a fresh machine, with a key file
44
+ * they were handed and did not inspect. A DER file, a private key, an HTML error page
45
+ * saved by mistake — all of these should produce "that is not a public key" and not a
46
+ * stack trace from node:crypto. The distinction matters: an unhandled exception tells
47
+ * the operator the tool is broken, when the truth is that their input is wrong.
48
+ */
49
+ export declare function tryKeyIdOfPem(text: string): string | null;
50
+ export declare class KeyError extends Error {
51
+ readonly code: string;
52
+ constructor(code: string, message: string);
53
+ }
54
+ /**
55
+ * The installation key, as the rest of the engine sees it.
56
+ *
57
+ * There is no `privatePem` field, and that is the point: the private key exists only
58
+ * inside the closure these methods share, so no caller can log it, serialise the
59
+ * handle and find it, or return it from an endpoint by spreading the object.
60
+ */
61
+ export interface InstallationKey {
62
+ /** sha256 of the DER SPKI, hex. The `keyid` in every envelope. */
63
+ readonly keyid: string;
64
+ /** The public key as PEM. The only key material that may leave the process. */
65
+ publicPem(): string;
66
+ /** ECDSA P-256 / SHA-256 over these bytes. DER-encoded, as `sign` returns. */
67
+ signBytes(bytes: Buffer): Buffer;
68
+ }
69
+ /**
70
+ * Verifies a signature against a PEM public key.
71
+ *
72
+ * `dsaEncoding: "der"` is the default and is stated because it is load-bearing:
73
+ * ECDSA signatures have two encodings, DER and IEEE-P1363 ("raw"), and a verifier that
74
+ * guesses wrong reports a valid signature as forged. DER is what `openssl dgst
75
+ * -verify` expects, which is what makes an independent check possible at all.
76
+ */
77
+ export declare function verifyBytes(publicPem: string, bytes: Buffer, signature: Buffer): boolean;
78
+ /** Builds a handle around a private key already in memory. */
79
+ /**
80
+ * Loads the installation key, generating it on first use.
81
+ *
82
+ * Creating it lazily rather than at install time keeps the promise simple: the key
83
+ * exists when it is first needed, in the data dir, with no separate setup step that
84
+ * can be skipped and leave signing broken later.
85
+ */
86
+ /**
87
+ * Loads or creates a key pair at explicit paths.
88
+ *
89
+ * Extracted so the policy key — which signs retirement lists and must be a *different*
90
+ * key from the one that signs records — shares one implementation with the installation
91
+ * key rather than a copy. A second copy of the permission dance is a second place for it to
92
+ * drift, and the drift would be invisible on Windows where the mode bits are advisory.
93
+ */
94
+ /**
95
+ * The policy key: what signs a retirement list, and nothing else.
96
+ *
97
+ * A second key for one reason, and it is the whole reason this exists. The attack key
98
+ * retirement defends against is a **stolen installation key**. If the retirement list were
99
+ * signed by that same key, the thief could hand out a confident "no keys were ever
100
+ * retired" and clear themselves — the list would prove nothing against exactly the threat
101
+ * it was built for. A policy key that can be kept somewhere else is what makes the list
102
+ * worth signing at all.
103
+ *
104
+ * It is created on first use like the installation key, so an operator who never retires
105
+ * anything never gets one. `docs/EVIDENCE.md` is explicit about the limit this does *not*
106
+ * remove: on a single machine both private keys sit in the same directory, and whoever
107
+ * holds the policy key can sign a false list.
108
+ */
109
+ export declare function policyKeyDir(dataDir: string): string;
110
+ export declare function policyPrivateKeyPath(dataDir: string): string;
111
+ export declare function policyPublicKeyPath(dataDir: string): string;
112
+ /** An in-memory key pair, for tests and the demo. Never written to disk. */
113
+ /**
114
+ * Builds a signing handle from a private key PEM already in memory.
115
+ *
116
+ * The counter-signing path needs this: a second party signs a record with a key that is
117
+ * not this installation's, and `loadOrCreateKey` would be wrong in two ways — it reads the
118
+ * data dir, and it *creates* a key when there is none. A counter-signer who silently
119
+ * generated a fresh key would sign with something nobody has the public half of, and the
120
+ * record would carry a signature no verifier could ever check.
121
+ *
122
+ * Refuses anything that is not a private key, by name, rather than throwing an OpenSSL
123
+ * error. The keyid is derived from the public half of the same key, so a handle can never
124
+ * disagree with itself about which key it is.
125
+ */
126
+ export type PrivateKeyRead = {
127
+ ok: true;
128
+ key: InstallationKey;
129
+ } | {
130
+ ok: false;
131
+ message: string;
132
+ };
package/key.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The installation key.
3
+ *
4
+ * Every signed record Imbas produces is signed by one key pair that belongs to this
5
+ * installation and nowhere else. That is a deliberate limit, not a shortcoming:
6
+ * Sigstore's keyless flow publishes the signer's identity to a public transparency
7
+ * log, and an operator running Imbas on their own machine should not have to announce
8
+ * to the world that they approved a change. So the key is local, the public half is
9
+ * the only thing that ever leaves, and what a signature proves is bounded by that —
10
+ * see `proves` / `does_not_prove` in the predicate.
11
+ *
12
+ * The private key is written once, with the tightest permission the OS offers, and is
13
+ * never read into a response, a log line, a provenance record or an export. Nothing
14
+ * in this module hands it out: the loader in `signing.ts` returns a handle whose only
15
+ * outward key material is `publicPem()`. The private PEM lives in a closure, so a
16
+ * caller cannot find it by spreading the object or serialising it.
17
+ *
18
+ * This file is compiled into the published verifier, so it holds only what *checking* a
19
+ * signature needs. Key creation and signing live in `signing.ts`, which the verifier does
20
+ * not compile — the tool that checks the evidence must not be able to manufacture it.
21
+ */
22
+ import { createHash, createPublicKey, verify } from "node:crypto";
23
+ import { join, resolve } from "node:path";
24
+ /** Where the installation key lives inside a data dir. */
25
+ export function keyDir(dataDir) {
26
+ return join(resolve(dataDir), "keys");
27
+ }
28
+ export function privateKeyPath(dataDir) {
29
+ return join(keyDir(dataDir), "installation-key.pem");
30
+ }
31
+ export function publicKeyPath(dataDir) {
32
+ return join(keyDir(dataDir), "installation-key.pub.pem");
33
+ }
34
+ /** sha256 of arbitrary bytes, hex. The DER export is bytes, so this is not `ids.sha256`. */
35
+ export function sha256Bytes(bytes) {
36
+ return createHash("sha256").update(bytes).digest("hex");
37
+ }
38
+ /**
39
+ * The keyid: sha256 of the DER SubjectPublicKeyInfo, hex.
40
+ *
41
+ * Over the DER rather than the PEM on purpose. PEM is base64 of the DER with line
42
+ * breaks and a header, so two encoders can produce different PEM text for the same
43
+ * key — and a keyid that changed with the formatting would be useless as an
44
+ * identifier of the key. The DER is the key; the PEM is a way of writing it down.
45
+ */
46
+ export function keyIdOf(publicKey) {
47
+ return sha256Bytes(publicKey.export({ type: "spki", format: "der" }));
48
+ }
49
+ /** Reads a keyid from a PEM public key without creating anything. */
50
+ export function keyIdOfPem(publicPem) {
51
+ return keyIdOf(createPublicKey(publicPem));
52
+ }
53
+ /**
54
+ * Reads a keyid, or null when the text is not a public key at all.
55
+ *
56
+ * Verifying is often the first thing anyone runs on a fresh machine, with a key file
57
+ * they were handed and did not inspect. A DER file, a private key, an HTML error page
58
+ * saved by mistake — all of these should produce "that is not a public key" and not a
59
+ * stack trace from node:crypto. The distinction matters: an unhandled exception tells
60
+ * the operator the tool is broken, when the truth is that their input is wrong.
61
+ */
62
+ export function tryKeyIdOfPem(text) {
63
+ try {
64
+ return keyIdOfPem(text);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export class KeyError extends Error {
71
+ code;
72
+ constructor(code, message) {
73
+ super(message);
74
+ this.code = code;
75
+ this.name = "KeyError";
76
+ }
77
+ }
78
+ /**
79
+ * Verifies a signature against a PEM public key.
80
+ *
81
+ * `dsaEncoding: "der"` is the default and is stated because it is load-bearing:
82
+ * ECDSA signatures have two encodings, DER and IEEE-P1363 ("raw"), and a verifier that
83
+ * guesses wrong reports a valid signature as forged. DER is what `openssl dgst
84
+ * -verify` expects, which is what makes an independent check possible at all.
85
+ */
86
+ export function verifyBytes(publicPem, bytes, signature) {
87
+ let key;
88
+ try {
89
+ key = createPublicKey(publicPem);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ try {
95
+ return verify("sha256", bytes, { key, dsaEncoding: "der" }, signature);
96
+ }
97
+ catch {
98
+ // A malformed signature (wrong length, garbage) makes `verify` throw rather than
99
+ // return false. Both mean "not verified", and only one of them is an exception.
100
+ return false;
101
+ }
102
+ }
103
+ /** Builds a handle around a private key already in memory. */
104
+ /**
105
+ * Loads the installation key, generating it on first use.
106
+ *
107
+ * Creating it lazily rather than at install time keeps the promise simple: the key
108
+ * exists when it is first needed, in the data dir, with no separate setup step that
109
+ * can be skipped and leave signing broken later.
110
+ */
111
+ /**
112
+ * Loads or creates a key pair at explicit paths.
113
+ *
114
+ * Extracted so the policy key — which signs retirement lists and must be a *different*
115
+ * key from the one that signs records — shares one implementation with the installation
116
+ * key rather than a copy. A second copy of the permission dance is a second place for it to
117
+ * drift, and the drift would be invisible on Windows where the mode bits are advisory.
118
+ */
119
+ /**
120
+ * The policy key: what signs a retirement list, and nothing else.
121
+ *
122
+ * A second key for one reason, and it is the whole reason this exists. The attack key
123
+ * retirement defends against is a **stolen installation key**. If the retirement list were
124
+ * signed by that same key, the thief could hand out a confident "no keys were ever
125
+ * retired" and clear themselves — the list would prove nothing against exactly the threat
126
+ * it was built for. A policy key that can be kept somewhere else is what makes the list
127
+ * worth signing at all.
128
+ *
129
+ * It is created on first use like the installation key, so an operator who never retires
130
+ * anything never gets one. `docs/EVIDENCE.md` is explicit about the limit this does *not*
131
+ * remove: on a single machine both private keys sit in the same directory, and whoever
132
+ * holds the policy key can sign a false list.
133
+ */
134
+ export function policyKeyDir(dataDir) {
135
+ return join(resolve(dataDir), "keys");
136
+ }
137
+ export function policyPrivateKeyPath(dataDir) {
138
+ return join(policyKeyDir(dataDir), "policy-key.pem");
139
+ }
140
+ export function policyPublicKeyPath(dataDir) {
141
+ return join(policyKeyDir(dataDir), "policy-key.pub.pem");
142
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "imbas-verify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Check a signed Imbas approval record without the engine. Verifies signatures, the patch digest and the provenance chain head; reads only the files you name.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "imbas-verify": "verify-cli.js"
9
+ },
10
+ "main": "./verify.js",
11
+ "types": "./verify.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./verify.d.ts",
15
+ "default": "./verify.js"
16
+ },
17
+ "./args": {
18
+ "types": "./args.d.ts",
19
+ "default": "./args.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "*.js",
24
+ "*.d.ts",
25
+ "README.md",
26
+ "FORMAT.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE",
29
+ "NOTICE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "keywords": [
35
+ "attestation",
36
+ "provenance",
37
+ "supply-chain",
38
+ "dsse",
39
+ "in-toto",
40
+ "verification"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/read-file.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reads a text file, returning a named failure instead of throwing.
3
+ *
4
+ * The same reasoning as `readAttestationFile`: a path the operator typed wrong must
5
+ * produce "cannot read that file", not a stack trace from `node:fs`. Verify is usually
6
+ * the first thing anyone runs on a fresh machine, with paths they were given.
7
+ */
8
+ export type TextRead = {
9
+ ok: true;
10
+ text: string;
11
+ } | {
12
+ ok: false;
13
+ check: "key_unreadable";
14
+ message: string;
15
+ };
16
+ export declare function readTextFile(path: string): TextRead;
17
+ /**
18
+ * Reads a file as raw bytes, for the patch.
19
+ *
20
+ * Separate from `readTextFile` because the patch digest is over bytes. Decoding to a
21
+ * string and re-encoding would go through UTF-8 a second time, and a patch is not
22
+ * guaranteed to be valid UTF-8 - the digest would then be of different bytes than the
23
+ * ones that were approved, and an honest file would report a mismatch.
24
+ */
25
+ export type BinaryRead = {
26
+ ok: true;
27
+ bytes: Buffer;
28
+ } | {
29
+ ok: false;
30
+ check: "patch_unreadable";
31
+ message: string;
32
+ };
33
+ export declare function readBinaryFile(path: string): BinaryRead;
package/read-file.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * File reads that return a named failure instead of throwing.
3
+ *
4
+ * Split from `attest.ts` for the same reason as `verify.ts`: the verifier needs these and
5
+ * nothing else in that module needs the engine.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ export function readTextFile(path) {
9
+ try {
10
+ return { ok: true, text: readFileSync(path, "utf8") };
11
+ }
12
+ catch (cause) {
13
+ const code = cause.code ?? "unknown";
14
+ return { ok: false, check: "key_unreadable", message: `Cannot read ${path} (${code}).` };
15
+ }
16
+ }
17
+ export function readBinaryFile(path) {
18
+ try {
19
+ return { ok: true, bytes: readFileSync(path) };
20
+ }
21
+ catch (cause) {
22
+ const code = cause.code ?? "unknown";
23
+ return { ok: false, check: "patch_unreadable", message: `Cannot read ${path} (${code}).` };
24
+ }
25
+ }
package/record.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The shape of the signed approval predicate, declared once.
3
+ *
4
+ * These interfaces lived in `predicate.ts`, beside the code that *builds* them. That was
5
+ * wrong for one reason: `attestation.ts` (the verifier) needs to name the type, and a type
6
+ * import still makes the compiler load the module it names. `predicate.ts` imports the
7
+ * gates, the pipeline state and the provenance store, so verifying a record dragged the
8
+ * whole engine into the program — and, in the published verifier, into the closure.
9
+ *
10
+ * Declaring them here means the verifier names nothing outside the package at all — not
11
+ * even in a type import. That matters twice over: a type import still makes the compiler
12
+ * load the named module while building, and it writes the name into the emitted `.d.ts`,
13
+ * where a consumer who installed only this package would see `any` or a typecheck error.
14
+ *
15
+ * `StageId` and `GateKind` therefore carry local declarations rather than imports, and
16
+ * `engine/test/verify-standalone.test.ts` asserts they still agree with the contract.
17
+ *
18
+ * `predicate.ts` re-exports everything in this file, so existing imports are unchanged.
19
+ * There is still exactly one declaration of the predicate: this one.
20
+ */
21
+ export type StageId = "intake" | "research" | "plan" | "build" | "review" | "demo" | "notify" | "handoff";
22
+ export type GateKind = "operator" | "engineer";
23
+ /** What the record says about one gate. */
24
+ export interface PredicateGate {
25
+ stage: StageId;
26
+ kind: GateKind;
27
+ /** The decision, or null if the gate was never decided. */
28
+ decision: "approve" | "reject" | null;
29
+ gate_id: string;
30
+ /**
31
+ * The artifact this gate decided, or null at a decision-only stage.
32
+ *
33
+ * HANDOFF produces nothing, so there is nothing to point at. Null says that
34
+ * plainly; a placeholder id would claim an artifact that never existed.
35
+ */
36
+ artifact_id: string | null;
37
+ requested_at: string;
38
+ decided_at: string | null;
39
+ principal_id: string | null;
40
+ principal_role: string | null;
41
+ authenticated_via: string | null;
42
+ /** Which explainer the approver acknowledged. Invariant 5, recorded. */
43
+ acknowledged_explainer_id: string | null;
44
+ explainer_view: string | null;
45
+ }
46
+ /** One stage in the pipeline that did not run. */
47
+ export interface PredicateNotRun {
48
+ stage: StageId;
49
+ /**
50
+ * Why, in the words that were shown when the stage was stepped over. Copied from
51
+ * the `stage_unavailable` record's explainer, never rewritten for the signature: a
52
+ * reason that reads differently in the record than in the log is a reason a reader
53
+ * cannot check.
54
+ */
55
+ reason: string;
56
+ }
57
+ /** One override, with the per-finding reasons exactly as they were recorded. */
58
+ export interface PredicateOverride {
59
+ override_id: string;
60
+ stage: StageId;
61
+ overridden_verdict: "changes_requested";
62
+ reviewer_artifact_id: string;
63
+ reviewer_principal_id: string;
64
+ reason: string;
65
+ finding_reasons: Array<{
66
+ finding_id: string;
67
+ reason: string;
68
+ }>;
69
+ /** Who set the verdict aside, and when. */
70
+ principal_id: string | null;
71
+ decided_at: string | null;
72
+ /** The provenance record carrying it, so a reader can find it in the chain. */
73
+ record_id: string;
74
+ }
75
+ /** A model that took part, and what it was, exactly as provenance recorded it. */
76
+ export interface PredicateModel {
77
+ stage: StageId;
78
+ adapter: string;
79
+ provider: string;
80
+ model: string;
81
+ mocked: boolean;
82
+ prompt_version: string | null;
83
+ }
84
+ export interface ApprovalPredicate {
85
+ /** The name and version of this predicate, so a reader can find the definition. */
86
+ predicate_name: "imbas.approval";
87
+ predicate_version: "1";
88
+ schema_version: string;
89
+ job_id: string;
90
+ job_title: string;
91
+ sample: boolean;
92
+ created_at: string;
93
+ completed_at: string;
94
+ /** Every gate in the job, in pipeline order. */
95
+ gates: PredicateGate[];
96
+ /** Every override, in the order they were recorded. */
97
+ overrides: PredicateOverride[];
98
+ /**
99
+ * Every stage in the pipeline that did not run, and why, copied from its
100
+ * `stage_unavailable` record.
101
+ *
102
+ * Signed rather than only logged, because the claim this record makes is "production
103
+ * was authorised for what was actually checked". A job that reached the engineer gate
104
+ * with DEMO and NOTIFY unexecuted is still a legitimate job — but a reader of the
105
+ * record must be able to see the gap without also being trusted to have read the
106
+ * chain. Empty means every stage ran; it is never derived by subtracting from the
107
+ * list of stages that happened to produce an artifact.
108
+ */
109
+ stages_not_run: PredicateNotRun[];
110
+ /** Every adapter that produced an artifact, in the order they ran. */
111
+ models: PredicateModel[];
112
+ /** The head of the provenance chain. Verifying the chain verifies this many records. */
113
+ provenance: {
114
+ head_hash: string;
115
+ record_count: number;
116
+ chain_valid: boolean;
117
+ };
118
+ /** The versions the record was produced under, so a reader can place it in time. */
119
+ versions: {
120
+ contracts: string;
121
+ glossary: string;
122
+ };
123
+ /** The installation whose key signed this. */
124
+ installation_keyid: string;
125
+ proves: string[];
126
+ does_not_prove: string[];
127
+ }
package/record.js ADDED
@@ -0,0 +1 @@
1
+ export {};