@crovia/seal 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 +17 -0
- package/README.md +92 -0
- package/dist/browser.js +10 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +2 -0
- package/package.json +73 -0
- package/src/_polyfill.ts +31 -0
- package/src/canonical.ts +171 -0
- package/src/index.ts +35 -0
- package/src/keys.ts +161 -0
- package/src/register.ts +85 -0
- package/src/seal.ts +231 -0
- package/src/types.ts +95 -0
- package/src/verify.ts +166 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for @crovia/seal.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** A signed continuity receipt over an arbitrary JSON payload. */
|
|
6
|
+
export interface Receipt {
|
|
7
|
+
/** Format identifier. Always "crovia.receipt.v1" for this version. */
|
|
8
|
+
v: "crovia.receipt.v1";
|
|
9
|
+
/** Receipt identifier: "cr_YYYY_<26 base32 chars>". */
|
|
10
|
+
id: string;
|
|
11
|
+
/** RFC 3339 UTC timestamp with millisecond precision. */
|
|
12
|
+
issued_at: string;
|
|
13
|
+
/** "sha256:<64 lowercase hex>" of the canonical UTF-8 bytes of payload. */
|
|
14
|
+
payload_hash: string;
|
|
15
|
+
/** Hash algorithm used for payload_hash. Currently always "sha256". */
|
|
16
|
+
payload_alg: "sha256";
|
|
17
|
+
/** Optional content-type hint for the payload (free-form, not signed semantics). */
|
|
18
|
+
payload_type?: string;
|
|
19
|
+
/** Previous receipt id from the same signer, or null for genesis. */
|
|
20
|
+
prev: string | null;
|
|
21
|
+
/** Monotonic sequence per signer, 0 for genesis. */
|
|
22
|
+
seq: number;
|
|
23
|
+
/** Signer's Ed25519 public key as 64 lowercase hex chars. */
|
|
24
|
+
signer: string;
|
|
25
|
+
/** Signature algorithm: always "ed25519". */
|
|
26
|
+
sig_alg: "ed25519";
|
|
27
|
+
/** Canonicalization scheme: always "csc-1". */
|
|
28
|
+
canon: "csc-1";
|
|
29
|
+
/** Domain separator string baked into the signed payload. */
|
|
30
|
+
domain: "CROVIA-RECEIPT-v1";
|
|
31
|
+
/** Detached signature over compute_payload(receipt) — 128 lowercase hex. */
|
|
32
|
+
sig: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Options for `seal()`. */
|
|
36
|
+
export interface SealOptions {
|
|
37
|
+
/** Bring-your-own Ed25519 key. If omitted, a fresh key is generated for this call. */
|
|
38
|
+
key?: KeyPair;
|
|
39
|
+
/** Previous receipt to chain against (provides prev + seq). */
|
|
40
|
+
prevReceipt?: Receipt;
|
|
41
|
+
/** Optional content-type hint (e.g., "text/plain", "application/json", "model-card"). */
|
|
42
|
+
payloadType?: string;
|
|
43
|
+
/** Override timestamp (testing only — must be RFC 3339 with ms precision). */
|
|
44
|
+
issuedAt?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Ed25519 key pair, raw 32-byte private + 32-byte public. */
|
|
48
|
+
export interface KeyPair {
|
|
49
|
+
/** 64 lowercase hex chars (32 bytes). */
|
|
50
|
+
privateHex: string;
|
|
51
|
+
/** 64 lowercase hex chars (32 bytes). */
|
|
52
|
+
publicHex: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Outcome of `verify()`. */
|
|
56
|
+
export interface VerifyResult {
|
|
57
|
+
/** True iff structure, signature, and self-consistency all check out. */
|
|
58
|
+
valid: boolean;
|
|
59
|
+
/** Errors encountered (empty when valid=true). */
|
|
60
|
+
errors: string[];
|
|
61
|
+
/** Receipt id (echoed for convenience). */
|
|
62
|
+
id?: string;
|
|
63
|
+
/** Signer pubkey hex. */
|
|
64
|
+
signer?: string;
|
|
65
|
+
/** payload_hash echoed for the caller to compare against their payload. */
|
|
66
|
+
payloadHash?: string;
|
|
67
|
+
/** prev id, for chain walks. */
|
|
68
|
+
prev?: string | null;
|
|
69
|
+
/** sequence, for chain walks. */
|
|
70
|
+
seq?: number;
|
|
71
|
+
/** issued_at echoed for time-range checks. */
|
|
72
|
+
issuedAt?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Options for `register()`. */
|
|
76
|
+
export interface RegisterOptions {
|
|
77
|
+
/** Substrate URL. Defaults to https://croviatrust.com */
|
|
78
|
+
endpoint?: string;
|
|
79
|
+
/** Optional fetch override (for testing or custom transports). */
|
|
80
|
+
fetch?: typeof globalThis.fetch;
|
|
81
|
+
/** Request timeout in ms. Defaults to 10_000. */
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Outcome of `register()`. */
|
|
86
|
+
export interface RegisterResult {
|
|
87
|
+
/** Whether the substrate accepted the receipt. */
|
|
88
|
+
accepted: boolean;
|
|
89
|
+
/** Substrate-assigned anchor id, if any. */
|
|
90
|
+
anchorId?: string;
|
|
91
|
+
/** HTTP status code returned by the substrate. */
|
|
92
|
+
status: number;
|
|
93
|
+
/** Error message if not accepted. */
|
|
94
|
+
error?: string;
|
|
95
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `verify()` — check the structure and signature of a receipt.
|
|
3
|
+
*
|
|
4
|
+
* If you also have the original payload, pass it as the second argument
|
|
5
|
+
* to additionally verify the payload_hash. Without the payload, only
|
|
6
|
+
* the signature and structure are checked.
|
|
7
|
+
*/
|
|
8
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
9
|
+
|
|
10
|
+
import { canonicalize } from "./canonical.js";
|
|
11
|
+
import { bytesToHex, verifyBytes } from "./keys.js";
|
|
12
|
+
import {
|
|
13
|
+
computePayload,
|
|
14
|
+
validateReceiptShape,
|
|
15
|
+
} from "./seal.js";
|
|
16
|
+
import type { Receipt, VerifyResult } from "./types.js";
|
|
17
|
+
|
|
18
|
+
function sha256Prefixed(data: Uint8Array): string {
|
|
19
|
+
return "sha256:" + bytesToHex(sha256(data));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Verify a continuity receipt.
|
|
24
|
+
*
|
|
25
|
+
* @param receipt The receipt object (or any value to be type-checked).
|
|
26
|
+
* @param payload Optional original payload to verify payload_hash against.
|
|
27
|
+
* If omitted, only the signature and structure are checked.
|
|
28
|
+
* @returns A VerifyResult — never throws.
|
|
29
|
+
*/
|
|
30
|
+
export async function verify(
|
|
31
|
+
receipt: unknown,
|
|
32
|
+
payload?: unknown,
|
|
33
|
+
): Promise<VerifyResult> {
|
|
34
|
+
const errors: string[] = [];
|
|
35
|
+
|
|
36
|
+
// Step 1: structural validation.
|
|
37
|
+
const shapeErr = validateReceiptShape(receipt);
|
|
38
|
+
if (shapeErr !== null) {
|
|
39
|
+
errors.push(`schema: ${shapeErr}`);
|
|
40
|
+
return { valid: false, errors };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const r = receipt as Receipt;
|
|
44
|
+
|
|
45
|
+
// Step 2: signature.
|
|
46
|
+
const { sig, ...withoutSig } = r;
|
|
47
|
+
const signingPayload = computePayload(withoutSig);
|
|
48
|
+
|
|
49
|
+
let sigOk = false;
|
|
50
|
+
try {
|
|
51
|
+
sigOk = await verifyBytes(r.signer, sig, signingPayload);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
errors.push(
|
|
54
|
+
`signature-verify: ${e instanceof Error ? e.message : String(e)}`,
|
|
55
|
+
);
|
|
56
|
+
return { valid: false, errors };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!sigOk) {
|
|
60
|
+
errors.push("signature: invalid");
|
|
61
|
+
return {
|
|
62
|
+
valid: false,
|
|
63
|
+
errors,
|
|
64
|
+
id: r.id,
|
|
65
|
+
signer: r.signer,
|
|
66
|
+
payloadHash: r.payload_hash,
|
|
67
|
+
prev: r.prev,
|
|
68
|
+
seq: r.seq,
|
|
69
|
+
issuedAt: r.issued_at,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 3: payload-hash check (only if caller provided the payload).
|
|
74
|
+
if (payload !== undefined) {
|
|
75
|
+
let computed: string;
|
|
76
|
+
try {
|
|
77
|
+
computed = sha256Prefixed(canonicalize(payload));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
errors.push(
|
|
80
|
+
`payload-canonicalize: ${e instanceof Error ? e.message : String(e)}`,
|
|
81
|
+
);
|
|
82
|
+
return { valid: false, errors };
|
|
83
|
+
}
|
|
84
|
+
if (computed !== r.payload_hash) {
|
|
85
|
+
errors.push(
|
|
86
|
+
`payload_hash mismatch: receipt=${r.payload_hash} computed=${computed}`,
|
|
87
|
+
);
|
|
88
|
+
return {
|
|
89
|
+
valid: false,
|
|
90
|
+
errors,
|
|
91
|
+
id: r.id,
|
|
92
|
+
signer: r.signer,
|
|
93
|
+
payloadHash: r.payload_hash,
|
|
94
|
+
prev: r.prev,
|
|
95
|
+
seq: r.seq,
|
|
96
|
+
issuedAt: r.issued_at,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
valid: true,
|
|
103
|
+
errors: [],
|
|
104
|
+
id: r.id,
|
|
105
|
+
signer: r.signer,
|
|
106
|
+
payloadHash: r.payload_hash,
|
|
107
|
+
prev: r.prev,
|
|
108
|
+
seq: r.seq,
|
|
109
|
+
issuedAt: r.issued_at,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify a chain of receipts in order: each receipt[i].prev must equal
|
|
115
|
+
* receipt[i-1].id, sequence must increment by 1, and signer must remain
|
|
116
|
+
* the same. All signatures must verify.
|
|
117
|
+
*
|
|
118
|
+
* @returns true iff the chain is internally consistent and all sigs valid.
|
|
119
|
+
*/
|
|
120
|
+
export async function verifyChain(receipts: Receipt[]): Promise<VerifyResult> {
|
|
121
|
+
if (receipts.length === 0) {
|
|
122
|
+
return { valid: false, errors: ["empty chain"] };
|
|
123
|
+
}
|
|
124
|
+
let prevId: string | null = null;
|
|
125
|
+
let prevSeq = -1;
|
|
126
|
+
let signer: string | null = null;
|
|
127
|
+
for (let i = 0; i < receipts.length; i++) {
|
|
128
|
+
const r = receipts[i]!;
|
|
129
|
+
const single = await verify(r);
|
|
130
|
+
if (!single.valid) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
errors: [`chain[${i}] invalid: ${single.errors.join("; ")}`],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (signer === null) signer = r.signer;
|
|
137
|
+
else if (signer !== r.signer) {
|
|
138
|
+
return {
|
|
139
|
+
valid: false,
|
|
140
|
+
errors: [`chain[${i}] signer changed from ${signer} to ${r.signer}`],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (r.seq !== prevSeq + 1) {
|
|
144
|
+
return {
|
|
145
|
+
valid: false,
|
|
146
|
+
errors: [
|
|
147
|
+
`chain[${i}] seq=${r.seq} but expected ${prevSeq + 1}`,
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (r.prev !== prevId) {
|
|
152
|
+
return {
|
|
153
|
+
valid: false,
|
|
154
|
+
errors: [`chain[${i}] prev=${r.prev} but expected ${prevId}`],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
prevId = r.id;
|
|
158
|
+
prevSeq = r.seq;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
valid: true,
|
|
162
|
+
errors: [],
|
|
163
|
+
id: receipts[receipts.length - 1]!.id,
|
|
164
|
+
signer: signer!,
|
|
165
|
+
};
|
|
166
|
+
}
|