witnora 0.9.3 → 0.10.2
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/README.md +76 -9
- package/dist/assurance-contract.js +312 -0
- package/dist/assurance-loop-demo.js +245 -0
- package/dist/authorization-state-machine.js +35 -0
- package/dist/authorization-v02.js +60 -0
- package/dist/bundle.js +22 -0
- package/dist/canonical-v02.js +197 -0
- package/dist/canonical.js +31 -0
- package/dist/cli.js +132 -0
- package/dist/command-help.js +81 -0
- package/dist/control-plane.js +12 -3
- package/dist/credentials.js +1 -1
- package/dist/design-partner-pilot.js +402 -0
- package/dist/design-partner-v02.js +449 -0
- package/dist/evidence-v02.js +118 -0
- package/dist/generic-eval.js +160 -0
- package/dist/github-design-partner-v02.js +399 -0
- package/dist/github-live-v02.js +45 -0
- package/dist/guided-setup.js +1 -1
- package/dist/index.js +1 -0
- package/dist/offline-verifier.js +375 -0
- package/dist/onboard.js +132 -0
- package/dist/onboarding-templates.js +49 -6
- package/dist/outcome-evaluator.js +51 -0
- package/dist/privacy-manifest.js +273 -0
- package/dist/probe-protocol-v02.js +101 -0
- package/dist/runtime-context.js +75 -0
- package/dist/sandbox.js +1 -1
- package/dist/schema-validator.js +141 -3
- package/dist/trust-v02.js +111 -0
- package/dist/try.js +1 -1
- package/dist/vendor/design-partner/failure-exercises.mjs +353 -0
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts +8 -0
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts.map +1 -1
- package/dist/vendor/onegent-runtime/browser-enforcement-runtime.js +43 -1
- package/dist/vendor/witnora-probe/canonical.d.ts +3 -0
- package/dist/vendor/witnora-probe/canonical.js +39 -0
- package/dist/vendor/witnora-probe/cli.d.ts +2 -0
- package/dist/vendor/witnora-probe/cli.js +36 -0
- package/dist/vendor/witnora-probe/config.d.ts +2 -0
- package/dist/vendor/witnora-probe/config.js +63 -0
- package/dist/vendor/witnora-probe/credential-resolver.d.ts +6 -0
- package/dist/vendor/witnora-probe/credential-resolver.js +17 -0
- package/dist/vendor/witnora-probe/crypto.d.ts +2 -0
- package/dist/vendor/witnora-probe/crypto.js +14 -0
- package/dist/vendor/witnora-probe/emulator.d.ts +51 -0
- package/dist/vendor/witnora-probe/emulator.js +164 -0
- package/dist/vendor/witnora-probe/index.d.ts +11 -0
- package/dist/vendor/witnora-probe/index.js +11 -0
- package/dist/vendor/witnora-probe/logger.d.ts +3 -0
- package/dist/vendor/witnora-probe/logger.js +26 -0
- package/dist/vendor/witnora-probe/metrics.d.ts +12 -0
- package/dist/vendor/witnora-probe/metrics.js +31 -0
- package/dist/vendor/witnora-probe/probe.d.ts +22 -0
- package/dist/vendor/witnora-probe/probe.js +208 -0
- package/dist/vendor/witnora-probe/request-verifier.d.ts +9 -0
- package/dist/vendor/witnora-probe/request-verifier.js +84 -0
- package/dist/vendor/witnora-probe/server.d.ts +9 -0
- package/dist/vendor/witnora-probe/server.js +85 -0
- package/dist/vendor/witnora-probe/storage.d.ts +16 -0
- package/dist/vendor/witnora-probe/storage.js +114 -0
- package/dist/vendor/witnora-probe/types.d.ts +126 -0
- package/dist/vendor/witnora-probe/types.js +2 -0
- package/dist/vendor/witnora-verifier-python/README.md +43 -0
- package/dist/vendor/witnora-verifier-python/pyproject.toml +23 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/__init__.py +33 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/canonical.py +218 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/cli.py +75 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/conformance.py +124 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/crypto.py +139 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/errors.py +15 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/event_chain.py +80 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/evidence.py +336 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/probe.py +188 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/producer.py +127 -0
- package/dist/vendor/witnora-verifier-python/src/witnora_verifier/trust.py +337 -0
- package/package.json +5 -5
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { compileAssuranceContract, diffAssuranceContracts, exampleAssuranceContract, resourceMatchesScope } from "./assurance-contract.js";
|
|
5
|
+
import { canonicalJson, sha256Canonical } from "./canonical.js";
|
|
6
|
+
import { serverAttestedValue, sourceSignedValue, outcomeProbeSignedValue, preconditionProbeSignedValue, resourceScopeProofSignedValue, verifyEvidencePacket, } from "./offline-verifier.js";
|
|
7
|
+
import { examplePrivacyManifest, transformForEgress } from "./privacy-manifest.js";
|
|
8
|
+
import { evaluateOutcome } from "./outcome-evaluator.js";
|
|
9
|
+
export async function runAssuranceLoopDemo(options) {
|
|
10
|
+
const now = options.now ?? new Date();
|
|
11
|
+
const attack = options.attack ?? "none";
|
|
12
|
+
const contract = exampleAssuranceContract();
|
|
13
|
+
const compiledContract = compileAssuranceContract(contract);
|
|
14
|
+
const privacyManifest = examplePrivacyManifest();
|
|
15
|
+
const privacyInput = {
|
|
16
|
+
actionType: "SUBMIT",
|
|
17
|
+
outcomeStatus: "SATISFIED",
|
|
18
|
+
policyDecision: "REQUIRE_APPROVAL",
|
|
19
|
+
resourceId: "sandbox/record-001",
|
|
20
|
+
actionParameters: { expectedRevision: 1, status: "SUBMITTED" },
|
|
21
|
+
prompt: "Confidential local task context",
|
|
22
|
+
credentials: "local-demo-write-credential",
|
|
23
|
+
rawResult: { id: "record-001", status: "SUBMITTED", revision: 2 },
|
|
24
|
+
};
|
|
25
|
+
const privacy = transformForEgress(privacyInput, privacyManifest, randomBytes(32));
|
|
26
|
+
const resourceScopeAllowed = resourceMatchesScope(contract.authority.resourceScope, privacyInput.resourceId);
|
|
27
|
+
const canonicalAction = {
|
|
28
|
+
tenantRef: "tenant:local-demo",
|
|
29
|
+
projectRef: "project:assurance-loop",
|
|
30
|
+
agentRef: "agent:example-workflow-agent",
|
|
31
|
+
agentVersion: "1.0.0",
|
|
32
|
+
environment: "sandbox",
|
|
33
|
+
capability: contract.capability.id,
|
|
34
|
+
action: contract.capability.action,
|
|
35
|
+
resourceCommitment: privacy.commitments.resourceId.digest,
|
|
36
|
+
parameterCommitment: privacy.commitments.actionParameters.digest,
|
|
37
|
+
outcomePredicateDigest: sha256Canonical(contract.effect),
|
|
38
|
+
contractDigest: compiledContract.sourceContractDigest,
|
|
39
|
+
policyRef: contract.authority.policyRef,
|
|
40
|
+
mandateRef: contract.authority.mandateRef,
|
|
41
|
+
};
|
|
42
|
+
const actionDigest = sha256Canonical(canonicalAction);
|
|
43
|
+
const target = new LocalReferenceTarget();
|
|
44
|
+
const before = target.read("record-001");
|
|
45
|
+
if (before.status !== "DRAFT")
|
|
46
|
+
throw new Error("Reference target precondition failed.");
|
|
47
|
+
const preconditionObservedAt = new Date(now.getTime() + 10).toISOString();
|
|
48
|
+
const policy = { decision: "REQUIRE_APPROVAL", actionDigest, policyRef: contract.authority.policyRef };
|
|
49
|
+
const approval = {
|
|
50
|
+
approved: true,
|
|
51
|
+
actionDigest,
|
|
52
|
+
tenantRef: String(canonicalAction.tenantRef),
|
|
53
|
+
agentRef: String(canonicalAction.agentRef),
|
|
54
|
+
environment: String(canonicalAction.environment),
|
|
55
|
+
contractDigest: compiledContract.sourceContractDigest,
|
|
56
|
+
policyRef: contract.authority.policyRef,
|
|
57
|
+
resourceCommitment: String(canonicalAction.resourceCommitment),
|
|
58
|
+
expiresAt: new Date(now.getTime() + 2 * 60_000).toISOString(),
|
|
59
|
+
approverRef: "reviewer:local-demo",
|
|
60
|
+
};
|
|
61
|
+
const grant = {
|
|
62
|
+
id: `grant_${randomUUID()}`,
|
|
63
|
+
tenantRef: String(canonicalAction.tenantRef),
|
|
64
|
+
agentRef: String(canonicalAction.agentRef),
|
|
65
|
+
environment: String(canonicalAction.environment),
|
|
66
|
+
actionDigest,
|
|
67
|
+
contractDigest: compiledContract.sourceContractDigest,
|
|
68
|
+
policyRef: contract.authority.policyRef,
|
|
69
|
+
mandateRef: contract.authority.mandateRef,
|
|
70
|
+
resourceCommitment: String(canonicalAction.resourceCommitment),
|
|
71
|
+
maxUses: 1,
|
|
72
|
+
consumedCount: 0,
|
|
73
|
+
issuedAt: now.toISOString(),
|
|
74
|
+
expiresAt: new Date(now.getTime() + 5 * 60_000).toISOString(),
|
|
75
|
+
};
|
|
76
|
+
const grants = new AtomicLocalGrantStore();
|
|
77
|
+
grants.issue(grant.id);
|
|
78
|
+
grants.consume(grant.id);
|
|
79
|
+
grant.consumedCount = 1;
|
|
80
|
+
// The only write credential exists inside this controlled adapter boundary.
|
|
81
|
+
target.controlledSubmit("record-001", { status: "SUBMITTED", expectedRevision: 1 }, actionDigest);
|
|
82
|
+
const observed = target.read("record-001");
|
|
83
|
+
const audit = target.auditSince(0);
|
|
84
|
+
const targetAuditReconciled = audit.length === 1 && audit[0].actionDigest === actionDigest && audit[0].revision === observed.revision;
|
|
85
|
+
const normalizedObservation = { status: observed.status, prohibitedSideEffect: false };
|
|
86
|
+
const outcomeEvaluation = evaluateOutcome(contract.effect, normalizedObservation, { targetAuditReconciled });
|
|
87
|
+
const outcomeSatisfied = outcomeEvaluation.status === "VERIFIED";
|
|
88
|
+
const eventPayloads = [
|
|
89
|
+
["witnora.run.started", { collector: "local-reference-collector", actionDigest }],
|
|
90
|
+
["witnora.precondition.observed", { status: "SATISFIED", rawObservationCommitment: sha256Canonical(before) }],
|
|
91
|
+
["witnora.policy.evaluated", policy],
|
|
92
|
+
["witnora.approval.recorded", approval],
|
|
93
|
+
["witnora.grant.consumed", { grantId: grant.id, actionDigest }],
|
|
94
|
+
["witnora.action.executed", { actionDigest, adapter: "local-reference-target" }],
|
|
95
|
+
["witnora.outcome.observed", { status: outcomeEvaluation.status, rawObservationCommitment: sha256Canonical(observed) }],
|
|
96
|
+
["witnora.run.completed", { droppedEvents: 0, targetAuditReconciled }],
|
|
97
|
+
];
|
|
98
|
+
const events = eventPayloads.reduce((result, [type, payload], index) => {
|
|
99
|
+
const previousHash = result.at(-1)?.eventHash ?? null;
|
|
100
|
+
const base = { sequence: index + 1, type, occurredAt: new Date(now.getTime() + index * 10).toISOString(), previousHash, payloadDigest: sha256Canonical(payload) };
|
|
101
|
+
result.push({ ...base, eventHash: sha256Canonical(base) });
|
|
102
|
+
return result;
|
|
103
|
+
}, []);
|
|
104
|
+
const artifactContent = Buffer.from(JSON.stringify({ before, observed, targetAuditReconciled }, null, 2));
|
|
105
|
+
const artifactBase64 = artifactContent.toString("base64");
|
|
106
|
+
const sourceKeys = generateKeyPairSync("ed25519");
|
|
107
|
+
const serverKeys = generateKeyPairSync("ed25519");
|
|
108
|
+
const probeKeys = generateKeyPairSync("ed25519");
|
|
109
|
+
const scopeKeys = generateKeyPairSync("ed25519");
|
|
110
|
+
const packet = {
|
|
111
|
+
schemaVersion: "witnora.verifiable_evidence_packet.v0.1",
|
|
112
|
+
packetId: `packet_${randomUUID()}`,
|
|
113
|
+
issuedAt: now.toISOString(),
|
|
114
|
+
contract,
|
|
115
|
+
compiledContract,
|
|
116
|
+
privacy: { manifest: privacyManifest, transformed: privacy },
|
|
117
|
+
action: { canonical: canonicalAction, digest: actionDigest },
|
|
118
|
+
precondition: {
|
|
119
|
+
status: "SATISFIED",
|
|
120
|
+
normalizedObservation: { status: before.status },
|
|
121
|
+
rawObservationCommitment: sha256Canonical(before),
|
|
122
|
+
observedAt: preconditionObservedAt,
|
|
123
|
+
dataFreshnessMs: 0,
|
|
124
|
+
probeRef: contract.observation.probeRef,
|
|
125
|
+
probeVersion: "1.0.0",
|
|
126
|
+
probeDigest: sha256Canonical({ implementation: "local-reference-read-path", version: "1.0.0" }),
|
|
127
|
+
observationSource: "local-reference-target/read-only",
|
|
128
|
+
independentReadPath: true,
|
|
129
|
+
probeSignature: emptySignature(),
|
|
130
|
+
},
|
|
131
|
+
resourceScopeProof: {
|
|
132
|
+
decision: resourceScopeAllowed ? "ALLOW" : "DENY",
|
|
133
|
+
contractScopeDigest: sha256Canonical(contract.authority.resourceScope),
|
|
134
|
+
resourceCommitment: String(canonicalAction.resourceCommitment),
|
|
135
|
+
evaluatedAt: new Date(now.getTime() + 15).toISOString(),
|
|
136
|
+
evaluatorRef: "local-policy-gateway-v1",
|
|
137
|
+
signature: emptySignature(),
|
|
138
|
+
},
|
|
139
|
+
policy,
|
|
140
|
+
approval,
|
|
141
|
+
grant,
|
|
142
|
+
events,
|
|
143
|
+
outcome: {
|
|
144
|
+
status: outcomeEvaluation.status,
|
|
145
|
+
expectedDigest: sha256Canonical(contract.effect),
|
|
146
|
+
normalizedObservation,
|
|
147
|
+
rawObservationCommitment: sha256Canonical(observed),
|
|
148
|
+
observedAt: new Date(now.getTime() + 60).toISOString(),
|
|
149
|
+
firstSatisfiedAt: new Date(now.getTime() + 60).toISOString(),
|
|
150
|
+
dataFreshnessMs: 0,
|
|
151
|
+
probeRef: contract.observation.probeRef,
|
|
152
|
+
probeVersion: "1.0.0",
|
|
153
|
+
probeDigest: sha256Canonical({ implementation: "local-reference-read-path", version: "1.0.0" }),
|
|
154
|
+
observationSource: "local-reference-target/read-only",
|
|
155
|
+
independentReadPath: true,
|
|
156
|
+
targetAuditReconciled,
|
|
157
|
+
probeSignature: emptySignature(),
|
|
158
|
+
},
|
|
159
|
+
artifacts: [{
|
|
160
|
+
path: "outcome-observation.json",
|
|
161
|
+
kind: "outcome_observation",
|
|
162
|
+
size: artifactContent.byteLength,
|
|
163
|
+
sha256: createHash("sha256").update(artifactContent).digest("hex"),
|
|
164
|
+
contentBase64: artifactBase64,
|
|
165
|
+
}],
|
|
166
|
+
evidenceStrength: { reported: true, recorded: true, enforced: true, outcomeVerified: outcomeSatisfied && targetAuditReconciled, independentlyReviewed: false },
|
|
167
|
+
sourceSignature: emptySignature(),
|
|
168
|
+
serverAttestation: emptySignature(),
|
|
169
|
+
};
|
|
170
|
+
packet.resourceScopeProof.signature = signValue(resourceScopeProofSignedValue(packet), "local-scope-evaluator-v1", scopeKeys.privateKey, scopeKeys.publicKey.export({ type: "spki", format: "pem" }).toString());
|
|
171
|
+
packet.precondition.probeSignature = signValue(preconditionProbeSignedValue(packet), "local-outcome-probe-v1", probeKeys.privateKey, probeKeys.publicKey.export({ type: "spki", format: "pem" }).toString());
|
|
172
|
+
packet.outcome.probeSignature = signValue(outcomeProbeSignedValue(packet), "local-outcome-probe-v1", probeKeys.privateKey, probeKeys.publicKey.export({ type: "spki", format: "pem" }).toString());
|
|
173
|
+
packet.sourceSignature = signValue(sourceSignedValue(packet), "local-source-v1", sourceKeys.privateKey, sourceKeys.publicKey.export({ type: "spki", format: "pem" }).toString());
|
|
174
|
+
packet.serverAttestation = signValue(serverAttestedValue(packet), "local-reference-server-v1", serverKeys.privateKey, serverKeys.publicKey.export({ type: "spki", format: "pem" }).toString());
|
|
175
|
+
applyAttack(packet, attack);
|
|
176
|
+
const trustBundle = {
|
|
177
|
+
schemaVersion: "witnora.verification_trust_bundle.v0.1",
|
|
178
|
+
keys: {
|
|
179
|
+
"local-source-v1": trustKey(sourceKeys.publicKey.export({ type: "spki", format: "pem" }).toString(), "source"),
|
|
180
|
+
"local-reference-server-v1": trustKey(serverKeys.publicKey.export({ type: "spki", format: "pem" }).toString(), "server_attestor"),
|
|
181
|
+
"local-outcome-probe-v1": trustKey(probeKeys.publicKey.export({ type: "spki", format: "pem" }).toString(), "outcome_probe"),
|
|
182
|
+
"local-scope-evaluator-v1": trustKey(scopeKeys.publicKey.export({ type: "spki", format: "pem" }).toString(), "resource_scope_evaluator"),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const report = verifyEvidencePacket(packet, new Date(now.getTime() + 1_000), trustBundle);
|
|
186
|
+
const changed = structuredClone(contract);
|
|
187
|
+
changed.subject.agentId = `${contract.subject.agentId}-v2`;
|
|
188
|
+
const revalidation = diffAssuranceContracts(contract, changed);
|
|
189
|
+
const outDir = resolve(options.outDir);
|
|
190
|
+
await mkdir(outDir, { recursive: true });
|
|
191
|
+
const packetPath = resolve(outDir, "witnora-evidence-packet.json");
|
|
192
|
+
const reportPath = resolve(outDir, "witnora-offline-verification.json");
|
|
193
|
+
const revalidationPath = resolve(outDir, "witnora-revalidation-diff.json");
|
|
194
|
+
const trustBundlePath = resolve(outDir, "witnora-trust-bundle.json");
|
|
195
|
+
await Promise.all([
|
|
196
|
+
writeFile(packetPath, `${JSON.stringify(packet, null, 2)}\n`, "utf8"),
|
|
197
|
+
writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
|
|
198
|
+
writeFile(revalidationPath, `${JSON.stringify(revalidation, null, 2)}\n`, "utf8"),
|
|
199
|
+
writeFile(trustBundlePath, `${JSON.stringify(trustBundle, null, 2)}\n`, "utf8"),
|
|
200
|
+
]);
|
|
201
|
+
return { packet, report, packetPath, reportPath, revalidationPath, trustBundlePath };
|
|
202
|
+
}
|
|
203
|
+
class LocalReferenceTarget {
|
|
204
|
+
record = { id: "record-001", status: "DRAFT", revision: 1 };
|
|
205
|
+
audit = [];
|
|
206
|
+
read(id) {
|
|
207
|
+
if (id !== this.record.id)
|
|
208
|
+
throw new Error("Record not found.");
|
|
209
|
+
return structuredClone(this.record);
|
|
210
|
+
}
|
|
211
|
+
controlledSubmit(id, input, actionDigest) {
|
|
212
|
+
if (id !== this.record.id || input.status !== "SUBMITTED" || input.expectedRevision !== this.record.revision)
|
|
213
|
+
throw new Error("Controlled execution rejected.");
|
|
214
|
+
this.record = { ...this.record, status: input.status, revision: this.record.revision + 1 };
|
|
215
|
+
this.audit.push({ sequence: this.audit.length + 1, actionDigest, revision: this.record.revision });
|
|
216
|
+
}
|
|
217
|
+
auditSince(sequence) {
|
|
218
|
+
return structuredClone(this.audit.filter((entry) => entry.sequence > sequence));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
class AtomicLocalGrantStore {
|
|
222
|
+
state = new Map();
|
|
223
|
+
issue(id) { if (this.state.has(id))
|
|
224
|
+
throw new Error("Grant already exists."); this.state.set(id, "ISSUED"); }
|
|
225
|
+
consume(id) { if (this.state.get(id) !== "ISSUED")
|
|
226
|
+
throw new Error("Grant replay rejected."); this.state.set(id, "CONSUMED"); }
|
|
227
|
+
}
|
|
228
|
+
function signValue(value, keyId, privateKey, publicKeyPem) {
|
|
229
|
+
return { keyId, algorithm: "Ed25519", publicKeyPem, valueDigest: sha256Canonical(value), signature: sign(null, Buffer.from(canonicalJson(value)), privateKey).toString("base64") };
|
|
230
|
+
}
|
|
231
|
+
function emptySignature() { return { keyId: "", algorithm: "Ed25519", publicKeyPem: "", valueDigest: "", signature: "" }; }
|
|
232
|
+
function trustKey(publicKeyPem, usage) {
|
|
233
|
+
return { publicKeyPem, usage, tenantRef: "tenant:local-demo", projectRef: "project:assurance-loop", environment: "sandbox" };
|
|
234
|
+
}
|
|
235
|
+
function applyAttack(packet, attack) {
|
|
236
|
+
if (attack === "parameter-substitution")
|
|
237
|
+
packet.action.canonical.parameterCommitment = "0".repeat(64);
|
|
238
|
+
if (attack === "replay")
|
|
239
|
+
packet.grant.consumedCount = 2;
|
|
240
|
+
if (attack === "false-success") {
|
|
241
|
+
packet.outcome.normalizedObservation = { status: "DRAFT", prohibitedSideEffect: false };
|
|
242
|
+
}
|
|
243
|
+
if (attack === "event-tamper")
|
|
244
|
+
packet.events[2].payloadDigest = "f".repeat(64);
|
|
245
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function decideCoveredDispatch(grant, request) {
|
|
2
|
+
if (!grant)
|
|
3
|
+
return denied("GRANT_MISSING");
|
|
4
|
+
if (grant.status === "REVOKED")
|
|
5
|
+
return denied("GRANT_REVOKED");
|
|
6
|
+
if (grant.status === "CONSUMED")
|
|
7
|
+
return denied("GRANT_ALREADY_CONSUMED");
|
|
8
|
+
if (request.incidentSuspended)
|
|
9
|
+
return denied("INCIDENT_SUSPENDED");
|
|
10
|
+
const now = Date.parse(request.now);
|
|
11
|
+
const notBefore = Date.parse(grant.notBefore);
|
|
12
|
+
const expiresAt = Date.parse(grant.expiresAt);
|
|
13
|
+
if (![now, notBefore, expiresAt].every(Number.isFinite) || notBefore >= expiresAt)
|
|
14
|
+
return denied("INVALID_TIME");
|
|
15
|
+
if (now < notBefore)
|
|
16
|
+
return denied("GRANT_NOT_YET_VALID");
|
|
17
|
+
if (now >= expiresAt)
|
|
18
|
+
return denied("GRANT_EXPIRED");
|
|
19
|
+
if (request.tenantRef !== grant.tenantRef)
|
|
20
|
+
return denied("TENANT_MISMATCH");
|
|
21
|
+
if (request.agentRef !== grant.agentRef)
|
|
22
|
+
return denied("AGENT_MISMATCH");
|
|
23
|
+
if (request.environment !== grant.environment)
|
|
24
|
+
return denied("ENVIRONMENT_MISMATCH");
|
|
25
|
+
if (request.policyRef !== grant.policyRef)
|
|
26
|
+
return denied("POLICY_VERSION_MISMATCH");
|
|
27
|
+
if (request.contractDigest !== grant.contractDigest)
|
|
28
|
+
return denied("CONTRACT_VERSION_MISMATCH");
|
|
29
|
+
if (request.actionDigest !== grant.actionDigest)
|
|
30
|
+
return denied("ACTION_DIGEST_MISMATCH");
|
|
31
|
+
return { allowed: true };
|
|
32
|
+
}
|
|
33
|
+
function denied(code) {
|
|
34
|
+
return { allowed: false, code };
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, verify } from "node:crypto";
|
|
2
|
+
import { canonicalBytesV02, sha256CanonicalV02 } from "./canonical-v02.js";
|
|
3
|
+
import { assertNotRevoked, verifyKeyCertificate } from "./trust-v02.js";
|
|
4
|
+
export function signApprovalV02(input, privateKeyPem) {
|
|
5
|
+
const unsigned = { ...input, approvalId: input.approvalId ?? randomUUID(), protocolVersion: "witnora.signed_action_approval.v0.2" };
|
|
6
|
+
return { ...unsigned, signature: sign(null, canonicalBytesV02(unsigned), createPrivateKey(privateKeyPem)).toString("base64") };
|
|
7
|
+
}
|
|
8
|
+
export function signExecutionGrantV02(input, privateKeyPem) {
|
|
9
|
+
const unsigned = { ...input, grantId: input.grantId ?? randomUUID(), nonce: input.nonce ?? randomBytes(24).toString("hex"), protocolVersion: "witnora.signed_execution_grant.v0.2" };
|
|
10
|
+
return { ...unsigned, signature: sign(null, canonicalBytesV02(unsigned), createPrivateKey(privateKeyPem)).toString("base64") };
|
|
11
|
+
}
|
|
12
|
+
export function approvalDigestV02(approval) { return sha256CanonicalV02(approval); }
|
|
13
|
+
export function verifyAuthorizationV02(input) {
|
|
14
|
+
const { approval, grant, trust, expected } = input;
|
|
15
|
+
const at = input.at ?? new Date();
|
|
16
|
+
if (approval.protocolVersion !== "witnora.signed_action_approval.v0.2" || grant.protocolVersion !== "witnora.signed_execution_grant.v0.2")
|
|
17
|
+
throw new Error("Unsupported signed authorization protocol.");
|
|
18
|
+
verifyKeyCertificate(trust.approvalCertificate, trust.root, new Date(approval.approvedAt), { role: "reviewer", tenantRef: expected.tenantRef, projectRef: expected.projectRef, environment: expected.environment });
|
|
19
|
+
verifyKeyCertificate(trust.grantCertificate, trust.root, new Date(grant.issuedAt), { role: "resource_scope_evaluator", tenantRef: expected.tenantRef, projectRef: expected.projectRef, environment: expected.environment });
|
|
20
|
+
assertNotRevoked(trust.approvalCertificate, trust.revocationJournal, new Date(approval.approvedAt));
|
|
21
|
+
assertNotRevoked(trust.grantCertificate, trust.revocationJournal, new Date(grant.issuedAt));
|
|
22
|
+
verifySignedApproval(approval, trust.approvalCertificate);
|
|
23
|
+
verifySignedGrant(grant, trust.grantCertificate);
|
|
24
|
+
for (const value of [approval, grant]) {
|
|
25
|
+
if (value.tenantRef !== expected.tenantRef || value.projectRef !== expected.projectRef || value.environment !== expected.environment || value.actionDigest !== expected.actionDigest)
|
|
26
|
+
throw new Error("Signed authorization scope or exact-action binding mismatch.");
|
|
27
|
+
}
|
|
28
|
+
if (grant.approvalDigest !== approvalDigestV02(approval))
|
|
29
|
+
throw new Error("Execution Grant is not bound to the signed approval.");
|
|
30
|
+
if (grant.maxUses !== 1)
|
|
31
|
+
throw new Error("Execution Grant must be single-use.");
|
|
32
|
+
const now = at.getTime();
|
|
33
|
+
const notBefore = parseInstant(grant.notBefore, "grant notBefore");
|
|
34
|
+
const grantExpiry = parseInstant(grant.expiresAt, "grant expiresAt");
|
|
35
|
+
const approvalExpiry = parseInstant(approval.expiresAt, "approval expiresAt");
|
|
36
|
+
if (now < notBefore || now > grantExpiry || now > approvalExpiry)
|
|
37
|
+
throw new Error("Signed authorization is not currently valid.");
|
|
38
|
+
}
|
|
39
|
+
function verifySignedApproval(value, certificate) {
|
|
40
|
+
if (value.signerKeyId !== certificate.subjectKeyId)
|
|
41
|
+
throw new Error("Signed approval key ID does not match its certificate.");
|
|
42
|
+
const { signature, ...unsigned } = value;
|
|
43
|
+
if (!verify(null, canonicalBytesV02(unsigned), createPublicKey(certificate.publicKeyPem), Buffer.from(signature, "base64")))
|
|
44
|
+
throw new Error("Signed approval signature is invalid.");
|
|
45
|
+
}
|
|
46
|
+
function verifySignedGrant(value, certificate) {
|
|
47
|
+
if (value.issuerKeyId !== certificate.subjectKeyId)
|
|
48
|
+
throw new Error("Signed execution Grant key ID does not match its certificate.");
|
|
49
|
+
const { signature, ...unsigned } = value;
|
|
50
|
+
if (!verify(null, canonicalBytesV02(unsigned), createPublicKey(certificate.publicKeyPem), Buffer.from(signature, "base64")))
|
|
51
|
+
throw new Error("Signed execution Grant signature is invalid.");
|
|
52
|
+
}
|
|
53
|
+
function parseInstant(value, field) {
|
|
54
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value))
|
|
55
|
+
throw new Error(`${field} must be an RFC 3339 UTC timestamp with at most millisecond precision.`);
|
|
56
|
+
const parsed = Date.parse(value);
|
|
57
|
+
if (!Number.isFinite(parsed))
|
|
58
|
+
throw new Error(`${field} is invalid.`);
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
package/dist/bundle.js
CHANGED
|
@@ -75,8 +75,30 @@ function evidenceStrengthFor(results) {
|
|
|
75
75
|
level: weakest.level,
|
|
76
76
|
claims: [...new Set(values.flatMap((value) => value.claims))],
|
|
77
77
|
limitations: [...new Set(values.flatMap((value) => value.limitations))],
|
|
78
|
+
...(() => {
|
|
79
|
+
const vectors = values.flatMap((value) => value.trustVector ? [value.trustVector] : []);
|
|
80
|
+
return vectors.length === values.length && vectors.length ? { trustVector: weakestTrustVector(vectors) } : {};
|
|
81
|
+
})(),
|
|
78
82
|
};
|
|
79
83
|
}
|
|
84
|
+
function weakestTrustVector(vectors) {
|
|
85
|
+
const capture = weakest(vectors.map((item) => item.capture), ["self_reported", "collector_recorded", "boundary_observed"]);
|
|
86
|
+
const mediation = weakest(vectors.map((item) => item.mediation), ["none", "advisory", "enforced"]);
|
|
87
|
+
const completeness = weakest(vectors.map((item) => item.completeness), ["unknown", "partial", "target_reconciled"]);
|
|
88
|
+
const attestation = weakest(vectors.map((item) => item.attestation), ["unsigned", "source_signed", "platform_signed", "externally_witnessed"]);
|
|
89
|
+
const review = weakest(vectors.map((item) => item.review), ["none", "internal", "independent_external"]);
|
|
90
|
+
const outcome = vectors.some((item) => item.outcome === "contradicted")
|
|
91
|
+
? "contradicted"
|
|
92
|
+
: vectors.some((item) => item.outcome === "unverified") ? "unverified" : "verified";
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: "witnora.evidence_trust_vector.v0.1",
|
|
95
|
+
capture, mediation, outcome, completeness, attestation, review,
|
|
96
|
+
limitations: [...new Set(vectors.flatMap((item) => item.limitations))],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function weakest(values, order) {
|
|
100
|
+
return values.reduce((left, right) => order.indexOf(left) <= order.indexOf(right) ? left : right);
|
|
101
|
+
}
|
|
80
102
|
function levelForScore(score, passed) {
|
|
81
103
|
if (!passed) {
|
|
82
104
|
return "No assurance decision";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const WITNORA_CANONICALIZATION_V02 = "witnora.canonical_json.v0.2";
|
|
3
|
+
const MAX_DEPTH = 64;
|
|
4
|
+
/** Strict v0.2 profile: JSON only, safe integers only, no duplicate keys or negative zero. */
|
|
5
|
+
export function canonicalJsonV02(value) {
|
|
6
|
+
return JSON.stringify(canonicalValueV02(value, "$", 0));
|
|
7
|
+
}
|
|
8
|
+
export function canonicalBytesV02(value) {
|
|
9
|
+
return Buffer.from(canonicalJsonV02(value), "utf8");
|
|
10
|
+
}
|
|
11
|
+
export function sha256CanonicalV02(value) {
|
|
12
|
+
return createHash("sha256").update(canonicalBytesV02(value)).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
export function parseStrictJsonV02(input) {
|
|
15
|
+
const parser = new StrictJsonParser(input);
|
|
16
|
+
const value = parser.parseValue(0);
|
|
17
|
+
parser.skipWhitespace();
|
|
18
|
+
if (!parser.done())
|
|
19
|
+
throw new Error(`Unexpected token at offset ${parser.offset()}.`);
|
|
20
|
+
return canonicalValueV02(value, "$", 0);
|
|
21
|
+
}
|
|
22
|
+
function canonicalValueV02(value, path, depth) {
|
|
23
|
+
if (depth > MAX_DEPTH)
|
|
24
|
+
throw new Error(`${path} exceeds the v0.2 nesting limit of ${MAX_DEPTH}.`);
|
|
25
|
+
if (value === null || typeof value === "boolean")
|
|
26
|
+
return value;
|
|
27
|
+
if (typeof value === "string") {
|
|
28
|
+
assertValidUnicode(value, path);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === "number") {
|
|
32
|
+
if (!Number.isFinite(value))
|
|
33
|
+
throw new Error(`${path} contains a non-finite number.`);
|
|
34
|
+
if (Object.is(value, -0))
|
|
35
|
+
throw new Error(`${path} contains negative zero.`);
|
|
36
|
+
if (!Number.isSafeInteger(value))
|
|
37
|
+
throw new Error(`${path} must be a safe integer; encode decimals as normalized strings.`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(value))
|
|
41
|
+
return value.map((entry, index) => canonicalValueV02(entry, `${path}[${index}]`, depth + 1));
|
|
42
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
43
|
+
const source = value;
|
|
44
|
+
const output = {};
|
|
45
|
+
for (const key of Object.keys(source).sort()) {
|
|
46
|
+
assertValidUnicode(key, `${path} key`);
|
|
47
|
+
if (source[key] === undefined)
|
|
48
|
+
throw new Error(`${path}.${key} is undefined.`);
|
|
49
|
+
output[key] = canonicalValueV02(source[key], `${path}.${key}`, depth + 1);
|
|
50
|
+
}
|
|
51
|
+
return output;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`${path} contains unsupported ${typeof value}.`);
|
|
54
|
+
}
|
|
55
|
+
class StrictJsonParser {
|
|
56
|
+
source;
|
|
57
|
+
index = 0;
|
|
58
|
+
constructor(source) {
|
|
59
|
+
this.source = source;
|
|
60
|
+
}
|
|
61
|
+
offset() { return this.index; }
|
|
62
|
+
done() { return this.index >= this.source.length; }
|
|
63
|
+
skipWhitespace() { while (/\s/u.test(this.source[this.index] ?? ""))
|
|
64
|
+
this.index += 1; }
|
|
65
|
+
parseValue(depth) {
|
|
66
|
+
if (depth > MAX_DEPTH)
|
|
67
|
+
throw new Error(`JSON exceeds the v0.2 nesting limit of ${MAX_DEPTH}.`);
|
|
68
|
+
this.skipWhitespace();
|
|
69
|
+
const token = this.source[this.index];
|
|
70
|
+
if (token === "{")
|
|
71
|
+
return this.parseObject(depth + 1);
|
|
72
|
+
if (token === "[")
|
|
73
|
+
return this.parseArray(depth + 1);
|
|
74
|
+
if (token === '"')
|
|
75
|
+
return this.parseString();
|
|
76
|
+
if (token === "t" && this.consume("true"))
|
|
77
|
+
return true;
|
|
78
|
+
if (token === "f" && this.consume("false"))
|
|
79
|
+
return false;
|
|
80
|
+
if (token === "n" && this.consume("null"))
|
|
81
|
+
return null;
|
|
82
|
+
if (token === "-" || /[0-9]/u.test(token ?? ""))
|
|
83
|
+
return this.parseNumber();
|
|
84
|
+
throw new Error(`Invalid JSON value at offset ${this.index}.`);
|
|
85
|
+
}
|
|
86
|
+
parseObject(depth) {
|
|
87
|
+
this.expect("{");
|
|
88
|
+
this.skipWhitespace();
|
|
89
|
+
const output = {};
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
if (this.peek("}")) {
|
|
92
|
+
this.index += 1;
|
|
93
|
+
return output;
|
|
94
|
+
}
|
|
95
|
+
while (true) {
|
|
96
|
+
this.skipWhitespace();
|
|
97
|
+
const key = this.parseString();
|
|
98
|
+
if (seen.has(key))
|
|
99
|
+
throw new Error(`Duplicate JSON object key ${JSON.stringify(key)}.`);
|
|
100
|
+
seen.add(key);
|
|
101
|
+
this.skipWhitespace();
|
|
102
|
+
this.expect(":");
|
|
103
|
+
output[key] = this.parseValue(depth);
|
|
104
|
+
this.skipWhitespace();
|
|
105
|
+
if (this.peek("}")) {
|
|
106
|
+
this.index += 1;
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
this.expect(",");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
parseArray(depth) {
|
|
113
|
+
this.expect("[");
|
|
114
|
+
this.skipWhitespace();
|
|
115
|
+
const output = [];
|
|
116
|
+
if (this.peek("]")) {
|
|
117
|
+
this.index += 1;
|
|
118
|
+
return output;
|
|
119
|
+
}
|
|
120
|
+
while (true) {
|
|
121
|
+
output.push(this.parseValue(depth));
|
|
122
|
+
this.skipWhitespace();
|
|
123
|
+
if (this.peek("]")) {
|
|
124
|
+
this.index += 1;
|
|
125
|
+
return output;
|
|
126
|
+
}
|
|
127
|
+
this.expect(",");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
parseString() {
|
|
131
|
+
const start = this.index;
|
|
132
|
+
this.expect('"');
|
|
133
|
+
let escaped = false;
|
|
134
|
+
while (!this.done()) {
|
|
135
|
+
const char = this.source[this.index++];
|
|
136
|
+
if (!escaped && char === '"') {
|
|
137
|
+
const token = this.source.slice(start, this.index);
|
|
138
|
+
try {
|
|
139
|
+
const value = JSON.parse(token);
|
|
140
|
+
assertValidUnicode(value, `JSON string at offset ${start}`);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new Error(`Invalid JSON string at offset ${start}.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!escaped && char === "\\")
|
|
148
|
+
escaped = true;
|
|
149
|
+
else
|
|
150
|
+
escaped = false;
|
|
151
|
+
if ((char?.charCodeAt(0) ?? 32) < 32)
|
|
152
|
+
throw new Error(`Unescaped control character at offset ${this.index - 1}.`);
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`Unterminated JSON string at offset ${start}.`);
|
|
155
|
+
}
|
|
156
|
+
parseNumber() {
|
|
157
|
+
const rest = this.source.slice(this.index);
|
|
158
|
+
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(rest);
|
|
159
|
+
if (!match)
|
|
160
|
+
throw new Error(`Invalid JSON number at offset ${this.index}.`);
|
|
161
|
+
this.index += match[0].length;
|
|
162
|
+
const value = Number(match[0]);
|
|
163
|
+
if (!Number.isFinite(value))
|
|
164
|
+
throw new Error("Non-finite JSON number is not supported.");
|
|
165
|
+
if (Object.is(value, -0))
|
|
166
|
+
throw new Error("Negative zero is not supported.");
|
|
167
|
+
if (!Number.isSafeInteger(value))
|
|
168
|
+
throw new Error("Only safe integer JSON numbers are supported; use normalized decimal strings.");
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
consume(value) {
|
|
172
|
+
if (!this.source.startsWith(value, this.index))
|
|
173
|
+
return false;
|
|
174
|
+
this.index += value.length;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
peek(value) { return this.source[this.index] === value; }
|
|
178
|
+
expect(value) {
|
|
179
|
+
if (!this.peek(value))
|
|
180
|
+
throw new Error(`Expected ${JSON.stringify(value)} at offset ${this.index}.`);
|
|
181
|
+
this.index += 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function assertValidUnicode(value, path) {
|
|
185
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
186
|
+
const code = value.charCodeAt(index);
|
|
187
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
188
|
+
const next = value.charCodeAt(index + 1);
|
|
189
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
190
|
+
throw new Error(`${path} contains an unpaired Unicode surrogate.`);
|
|
191
|
+
index += 1;
|
|
192
|
+
}
|
|
193
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
194
|
+
throw new Error(`${path} contains an unpaired Unicode surrogate.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const WITNORA_CANONICALIZATION_VERSION = "witnora.canonical_json.v0.1";
|
|
3
|
+
/** Stable JSON used by v0.1 digests. Arrays remain ordered; undefined is rejected. */
|
|
4
|
+
export function canonicalJson(value) {
|
|
5
|
+
return JSON.stringify(canonicalValue(value, "$"));
|
|
6
|
+
}
|
|
7
|
+
export function sha256Canonical(value) {
|
|
8
|
+
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
|
|
9
|
+
}
|
|
10
|
+
function canonicalValue(value, path) {
|
|
11
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
12
|
+
return value;
|
|
13
|
+
if (typeof value === "number") {
|
|
14
|
+
if (!Number.isFinite(value))
|
|
15
|
+
throw new Error(`${path} contains a non-finite number.`);
|
|
16
|
+
return Object.is(value, -0) ? 0 : value;
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.map((item, index) => canonicalValue(item, `${path}[${index}]`));
|
|
20
|
+
if (value && typeof value === "object") {
|
|
21
|
+
const source = value;
|
|
22
|
+
const output = {};
|
|
23
|
+
for (const key of Object.keys(source).sort()) {
|
|
24
|
+
if (source[key] === undefined)
|
|
25
|
+
throw new Error(`${path}.${key} is undefined; canonical input must be explicit.`);
|
|
26
|
+
output[key] = canonicalValue(source[key], `${path}.${key}`);
|
|
27
|
+
}
|
|
28
|
+
return output;
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`${path} contains unsupported ${typeof value}.`);
|
|
31
|
+
}
|