@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload intake and COSE Sign1 assembly for `forestrie sign-statement`
|
|
3
|
+
* (FOR-341).
|
|
4
|
+
*
|
|
5
|
+
* The signed statement is a plain COSE Sign1 (forestrie-demo-01.md, answer
|
|
6
|
+
* 5) with **all interpretable labels in the protected header**:
|
|
7
|
+
* `{1: alg (ES256, -7), 3: cty, 4: kid}`, signed via `@forestrie/encoding`
|
|
8
|
+
* `signCoseSign1Statement` (encoding >= 0.2.0). Nothing rides unprotected —
|
|
9
|
+
* per SCITT, the content type that tells a relying party how to interpret
|
|
10
|
+
* the signed bytes must be covered by the signature (review F1,
|
|
11
|
+
* plan-2607-14 W1.2), and the algorithm is pinned the same way.
|
|
12
|
+
*
|
|
13
|
+
* The encoder emits canonical tag-free CBOR, so the wire stays a plain
|
|
14
|
+
* untagged four-tuple (`84 58 xx ...`) that strict "plain COSE Sign1"
|
|
15
|
+
* consumers (any SCRAPI client) accept — no cbor-x tag 64 re-encode
|
|
16
|
+
* workaround is needed now that no unprotected header is merged in.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import {
|
|
21
|
+
COSE_ALG_ES256,
|
|
22
|
+
coseKeyThumbprintUriP256,
|
|
23
|
+
signCoseSign1Statement,
|
|
24
|
+
} from "@forestrie/encoding";
|
|
25
|
+
import { errorMessage, type Es256SigningKey } from "./sign-statement-key.js";
|
|
26
|
+
|
|
27
|
+
/** COSE header label for content type (RFC 9052 §3.1) — protected. */
|
|
28
|
+
export const COSE_CONTENT_TYPE = 3;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the statement payload from a file, or stdin when `pathOrDash`
|
|
32
|
+
* is `-`.
|
|
33
|
+
*/
|
|
34
|
+
export function readPayloadBytes(pathOrDash: string): Uint8Array {
|
|
35
|
+
try {
|
|
36
|
+
const data =
|
|
37
|
+
pathOrDash === "-" ? readFileSync(0) : readFileSync(pathOrDash);
|
|
38
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const source =
|
|
41
|
+
pathOrDash === "-" ? "stdin" : `payload file ${pathOrDash}`;
|
|
42
|
+
throw new Error(`cannot read ${source}: ${errorMessage(err)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `--iss` keyword deriving the RFC 9679 COSE Key Thumbprint URI. */
|
|
47
|
+
export const ISS_CKT_KEYWORD = "ckt";
|
|
48
|
+
|
|
49
|
+
/** Statement content type plus CWT claims inputs (FOR-371). */
|
|
50
|
+
export type SignStatementBuildOptions = {
|
|
51
|
+
/** COSE content type header value (label 3, protected). */
|
|
52
|
+
contentType: string;
|
|
53
|
+
/**
|
|
54
|
+
* Issuer (CWT claim 1): literal StringOrURI, or {@link ISS_CKT_KEYWORD}
|
|
55
|
+
* to derive the RFC 9679 thumbprint URI from the signing key.
|
|
56
|
+
* Default: lowercase hex of the kid bytes (devdocs ADR-0055).
|
|
57
|
+
*/
|
|
58
|
+
iss?: string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Subject (CWT claim 2): issuer-scoped StringOrURI.
|
|
61
|
+
* Default: `sha-256:<hex>` of the payload bytes.
|
|
62
|
+
*/
|
|
63
|
+
sub?: string | undefined;
|
|
64
|
+
/** Issued-at (CWT claim 6), seconds since epoch. Omitted by default. */
|
|
65
|
+
iat?: number | undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A signed statement plus the claims that were actually bound into it. */
|
|
69
|
+
export type SignedStatement = {
|
|
70
|
+
/** CBOR COSE Sign1 bytes (untagged array(4)). */
|
|
71
|
+
statement: Uint8Array;
|
|
72
|
+
/** Resolved issuer (CWT claim 1). */
|
|
73
|
+
iss: string;
|
|
74
|
+
/** Resolved subject (CWT claim 2). */
|
|
75
|
+
sub: string;
|
|
76
|
+
/** Issued-at (CWT claim 6), when requested. */
|
|
77
|
+
iat?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sign `payload` as a plain COSE Sign1 signed statement.
|
|
82
|
+
*
|
|
83
|
+
* Protected header carries `{1: ES256, 3: contentType, 4: kid, 15: CWT
|
|
84
|
+
* claims}` (SCITT signed statement, FOR-371); the unprotected header is
|
|
85
|
+
* empty. The signature covers everything interpretable — alg, cty, and the
|
|
86
|
+
* claims are all inside the protected bstr in the Sig_structure.
|
|
87
|
+
*
|
|
88
|
+
* @param payload - Statement payload bytes
|
|
89
|
+
* @param key - Loaded ES256 signing key (kid = first 32 bytes of `x||y`)
|
|
90
|
+
* @param options - Content type and claims; iss/sub default per ADR-0055
|
|
91
|
+
* @returns Statement bytes plus the resolved iss/sub (and iat when set)
|
|
92
|
+
*/
|
|
93
|
+
export async function buildSignedStatement(
|
|
94
|
+
payload: Uint8Array,
|
|
95
|
+
key: Es256SigningKey,
|
|
96
|
+
options: SignStatementBuildOptions,
|
|
97
|
+
): Promise<SignedStatement> {
|
|
98
|
+
const iss =
|
|
99
|
+
options.iss === ISS_CKT_KEYWORD
|
|
100
|
+
? await coseKeyThumbprintUriP256(key.publicXY)
|
|
101
|
+
: (options.iss ??
|
|
102
|
+
Buffer.from(key.kid).toString("hex"));
|
|
103
|
+
const sub =
|
|
104
|
+
options.sub ??
|
|
105
|
+
`sha-256:${createHash("sha256").update(payload).digest("hex")}`;
|
|
106
|
+
const statement = await signCoseSign1Statement(
|
|
107
|
+
payload,
|
|
108
|
+
key.kid,
|
|
109
|
+
key.privateKey,
|
|
110
|
+
{
|
|
111
|
+
alg: COSE_ALG_ES256,
|
|
112
|
+
cty: options.contentType,
|
|
113
|
+
cwtClaims: { iss, sub, ...(options.iat !== undefined && { iat: options.iat }) },
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
return { statement, iss, sub, ...(options.iat !== undefined && { iat: options.iat }) };
|
|
117
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ES256 signing-key loading and kid derivation for `forestrie
|
|
3
|
+
* sign-statement` (FOR-341).
|
|
4
|
+
*
|
|
5
|
+
* Key import follows @forestrie/grant-builder `es256-pem-grant`:
|
|
6
|
+
* node:crypto `createPrivateKey` accepts SEC1 or PKCS#8 EC PEMs (WebCrypto
|
|
7
|
+
* alone imports only PKCS#8); JWK key files are routed through the same
|
|
8
|
+
* call so every input is validated identically. The normalized private JWK
|
|
9
|
+
* is then imported into WebCrypto because `@forestrie/encoding`
|
|
10
|
+
* `signCoseSign1Statement` signs with `crypto.subtle`.
|
|
11
|
+
*
|
|
12
|
+
* kid binding (forestrie-demo-01.md, answer 5): first 32 bytes of the
|
|
13
|
+
* uncompressed public point `x||y` under ES256.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
createPrivateKey,
|
|
17
|
+
type KeyObject,
|
|
18
|
+
type webcrypto,
|
|
19
|
+
} from "node:crypto";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { parsePemResilient } from "./openssl-error-queue.js";
|
|
22
|
+
|
|
23
|
+
/** kid length: first 32 bytes of `x||y` (i.e. the x coordinate). */
|
|
24
|
+
export const ES256_KID_BYTES = 32;
|
|
25
|
+
|
|
26
|
+
/** P-256 coordinate length in bytes. */
|
|
27
|
+
const P256_COORD_BYTES = 32;
|
|
28
|
+
|
|
29
|
+
/** Loaded ES256 signing key, ready for `signCoseSign1Statement`. */
|
|
30
|
+
export type Es256SigningKey = {
|
|
31
|
+
/** WebCrypto ECDSA P-256 private key (usage: sign). */
|
|
32
|
+
privateKey: CryptoKey;
|
|
33
|
+
/** Signer binding: first {@link ES256_KID_BYTES} bytes of `x||y`. */
|
|
34
|
+
kid: Uint8Array;
|
|
35
|
+
/** Uncompressed public point `x||y` (64 bytes). */
|
|
36
|
+
publicXY: Uint8Array;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** `Error.message` (or stringified value) for wrapped error causes. */
|
|
40
|
+
export function errorMessage(err: unknown): string {
|
|
41
|
+
return err instanceof Error ? err.message : String(err);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load an ES256 (EC P-256) private key from a PEM or JWK file.
|
|
46
|
+
*
|
|
47
|
+
* @param keyPath - SEC1/PKCS#8 PEM or JWK JSON private key file
|
|
48
|
+
* @returns WebCrypto sign key plus the derived kid and public point
|
|
49
|
+
*/
|
|
50
|
+
export async function loadEs256SigningKey(
|
|
51
|
+
keyPath: string,
|
|
52
|
+
): Promise<Es256SigningKey> {
|
|
53
|
+
let text: string;
|
|
54
|
+
try {
|
|
55
|
+
text = readFileSync(keyPath, "utf8");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new Error(`cannot read key file ${keyPath}: ${errorMessage(err)}`);
|
|
58
|
+
}
|
|
59
|
+
return es256SigningKeyFromText(text, keyPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** As {@link loadEs256SigningKey}, from key material already in memory. */
|
|
63
|
+
export async function es256SigningKeyFromText(
|
|
64
|
+
text: string,
|
|
65
|
+
source: string,
|
|
66
|
+
): Promise<Es256SigningKey> {
|
|
67
|
+
const keyObject = importPrivateKeyObject(text, source);
|
|
68
|
+
const jwk = keyObject.export({ format: "jwk" });
|
|
69
|
+
if (
|
|
70
|
+
jwk.kty !== "EC" ||
|
|
71
|
+
jwk.crv !== "P-256" ||
|
|
72
|
+
typeof jwk.x !== "string" ||
|
|
73
|
+
typeof jwk.y !== "string" ||
|
|
74
|
+
typeof jwk.d !== "string"
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`${source}: sign-statement requires an ES256 (EC P-256) private key`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const x = base64UrlToBytes(jwk.x);
|
|
82
|
+
const y = base64UrlToBytes(jwk.y);
|
|
83
|
+
if (x.length !== P256_COORD_BYTES || y.length !== P256_COORD_BYTES) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${source}: P-256 public key coordinates must be 32 bytes each`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const publicXY = new Uint8Array(P256_COORD_BYTES * 2);
|
|
89
|
+
publicXY.set(x, 0);
|
|
90
|
+
publicXY.set(y, P256_COORD_BYTES);
|
|
91
|
+
|
|
92
|
+
const privateKey = await crypto.subtle.importKey(
|
|
93
|
+
"jwk",
|
|
94
|
+
{ kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, d: jwk.d, ext: true },
|
|
95
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
96
|
+
false,
|
|
97
|
+
["sign"],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
privateKey,
|
|
102
|
+
kid: publicXY.slice(0, ES256_KID_BYTES),
|
|
103
|
+
publicXY,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Route PEM or JWK-JSON key text through node:crypto for validation. */
|
|
108
|
+
function importPrivateKeyObject(text: string, source: string): KeyObject {
|
|
109
|
+
if (text.includes("-----BEGIN")) {
|
|
110
|
+
try {
|
|
111
|
+
// `parsePemResilient` retries once so a valid key is never rejected
|
|
112
|
+
// because the OpenSSL error queue was poisoned elsewhere (FOR-343).
|
|
113
|
+
return parsePemResilient(() =>
|
|
114
|
+
createPrivateKey({ key: text, format: "pem" }),
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${source}: not a readable EC private key PEM: ${errorMessage(err)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let parsed: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(text);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${source}: key file is neither a PEM nor a JWK JSON document`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return createPrivateKey({
|
|
132
|
+
key: parsed as webcrypto.JsonWebKey,
|
|
133
|
+
format: "jwk",
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${source}: not a usable private JWK: ${errorMessage(err)}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Decode base64url (JWK coordinate encoding) to bytes. */
|
|
143
|
+
function base64UrlToBytes(b64url: string): Uint8Array {
|
|
144
|
+
return new Uint8Array(Buffer.from(b64url, "base64url"));
|
|
145
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { bytesEqual } from "./bytes.js";
|
|
2
|
+
import { ethCall, normalizeHexAddress } from "@forestrie/chain-rpc";
|
|
3
|
+
import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
|
|
4
|
+
import {
|
|
5
|
+
calculateRoot,
|
|
6
|
+
verifyInclusion,
|
|
7
|
+
type Hasher,
|
|
8
|
+
} from "@forestrie/merklelog";
|
|
9
|
+
import {
|
|
10
|
+
decodeTrustRootFromGenesis,
|
|
11
|
+
parseReceipt,
|
|
12
|
+
univocityLeafHash,
|
|
13
|
+
} from "@forestrie/receipt-verify";
|
|
14
|
+
|
|
15
|
+
/** Re-exported for existing local consumers (was CLI-private before FOR-297 D5 hoist). */
|
|
16
|
+
export { univocityLeafHash };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Chain-anchored receipt check (FOR-347 / demo step 5a): read the
|
|
20
|
+
* Univocity `logState(bytes32)` accumulator over JSON-RPC and assert the
|
|
21
|
+
* receipt's peak is one of the anchored accumulator peaks. This is the
|
|
22
|
+
* ONLY networked path in `forestrie verify`; the core verify stays pure
|
|
23
|
+
* over bytes.
|
|
24
|
+
*
|
|
25
|
+
* Anchoring test: for a detached-payload receipt the checkpoint signature
|
|
26
|
+
* covers the peak (COSE detached content), so the peak the receipt commits
|
|
27
|
+
* to is exactly the one the signature verifies over. We therefore try the
|
|
28
|
+
* genesis trust key against each on-chain accumulator peak as the detached
|
|
29
|
+
* payload — a match proves the signed peak is on-chain, and the offline
|
|
30
|
+
* verify already proved leaf inclusion under that same signed peak. For an
|
|
31
|
+
* attached-payload receipt the peak is explicit and byte-compared.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Selector for `logState(bytes32)` (arbor publishproof Univocity ABI). */
|
|
35
|
+
const LOG_STATE_SELECTOR = "0xeecac1b7";
|
|
36
|
+
|
|
37
|
+
export type OnChainLogState = {
|
|
38
|
+
/** Anchored accumulator peaks (32 bytes each), contract order. */
|
|
39
|
+
accumulator: Uint8Array[];
|
|
40
|
+
/** Anchored MMR size. */
|
|
41
|
+
size: bigint;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type AnchorCheck = OnChainLogState & {
|
|
45
|
+
/** True when the receipt's peak is one of the anchored accumulator peaks. */
|
|
46
|
+
anchored: boolean;
|
|
47
|
+
/** Index of the matched accumulator peak, or null. */
|
|
48
|
+
matchedPeak: number | null;
|
|
49
|
+
/** Stable failure token when not anchored. */
|
|
50
|
+
reason?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classify an unanchored peak so honest receipts never fail tamper-shaped
|
|
55
|
+
* (FOR-368 Phase 0, plan-2607-29):
|
|
56
|
+
* - the leaf predates the anchored size but its peak is absent — the log
|
|
57
|
+
* grew and BURIED the peak (`peak_not_current`): an honest-receipt
|
|
58
|
+
* condition needing growth evidence, not a tamper verdict;
|
|
59
|
+
* - the leaf is at/after the anchored size — the entry simply is not
|
|
60
|
+
* anchored yet (`receipt_newer_than_anchored_state`).
|
|
61
|
+
* Tampered receipts surface earlier (signature/inclusion) or as a
|
|
62
|
+
* recomputed peak that matches no anchored state ever; the chain rungs
|
|
63
|
+
* that PROVE the buried case land in later plan phases.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyUnanchoredPeak(
|
|
66
|
+
leafMmrIndex: bigint,
|
|
67
|
+
anchoredSize: bigint,
|
|
68
|
+
): string {
|
|
69
|
+
if (leafMmrIndex >= anchoredSize) {
|
|
70
|
+
return "receipt_newer_than_anchored_state";
|
|
71
|
+
}
|
|
72
|
+
return "peak_not_current";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Leaf MMR index from a parsed receipt proof. */
|
|
76
|
+
export function receiptLeafIndex(receiptCbor: Uint8Array): bigint {
|
|
77
|
+
const { proof } = parseReceipt(receiptCbor);
|
|
78
|
+
return proof.leafIndex !== undefined ? proof.leafIndex : proof.mmrIndex!;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize a log id (UUID with dashes, 32-hex UUID form, or 64-hex
|
|
83
|
+
* contract form) to the 32-byte contract key: Univocity stores logs with
|
|
84
|
+
* the UUID in the low 16 bytes, zero-padded on the left.
|
|
85
|
+
*/
|
|
86
|
+
export function toContractLogId(logId: string): string {
|
|
87
|
+
const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
|
|
88
|
+
if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`--log-id must be a UUID or 16/32-byte hex id, got '${logId}'`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return "0x" + hex.padStart(64, "0");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readWord(hex: string, wordIndex: number): bigint {
|
|
97
|
+
const start = wordIndex * 64;
|
|
98
|
+
if (hex.length < start + 64) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`logState result too short: need word ${wordIndex}, have ${hex.length} hex chars`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return BigInt("0x" + hex.slice(start, start + 64));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
107
|
+
const out = new Uint8Array(hex.length / 2);
|
|
108
|
+
for (let i = 0; i < out.length; i++) {
|
|
109
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decode the `logState(bytes32)` return — the dynamic tuple
|
|
116
|
+
* `(bytes32[] accumulator, uint64 size)`: word0 = offset to the tuple,
|
|
117
|
+
* then relative to the tuple start: word = offset to the accumulator
|
|
118
|
+
* array, word = `size`, and the array as length + elements.
|
|
119
|
+
*/
|
|
120
|
+
export function decodeLogStateResult(resultHex: string): OnChainLogState {
|
|
121
|
+
const hex = resultHex.replace(/^0x/, "");
|
|
122
|
+
if (hex.length === 0) {
|
|
123
|
+
throw new Error("logState returned empty result (log not found?)");
|
|
124
|
+
}
|
|
125
|
+
const tupleOffset = readWord(hex, 0);
|
|
126
|
+
const base = Number(tupleOffset) / 32; // word index of the tuple start
|
|
127
|
+
const accumulatorOffset = readWord(hex, base);
|
|
128
|
+
const size = readWord(hex, base + 1);
|
|
129
|
+
const arrayBase = base + Number(accumulatorOffset) / 32;
|
|
130
|
+
const length = Number(readWord(hex, arrayBase));
|
|
131
|
+
const accumulator: Uint8Array[] = [];
|
|
132
|
+
for (let i = 0; i < length; i++) {
|
|
133
|
+
const start = (arrayBase + 1 + i) * 64;
|
|
134
|
+
if (hex.length < start + 64) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`logState accumulator truncated at peak ${i} of ${length}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
accumulator.push(hexToBytes(hex.slice(start, start + 64)));
|
|
140
|
+
}
|
|
141
|
+
return { accumulator, size };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `eth_call` `logState(logId)` via `@forestrie/chain-rpc`. */
|
|
145
|
+
export async function fetchOnChainLogState(opts: {
|
|
146
|
+
univocity: string;
|
|
147
|
+
logId: string;
|
|
148
|
+
rpcUrl: string;
|
|
149
|
+
}): Promise<OnChainLogState> {
|
|
150
|
+
const address = normalizeHexAddress(opts.univocity);
|
|
151
|
+
if (address === null) {
|
|
152
|
+
throw new Error(`--univocity is not a valid address: '${opts.univocity}'`);
|
|
153
|
+
}
|
|
154
|
+
const data = LOG_STATE_SELECTOR + toContractLogId(opts.logId).slice(2);
|
|
155
|
+
const result = await ethCall(opts.rpcUrl, `0x${address}`, data);
|
|
156
|
+
if (typeof result !== "string" || result === "0x") {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`logState eth_call returned no data for log ${opts.logId} at ${opts.univocity}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return decodeLogStateResult(result);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
/** Web Crypto SHA-256 hasher (mirrors @forestrie/receipt-verify SubtleHasher). */
|
|
166
|
+
class SubtleHasher implements Hasher {
|
|
167
|
+
private chunks: Uint8Array[] = [];
|
|
168
|
+
reset(): void {
|
|
169
|
+
this.chunks = [];
|
|
170
|
+
}
|
|
171
|
+
update(data: Uint8Array): void {
|
|
172
|
+
this.chunks.push(data);
|
|
173
|
+
}
|
|
174
|
+
async digest(): Promise<Uint8Array> {
|
|
175
|
+
const total = this.chunks.reduce((s, c) => s + c.length, 0);
|
|
176
|
+
const combined = new Uint8Array(total);
|
|
177
|
+
let offset = 0;
|
|
178
|
+
for (const c of this.chunks) {
|
|
179
|
+
combined.set(c, offset);
|
|
180
|
+
offset += c.length;
|
|
181
|
+
}
|
|
182
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", combined));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type RecomputedPeak = {
|
|
187
|
+
/** The accumulator peak the leaf's inclusion proof commits to. */
|
|
188
|
+
peak: Uint8Array;
|
|
189
|
+
/**
|
|
190
|
+
* True when the proof path checks out: always for a detached receipt (the
|
|
191
|
+
* peak IS the path recomputation), byte-verified for an attached peak.
|
|
192
|
+
*/
|
|
193
|
+
inclusionOk: boolean;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Recompute the receipt's accumulator peak from the leaf commitment and the
|
|
198
|
+
* receipt's own inclusion proof — pure bytes, NO signature involved. This is
|
|
199
|
+
* the signature-free half of chain-anchored verification (FOR-297 approach C):
|
|
200
|
+
* `leaf = SHA-256(idtimestamp ‖ inner)`, walk the label-396 proof path to the
|
|
201
|
+
* peak, then the caller matches that peak against the on-chain accumulator.
|
|
202
|
+
* Binding and inclusion are inherent: a wrong payload or tampered path yields
|
|
203
|
+
* a peak that cannot appear in the anchored accumulator.
|
|
204
|
+
*/
|
|
205
|
+
export async function recomputeReceiptPeak(opts: {
|
|
206
|
+
receiptCbor: Uint8Array;
|
|
207
|
+
idtimestampBe8: Uint8Array;
|
|
208
|
+
/** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
|
|
209
|
+
inner: Uint8Array;
|
|
210
|
+
}): Promise<RecomputedPeak> {
|
|
211
|
+
const { explicitPeak, proof } = parseReceipt(opts.receiptCbor);
|
|
212
|
+
const leafHash = await univocityLeafHash(opts.idtimestampBe8, opts.inner);
|
|
213
|
+
const hasher = new SubtleHasher();
|
|
214
|
+
const leafIdx =
|
|
215
|
+
proof.leafIndex !== undefined ? proof.leafIndex : proof.mmrIndex!;
|
|
216
|
+
if (explicitPeak !== null) {
|
|
217
|
+
const inclusionOk = await verifyInclusion(
|
|
218
|
+
hasher,
|
|
219
|
+
leafHash,
|
|
220
|
+
proof,
|
|
221
|
+
explicitPeak,
|
|
222
|
+
);
|
|
223
|
+
return { peak: explicitPeak, inclusionOk };
|
|
224
|
+
}
|
|
225
|
+
const peak = await calculateRoot(hasher, leafHash, proof, leafIdx);
|
|
226
|
+
return { peak, inclusionOk: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Assert the receipt's peak is anchored in the on-chain accumulator.
|
|
231
|
+
* Two peak-location strategies:
|
|
232
|
+
* - default (offline verify passed): a detached receipt's peak is located by
|
|
233
|
+
* trying the genesis trust key against each on-chain peak as the detached
|
|
234
|
+
* payload; an attached peak is byte-compared.
|
|
235
|
+
* - `recomputedPeak` (anchor-only, FOR-297 approach C): the caller recomputed
|
|
236
|
+
* the peak from leaf + proof via {@link recomputeReceiptPeak}; byte-compare
|
|
237
|
+
* it directly — no signature, no trust key. Trust is the contract anchor.
|
|
238
|
+
*/
|
|
239
|
+
export async function checkReceiptAnchored(opts: {
|
|
240
|
+
/** Required unless `recomputedPeak` locates the peak without a trust key. */
|
|
241
|
+
genesisCbor: Uint8Array | undefined;
|
|
242
|
+
receiptCbor: Uint8Array;
|
|
243
|
+
univocity: string;
|
|
244
|
+
logId: string;
|
|
245
|
+
rpcUrl: string;
|
|
246
|
+
recomputedPeak?: Uint8Array;
|
|
247
|
+
}): Promise<AnchorCheck> {
|
|
248
|
+
if (opts.recomputedPeak !== undefined) {
|
|
249
|
+
const state = await fetchOnChainLogState(opts);
|
|
250
|
+
let matchedPeak: number | null = null;
|
|
251
|
+
for (let i = 0; i < state.accumulator.length; i++) {
|
|
252
|
+
if (bytesEqual(opts.recomputedPeak, state.accumulator[i]!)) {
|
|
253
|
+
matchedPeak = i;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const anchored = matchedPeak !== null;
|
|
258
|
+
return {
|
|
259
|
+
...state,
|
|
260
|
+
anchored,
|
|
261
|
+
matchedPeak,
|
|
262
|
+
...(anchored
|
|
263
|
+
? {}
|
|
264
|
+
: {
|
|
265
|
+
reason: classifyUnanchoredPeak(
|
|
266
|
+
receiptLeafIndex(opts.receiptCbor),
|
|
267
|
+
state.size,
|
|
268
|
+
),
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const { explicitPeak } = parseReceipt(opts.receiptCbor);
|
|
274
|
+
if (opts.genesisCbor === undefined) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"genesis is required to locate the receipt peak on-chain without a recomputed peak",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const trustRoot = await decodeTrustRootFromGenesis(opts.genesisCbor);
|
|
280
|
+
if (explicitPeak === null && !(trustRoot instanceof CryptoKey)) {
|
|
281
|
+
// Detached receipts locate their peak via the ES256 signature; a
|
|
282
|
+
// KS256 root cannot verify here (same surface as `no_es256_trust_key`).
|
|
283
|
+
return {
|
|
284
|
+
accumulator: [],
|
|
285
|
+
size: 0n,
|
|
286
|
+
anchored: false,
|
|
287
|
+
matchedPeak: null,
|
|
288
|
+
reason: "no_es256_trust_key",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const state = await fetchOnChainLogState(opts);
|
|
293
|
+
|
|
294
|
+
let matchedPeak: number | null = null;
|
|
295
|
+
for (let i = 0; i < state.accumulator.length; i++) {
|
|
296
|
+
const peak = state.accumulator[i]!;
|
|
297
|
+
if (explicitPeak !== null) {
|
|
298
|
+
if (bytesEqual(explicitPeak, peak)) {
|
|
299
|
+
matchedPeak = i;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
} else if (
|
|
303
|
+
await verifyCoseSign1WithParsedKey(opts.receiptCbor, trustRoot as CryptoKey, {
|
|
304
|
+
detachedPayload: peak,
|
|
305
|
+
})
|
|
306
|
+
) {
|
|
307
|
+
matchedPeak = i;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const anchored = matchedPeak !== null;
|
|
313
|
+
return {
|
|
314
|
+
...state,
|
|
315
|
+
anchored,
|
|
316
|
+
matchedPeak,
|
|
317
|
+
...(anchored
|
|
318
|
+
? {}
|
|
319
|
+
: {
|
|
320
|
+
reason: classifyUnanchoredPeak(
|
|
321
|
+
receiptLeafIndex(opts.receiptCbor),
|
|
322
|
+
state.size,
|
|
323
|
+
),
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
}
|