@forestrie/forestrie-cli 0.8.0
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/LICENSE +21 -0
- package/README.md +425 -0
- package/TRUST-MODEL.md +8 -0
- package/dist/cli.js +33811 -0
- package/dist/decode-receipt.d.ts +19 -0
- package/dist/decode-receipt.d.ts.map +1 -0
- package/dist/decode-receipt.js +19 -0
- package/dist/decode-receipt.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/decode-receipt-cbor.d.ts +26 -0
- package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
- package/dist/lib/decode-receipt-cbor.js +182 -0
- package/dist/lib/decode-receipt-cbor.js.map +1 -0
- package/dist/lib/decode-receipt-decode.d.ts +103 -0
- package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
- package/dist/lib/decode-receipt-decode.js +237 -0
- package/dist/lib/decode-receipt-decode.js.map +1 -0
- package/dist/lib/decode-receipt-labels.d.ts +54 -0
- package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
- package/dist/lib/decode-receipt-labels.js +117 -0
- package/dist/lib/decode-receipt-labels.js.map +1 -0
- package/dist/lib/decode-receipt-render.d.ts +11 -0
- package/dist/lib/decode-receipt-render.d.ts.map +1 -0
- package/dist/lib/decode-receipt-render.js +242 -0
- package/dist/lib/decode-receipt-render.js.map +1 -0
- package/package.json +83 -0
- package/src/cli.ts +5 -0
- package/src/command.ts +51 -0
- package/src/commands/admin-onboard-token.ts +51 -0
- package/src/commands/admin.ts +20 -0
- package/src/commands/complete-grant.ts +41 -0
- package/src/commands/create-consistency-proof.ts +44 -0
- package/src/commands/create-log.ts +95 -0
- package/src/commands/decode-receipt.ts +20 -0
- package/src/commands/delegate.ts +55 -0
- package/src/commands/deploy.ts +72 -0
- package/src/commands/fetch-accumulator.ts +49 -0
- package/src/commands/onboard-genesis.ts +70 -0
- package/src/commands/onboard-request.ts +59 -0
- package/src/commands/register-grant.ts +74 -0
- package/src/commands/register.ts +54 -0
- package/src/commands/resolve-receipt.ts +101 -0
- package/src/commands/sign-statement.ts +53 -0
- package/src/commands/verify-grant.ts +89 -0
- package/src/commands/verify.ts +92 -0
- package/src/commoncli.ts +34 -0
- package/src/decode-receipt.ts +56 -0
- package/src/index.ts +10 -0
- package/src/lib/bytes.ts +7 -0
- package/src/lib/checkpoint-provider.ts +297 -0
- package/src/lib/complete-grant-derive.ts +237 -0
- package/src/lib/consistency-proof.ts +271 -0
- package/src/lib/create-log-build.ts +168 -0
- package/src/lib/create-log-prepare.ts +75 -0
- package/src/lib/create-receipt-chain-verify.ts +267 -0
- package/src/lib/create-receipt-derive.ts +208 -0
- package/src/lib/create-receipt-inputs.ts +79 -0
- package/src/lib/decode-checkpoint-calldata.ts +188 -0
- package/src/lib/decode-receipt-cbor.ts +202 -0
- package/src/lib/decode-receipt-decode.ts +333 -0
- package/src/lib/decode-receipt-labels.ts +132 -0
- package/src/lib/decode-receipt-render.ts +284 -0
- package/src/lib/delegate-flow.ts +289 -0
- package/src/lib/delegate-voucher.ts +122 -0
- package/src/lib/deploy-artifact.ts +79 -0
- package/src/lib/deploy-bootstrap.ts +100 -0
- package/src/lib/deploy-flow.ts +184 -0
- package/src/lib/deploy-genesis-log-id.ts +29 -0
- package/src/lib/deploy-rpc.ts +40 -0
- package/src/lib/deployment-record.ts +65 -0
- package/src/lib/fsio.ts +91 -0
- package/src/lib/grant-flags-local.ts +19 -0
- package/src/lib/onboard-attestation.ts +120 -0
- package/src/lib/onboard-genesis-body.ts +94 -0
- package/src/lib/openssl-error-queue.ts +56 -0
- package/src/lib/register-flow.ts +292 -0
- package/src/lib/register-grant-build.ts +173 -0
- package/src/lib/register-grant-complete.ts +47 -0
- package/src/lib/register-grant-flow.ts +258 -0
- package/src/lib/resolve-receipt-freshen.ts +221 -0
- package/src/lib/sign-statement-build.ts +117 -0
- package/src/lib/sign-statement-key.ts +145 -0
- package/src/lib/verify-anchored.ts +326 -0
- package/src/lib/verify-checkpoint-chain.ts +160 -0
- package/src/lib/verify-eventscan.ts +214 -0
- package/src/lib/verify-inputs.ts +143 -0
- package/src/lib/verify-known-accumulator.ts +161 -0
- package/src/lib/verify-report.ts +265 -0
- package/src/lib/x402-payment.ts +115 -0
- package/src/main/admin-onboard-token.ts +216 -0
- package/src/main/complete-grant.ts +265 -0
- package/src/main/create-consistency-proof.ts +83 -0
- package/src/main/create-log.ts +347 -0
- package/src/main/create-receipt.ts +763 -0
- package/src/main/decode-receipt.ts +72 -0
- package/src/main/delegate.ts +141 -0
- package/src/main/deploy.ts +273 -0
- package/src/main/fetch-accumulator.ts +181 -0
- package/src/main/not-implemented.ts +43 -0
- package/src/main/onboard-genesis.ts +192 -0
- package/src/main/onboard-request.ts +333 -0
- package/src/main/register-grant.ts +270 -0
- package/src/main/register.ts +243 -0
- package/src/main/sign-statement.ts +168 -0
- package/src/main/verify.ts +767 -0
- package/src/options/admin-onboard-token.ts +48 -0
- package/src/options/common.ts +69 -0
- package/src/options/complete-grant.ts +40 -0
- package/src/options/create-consistency-proof.ts +63 -0
- package/src/options/create-log.ts +99 -0
- package/src/options/create-receipt.ts +243 -0
- package/src/options/decode-receipt.ts +25 -0
- package/src/options/delegate.ts +93 -0
- package/src/options/deploy.ts +105 -0
- package/src/options/fetch-accumulator.ts +62 -0
- package/src/options/onboard-genesis.ts +74 -0
- package/src/options/onboard-request.ts +57 -0
- package/src/options/register-grant.ts +72 -0
- package/src/options/register.ts +71 -0
- package/src/options/sign-statement.ts +64 -0
- package/src/options/verify.ts +250 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { Out } from "@forestrie/cli-kit/reporting";
|
|
2
|
+
import { readTextFile, writeOutputFile } from "../lib/fsio.js";
|
|
3
|
+
import type { RegisterGrantOptions } from "../options/register-grant.js";
|
|
4
|
+
import {
|
|
5
|
+
RegisterGrantBuildError,
|
|
6
|
+
buildGrantStatement,
|
|
7
|
+
type BuiltGrantStatement,
|
|
8
|
+
} from "../lib/register-grant-build.js";
|
|
9
|
+
import { completeGrantBase64 } from "../lib/register-grant-complete.js";
|
|
10
|
+
import {
|
|
11
|
+
RegisterFlowError,
|
|
12
|
+
runRegisterGrantFlow,
|
|
13
|
+
type RegisterGrantFlowDeps,
|
|
14
|
+
type RegisterGrantFlowResult,
|
|
15
|
+
} from "../lib/register-grant-flow.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* FOR-343: register a grant statement authorizing use of a child/data log.
|
|
19
|
+
* One grant binds exactly ONE signer; several signers on one data log =
|
|
20
|
+
* several grants naming that log, all sequenced into the OWNER (auth)
|
|
21
|
+
* log. Build the signed grant (`@forestrie/grant-builder` ES256 profile),
|
|
22
|
+
* POST it to `/register/{bootstrap}/grants` with the parent grant as CBOR
|
|
23
|
+
* body evidence (`@forestrie/scrapi-client`), follow the 303 to the
|
|
24
|
+
* receipt, then emit the COMPLETED grant (receipt + idtimestamp attached)
|
|
25
|
+
* — the bearer credential `forestrie register` consumes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** `--json` success shape. */
|
|
29
|
+
export type RegisterGrantReport = {
|
|
30
|
+
command: "register-grant";
|
|
31
|
+
status: "receipt";
|
|
32
|
+
/** Owner (auth) log the grant leaf was sequenced into. */
|
|
33
|
+
ownerLog: string;
|
|
34
|
+
/** Target child/data log the grant authorizes. */
|
|
35
|
+
dataLog: string;
|
|
36
|
+
/** The ONE authorized signer (ES256 x||y, hex) committed as grantData. */
|
|
37
|
+
grantDataHex: string;
|
|
38
|
+
entryId: string;
|
|
39
|
+
statusUrl: string;
|
|
40
|
+
receiptUrl: string;
|
|
41
|
+
receiptBytes: number;
|
|
42
|
+
/** Present when `--out-b64` was given. */
|
|
43
|
+
outB64?: string;
|
|
44
|
+
/** Completed grant base64 — only when not written to `--out-b64`. */
|
|
45
|
+
grantB64?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** `--json` failure shape (problem = CBOR problem-details passthrough). */
|
|
49
|
+
export type RegisterGrantErrorReport = {
|
|
50
|
+
error:
|
|
51
|
+
| "key_read_failed"
|
|
52
|
+
| "grant_build_failed"
|
|
53
|
+
| "registration_failed"
|
|
54
|
+
| "status_failed"
|
|
55
|
+
| "receipt_failed"
|
|
56
|
+
| "network_failed"
|
|
57
|
+
| "timeout";
|
|
58
|
+
command: "register-grant";
|
|
59
|
+
message: string;
|
|
60
|
+
httpStatus?: number;
|
|
61
|
+
detail?: string;
|
|
62
|
+
problem?: Record<string, unknown>;
|
|
63
|
+
statusUrl?: string;
|
|
64
|
+
receiptUrl?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Test seam: effects injected by the register-grant tests (real by default). */
|
|
68
|
+
export type RegisterGrantRunDeps = RegisterGrantFlowDeps;
|
|
69
|
+
|
|
70
|
+
const FLOW_ERROR_CODES = {
|
|
71
|
+
register: "registration_failed",
|
|
72
|
+
status: "status_failed",
|
|
73
|
+
receipt: "receipt_failed",
|
|
74
|
+
network: "network_failed",
|
|
75
|
+
timeout: "timeout",
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
async function readPemFile(path: string, flag: string): Promise<string> {
|
|
79
|
+
return readTextFile(path, `${flag} key file not found: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reportError(
|
|
83
|
+
out: Out,
|
|
84
|
+
options: RegisterGrantOptions,
|
|
85
|
+
report: RegisterGrantErrorReport,
|
|
86
|
+
): void {
|
|
87
|
+
if (options.json) {
|
|
88
|
+
out.out(JSON.stringify(report, null, 2));
|
|
89
|
+
} else {
|
|
90
|
+
out.warn("forestrie register-grant: %s", report.message);
|
|
91
|
+
if (report.detail !== undefined && !report.message.includes(report.detail)) {
|
|
92
|
+
out.warn(" detail: %s", report.detail);
|
|
93
|
+
}
|
|
94
|
+
if (report.problem !== undefined) {
|
|
95
|
+
out.log("problem details: %s", JSON.stringify(report.problem));
|
|
96
|
+
}
|
|
97
|
+
if (report.statusUrl !== undefined) {
|
|
98
|
+
out.warn(" statusUrl: %s", report.statusUrl);
|
|
99
|
+
}
|
|
100
|
+
if (report.receiptUrl !== undefined) {
|
|
101
|
+
out.warn(" receiptUrl: %s", report.receiptUrl);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function flowErrorReport(err: RegisterFlowError): RegisterGrantErrorReport {
|
|
108
|
+
const report: RegisterGrantErrorReport = {
|
|
109
|
+
error: FLOW_ERROR_CODES[err.stage],
|
|
110
|
+
command: "register-grant",
|
|
111
|
+
message: err.message,
|
|
112
|
+
};
|
|
113
|
+
if (err.httpStatus !== undefined) report.httpStatus = err.httpStatus;
|
|
114
|
+
if (err.detail !== undefined) report.detail = err.detail;
|
|
115
|
+
if (err.problem !== undefined) report.problem = err.problem;
|
|
116
|
+
if (err.statusUrl !== undefined) report.statusUrl = err.statusUrl;
|
|
117
|
+
if (err.receiptUrl !== undefined) report.receiptUrl = err.receiptUrl;
|
|
118
|
+
return report;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function reportCompletedGrant(
|
|
122
|
+
out: Out,
|
|
123
|
+
options: RegisterGrantOptions,
|
|
124
|
+
built: BuiltGrantStatement,
|
|
125
|
+
result: RegisterGrantFlowResult,
|
|
126
|
+
completedB64: string,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const grantDataHex = Buffer.from(built.grantData).toString("hex");
|
|
129
|
+
if (options.outB64 !== undefined) {
|
|
130
|
+
await writeOutputFile(options.outB64, completedB64);
|
|
131
|
+
}
|
|
132
|
+
if (options.json) {
|
|
133
|
+
const report: RegisterGrantReport = {
|
|
134
|
+
command: "register-grant",
|
|
135
|
+
status: "receipt",
|
|
136
|
+
ownerLog: options.ownerLog,
|
|
137
|
+
dataLog: options.dataLog,
|
|
138
|
+
grantDataHex,
|
|
139
|
+
entryId: result.entryIdHex,
|
|
140
|
+
statusUrl: result.statusUrl,
|
|
141
|
+
receiptUrl: result.receiptUrl,
|
|
142
|
+
receiptBytes: result.receipt.length,
|
|
143
|
+
};
|
|
144
|
+
if (options.outB64 !== undefined) {
|
|
145
|
+
report.outB64 = options.outB64;
|
|
146
|
+
} else {
|
|
147
|
+
report.grantB64 = completedB64;
|
|
148
|
+
}
|
|
149
|
+
out.out(JSON.stringify(report, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Human mode: without --out-b64 the completed grant base64 is the
|
|
153
|
+
// pipeable product on stdout; the summary narrates on stderr. With
|
|
154
|
+
// --out-b64 the summary is the stdout product (mirrors register).
|
|
155
|
+
const emit = options.outB64 === undefined ? out.print : out.out;
|
|
156
|
+
emit("ownerLog: %s (grant leaf)", options.ownerLog);
|
|
157
|
+
emit("dataLog: %s (authorized)", options.dataLog);
|
|
158
|
+
emit("signer: %s", grantDataHex);
|
|
159
|
+
emit("entryId: %s", result.entryIdHex);
|
|
160
|
+
emit("statusUrl: %s", result.statusUrl);
|
|
161
|
+
emit("receiptUrl: %s", result.receiptUrl);
|
|
162
|
+
if (options.outB64 !== undefined) {
|
|
163
|
+
emit("wrote completed grant (base64) to %s", options.outB64);
|
|
164
|
+
} else {
|
|
165
|
+
out.out("%s", completedB64);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runRegisterGrant(
|
|
170
|
+
out: Out,
|
|
171
|
+
options: RegisterGrantOptions,
|
|
172
|
+
deps: RegisterGrantRunDeps = {},
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
// 1. Key material.
|
|
175
|
+
let signWithPem: string;
|
|
176
|
+
let signerPem: string;
|
|
177
|
+
try {
|
|
178
|
+
signWithPem = await readPemFile(options.signWith, "--sign-with");
|
|
179
|
+
signerPem = await readPemFile(options.signerPem, "--signer-pem");
|
|
180
|
+
} catch (err) {
|
|
181
|
+
reportError(out, options, {
|
|
182
|
+
error: "key_read_failed",
|
|
183
|
+
command: "register-grant",
|
|
184
|
+
message: err instanceof Error ? err.message : String(err),
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. Build + sign the grant transparent statement (one grant, ONE signer).
|
|
190
|
+
let built: BuiltGrantStatement;
|
|
191
|
+
try {
|
|
192
|
+
built = buildGrantStatement({
|
|
193
|
+
targetLog: options.dataLog,
|
|
194
|
+
ownerLog: options.ownerLog,
|
|
195
|
+
signWithPem,
|
|
196
|
+
signerPem,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof RegisterGrantBuildError) {
|
|
200
|
+
reportError(out, options, {
|
|
201
|
+
error: "grant_build_failed",
|
|
202
|
+
command: "register-grant",
|
|
203
|
+
message: err.message,
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const flowDeps: RegisterGrantFlowDeps = {
|
|
211
|
+
...deps,
|
|
212
|
+
onProgress:
|
|
213
|
+
deps.onProgress ??
|
|
214
|
+
((progress) => {
|
|
215
|
+
switch (progress.phase) {
|
|
216
|
+
case "registered":
|
|
217
|
+
out.print("grant registered; status: %s", progress.statusUrl);
|
|
218
|
+
break;
|
|
219
|
+
case "status-pending":
|
|
220
|
+
out.log(
|
|
221
|
+
"sequencing pending (attempt %d): %s",
|
|
222
|
+
progress.attempt,
|
|
223
|
+
progress.statusUrl,
|
|
224
|
+
);
|
|
225
|
+
break;
|
|
226
|
+
case "receipt-located":
|
|
227
|
+
out.print(
|
|
228
|
+
"sequenced as entry %s; receipt: %s",
|
|
229
|
+
progress.entryIdHex,
|
|
230
|
+
progress.receiptUrl,
|
|
231
|
+
);
|
|
232
|
+
break;
|
|
233
|
+
case "receipt-pending":
|
|
234
|
+
out.log(
|
|
235
|
+
"receipt objects still writing (attempt %d): %s",
|
|
236
|
+
progress.attempt,
|
|
237
|
+
progress.receiptUrl,
|
|
238
|
+
);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// 3. Register the grant, follow to the receipt, complete the grant.
|
|
245
|
+
try {
|
|
246
|
+
const result = await runRegisterGrantFlow(
|
|
247
|
+
{
|
|
248
|
+
baseUrl: options.baseUrl,
|
|
249
|
+
bootstrapLogId: options.bootstrapLog,
|
|
250
|
+
grantBase64: built.grantBase64,
|
|
251
|
+
parentGrantBase64: options.parentGrantB64,
|
|
252
|
+
timeoutMs: options.timeoutMs,
|
|
253
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
254
|
+
},
|
|
255
|
+
flowDeps,
|
|
256
|
+
);
|
|
257
|
+
const completedB64 = completeGrantBase64(
|
|
258
|
+
built.grantBase64,
|
|
259
|
+
result.receipt,
|
|
260
|
+
result.entryIdHex,
|
|
261
|
+
);
|
|
262
|
+
await reportCompletedGrant(out, options, built, result, completedB64);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err instanceof RegisterFlowError) {
|
|
265
|
+
reportError(out, options, flowErrorReport(err));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { Out } from "@forestrie/cli-kit/reporting";
|
|
2
|
+
import {
|
|
3
|
+
readBytesFile,
|
|
4
|
+
readStdinBytes,
|
|
5
|
+
writeOutputFile,
|
|
6
|
+
} from "../lib/fsio.js";
|
|
7
|
+
import type { RegisterOptions } from "../options/register.js";
|
|
8
|
+
import {
|
|
9
|
+
RegisterFlowError,
|
|
10
|
+
runRegisterFlow,
|
|
11
|
+
type RegisterFlowDeps,
|
|
12
|
+
type RegisterFlowResult,
|
|
13
|
+
} from "../lib/register-flow.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* FOR-342: register a signed statement via SCRAPI
|
|
17
|
+
* (`@forestrie/scrapi-client`) — POST with the `Authorization:
|
|
18
|
+
* Forestrie-Grant` header (opaque bearer at this step), follow the 303,
|
|
19
|
+
* poll, download the receipt. Any SCRAPI client, plain COSE Sign1.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** `--json` success shape. */
|
|
23
|
+
export type RegisterReport = {
|
|
24
|
+
command: "register";
|
|
25
|
+
status: "receipt";
|
|
26
|
+
entryId: string;
|
|
27
|
+
statusUrl: string;
|
|
28
|
+
receiptUrl: string;
|
|
29
|
+
receiptBytes: number;
|
|
30
|
+
/** Present when `--out` was given. */
|
|
31
|
+
out?: string;
|
|
32
|
+
/** Receipt bytes, base64 — only when not written to `--out`. */
|
|
33
|
+
receiptB64?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** `--json` failure shape (problem = CBOR problem-details passthrough). */
|
|
37
|
+
export type RegisterErrorReport = {
|
|
38
|
+
error:
|
|
39
|
+
| "statement_read_failed"
|
|
40
|
+
| "registration_failed"
|
|
41
|
+
| "status_failed"
|
|
42
|
+
| "receipt_failed"
|
|
43
|
+
| "network_failed"
|
|
44
|
+
| "timeout";
|
|
45
|
+
command: "register";
|
|
46
|
+
message: string;
|
|
47
|
+
httpStatus?: number;
|
|
48
|
+
detail?: string;
|
|
49
|
+
problem?: Record<string, unknown>;
|
|
50
|
+
statusUrl?: string;
|
|
51
|
+
receiptUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Test seam: effects injected by the register tests (real by default). */
|
|
55
|
+
export type RegisterRunDeps = RegisterFlowDeps & {
|
|
56
|
+
/** Statement source when `--statement` is `-`/absent (default: stdin). */
|
|
57
|
+
readStdin?: () => Promise<Uint8Array>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const FLOW_ERROR_CODES = {
|
|
61
|
+
register: "registration_failed",
|
|
62
|
+
status: "status_failed",
|
|
63
|
+
receipt: "receipt_failed",
|
|
64
|
+
network: "network_failed",
|
|
65
|
+
timeout: "timeout",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
async function readStatementBytes(
|
|
69
|
+
statement: string | undefined,
|
|
70
|
+
readStdin: () => Promise<Uint8Array>,
|
|
71
|
+
): Promise<Uint8Array> {
|
|
72
|
+
if (statement === undefined || statement === "-") {
|
|
73
|
+
return readStdin();
|
|
74
|
+
}
|
|
75
|
+
return readBytesFile(statement, `statement file not found: ${statement}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportError(
|
|
79
|
+
out: Out,
|
|
80
|
+
options: RegisterOptions,
|
|
81
|
+
report: RegisterErrorReport,
|
|
82
|
+
): void {
|
|
83
|
+
if (options.json) {
|
|
84
|
+
out.out(JSON.stringify(report, null, 2));
|
|
85
|
+
} else {
|
|
86
|
+
out.warn("forestrie register: %s", report.message);
|
|
87
|
+
if (report.detail !== undefined && !report.message.includes(report.detail)) {
|
|
88
|
+
out.warn(" detail: %s", report.detail);
|
|
89
|
+
}
|
|
90
|
+
if (report.problem !== undefined) {
|
|
91
|
+
out.log("problem details: %s", JSON.stringify(report.problem));
|
|
92
|
+
}
|
|
93
|
+
if (report.statusUrl !== undefined) {
|
|
94
|
+
out.warn(" statusUrl: %s", report.statusUrl);
|
|
95
|
+
}
|
|
96
|
+
if (report.receiptUrl !== undefined) {
|
|
97
|
+
out.warn(" receiptUrl: %s", report.receiptUrl);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function flowErrorReport(err: RegisterFlowError): RegisterErrorReport {
|
|
104
|
+
const report: RegisterErrorReport = {
|
|
105
|
+
error: FLOW_ERROR_CODES[err.stage],
|
|
106
|
+
command: "register",
|
|
107
|
+
message: err.message,
|
|
108
|
+
};
|
|
109
|
+
if (err.httpStatus !== undefined) report.httpStatus = err.httpStatus;
|
|
110
|
+
if (err.detail !== undefined) report.detail = err.detail;
|
|
111
|
+
if (err.problem !== undefined) report.problem = err.problem;
|
|
112
|
+
if (err.statusUrl !== undefined) report.statusUrl = err.statusUrl;
|
|
113
|
+
if (err.receiptUrl !== undefined) report.receiptUrl = err.receiptUrl;
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function reportReceipt(
|
|
118
|
+
out: Out,
|
|
119
|
+
options: RegisterOptions,
|
|
120
|
+
result: RegisterFlowResult,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (options.out !== undefined) {
|
|
123
|
+
await writeOutputFile(options.out, result.receipt);
|
|
124
|
+
}
|
|
125
|
+
if (options.json) {
|
|
126
|
+
const report: RegisterReport = {
|
|
127
|
+
command: "register",
|
|
128
|
+
status: "receipt",
|
|
129
|
+
entryId: result.entryIdHex,
|
|
130
|
+
statusUrl: result.statusUrl,
|
|
131
|
+
receiptUrl: result.receiptUrl,
|
|
132
|
+
receiptBytes: result.receipt.length,
|
|
133
|
+
};
|
|
134
|
+
if (options.out !== undefined) {
|
|
135
|
+
report.out = options.out;
|
|
136
|
+
} else {
|
|
137
|
+
report.receiptB64 = Buffer.from(result.receipt).toString("base64");
|
|
138
|
+
}
|
|
139
|
+
out.out(JSON.stringify(report, null, 2));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Human mode: receipt to --out keeps stdout clean; without --out the
|
|
143
|
+
// summary is the pipeable product.
|
|
144
|
+
const emit = options.out !== undefined ? out.print : out.out;
|
|
145
|
+
emit("entryId: %s", result.entryIdHex);
|
|
146
|
+
emit("statusUrl: %s", result.statusUrl);
|
|
147
|
+
emit("receiptUrl: %s", result.receiptUrl);
|
|
148
|
+
if (options.out !== undefined) {
|
|
149
|
+
emit(
|
|
150
|
+
"wrote receipt (%d bytes) to %s",
|
|
151
|
+
result.receipt.length,
|
|
152
|
+
options.out,
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
emit(
|
|
156
|
+
"receipt: %d bytes (pass --out <path> to save the CBOR)",
|
|
157
|
+
result.receipt.length,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function runRegister(
|
|
163
|
+
out: Out,
|
|
164
|
+
options: RegisterOptions,
|
|
165
|
+
deps: RegisterRunDeps = {},
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const readStdin = deps.readStdin ?? readStdinBytes;
|
|
168
|
+
|
|
169
|
+
let statement: Uint8Array;
|
|
170
|
+
try {
|
|
171
|
+
statement = await readStatementBytes(options.statement, readStdin);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
reportError(out, options, {
|
|
174
|
+
error: "statement_read_failed",
|
|
175
|
+
command: "register",
|
|
176
|
+
message: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (statement.length === 0) {
|
|
181
|
+
reportError(out, options, {
|
|
182
|
+
error: "statement_read_failed",
|
|
183
|
+
command: "register",
|
|
184
|
+
message: "signed statement is empty",
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const flowDeps: RegisterFlowDeps = {
|
|
190
|
+
...deps,
|
|
191
|
+
onProgress:
|
|
192
|
+
deps.onProgress ??
|
|
193
|
+
((progress) => {
|
|
194
|
+
switch (progress.phase) {
|
|
195
|
+
case "registered":
|
|
196
|
+
out.print("registered; status: %s", progress.statusUrl);
|
|
197
|
+
break;
|
|
198
|
+
case "status-pending":
|
|
199
|
+
out.log(
|
|
200
|
+
"sequencing pending (attempt %d): %s",
|
|
201
|
+
progress.attempt,
|
|
202
|
+
progress.statusUrl,
|
|
203
|
+
);
|
|
204
|
+
break;
|
|
205
|
+
case "receipt-located":
|
|
206
|
+
out.print(
|
|
207
|
+
"sequenced as entry %s; receipt: %s",
|
|
208
|
+
progress.entryIdHex,
|
|
209
|
+
progress.receiptUrl,
|
|
210
|
+
);
|
|
211
|
+
break;
|
|
212
|
+
case "receipt-pending":
|
|
213
|
+
out.log(
|
|
214
|
+
"receipt objects still writing (attempt %d): %s",
|
|
215
|
+
progress.attempt,
|
|
216
|
+
progress.receiptUrl,
|
|
217
|
+
);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const result = await runRegisterFlow(
|
|
225
|
+
{
|
|
226
|
+
baseUrl: options.baseUrl,
|
|
227
|
+
logId: options.logId,
|
|
228
|
+
grantB64: options.grantB64,
|
|
229
|
+
statement,
|
|
230
|
+
timeoutMs: options.timeoutMs,
|
|
231
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
232
|
+
},
|
|
233
|
+
flowDeps,
|
|
234
|
+
);
|
|
235
|
+
await reportReceipt(out, options, result);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err instanceof RegisterFlowError) {
|
|
238
|
+
reportError(out, options, flowErrorReport(err));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import type { Out } from "@forestrie/cli-kit/reporting";
|
|
3
|
+
import type { SignStatementOptions } from "../options/sign-statement.js";
|
|
4
|
+
import {
|
|
5
|
+
buildSignedStatement,
|
|
6
|
+
readPayloadBytes,
|
|
7
|
+
type SignedStatement,
|
|
8
|
+
} from "../lib/sign-statement-build.js";
|
|
9
|
+
import {
|
|
10
|
+
errorMessage,
|
|
11
|
+
loadEs256SigningKey,
|
|
12
|
+
} from "../lib/sign-statement-key.js";
|
|
13
|
+
|
|
14
|
+
/** Structured `--json` success report — the shape is a contract. */
|
|
15
|
+
export type SignStatementReport = {
|
|
16
|
+
command: "sign-statement";
|
|
17
|
+
/** COSE algorithm used for the signature. */
|
|
18
|
+
alg: "ES256";
|
|
19
|
+
/** Signer binding, hex: first 32 bytes of `x||y`. */
|
|
20
|
+
kid: string;
|
|
21
|
+
/** Payload size in bytes. */
|
|
22
|
+
payloadBytes: number;
|
|
23
|
+
/** COSE content type header value (label 3, protected). */
|
|
24
|
+
contentType: string;
|
|
25
|
+
/** Issuer (CWT claim 1, protected label 15). */
|
|
26
|
+
iss: string;
|
|
27
|
+
/** Subject (CWT claim 2, protected label 15). */
|
|
28
|
+
sub: string;
|
|
29
|
+
/** Issued-at (CWT claim 6), when `--iat` was given. */
|
|
30
|
+
iat?: number;
|
|
31
|
+
/** Signed statement (COSE Sign1 CBOR) size in bytes. */
|
|
32
|
+
statementBytes: number;
|
|
33
|
+
/** Output path, when `--out` was given. */
|
|
34
|
+
out?: string;
|
|
35
|
+
/** Base64 COSE Sign1 bytes, when no `--out` (JSON owns stdout). */
|
|
36
|
+
statementB64?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Structured `--json` error report. */
|
|
40
|
+
export type SignStatementError = {
|
|
41
|
+
error: "sign_statement_failed";
|
|
42
|
+
command: "sign-statement";
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* FOR-341: build a plain COSE Sign1 signed statement
|
|
48
|
+
* (`@forestrie/encoding`). `kid` = first 32 bytes of x||y under ES256;
|
|
49
|
+
* alg (label 1), content type (label 3), and kid (label 4) all in the
|
|
50
|
+
* **protected** header — the signature covers them; nothing interpretable
|
|
51
|
+
* rides unprotected (review F1). Statement bytes go to `--out`, or raw to
|
|
52
|
+
* stdout — except under `--json` without `--out`, where they are returned
|
|
53
|
+
* base64 inside the report (stdout carries JSON).
|
|
54
|
+
*/
|
|
55
|
+
export async function runSignStatement(
|
|
56
|
+
out: Out,
|
|
57
|
+
options: SignStatementOptions,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
let payload: Uint8Array;
|
|
60
|
+
let signed: SignedStatement;
|
|
61
|
+
let kidHex: string;
|
|
62
|
+
try {
|
|
63
|
+
rejectEmptyClaim("--iss", options.iss, "the default hex-kid issuer");
|
|
64
|
+
rejectEmptyClaim("--sub", options.sub, "the default payload-hash subject");
|
|
65
|
+
payload = readPayloadBytes(options.payload);
|
|
66
|
+
const key = await loadEs256SigningKey(options.key);
|
|
67
|
+
signed = await buildSignedStatement(payload, key, {
|
|
68
|
+
contentType: options.contentType,
|
|
69
|
+
iss: options.iss,
|
|
70
|
+
sub: options.sub,
|
|
71
|
+
iat: resolveIatOption(options.iat),
|
|
72
|
+
});
|
|
73
|
+
kidHex = Buffer.from(key.kid).toString("hex");
|
|
74
|
+
if (options.out !== undefined) {
|
|
75
|
+
writeFileSync(options.out, signed.statement);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
reportFailure(out, options, errorMessage(err));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const statement = signed.statement;
|
|
82
|
+
|
|
83
|
+
if (options.json) {
|
|
84
|
+
const report: SignStatementReport = {
|
|
85
|
+
command: "sign-statement",
|
|
86
|
+
alg: "ES256",
|
|
87
|
+
kid: kidHex,
|
|
88
|
+
payloadBytes: payload.length,
|
|
89
|
+
contentType: options.contentType,
|
|
90
|
+
iss: signed.iss,
|
|
91
|
+
sub: signed.sub,
|
|
92
|
+
...(signed.iat !== undefined && { iat: signed.iat }),
|
|
93
|
+
statementBytes: statement.length,
|
|
94
|
+
...(options.out !== undefined
|
|
95
|
+
? { out: options.out }
|
|
96
|
+
: { statementB64: Buffer.from(statement).toString("base64") }),
|
|
97
|
+
};
|
|
98
|
+
out.out(JSON.stringify(report, null, 2));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (options.out === undefined) {
|
|
103
|
+
// Raw CBOR to stdout (pipeable); the summary stays on stderr.
|
|
104
|
+
writeFileSync(1, statement);
|
|
105
|
+
}
|
|
106
|
+
out.print("signed statement: plain COSE Sign1 (ES256)");
|
|
107
|
+
out.print(" kid: %s", kidHex);
|
|
108
|
+
out.print(" iss: %s", signed.iss);
|
|
109
|
+
out.print(" sub: %s", signed.sub);
|
|
110
|
+
if (signed.iat !== undefined) out.print(" iat: %d", signed.iat);
|
|
111
|
+
out.print(" payload: %d bytes (%s)", payload.length, options.contentType);
|
|
112
|
+
out.print(
|
|
113
|
+
" statement: %d bytes -> %s",
|
|
114
|
+
statement.length,
|
|
115
|
+
options.out ?? "stdout",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Largest iat the canonical encoder accepts (4-byte CBOR uint). */
|
|
120
|
+
const IAT_MAX_SECONDS = 0xffffffff;
|
|
121
|
+
|
|
122
|
+
/** `--iat now` → current unix seconds; digits → bounded integer. */
|
|
123
|
+
function resolveIatOption(raw: string | undefined): number | undefined {
|
|
124
|
+
if (raw === undefined) return undefined;
|
|
125
|
+
if (raw === "now") return Math.floor(Date.now() / 1000);
|
|
126
|
+
if (!/^\d+$/.test(raw)) {
|
|
127
|
+
throw new Error(`--iat must be 'now' or unix seconds, got '${raw}'`);
|
|
128
|
+
}
|
|
129
|
+
const seconds = Number.parseInt(raw, 10);
|
|
130
|
+
if (seconds > IAT_MAX_SECONDS) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`--iat ${raw} exceeds ${IAT_MAX_SECONDS} (unix seconds; a 13-digit value is usually milliseconds — divide by 1000)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return seconds;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** An explicit empty claim is an error, never a silent default. */
|
|
139
|
+
function rejectEmptyClaim(
|
|
140
|
+
flag: string,
|
|
141
|
+
value: string | undefined,
|
|
142
|
+
fallbackDescription: string,
|
|
143
|
+
): void {
|
|
144
|
+
if (value === "") {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${flag} must not be empty (omit it for ${fallbackDescription})`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Human mode: one line on stderr. `--json`: the report on stdout. */
|
|
152
|
+
function reportFailure(
|
|
153
|
+
out: Out,
|
|
154
|
+
options: SignStatementOptions,
|
|
155
|
+
message: string,
|
|
156
|
+
): void {
|
|
157
|
+
if (options.json) {
|
|
158
|
+
const report: SignStatementError = {
|
|
159
|
+
error: "sign_statement_failed",
|
|
160
|
+
command: "sign-statement",
|
|
161
|
+
message,
|
|
162
|
+
};
|
|
163
|
+
out.out(JSON.stringify(report, null, 2));
|
|
164
|
+
} else {
|
|
165
|
+
out.warn("forestrie sign-statement: %s", message);
|
|
166
|
+
}
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
}
|