prae-cli 0.8.0 → 0.9.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/lib/bin.js +4 -4
- package/lib/{checkpoint-DiznOVpS.js → checkpoint-C_q_Rpa5.js} +1 -1
- package/lib/{cli-UAE968If.js → cli-B_B6-bUZ.js} +1020 -51
- package/lib/{evaluate-wMPkjcIC.js → evaluate-BqLxhYiP.js} +10 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +4 -4
- package/lib/{instructions-DulxSqFd.js → instructions-ya0LVnZy.js} +1 -1
- package/lib/{mcp-doNYU-1C.js → mcp-BCXbpqu4.js} +1 -1
- package/lib/playground.js +23 -3
- package/lib/{receipt-check-s3Aryyz-.js → receipt-check-DQK_gVPc.js} +1 -1
- package/lib/src-7X6dO7gC.js +3 -0
- package/lib/src-LA7NdLKb.js +399 -0
- package/lib/{test-run-XNIRHKOu.js → test-run-CCDgJ0Dp.js} +1 -1
- package/lib/{views-CCwVXpo2.js → views-D4UEfbzt.js} +257 -1
- package/package.json +6 -5
- package/lib/src-C9L6NBvu.js +0 -3
- package/lib/src-Ciggx9ja.js +0 -242
|
@@ -799,6 +799,18 @@ function renderNav(items, active) {
|
|
|
799
799
|
|
|
800
800
|
//#endregion
|
|
801
801
|
//#region ../controls/src/index.ts
|
|
802
|
+
const EU_AI_ACT = {
|
|
803
|
+
id: "eu-ai-act-2024",
|
|
804
|
+
name: "EU AI Act — Regulation (EU) 2024/1689"
|
|
805
|
+
};
|
|
806
|
+
const NIST_600_1 = {
|
|
807
|
+
id: "nist-ai-600-1-2024",
|
|
808
|
+
name: "NIST AI 600-1 (2024) — Generative AI profile"
|
|
809
|
+
};
|
|
810
|
+
const SOC_2 = {
|
|
811
|
+
id: "soc2-tsc-2017",
|
|
812
|
+
name: "SOC 2 — Trust Services Criteria (2017)"
|
|
813
|
+
};
|
|
802
814
|
/**
|
|
803
815
|
* What each control asks for, and what this profile actually produces.
|
|
804
816
|
*
|
|
@@ -807,9 +819,35 @@ function renderNav(items, active) {
|
|
|
807
819
|
* the obligation, and a tool that told them the answer would be selling the
|
|
808
820
|
* one thing it cannot supply.
|
|
809
821
|
*/
|
|
822
|
+
const isEvent = (e) => e.tool.startsWith("prae:") || e.tool === "system-prompt";
|
|
823
|
+
const decisionsOf = (data) => data.entries.filter((e) => !isEvent(e));
|
|
824
|
+
const allRules = (data) => (data.pack?.rules ?? []).map((r) => r.id);
|
|
825
|
+
/** Rows under the given rules. */
|
|
826
|
+
const under = (data, rules) => {
|
|
827
|
+
const ids = new Set(rules);
|
|
828
|
+
return {
|
|
829
|
+
rules,
|
|
830
|
+
rows: data.entries.filter((e) => e.rule !== void 0 && ids.has(e.rule))
|
|
831
|
+
};
|
|
832
|
+
};
|
|
833
|
+
/** Refusals and holds, with the rules that produced them. */
|
|
834
|
+
function heldAndRefused(data) {
|
|
835
|
+
const rows = decisionsOf(data).filter((e) => e.verdict === "deny" || e.verdict === "ask");
|
|
836
|
+
return {
|
|
837
|
+
rules: [...new Set(rows.flatMap((e) => e.rule ? [e.rule] : []))],
|
|
838
|
+
rows
|
|
839
|
+
};
|
|
840
|
+
}
|
|
810
841
|
const OBLIGATIONS = [
|
|
811
842
|
{
|
|
812
843
|
id: "EU AI Act art. 12",
|
|
844
|
+
code: "Art. 12",
|
|
845
|
+
name: "Record-keeping",
|
|
846
|
+
standard: EU_AI_ACT,
|
|
847
|
+
trace: (data) => ({
|
|
848
|
+
rules: [],
|
|
849
|
+
rows: data.entries
|
|
850
|
+
}),
|
|
813
851
|
asks: "Automatic recording of events over the lifetime of the system, to a degree that enables tracing.",
|
|
814
852
|
evidence: (data) => ({
|
|
815
853
|
state: data.entries.length > 0 ? "covered" : "partial",
|
|
@@ -818,6 +856,12 @@ const OBLIGATIONS = [
|
|
|
818
856
|
},
|
|
819
857
|
{
|
|
820
858
|
id: "NIST AI 600-1 · GV-1.2",
|
|
859
|
+
name: "Trustworthy AI in policy",
|
|
860
|
+
standard: NIST_600_1,
|
|
861
|
+
trace: (data) => ({
|
|
862
|
+
rules: allRules(data),
|
|
863
|
+
rows: []
|
|
864
|
+
}),
|
|
821
865
|
asks: "Characteristics of trustworthy AI are integrated into organisational policies.",
|
|
822
866
|
evidence: (data) => ({
|
|
823
867
|
state: data.pack === void 0 ? "absent" : "covered",
|
|
@@ -826,6 +870,8 @@ const OBLIGATIONS = [
|
|
|
826
870
|
},
|
|
827
871
|
{
|
|
828
872
|
id: "NIST AI 600-1 · MS-2.6",
|
|
873
|
+
name: "Regular safety evaluation",
|
|
874
|
+
standard: NIST_600_1,
|
|
829
875
|
asks: "AI system is evaluated regularly for safety risks, with results documented.",
|
|
830
876
|
evidence: () => ({
|
|
831
877
|
state: "partial",
|
|
@@ -834,6 +880,9 @@ const OBLIGATIONS = [
|
|
|
834
880
|
},
|
|
835
881
|
{
|
|
836
882
|
id: "SOC 2 · CC7.2",
|
|
883
|
+
name: "Monitoring for anomalies",
|
|
884
|
+
standard: SOC_2,
|
|
885
|
+
trace: (data) => heldAndRefused(data),
|
|
837
886
|
asks: "The entity monitors system components for anomalies indicative of malicious acts.",
|
|
838
887
|
evidence: (data) => ({
|
|
839
888
|
state: data.entries.some((e) => e.verdict === "deny") ? "covered" : "partial",
|
|
@@ -842,6 +891,12 @@ const OBLIGATIONS = [
|
|
|
842
891
|
},
|
|
843
892
|
{
|
|
844
893
|
id: "SOC 2 · CC6.1",
|
|
894
|
+
name: "Logical access restriction",
|
|
895
|
+
standard: SOC_2,
|
|
896
|
+
trace: (data) => ({
|
|
897
|
+
rules: allRules(data),
|
|
898
|
+
rows: []
|
|
899
|
+
}),
|
|
845
900
|
asks: "Logical access to protected information assets is restricted to authorised users.",
|
|
846
901
|
evidence: () => ({
|
|
847
902
|
state: "partial",
|
|
@@ -937,6 +992,28 @@ function frameworkRow(frameworkId, control) {
|
|
|
937
992
|
return {
|
|
938
993
|
id: `${framework?.short ?? frameworkId} · ${control}`,
|
|
939
994
|
asks: `${name}. ${risk?.summary ?? ""}`.trim(),
|
|
995
|
+
trace: (data) => {
|
|
996
|
+
const by = BOUNDED_BY[control];
|
|
997
|
+
if (by !== void 0) return under(data, citing(data.pack, by.framework, by.control).map((r) => r.id));
|
|
998
|
+
const rules = citing(data.pack, frameworkId, control).map((r) => r.id);
|
|
999
|
+
if (rules.length > 0) return under(data, rules);
|
|
1000
|
+
if (control === "ASI06") return {
|
|
1001
|
+
rules: [],
|
|
1002
|
+
rows: data.entries.filter((e) => e.rule === "PRAE-ASM" || e.rule === "PRAE-CTX")
|
|
1003
|
+
};
|
|
1004
|
+
if (control === "ASI10") return {
|
|
1005
|
+
rules: [
|
|
1006
|
+
"ASI02",
|
|
1007
|
+
"ASI03",
|
|
1008
|
+
"ASI08"
|
|
1009
|
+
].flatMap((c) => citing(data.pack, AGENTIC, c)).map((r) => r.id),
|
|
1010
|
+
rows: decisionsOf(data).filter((e) => e.verdict === "deny")
|
|
1011
|
+
};
|
|
1012
|
+
return {
|
|
1013
|
+
rules: [],
|
|
1014
|
+
rows: []
|
|
1015
|
+
};
|
|
1016
|
+
},
|
|
940
1017
|
evidence: (data) => {
|
|
941
1018
|
if (data.pack === void 0) return {
|
|
942
1019
|
state: "absent",
|
|
@@ -967,6 +1044,184 @@ function frameworkRow(frameworkId, control) {
|
|
|
967
1044
|
};
|
|
968
1045
|
}
|
|
969
1046
|
const rowsOf = (frameworkId) => Object.keys(FRAMEWORKS[frameworkId]?.controls ?? {}).map((control) => frameworkRow(frameworkId, control));
|
|
1047
|
+
const ISO_42001 = {
|
|
1048
|
+
id: "iso-iec-42001-2023",
|
|
1049
|
+
name: "ISO/IEC 42001:2023 — AI management system, Annex A"
|
|
1050
|
+
};
|
|
1051
|
+
const ISO_27001 = {
|
|
1052
|
+
id: "iso-iec-27001-2022",
|
|
1053
|
+
name: "ISO/IEC 27001:2022 — Annex A"
|
|
1054
|
+
};
|
|
1055
|
+
/** Rules in the pack whose `when` names a capability matching `test`. */
|
|
1056
|
+
function rulesOn(pack, test) {
|
|
1057
|
+
return (pack?.rules ?? []).filter((rule) => {
|
|
1058
|
+
const cap = rule.when.capability;
|
|
1059
|
+
return (cap === void 0 ? [] : typeof cap === "string" ? [cap] : cap).some(test);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
/** The record itself: recorded, chained, and how much of it is signed. */
|
|
1063
|
+
function recorded(data) {
|
|
1064
|
+
const n = data.entries.length;
|
|
1065
|
+
if (n === 0) return {
|
|
1066
|
+
state: "partial",
|
|
1067
|
+
note: "Recording is configured but nothing has been logged yet."
|
|
1068
|
+
};
|
|
1069
|
+
const signed = data.entries.filter((e) => e.sig !== void 0).length;
|
|
1070
|
+
if (!data.verification.ok) return {
|
|
1071
|
+
state: "partial",
|
|
1072
|
+
note: `${n} rows recorded, but the chain does not verify: the log is present and its integrity is not.`
|
|
1073
|
+
};
|
|
1074
|
+
return {
|
|
1075
|
+
state: "covered",
|
|
1076
|
+
note: `${n} rows recorded, each hash-linked to the one before, chain intact${signed > 0 ? `, ${signed} of ${n} signed` : ", unsigned"} — an edited, removed or reordered row breaks it.`
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/** Refusals and holds, recorded with their clause. */
|
|
1080
|
+
function monitored(data) {
|
|
1081
|
+
const refused = decisionsOf(data).filter((e) => e.verdict === "deny").length;
|
|
1082
|
+
const held = decisionsOf(data).filter((e) => e.verdict === "ask").length;
|
|
1083
|
+
return refused + held > 0 ? {
|
|
1084
|
+
state: "covered",
|
|
1085
|
+
note: `Every call is judged as it happens; this record holds ${refused} refusal${refused === 1 ? "" : "s"} and ${held} hold${held === 1 ? "" : "s"} for a human, each with the clause that produced it.`
|
|
1086
|
+
} : {
|
|
1087
|
+
state: "partial",
|
|
1088
|
+
note: "Every call is judged as it happens, but this record contains no refusal or hold yet, so the monitoring is unproven in practice."
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const ISO_CONTROLS = [
|
|
1092
|
+
{
|
|
1093
|
+
id: "ISO/IEC 42001 · A.2.2",
|
|
1094
|
+
name: "AI policy",
|
|
1095
|
+
standard: ISO_42001,
|
|
1096
|
+
trace: (data) => ({
|
|
1097
|
+
rules: allRules(data),
|
|
1098
|
+
rows: []
|
|
1099
|
+
}),
|
|
1100
|
+
asks: "The organisation has a documented policy for developing or using AI systems.",
|
|
1101
|
+
evidence: (data) => data.pack === void 0 ? {
|
|
1102
|
+
state: "absent",
|
|
1103
|
+
note: "No pack supplied to this view."
|
|
1104
|
+
} : {
|
|
1105
|
+
state: "partial",
|
|
1106
|
+
note: `The executable part of an AI policy: ${data.pack.rules.length} rules declared as data and applied to every tool call. The policy document and its approval are the organisation's.`
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
id: "ISO/IEC 42001 · A.6.2.6",
|
|
1111
|
+
name: "AI system operation and monitoring",
|
|
1112
|
+
standard: ISO_42001,
|
|
1113
|
+
trace: heldAndRefused,
|
|
1114
|
+
asks: "The AI system is monitored while it operates, and what is found is acted on.",
|
|
1115
|
+
evidence: monitored
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
id: "ISO/IEC 42001 · A.6.2.8",
|
|
1119
|
+
name: "Recording of event logs",
|
|
1120
|
+
standard: ISO_42001,
|
|
1121
|
+
trace: (data) => ({
|
|
1122
|
+
rules: [],
|
|
1123
|
+
rows: data.entries
|
|
1124
|
+
}),
|
|
1125
|
+
asks: "Event logs are kept while the AI system is in use.",
|
|
1126
|
+
evidence: recorded
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
id: "ISO/IEC 42001 · A.9.4",
|
|
1130
|
+
name: "Intended use of the AI system",
|
|
1131
|
+
standard: ISO_42001,
|
|
1132
|
+
trace: (data) => {
|
|
1133
|
+
const rows = decisionsOf(data).filter((e) => e.verdict === "deny");
|
|
1134
|
+
return {
|
|
1135
|
+
rules: [...new Set(rows.flatMap((e) => e.rule ? [e.rule] : []))],
|
|
1136
|
+
rows
|
|
1137
|
+
};
|
|
1138
|
+
},
|
|
1139
|
+
asks: "The AI system is used only for its intended uses.",
|
|
1140
|
+
evidence: (data) => {
|
|
1141
|
+
if (data.pack === void 0) return {
|
|
1142
|
+
state: "absent",
|
|
1143
|
+
note: "No pack supplied to this view."
|
|
1144
|
+
};
|
|
1145
|
+
const refused = decisionsOf(data).filter((e) => e.verdict === "deny").length;
|
|
1146
|
+
return refused > 0 ? {
|
|
1147
|
+
state: "covered",
|
|
1148
|
+
note: `Intended use is enforced as scope: ${refused} action${refused === 1 ? "" : "s"} outside it refused before execution in this record.`
|
|
1149
|
+
} : {
|
|
1150
|
+
state: "partial",
|
|
1151
|
+
note: "Intended use is enforced as scope by the pack; nothing outside it has been attempted in this record yet."
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
id: "ISO/IEC 27001 · A.8.3",
|
|
1157
|
+
name: "Information access restriction",
|
|
1158
|
+
standard: ISO_27001,
|
|
1159
|
+
trace: (data) => ({
|
|
1160
|
+
rules: rulesOn(data.pack, (c) => c.startsWith("fs:")).map((r) => r.id),
|
|
1161
|
+
rows: decisionsOf(data).filter((e) => e.verdict === "deny" && e.capability?.startsWith("fs:"))
|
|
1162
|
+
}),
|
|
1163
|
+
asks: "Access to information is restricted according to the access-control policy.",
|
|
1164
|
+
evidence: (data) => {
|
|
1165
|
+
const rules = rulesOn(data.pack, (c) => c.startsWith("fs:"));
|
|
1166
|
+
if (rules.length === 0) return {
|
|
1167
|
+
state: "absent",
|
|
1168
|
+
note: "No rule in this pack restricts file access."
|
|
1169
|
+
};
|
|
1170
|
+
const refused = decisionsOf(data).filter((e) => e.verdict === "deny" && e.capability?.startsWith("fs:")).length;
|
|
1171
|
+
return refused > 0 ? {
|
|
1172
|
+
state: "covered",
|
|
1173
|
+
note: `${rules.length} file-access rules restrict what the agent may read and write; ${refused} access${refused === 1 ? "" : "es"} refused in this record. Restriction is per agent, not per human.`
|
|
1174
|
+
} : {
|
|
1175
|
+
state: "partial",
|
|
1176
|
+
note: `${rules.length} file-access rules restrict what the agent may read and write; none has refused an access in this record yet.`
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
id: "ISO/IEC 27001 · A.8.12",
|
|
1182
|
+
name: "Data leakage prevention",
|
|
1183
|
+
standard: ISO_27001,
|
|
1184
|
+
trace: (data) => ({
|
|
1185
|
+
rules: [...new Set([...rulesOn(data.pack, (c) => c === "net:fetch").map((r) => r.id), ...(data.pack?.rules ?? []).filter((r) => r.when.secrets !== void 0).map((r) => r.id)])],
|
|
1186
|
+
rows: decisionsOf(data).filter((e) => e.verdict === "deny" && (e.capability === "net:fetch" || e.secrets !== void 0 || e.phase === "output"))
|
|
1187
|
+
}),
|
|
1188
|
+
asks: "Leakage of sensitive information is prevented where it is processed or sent.",
|
|
1189
|
+
evidence: (data) => {
|
|
1190
|
+
const rules = [...rulesOn(data.pack, (c) => c === "net:fetch"), ...(data.pack?.rules ?? []).filter((r) => r.when.secrets !== void 0)];
|
|
1191
|
+
if (rules.length === 0) return {
|
|
1192
|
+
state: "absent",
|
|
1193
|
+
note: "No rule in this pack governs egress or credentials."
|
|
1194
|
+
};
|
|
1195
|
+
const stopped = decisionsOf(data).filter((e) => e.verdict === "deny" && (e.capability === "net:fetch" || e.secrets !== void 0 || e.phase === "output")).length;
|
|
1196
|
+
return stopped > 0 ? {
|
|
1197
|
+
state: "covered",
|
|
1198
|
+
note: `${new Set(rules.map((r) => r.id)).size} egress and credential rules; ${stopped} send${stopped === 1 ? "" : "s"} or credential read${stopped === 1 ? "" : "s"} stopped in this record before the data left.`
|
|
1199
|
+
} : {
|
|
1200
|
+
state: "partial",
|
|
1201
|
+
note: `${new Set(rules.map((r) => r.id)).size} egress and credential rules in force; none has stopped anything in this record yet.`
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
{
|
|
1206
|
+
id: "ISO/IEC 27001 · A.8.15",
|
|
1207
|
+
name: "Logging",
|
|
1208
|
+
standard: ISO_27001,
|
|
1209
|
+
trace: (data) => ({
|
|
1210
|
+
rules: [],
|
|
1211
|
+
rows: data.entries
|
|
1212
|
+
}),
|
|
1213
|
+
asks: "Activities, exceptions and other relevant events are logged, and the logs are protected.",
|
|
1214
|
+
evidence: recorded
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
id: "ISO/IEC 27001 · A.8.16",
|
|
1218
|
+
name: "Monitoring activities",
|
|
1219
|
+
standard: ISO_27001,
|
|
1220
|
+
trace: heldAndRefused,
|
|
1221
|
+
asks: "Systems are monitored for anomalous behaviour, and potential incidents are evaluated.",
|
|
1222
|
+
evidence: monitored
|
|
1223
|
+
}
|
|
1224
|
+
];
|
|
970
1225
|
/**
|
|
971
1226
|
* The obligations first, then the risks framework by framework. Two kinds of
|
|
972
1227
|
* row in one table on purpose: an auditor reads down one list and finds the
|
|
@@ -976,7 +1231,8 @@ const rowsOf = (frameworkId) => Object.keys(FRAMEWORKS[frameworkId]?.controls ??
|
|
|
976
1231
|
const CONTROLS = [
|
|
977
1232
|
...OBLIGATIONS,
|
|
978
1233
|
...rowsOf(OWASP),
|
|
979
|
-
...rowsOf(AGENTIC)
|
|
1234
|
+
...rowsOf(AGENTIC),
|
|
1235
|
+
...ISO_CONTROLS
|
|
980
1236
|
];
|
|
981
1237
|
|
|
982
1238
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prae-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Operator policy for agent harnesses: validate packs, explain a decision, test a pack against security invariants, verify a decision log, and provision a governed DSH profile.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -33,19 +33,20 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
35
35
|
"capmark": "^0.3.1",
|
|
36
|
-
"dsh-overlay-check": "^0.4.0"
|
|
36
|
+
"dsh-overlay-check": "^0.4.0",
|
|
37
|
+
"yaml": "^2.8.3"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/node": "^22.10.0",
|
|
40
41
|
"tsdown": "^0.15.6",
|
|
41
42
|
"typescript": "^5.9.2",
|
|
42
43
|
"vitest": "^3.2.4",
|
|
43
|
-
"@prae/extract": "0.1.0",
|
|
44
|
-
"@prae/inspect": "0.1.0",
|
|
45
44
|
"@prae/controls": "0.1.0",
|
|
45
|
+
"@prae/inspect": "0.1.0",
|
|
46
|
+
"@prae/extract": "0.1.0",
|
|
46
47
|
"@prae/instructions": "0.1.0",
|
|
47
|
-
"@prae/kernel": "0.1.0",
|
|
48
48
|
"@prae/ledger": "0.1.0",
|
|
49
|
+
"@prae/kernel": "0.1.0",
|
|
49
50
|
"@prae/tokens": "0.1.0"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
package/lib/src-C9L6NBvu.js
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
import { a as generateKeypair, c as verifyReceipt, d as canonical, f as chain, i as fingerprint, l as verifySignature, m as seal, n as readEntries, o as makeReceipt, p as hashEntry, r as verifyEntries, s as signEntry, t as Ledger, u as GENESIS } from "./src-Ciggx9ja.js";
|
|
2
|
-
|
|
3
|
-
export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, signEntry, verifyEntries, verifyReceipt, verifySignature };
|
package/lib/src-Ciggx9ja.js
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
|
|
4
|
-
|
|
5
|
-
//#region ../ledger/src/entry.ts
|
|
6
|
-
/** The hash a chain starts from. */
|
|
7
|
-
const GENESIS = "0".repeat(64);
|
|
8
|
-
/**
|
|
9
|
-
* Deterministic serialization: object keys sorted at every depth.
|
|
10
|
-
*
|
|
11
|
-
* A hash over `JSON.stringify(entry)` would depend on key insertion order,
|
|
12
|
-
* which means a re-serialized entry could hash differently while saying
|
|
13
|
-
* exactly the same thing — and a verifier that reports tampering on a
|
|
14
|
-
* faithful copy trains its operators to ignore it.
|
|
15
|
-
*/
|
|
16
|
-
function canonical(value) {
|
|
17
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
18
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
19
|
-
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
20
|
-
}
|
|
21
|
-
/** Hash one entry's content together with its position and predecessor. */
|
|
22
|
-
function hashEntry(record, seq, prev) {
|
|
23
|
-
return createHash("sha256").update(canonical({
|
|
24
|
-
...record,
|
|
25
|
-
seq,
|
|
26
|
-
prev
|
|
27
|
-
})).digest("hex");
|
|
28
|
-
}
|
|
29
|
-
/** Chain a record onto a predecessor. */
|
|
30
|
-
function chain(record, seq, prev) {
|
|
31
|
-
return {
|
|
32
|
-
...record,
|
|
33
|
-
seq,
|
|
34
|
-
prev,
|
|
35
|
-
hash: hashEntry(record, seq, prev)
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/** The short form an auditor quotes from a screenshot. */
|
|
39
|
-
function seal(entry) {
|
|
40
|
-
return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
//#endregion
|
|
44
|
-
//#region ../ledger/src/receipt.ts
|
|
45
|
-
function generateKeypair() {
|
|
46
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
47
|
-
return {
|
|
48
|
-
publicKey: publicKey.export({
|
|
49
|
-
type: "spki",
|
|
50
|
-
format: "pem"
|
|
51
|
-
}).toString(),
|
|
52
|
-
privateKey: privateKey.export({
|
|
53
|
-
type: "pkcs8",
|
|
54
|
-
format: "pem"
|
|
55
|
-
}).toString()
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Sign a row's seal. The seal already covers the whole canonical record. */
|
|
59
|
-
function signEntry(entry, privateKeyPem) {
|
|
60
|
-
const key = createPrivateKey(privateKeyPem);
|
|
61
|
-
return sign(null, Buffer.from(entry.hash, "hex"), key).toString("base64");
|
|
62
|
-
}
|
|
63
|
-
function verifySignature(entry, signature, publicKeyPem) {
|
|
64
|
-
try {
|
|
65
|
-
const key = createPublicKey(publicKeyPem);
|
|
66
|
-
return verify(null, Buffer.from(entry.hash, "hex"), key, Buffer.from(signature, "base64"));
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
function makeReceipt(entry, privateKeyPem, publicKeyPem) {
|
|
72
|
-
return {
|
|
73
|
-
receipt: "prae/1",
|
|
74
|
-
entry,
|
|
75
|
-
signature: signEntry(entry, privateKeyPem),
|
|
76
|
-
publicKey: publicKeyPem
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
/** Verify a receipt from nothing but the receipt. */
|
|
80
|
-
function verifyReceipt(receipt) {
|
|
81
|
-
if (receipt.receipt !== "prae/1") return {
|
|
82
|
-
ok: false,
|
|
83
|
-
reason: `unknown receipt format ${String(receipt.receipt)}`
|
|
84
|
-
};
|
|
85
|
-
const { seq, prev, hash, sig,...record } = receipt.entry;
|
|
86
|
-
if (hashEntry(record, seq, prev) !== hash) return {
|
|
87
|
-
ok: false,
|
|
88
|
-
reason: "entry contents do not match their seal; the row was altered"
|
|
89
|
-
};
|
|
90
|
-
if (!verifySignature(receipt.entry, receipt.signature, receipt.publicKey)) return {
|
|
91
|
-
ok: false,
|
|
92
|
-
reason: "signature does not verify; not sealed by this key"
|
|
93
|
-
};
|
|
94
|
-
return { ok: true };
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* A short identifier for a public key, for pinning by eye.
|
|
98
|
-
*
|
|
99
|
-
* A counterparty checks a receipt against a key they already trust; comparing
|
|
100
|
-
* the whole PEM by hand invites skimming, and eight grouped hex bytes do not.
|
|
101
|
-
*/
|
|
102
|
-
function fingerprint(publicKeyPem) {
|
|
103
|
-
const der = createPublicKey(publicKeyPem).export({
|
|
104
|
-
type: "spki",
|
|
105
|
-
format: "der"
|
|
106
|
-
});
|
|
107
|
-
return createHash("sha256").update(der).digest("hex").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
//#endregion
|
|
111
|
-
//#region ../ledger/src/ledger.ts
|
|
112
|
-
/**
|
|
113
|
-
* Read a ledger held as text.
|
|
114
|
-
*
|
|
115
|
-
* Split out from the class because a ledger is not always a file being
|
|
116
|
-
* appended to: a build step renders one it imported, and a reader pastes one
|
|
117
|
-
* in. Those callers need the same parsing and the same verification the CLI
|
|
118
|
-
* runs, and a second implementation of either is how two answers to "does this
|
|
119
|
-
* chain hold" come to exist.
|
|
120
|
-
*/
|
|
121
|
-
function readEntries(text) {
|
|
122
|
-
return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
123
|
-
}
|
|
124
|
-
/** Re-derive every hash and confirm each entry names its predecessor. */
|
|
125
|
-
function verifyEntries(text) {
|
|
126
|
-
const problems = [];
|
|
127
|
-
let expectedPrev = GENESIS;
|
|
128
|
-
let expectedSeq = 0;
|
|
129
|
-
let count = 0;
|
|
130
|
-
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
131
|
-
for (const [index, line] of lines.entries()) {
|
|
132
|
-
const at = index + 1;
|
|
133
|
-
count += 1;
|
|
134
|
-
let entry;
|
|
135
|
-
try {
|
|
136
|
-
entry = JSON.parse(line);
|
|
137
|
-
} catch {
|
|
138
|
-
problems.push({
|
|
139
|
-
line: at,
|
|
140
|
-
message: "not valid JSON"
|
|
141
|
-
});
|
|
142
|
-
return {
|
|
143
|
-
ok: false,
|
|
144
|
-
entries: count,
|
|
145
|
-
problems
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
if (entry.seq !== expectedSeq) problems.push({
|
|
149
|
-
line: at,
|
|
150
|
-
message: `expected seq ${expectedSeq}, found ${entry.seq}`
|
|
151
|
-
});
|
|
152
|
-
if (entry.prev !== expectedPrev) problems.push({
|
|
153
|
-
line: at,
|
|
154
|
-
message: "does not name the previous entry; a row was removed or reordered"
|
|
155
|
-
});
|
|
156
|
-
const { seq, prev, hash, sig: _sig,...record } = entry;
|
|
157
|
-
if (hashEntry(record, seq, prev) !== hash) problems.push({
|
|
158
|
-
line: at,
|
|
159
|
-
message: "contents do not match the sealed hash"
|
|
160
|
-
});
|
|
161
|
-
expectedPrev = entry.hash;
|
|
162
|
-
expectedSeq = entry.seq + 1;
|
|
163
|
-
}
|
|
164
|
-
return {
|
|
165
|
-
ok: problems.length === 0,
|
|
166
|
-
entries: count,
|
|
167
|
-
problems
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
var Ledger = class Ledger {
|
|
171
|
-
path;
|
|
172
|
-
seq;
|
|
173
|
-
last;
|
|
174
|
-
constructor(path, seq, last) {
|
|
175
|
-
this.path = path;
|
|
176
|
-
this.seq = seq;
|
|
177
|
-
this.last = last;
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Open a ledger, reading the tail to continue an existing chain.
|
|
181
|
-
*
|
|
182
|
-
* A corrupt or truncated final line is a hard error rather than something to
|
|
183
|
-
* skip past: appending onto a chain whose end we could not read would bury
|
|
184
|
-
* the damage under valid-looking entries.
|
|
185
|
-
*/
|
|
186
|
-
/** Sign every appended row with this Ed25519 private key (PEM). */
|
|
187
|
-
signWith;
|
|
188
|
-
static open(path, options = {}) {
|
|
189
|
-
const ledger = Ledger.openUnsigned(path);
|
|
190
|
-
ledger.signWith = options.signWith;
|
|
191
|
-
return ledger;
|
|
192
|
-
}
|
|
193
|
-
static openUnsigned(path) {
|
|
194
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
195
|
-
let text = "";
|
|
196
|
-
try {
|
|
197
|
-
text = readFileSync(path, "utf8");
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (error.code !== "ENOENT") throw error;
|
|
200
|
-
writeFileSync(path, "", { flag: "wx" });
|
|
201
|
-
return new Ledger(path, 0, GENESIS);
|
|
202
|
-
}
|
|
203
|
-
const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
|
|
204
|
-
if (tail === void 0) return new Ledger(path, 0, GENESIS);
|
|
205
|
-
let entry;
|
|
206
|
-
try {
|
|
207
|
-
entry = JSON.parse(tail);
|
|
208
|
-
} catch {
|
|
209
|
-
throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
|
|
210
|
-
}
|
|
211
|
-
return new Ledger(path, entry.seq + 1, entry.hash);
|
|
212
|
-
}
|
|
213
|
-
/** Append one decision and return the entry as written. */
|
|
214
|
-
append(record) {
|
|
215
|
-
const sealed = chain(record, this.seq, this.last);
|
|
216
|
-
const entry = this.signWith === void 0 ? sealed : {
|
|
217
|
-
...sealed,
|
|
218
|
-
sig: signEntry(sealed, this.signWith)
|
|
219
|
-
};
|
|
220
|
-
appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
|
|
221
|
-
this.seq += 1;
|
|
222
|
-
this.last = entry.hash;
|
|
223
|
-
return entry;
|
|
224
|
-
}
|
|
225
|
-
/** Every entry, in order. */
|
|
226
|
-
read() {
|
|
227
|
-
return readEntries(readFileSync(this.path, "utf8"));
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Re-derive every hash and confirm each entry names its predecessor.
|
|
231
|
-
*
|
|
232
|
-
* Catches a deleted row, an edited field, and a reordering. Does not catch a
|
|
233
|
-
* wholesale rewrite of the file by someone holding the same code — see the
|
|
234
|
-
* note in `entry.ts`.
|
|
235
|
-
*/
|
|
236
|
-
verify() {
|
|
237
|
-
return verifyEntries(readFileSync(this.path, "utf8"));
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
//#endregion
|
|
242
|
-
export { generateKeypair as a, verifyReceipt as c, canonical as d, chain as f, fingerprint as i, verifySignature as l, seal as m, readEntries as n, makeReceipt as o, hashEntry as p, verifyEntries as r, signEntry as s, Ledger as t, GENESIS as u };
|