@forestrie/receipt-verify 0.1.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/dist/cose-key.d.ts +10 -0
- package/dist/cose-key.d.ts.map +1 -0
- package/dist/cose-key.js +9 -0
- package/dist/decode-forestrie-grant-cose.d.ts +9 -0
- package/dist/decode-forestrie-grant-cose.d.ts.map +1 -0
- package/dist/decode-forestrie-grant-cose.js +62 -0
- package/dist/decode-trust-root-cbor.d.ts +6 -0
- package/dist/decode-trust-root-cbor.d.ts.map +1 -0
- package/dist/decode-trust-root-cbor.js +85 -0
- package/dist/decode-trust-root-from-genesis.d.ts +7 -0
- package/dist/decode-trust-root-from-genesis.d.ts.map +1 -0
- package/dist/decode-trust-root-from-genesis.js +64 -0
- package/dist/forest-genesis-labels.d.ts +10 -0
- package/dist/forest-genesis-labels.d.ts.map +1 -0
- package/dist/forest-genesis-labels.js +9 -0
- package/dist/grant-codec.d.ts +8 -0
- package/dist/grant-codec.d.ts.map +1 -0
- package/dist/grant-codec.js +88 -0
- package/dist/grant-commitment.d.ts +3 -0
- package/dist/grant-commitment.d.ts.map +1 -0
- package/dist/grant-commitment.js +55 -0
- package/dist/grant-data.d.ts +7 -0
- package/dist/grant-data.d.ts.map +1 -0
- package/dist/grant-data.js +7 -0
- package/dist/grant.d.ts +12 -0
- package/dist/grant.d.ts.map +1 -0
- package/dist/grant.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/leaf-commitment.d.ts +2 -0
- package/dist/leaf-commitment.d.ts.map +1 -0
- package/dist/leaf-commitment.js +27 -0
- package/dist/parse-receipt.d.ts +15 -0
- package/dist/parse-receipt.d.ts.map +1 -0
- package/dist/parse-receipt.js +91 -0
- package/dist/receipt-verify-result.d.ts +9 -0
- package/dist/receipt-verify-result.d.ts.map +1 -0
- package/dist/receipt-verify-result.js +1 -0
- package/dist/root-verify-key.d.ts +9 -0
- package/dist/root-verify-key.d.ts.map +1 -0
- package/dist/root-verify-key.js +7 -0
- package/dist/subtle-hasher.d.ts +9 -0
- package/dist/subtle-hasher.d.ts.map +1 -0
- package/dist/subtle-hasher.js +21 -0
- package/dist/uuid-bytes.d.ts +4 -0
- package/dist/uuid-bytes.d.ts.map +1 -0
- package/dist/uuid-bytes.js +21 -0
- package/dist/verify-grant-receipt-offline.d.ts +13 -0
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -0
- package/dist/verify-grant-receipt-offline.js +101 -0
- package/package.json +45 -0
- package/src/cose-key.ts +9 -0
- package/src/decode-forestrie-grant-cose.ts +79 -0
- package/src/decode-trust-root-cbor.ts +113 -0
- package/src/decode-trust-root-from-genesis.ts +87 -0
- package/src/forest-genesis-labels.ts +10 -0
- package/src/grant-codec.ts +106 -0
- package/src/grant-commitment.ts +64 -0
- package/src/grant-data.ts +12 -0
- package/src/grant.ts +12 -0
- package/src/index.ts +11 -0
- package/src/leaf-commitment.ts +35 -0
- package/src/parse-receipt.ts +120 -0
- package/src/receipt-verify-result.ts +13 -0
- package/src/root-verify-key.ts +19 -0
- package/src/subtle-hasher.ts +26 -0
- package/src/uuid-bytes.ts +20 -0
- package/src/verify-grant-receipt-offline.ts +145 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
|
|
2
|
+
import type { Grant } from "./grant.js";
|
|
3
|
+
import { grantDataToBytes } from "./grant-data.js";
|
|
4
|
+
import { fromPaddedWire32, toPaddedWire32 } from "./uuid-bytes.js";
|
|
5
|
+
|
|
6
|
+
const CBOR_KEY_IDTIMESTAMP = 0;
|
|
7
|
+
const CBOR_KEY_LOG_ID = 1;
|
|
8
|
+
const CBOR_KEY_OWNER_LOG_ID = 2;
|
|
9
|
+
const CBOR_KEY_GRANT_FLAGS = 3;
|
|
10
|
+
const CBOR_KEY_MAX_HEIGHT = 4;
|
|
11
|
+
const CBOR_KEY_MIN_GROWTH = 5;
|
|
12
|
+
const CBOR_KEY_GRANT_DATA = 6;
|
|
13
|
+
const WIRE_GRANT_FLAGS_BYTES = 8;
|
|
14
|
+
const IDTIMESTAMP_BYTES = 8;
|
|
15
|
+
|
|
16
|
+
function leftPad(b: Uint8Array, length: number): Uint8Array {
|
|
17
|
+
if (b.length >= length) {
|
|
18
|
+
return b.length === length ? b : b.slice(-length);
|
|
19
|
+
}
|
|
20
|
+
const out = new Uint8Array(length);
|
|
21
|
+
out.set(b, length - b.length);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapToGrant(m: Map<number, unknown> | Record<number, unknown>): Grant {
|
|
26
|
+
const get = (k: number): unknown =>
|
|
27
|
+
m instanceof Map ? m.get(k) : (m as Record<number, unknown>)[k];
|
|
28
|
+
|
|
29
|
+
const requireBstr = (v: unknown, minLen = 0): Uint8Array => {
|
|
30
|
+
if (!(v instanceof Uint8Array))
|
|
31
|
+
throw new Error("Grant payload: value must be bstr");
|
|
32
|
+
if (v.length < minLen) throw new Error("Grant payload: bstr too short");
|
|
33
|
+
return v;
|
|
34
|
+
};
|
|
35
|
+
const requireUint = (v: unknown): number => {
|
|
36
|
+
if (typeof v === "number" && Number.isInteger(v)) return v;
|
|
37
|
+
if (typeof v === "bigint") return Number(v);
|
|
38
|
+
throw new Error("Grant payload: expected uint");
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
logId: fromPaddedWire32(requireBstr(get(CBOR_KEY_LOG_ID))),
|
|
43
|
+
ownerLogId: fromPaddedWire32(requireBstr(get(CBOR_KEY_OWNER_LOG_ID))),
|
|
44
|
+
grant: leftPad(
|
|
45
|
+
requireBstr(get(CBOR_KEY_GRANT_FLAGS)),
|
|
46
|
+
WIRE_GRANT_FLAGS_BYTES,
|
|
47
|
+
),
|
|
48
|
+
maxHeight: requireUint(get(CBOR_KEY_MAX_HEIGHT)),
|
|
49
|
+
minGrowth: requireUint(get(CBOR_KEY_MIN_GROWTH)),
|
|
50
|
+
grantData:
|
|
51
|
+
get(CBOR_KEY_GRANT_DATA) instanceof Uint8Array
|
|
52
|
+
? (get(CBOR_KEY_GRANT_DATA) as Uint8Array)
|
|
53
|
+
: new Uint8Array(0),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeGrantPayload(bytes: Uint8Array): Grant {
|
|
58
|
+
if (!bytes?.length) throw new Error("Grant payload is empty");
|
|
59
|
+
const raw = decodeCbor(bytes) as unknown;
|
|
60
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
61
|
+
throw new Error("Grant payload must be a CBOR map");
|
|
62
|
+
}
|
|
63
|
+
return mapToGrant(raw as Map<number, unknown> | Record<number, unknown>);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeGrantResponse(bytes: Uint8Array): {
|
|
67
|
+
grant: Grant;
|
|
68
|
+
idtimestamp: Uint8Array;
|
|
69
|
+
} {
|
|
70
|
+
const raw = decodeCbor(bytes) as unknown;
|
|
71
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
72
|
+
throw new Error("Grant response must be a CBOR map");
|
|
73
|
+
}
|
|
74
|
+
const m = raw as Map<number, unknown> | Record<number, unknown>;
|
|
75
|
+
const get = (k: number): unknown =>
|
|
76
|
+
m instanceof Map ? m.get(k) : (m as Record<number, unknown>)[k];
|
|
77
|
+
const idtimestampVal = get(CBOR_KEY_IDTIMESTAMP);
|
|
78
|
+
if (
|
|
79
|
+
!(idtimestampVal instanceof Uint8Array) ||
|
|
80
|
+
idtimestampVal.length < IDTIMESTAMP_BYTES
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("Grant response: key 0 must be 8-byte bstr");
|
|
83
|
+
}
|
|
84
|
+
const idtimestamp = new Uint8Array(IDTIMESTAMP_BYTES);
|
|
85
|
+
idtimestamp.set(
|
|
86
|
+
idtimestampVal.length === IDTIMESTAMP_BYTES
|
|
87
|
+
? idtimestampVal
|
|
88
|
+
: idtimestampVal.slice(-IDTIMESTAMP_BYTES),
|
|
89
|
+
);
|
|
90
|
+
return { grant: mapToGrant(m), idtimestamp };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function encodeGrantPayload(grant: Grant): Uint8Array {
|
|
94
|
+
const map = new Map<number, unknown>([
|
|
95
|
+
[CBOR_KEY_LOG_ID, toPaddedWire32(grant.logId)],
|
|
96
|
+
[CBOR_KEY_OWNER_LOG_ID, toPaddedWire32(grant.ownerLogId)],
|
|
97
|
+
[CBOR_KEY_GRANT_FLAGS, leftPad(grant.grant, WIRE_GRANT_FLAGS_BYTES)],
|
|
98
|
+
[CBOR_KEY_MAX_HEIGHT, grant.maxHeight ?? 0],
|
|
99
|
+
[CBOR_KEY_MIN_GROWTH, grant.minGrowth ?? 0],
|
|
100
|
+
[
|
|
101
|
+
CBOR_KEY_GRANT_DATA,
|
|
102
|
+
grantDataToBytes(grant.grantData ?? new Uint8Array(0)),
|
|
103
|
+
],
|
|
104
|
+
]);
|
|
105
|
+
return new Uint8Array(encodeCbor(map));
|
|
106
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { grantDataToBytes } from "./grant-data.js";
|
|
2
|
+
import type { Grant } from "./grant.js";
|
|
3
|
+
import { toPaddedWire32 } from "./uuid-bytes.js";
|
|
4
|
+
|
|
5
|
+
const GRANT_FLAGS_32_BYTES = 32;
|
|
6
|
+
const U64_BYTES = 8;
|
|
7
|
+
|
|
8
|
+
function u64Be(n: number): Uint8Array {
|
|
9
|
+
const out = new Uint8Array(U64_BYTES);
|
|
10
|
+
let v = BigInt(n) & 0xffffffffffffffffn;
|
|
11
|
+
for (let i = 7; i >= 0; i--) {
|
|
12
|
+
out[i] = Number(v & 0xffn);
|
|
13
|
+
v >>= 8n;
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function grantFlags32(flags: Uint8Array): Uint8Array {
|
|
19
|
+
const out = new Uint8Array(GRANT_FLAGS_32_BYTES);
|
|
20
|
+
if (flags.length >= 8) {
|
|
21
|
+
out.set(flags.slice(-8), 24);
|
|
22
|
+
} else if (flags.length > 0) {
|
|
23
|
+
out.set(flags, 32 - flags.length);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function grantCommitmentPreimage(grant: Grant): Uint8Array {
|
|
29
|
+
const logId = toPaddedWire32(grant.logId);
|
|
30
|
+
const flags32 = grantFlags32(grant.grant);
|
|
31
|
+
const maxHeight = grant.maxHeight ?? 0;
|
|
32
|
+
const minGrowth = grant.minGrowth ?? 0;
|
|
33
|
+
const ownerLogId = toPaddedWire32(grant.ownerLogId);
|
|
34
|
+
const grantData = grantDataToBytes(grant.grantData ?? new Uint8Array(0));
|
|
35
|
+
|
|
36
|
+
const total =
|
|
37
|
+
logId.length +
|
|
38
|
+
flags32.length +
|
|
39
|
+
U64_BYTES * 2 +
|
|
40
|
+
ownerLogId.length +
|
|
41
|
+
grantData.length;
|
|
42
|
+
const out = new Uint8Array(total);
|
|
43
|
+
let off = 0;
|
|
44
|
+
out.set(logId, off);
|
|
45
|
+
off += logId.length;
|
|
46
|
+
out.set(flags32, off);
|
|
47
|
+
off += flags32.length;
|
|
48
|
+
out.set(u64Be(maxHeight), off);
|
|
49
|
+
off += U64_BYTES;
|
|
50
|
+
out.set(u64Be(minGrowth), off);
|
|
51
|
+
off += U64_BYTES;
|
|
52
|
+
out.set(ownerLogId, off);
|
|
53
|
+
off += ownerLogId.length;
|
|
54
|
+
out.set(grantData, off);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function grantCommitmentHashFromGrant(
|
|
59
|
+
grant: Grant,
|
|
60
|
+
): Promise<Uint8Array> {
|
|
61
|
+
const preimage = grantCommitmentPreimage(grant);
|
|
62
|
+
const hash = await crypto.subtle.digest("SHA-256", preimage as BufferSource);
|
|
63
|
+
return new Uint8Array(hash);
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface GrantDataEs256Xy {
|
|
2
|
+
readonly kind: "es256-xy";
|
|
3
|
+
readonly xy: Uint8Array;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type GrantData = Uint8Array | GrantDataEs256Xy;
|
|
7
|
+
|
|
8
|
+
export function grantDataToBytes(data: Uint8Array | GrantData): Uint8Array {
|
|
9
|
+
if (data instanceof Uint8Array) return data;
|
|
10
|
+
if (data.kind === "es256-xy") return data.xy;
|
|
11
|
+
throw new Error("Unsupported GrantData variant");
|
|
12
|
+
}
|
package/src/grant.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GrantData } from "./grant-data.js";
|
|
2
|
+
|
|
3
|
+
/** On-chain grant content aligned with Univocity `PublishGrant`. */
|
|
4
|
+
export interface Grant {
|
|
5
|
+
logId: Uint8Array;
|
|
6
|
+
grant: Uint8Array;
|
|
7
|
+
request?: bigint;
|
|
8
|
+
maxHeight?: number;
|
|
9
|
+
minGrowth?: number;
|
|
10
|
+
ownerLogId: Uint8Array;
|
|
11
|
+
grantData: Uint8Array | GrantData;
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ReceiptVerifyResult,
|
|
3
|
+
ReceiptVerifyStage,
|
|
4
|
+
} from "./receipt-verify-result.js";
|
|
5
|
+
export type { Grant } from "./grant.js";
|
|
6
|
+
export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
|
|
7
|
+
export { parseReceipt } from "./parse-receipt.js";
|
|
8
|
+
export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
|
|
9
|
+
export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
|
|
10
|
+
export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
|
|
11
|
+
export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const IDTIMESTAMP_BYTES = 8;
|
|
2
|
+
|
|
3
|
+
function writeU64BE(out: Uint8Array, offset: number, value: bigint): void {
|
|
4
|
+
let v = value & 0xffffffffffffffffn;
|
|
5
|
+
for (let i = 7; i >= 0; i--) {
|
|
6
|
+
out[offset + i] = Number(v & 0xffn);
|
|
7
|
+
v >>= 8n;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function univocityLeafHash(
|
|
12
|
+
idtimestamp: bigint | Uint8Array,
|
|
13
|
+
grantCommitmentHash: Uint8Array,
|
|
14
|
+
): Promise<Uint8Array> {
|
|
15
|
+
const idtimestampBytes = new Uint8Array(IDTIMESTAMP_BYTES);
|
|
16
|
+
if (typeof idtimestamp === "bigint") {
|
|
17
|
+
writeU64BE(idtimestampBytes, 0, idtimestamp);
|
|
18
|
+
} else {
|
|
19
|
+
if (idtimestamp.length < IDTIMESTAMP_BYTES) {
|
|
20
|
+
throw new Error("idtimestamp must be at least 8 bytes");
|
|
21
|
+
}
|
|
22
|
+
idtimestampBytes.set(
|
|
23
|
+
idtimestamp.length > IDTIMESTAMP_BYTES
|
|
24
|
+
? idtimestamp.slice(-IDTIMESTAMP_BYTES)
|
|
25
|
+
: idtimestamp,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const preimage = new Uint8Array(
|
|
29
|
+
idtimestampBytes.length + grantCommitmentHash.length,
|
|
30
|
+
);
|
|
31
|
+
preimage.set(idtimestampBytes);
|
|
32
|
+
preimage.set(grantCommitmentHash, idtimestampBytes.length);
|
|
33
|
+
const hash = await crypto.subtle.digest("SHA-256", preimage);
|
|
34
|
+
return new Uint8Array(hash);
|
|
35
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { decode as decodeCbor } from "cbor-x";
|
|
2
|
+
import type { Proof } from "@forestrie/merklelog";
|
|
3
|
+
|
|
4
|
+
const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
|
|
5
|
+
|
|
6
|
+
type CoseSign1 = [
|
|
7
|
+
protectedHeader: Uint8Array,
|
|
8
|
+
unprotectedHeader: Map<number, unknown> | Record<string, unknown>,
|
|
9
|
+
payload: Uint8Array | null,
|
|
10
|
+
signature: Uint8Array,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function unwrapCoseSign1Tag(value: unknown): unknown {
|
|
14
|
+
if (value && typeof value === "object" && !(value instanceof Map)) {
|
|
15
|
+
const tagged = value as { tag?: number; value?: unknown };
|
|
16
|
+
if (
|
|
17
|
+
Object.prototype.hasOwnProperty.call(tagged, "value") &&
|
|
18
|
+
tagged.tag === 18
|
|
19
|
+
) {
|
|
20
|
+
return tagged.value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requireCoseSign1(value: unknown): CoseSign1 {
|
|
27
|
+
if (!Array.isArray(value) || value.length !== 4) {
|
|
28
|
+
throw new Error("Receipt is not a COSE Sign1 array");
|
|
29
|
+
}
|
|
30
|
+
const [p, u, payload, sig] = value as unknown[];
|
|
31
|
+
if (!(p instanceof Uint8Array) || !(sig instanceof Uint8Array)) {
|
|
32
|
+
throw new Error("Invalid COSE Sign1 structure");
|
|
33
|
+
}
|
|
34
|
+
if (!(payload === null || payload instanceof Uint8Array)) {
|
|
35
|
+
throw new Error("Invalid COSE Sign1 payload");
|
|
36
|
+
}
|
|
37
|
+
return [
|
|
38
|
+
p,
|
|
39
|
+
u as Map<number, unknown> | Record<string, unknown>,
|
|
40
|
+
payload,
|
|
41
|
+
sig,
|
|
42
|
+
] as CoseSign1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toHeaderMap(
|
|
46
|
+
value: Map<number, unknown> | Record<string, unknown>,
|
|
47
|
+
): Map<number, unknown> {
|
|
48
|
+
if (value instanceof Map) return value as Map<number, unknown>;
|
|
49
|
+
const out = new Map<number, unknown>();
|
|
50
|
+
if (typeof value === "object" && value !== null) {
|
|
51
|
+
for (const [k, v] of Object.entries(value)) {
|
|
52
|
+
const n = Number(k);
|
|
53
|
+
if (Number.isFinite(n)) out.set(n, v);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseReceipt(receiptBytes: Uint8Array): {
|
|
60
|
+
explicitPeak: Uint8Array | null;
|
|
61
|
+
proof: Proof;
|
|
62
|
+
receiptCbor: Uint8Array;
|
|
63
|
+
coseSign1: CoseSign1;
|
|
64
|
+
} {
|
|
65
|
+
const decoded = decodeCbor(receiptBytes) as unknown;
|
|
66
|
+
const unwrapped = unwrapCoseSign1Tag(decoded);
|
|
67
|
+
requireCoseSign1(unwrapped);
|
|
68
|
+
|
|
69
|
+
const coseSign1 = requireCoseSign1(unwrapped);
|
|
70
|
+
const payload = coseSign1[2];
|
|
71
|
+
let explicitPeak: Uint8Array | null = null;
|
|
72
|
+
if (payload instanceof Uint8Array) {
|
|
73
|
+
if (payload.length !== 32) {
|
|
74
|
+
throw new Error("Receipt payload must be 32 bytes (peak hash)");
|
|
75
|
+
}
|
|
76
|
+
explicitPeak = payload;
|
|
77
|
+
} else if (payload !== null && payload !== undefined) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"Receipt payload must be nil (detached) or 32-byte peak hash",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const unprotected = toHeaderMap(coseSign1[1]);
|
|
84
|
+
const proofsRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
|
|
85
|
+
if (!proofsRaw || typeof proofsRaw !== "object") {
|
|
86
|
+
throw new Error("Receipt missing header 396 (inclusion proof)");
|
|
87
|
+
}
|
|
88
|
+
const proofsMap = proofsRaw as Map<number, unknown> | Record<number, unknown>;
|
|
89
|
+
const proofList = (
|
|
90
|
+
proofsMap instanceof Map
|
|
91
|
+
? proofsMap.get(-1)
|
|
92
|
+
: (proofsMap as Record<number, unknown>)[-1]
|
|
93
|
+
) as unknown[] | undefined;
|
|
94
|
+
if (!Array.isArray(proofList) || proofList.length === 0) {
|
|
95
|
+
throw new Error("Receipt proof -1 must be non-empty array");
|
|
96
|
+
}
|
|
97
|
+
const entry = proofList[0] as Record<number, unknown> | Map<number, unknown>;
|
|
98
|
+
const mmrIndexRaw =
|
|
99
|
+
entry instanceof Map ? entry.get(1) : (entry as Record<number, unknown>)[1];
|
|
100
|
+
const pathRaw =
|
|
101
|
+
entry instanceof Map ? entry.get(2) : (entry as Record<number, unknown>)[2];
|
|
102
|
+
if (mmrIndexRaw === undefined || !Array.isArray(pathRaw)) {
|
|
103
|
+
throw new Error("Proof entry must have 1: mmrIndex, 2: path");
|
|
104
|
+
}
|
|
105
|
+
const mmrIndex =
|
|
106
|
+
typeof mmrIndexRaw === "bigint" ? mmrIndexRaw : BigInt(Number(mmrIndexRaw));
|
|
107
|
+
const path = pathRaw.map((h: unknown) => {
|
|
108
|
+
if (!(h instanceof Uint8Array) || h.length !== 32) {
|
|
109
|
+
throw new Error("Proof path elements must be 32-byte hashes");
|
|
110
|
+
}
|
|
111
|
+
return h;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
explicitPeak,
|
|
116
|
+
proof: { path, mmrIndex },
|
|
117
|
+
receiptCbor: receiptBytes,
|
|
118
|
+
coseSign1,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Failure stage for falsifiable tests and CLI exit messaging (ADR-0045). */
|
|
2
|
+
export type ReceiptVerifyStage =
|
|
3
|
+
| "parse"
|
|
4
|
+
| "signature"
|
|
5
|
+
| "inclusion"
|
|
6
|
+
| "binding";
|
|
7
|
+
|
|
8
|
+
export type ReceiptVerifyResult = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
stage: ReceiptVerifyStage;
|
|
11
|
+
/** Present when ok === false; stable snake_case token for tests. */
|
|
12
|
+
reason?: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { COSE_ALG_KS256 } from "./cose-key.js";
|
|
2
|
+
|
|
3
|
+
export interface ParsedKs256RootKey {
|
|
4
|
+
kind: "KS256";
|
|
5
|
+
alg: typeof COSE_ALG_KS256;
|
|
6
|
+
address: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isParsedKs256RootKey(key: unknown): key is ParsedKs256RootKey {
|
|
10
|
+
return (
|
|
11
|
+
typeof key === "object" &&
|
|
12
|
+
key !== null &&
|
|
13
|
+
(key as ParsedKs256RootKey).kind === "KS256" &&
|
|
14
|
+
(key as ParsedKs256RootKey).address instanceof Uint8Array &&
|
|
15
|
+
(key as ParsedKs256RootKey).address.length === 20
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RootVerifyKey = CryptoKey | ParsedKs256RootKey;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Hasher } from "@forestrie/merklelog";
|
|
2
|
+
|
|
3
|
+
/** Web Crypto SHA-256 hasher for Node and browser offline verify. */
|
|
4
|
+
export class SubtleHasher implements Hasher {
|
|
5
|
+
private chunks: Uint8Array[] = [];
|
|
6
|
+
|
|
7
|
+
reset(): void {
|
|
8
|
+
this.chunks = [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
update(data: Uint8Array): void {
|
|
12
|
+
this.chunks.push(data);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async digest(): Promise<Uint8Array> {
|
|
16
|
+
const totalLength = this.chunks.reduce((s, c) => s + c.length, 0);
|
|
17
|
+
const combined = new Uint8Array(totalLength);
|
|
18
|
+
let offset = 0;
|
|
19
|
+
for (const c of this.chunks) {
|
|
20
|
+
combined.set(c, offset);
|
|
21
|
+
offset += c.length;
|
|
22
|
+
}
|
|
23
|
+
const h = await crypto.subtle.digest("SHA-256", combined);
|
|
24
|
+
return new Uint8Array(h);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const WIRE_LOG_ID_BYTES = 32;
|
|
2
|
+
|
|
3
|
+
export function toPaddedWire32(bytes: Uint8Array): Uint8Array {
|
|
4
|
+
if (bytes.length === WIRE_LOG_ID_BYTES) return bytes;
|
|
5
|
+
const out = new Uint8Array(WIRE_LOG_ID_BYTES);
|
|
6
|
+
if (bytes.length >= WIRE_LOG_ID_BYTES) {
|
|
7
|
+
out.set(bytes.slice(-WIRE_LOG_ID_BYTES));
|
|
8
|
+
} else {
|
|
9
|
+
out.set(bytes, WIRE_LOG_ID_BYTES - bytes.length);
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function fromPaddedWire32(wire: Uint8Array): Uint8Array {
|
|
15
|
+
if (wire.length === 16) return wire;
|
|
16
|
+
if (wire.length !== WIRE_LOG_ID_BYTES) {
|
|
17
|
+
throw new Error(`Expected ${WIRE_LOG_ID_BYTES}-byte wire log id`);
|
|
18
|
+
}
|
|
19
|
+
return wire.slice(-16);
|
|
20
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
|
|
2
|
+
import {
|
|
3
|
+
calculateRoot,
|
|
4
|
+
verifyInclusion,
|
|
5
|
+
type Proof,
|
|
6
|
+
} from "@forestrie/merklelog";
|
|
7
|
+
import type { Grant } from "./grant.js";
|
|
8
|
+
import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
|
|
9
|
+
import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
|
|
10
|
+
import { es256ReceiptVerifyKeys } from "./decode-trust-root-cbor.js";
|
|
11
|
+
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
12
|
+
import { parseReceipt } from "./parse-receipt.js";
|
|
13
|
+
import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
|
|
14
|
+
import { SubtleHasher } from "./subtle-hasher.js";
|
|
15
|
+
|
|
16
|
+
export type VerifyGrantReceiptOfflineInput = {
|
|
17
|
+
genesisCbor: Uint8Array;
|
|
18
|
+
receiptCbor: Uint8Array;
|
|
19
|
+
grant: Grant;
|
|
20
|
+
idtimestampBe8: Uint8Array;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function readIdtimestampBe8(bytes: Uint8Array): bigint {
|
|
24
|
+
if (!bytes || bytes.length < 8) {
|
|
25
|
+
throw new Error("idtimestamp required for receipt verification (8 bytes)");
|
|
26
|
+
}
|
|
27
|
+
const view =
|
|
28
|
+
bytes.length === 8
|
|
29
|
+
? new DataView(bytes.buffer, bytes.byteOffset, 8)
|
|
30
|
+
: new DataView(bytes.buffer, bytes.byteOffset + bytes.length - 8, 8);
|
|
31
|
+
return view.getBigUint64(0, false);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function verifySignatureAndInclusion(opts: {
|
|
35
|
+
receiptCbor: Uint8Array;
|
|
36
|
+
explicitPeak: Uint8Array | null;
|
|
37
|
+
proof: Proof;
|
|
38
|
+
leafHash: Uint8Array;
|
|
39
|
+
verifyKeys: CryptoKey[];
|
|
40
|
+
}): Promise<ReceiptVerifyResult> {
|
|
41
|
+
const hasher = new SubtleHasher();
|
|
42
|
+
const leafIdx =
|
|
43
|
+
opts.proof.leafIndex !== undefined
|
|
44
|
+
? opts.proof.leafIndex
|
|
45
|
+
: opts.proof.mmrIndex!;
|
|
46
|
+
const peak =
|
|
47
|
+
opts.explicitPeak !== null
|
|
48
|
+
? opts.explicitPeak
|
|
49
|
+
: await calculateRoot(hasher, opts.leafHash, opts.proof, leafIdx);
|
|
50
|
+
|
|
51
|
+
let sigOk = false;
|
|
52
|
+
for (const candidateKey of opts.verifyKeys) {
|
|
53
|
+
sigOk = await verifyCoseSign1WithParsedKey(opts.receiptCbor, candidateKey, {
|
|
54
|
+
logPrefix: "grant-receipt-offline",
|
|
55
|
+
detachedPayload: peak,
|
|
56
|
+
});
|
|
57
|
+
if (sigOk) break;
|
|
58
|
+
if (opts.explicitPeak !== null) {
|
|
59
|
+
sigOk = await verifyCoseSign1WithParsedKey(
|
|
60
|
+
opts.receiptCbor,
|
|
61
|
+
candidateKey,
|
|
62
|
+
{ logPrefix: "grant-receipt-offline" },
|
|
63
|
+
);
|
|
64
|
+
if (sigOk) break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!sigOk) {
|
|
69
|
+
if (opts.explicitPeak !== null) {
|
|
70
|
+
const inclusionOk = await verifyInclusion(
|
|
71
|
+
hasher,
|
|
72
|
+
opts.leafHash,
|
|
73
|
+
opts.proof,
|
|
74
|
+
opts.explicitPeak,
|
|
75
|
+
);
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
stage: "signature",
|
|
79
|
+
reason: inclusionOk ? "signature_invalid" : "signature_invalid",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, stage: "signature", reason: "signature_invalid" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const inclusionOk = await verifyInclusion(
|
|
86
|
+
hasher,
|
|
87
|
+
opts.leafHash,
|
|
88
|
+
opts.proof,
|
|
89
|
+
peak,
|
|
90
|
+
);
|
|
91
|
+
if (!inclusionOk) {
|
|
92
|
+
return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, stage: "binding" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
|
|
99
|
+
*/
|
|
100
|
+
export async function verifyGrantReceiptOffline(
|
|
101
|
+
input: VerifyGrantReceiptOfflineInput,
|
|
102
|
+
): Promise<ReceiptVerifyResult> {
|
|
103
|
+
let parsed: ReturnType<typeof parseReceipt>;
|
|
104
|
+
try {
|
|
105
|
+
parsed = parseReceipt(input.receiptCbor);
|
|
106
|
+
} catch {
|
|
107
|
+
return { ok: false, stage: "parse", reason: "receipt_malformed" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let trustRoot;
|
|
111
|
+
try {
|
|
112
|
+
trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
|
|
113
|
+
} catch {
|
|
114
|
+
return { ok: false, stage: "parse", reason: "genesis_invalid" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
|
|
118
|
+
if (!verifyKeys.length) {
|
|
119
|
+
return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let inner: Uint8Array;
|
|
123
|
+
try {
|
|
124
|
+
inner = await grantCommitmentHashFromGrant(input.grant);
|
|
125
|
+
} catch {
|
|
126
|
+
return { ok: false, stage: "binding", reason: "grant_invalid" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let idtimestamp: bigint;
|
|
130
|
+
try {
|
|
131
|
+
idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, stage: "binding", reason: "idtimestamp_invalid" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const leafHash = await univocityLeafHash(idtimestamp, inner);
|
|
137
|
+
|
|
138
|
+
return verifySignatureAndInclusion({
|
|
139
|
+
receiptCbor: input.receiptCbor,
|
|
140
|
+
explicitPeak: parsed.explicitPeak,
|
|
141
|
+
proof: parsed.proof,
|
|
142
|
+
leafHash,
|
|
143
|
+
verifyKeys: verifyKeys as CryptoKey[],
|
|
144
|
+
});
|
|
145
|
+
}
|