@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/canonical.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSC-1 — Crovia Seal Canonicalization v1.
|
|
3
|
+
*
|
|
4
|
+
* Strict subset of RFC 8785 (JCS):
|
|
5
|
+
* - Object keys sorted by UTF-16 code-unit order
|
|
6
|
+
* - String escapes per RFC 8259 (mandatory short escapes only)
|
|
7
|
+
* - Integers only (floats forbidden in signed payloads)
|
|
8
|
+
* - No insignificant whitespace
|
|
9
|
+
*
|
|
10
|
+
* This implementation MUST produce byte-identical output to the Python
|
|
11
|
+
* reference (`reference/python/crovia_seal/canonical.py`) for every shared
|
|
12
|
+
* conformance vector. Any divergence is a bug.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// JS-safe integer range — same as Python reference.
|
|
16
|
+
const JS_SAFE_INT_MIN = -(2 ** 53 - 1);
|
|
17
|
+
const JS_SAFE_INT_MAX = 2 ** 53 - 1;
|
|
18
|
+
|
|
19
|
+
const ESCAPE_MAP: Record<number, string> = {
|
|
20
|
+
0x22: '\\"', // "
|
|
21
|
+
0x5c: "\\\\", // \
|
|
22
|
+
0x08: "\\b",
|
|
23
|
+
0x0c: "\\f",
|
|
24
|
+
0x0a: "\\n",
|
|
25
|
+
0x0d: "\\r",
|
|
26
|
+
0x09: "\\t",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class CanonicalizationError extends Error {
|
|
30
|
+
constructor(message: string) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CanonicalizationError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serializeString(s: string): string {
|
|
37
|
+
let out = '"';
|
|
38
|
+
for (let i = 0; i < s.length; i++) {
|
|
39
|
+
const cp = s.charCodeAt(i);
|
|
40
|
+
if (cp < 0x20) {
|
|
41
|
+
const esc = ESCAPE_MAP[cp];
|
|
42
|
+
if (esc !== undefined) {
|
|
43
|
+
out += esc;
|
|
44
|
+
} else {
|
|
45
|
+
out += "\\u" + cp.toString(16).padStart(4, "0");
|
|
46
|
+
}
|
|
47
|
+
} else if (cp === 0x22 || cp === 0x5c) {
|
|
48
|
+
out += ESCAPE_MAP[cp];
|
|
49
|
+
} else {
|
|
50
|
+
out += s[i];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
out += '"';
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function serializeNumber(n: number): string {
|
|
58
|
+
if (typeof n !== "number" || !Number.isFinite(n)) {
|
|
59
|
+
throw new CanonicalizationError(
|
|
60
|
+
`CSC-1 forbids non-finite numbers; got ${n}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(n)) {
|
|
64
|
+
throw new CanonicalizationError(
|
|
65
|
+
"CSC-1 forbids float in signed payloads; " +
|
|
66
|
+
'encode numeric parameters as strings (e.g. temperature="0.7")',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (n < JS_SAFE_INT_MIN || n > JS_SAFE_INT_MAX) {
|
|
70
|
+
throw new CanonicalizationError(
|
|
71
|
+
`integer ${n} outside JS-safe range [${JS_SAFE_INT_MIN}, ${JS_SAFE_INT_MAX}]; ` +
|
|
72
|
+
"encode large integers as strings",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// Number.prototype.toString() on integers produces the shortest decimal,
|
|
76
|
+
// no leading zeros, leading "-" for negatives, "0" for zero. Matches
|
|
77
|
+
// Python's str(int) byte-for-byte.
|
|
78
|
+
return String(n);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serializeArray(arr: unknown[]): string {
|
|
82
|
+
const parts = arr.map((v) => serialize(v));
|
|
83
|
+
return "[" + parts.join(",") + "]";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function utf16Compare(a: string, b: string): number {
|
|
87
|
+
// RFC 8785 §3.2.3: keys sorted by UTF-16 code-unit value.
|
|
88
|
+
// JavaScript strings ARE UTF-16, so direct < / > comparison works
|
|
89
|
+
// for BMP keys. For supplementary-plane keys the surrogate halves
|
|
90
|
+
// already sort correctly because we compare code units, not code points.
|
|
91
|
+
const len = Math.min(a.length, b.length);
|
|
92
|
+
for (let i = 0; i < len; i++) {
|
|
93
|
+
const ac = a.charCodeAt(i);
|
|
94
|
+
const bc = b.charCodeAt(i);
|
|
95
|
+
if (ac !== bc) return ac - bc;
|
|
96
|
+
}
|
|
97
|
+
return a.length - b.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function serializeObject(obj: Record<string, unknown>): string {
|
|
101
|
+
// Reject non-string keys via JS prototype. Object keys in JS are always
|
|
102
|
+
// strings (Symbol keys are skipped by Object.keys), so this is ok by
|
|
103
|
+
// construction. But we still defend against malformed input shapes.
|
|
104
|
+
const keys = Object.keys(obj);
|
|
105
|
+
for (const k of keys) {
|
|
106
|
+
if (typeof k !== "string") {
|
|
107
|
+
throw new CanonicalizationError(
|
|
108
|
+
`object key must be string, got ${typeof k}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Detect duplicates (impossible from a literal object, but possible
|
|
114
|
+
// from manually-constructed values).
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
for (const k of keys) {
|
|
117
|
+
if (seen.has(k)) {
|
|
118
|
+
throw new CanonicalizationError(`duplicate object key: ${k}`);
|
|
119
|
+
}
|
|
120
|
+
seen.add(k);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sorted = [...keys].sort(utf16Compare);
|
|
124
|
+
const parts = sorted.map(
|
|
125
|
+
(k) => serializeString(k) + ":" + serialize(obj[k]),
|
|
126
|
+
);
|
|
127
|
+
return "{" + parts.join(",") + "}";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function serialize(value: unknown): string {
|
|
131
|
+
if (value === null) return "null";
|
|
132
|
+
if (value === true) return "true";
|
|
133
|
+
if (value === false) return "false";
|
|
134
|
+
if (typeof value === "string") return serializeString(value);
|
|
135
|
+
if (typeof value === "number") return serializeNumber(value);
|
|
136
|
+
if (typeof value === "bigint") {
|
|
137
|
+
if (value < BigInt(JS_SAFE_INT_MIN) || value > BigInt(JS_SAFE_INT_MAX)) {
|
|
138
|
+
throw new CanonicalizationError(
|
|
139
|
+
`bigint ${value} outside JS-safe range; encode as string`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return value.toString();
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(value)) return serializeArray(value);
|
|
145
|
+
if (typeof value === "object") {
|
|
146
|
+
return serializeObject(value as Record<string, unknown>);
|
|
147
|
+
}
|
|
148
|
+
if (value === undefined) {
|
|
149
|
+
throw new CanonicalizationError("CSC-1 cannot serialize undefined");
|
|
150
|
+
}
|
|
151
|
+
throw new CanonicalizationError(
|
|
152
|
+
`CSC-1 cannot serialize value of type ${typeof value}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Canonicalize a JSON-compatible value to its UTF-8 byte representation.
|
|
158
|
+
* Output is byte-identical to the Python reference implementation.
|
|
159
|
+
*/
|
|
160
|
+
export function canonicalize(value: unknown): Uint8Array {
|
|
161
|
+
const str = serialize(value);
|
|
162
|
+
return new TextEncoder().encode(str);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Canonicalize and return the string form (UTF-8 will be identical).
|
|
167
|
+
* Useful for debugging.
|
|
168
|
+
*/
|
|
169
|
+
export function canonicalizeString(value: unknown): string {
|
|
170
|
+
return serialize(value);
|
|
171
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crovia/seal — Immutable continuity receipts for evolving AI systems.
|
|
3
|
+
*
|
|
4
|
+
* Public API:
|
|
5
|
+
* seal(payload, opts?) → Receipt
|
|
6
|
+
* verify(receipt, payload?) → VerifyResult
|
|
7
|
+
* verifyChain(receipts) → VerifyResult
|
|
8
|
+
* register(receipt, opts?) → RegisterResult (optional)
|
|
9
|
+
* generateKey() / generateKeySync() → KeyPair
|
|
10
|
+
* canonicalize(value) → Uint8Array
|
|
11
|
+
*
|
|
12
|
+
* Wire format: crovia.receipt.v1 (Ed25519 + CSC-1 canonical JSON).
|
|
13
|
+
* Cross-language byte-identity with the Python reference is part of
|
|
14
|
+
* the conformance contract.
|
|
15
|
+
*/
|
|
16
|
+
export { seal, computePayload, validateReceiptShape } from "./seal.js";
|
|
17
|
+
export { verify, verifyChain } from "./verify.js";
|
|
18
|
+
export { register } from "./register.js";
|
|
19
|
+
export {
|
|
20
|
+
generateKey,
|
|
21
|
+
generateKeySync,
|
|
22
|
+
publicFromPrivate,
|
|
23
|
+
signBytes,
|
|
24
|
+
verifyBytes,
|
|
25
|
+
} from "./keys.js";
|
|
26
|
+
export { canonicalize, canonicalizeString, CanonicalizationError } from "./canonical.js";
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
Receipt,
|
|
30
|
+
KeyPair,
|
|
31
|
+
SealOptions,
|
|
32
|
+
VerifyResult,
|
|
33
|
+
RegisterOptions,
|
|
34
|
+
RegisterResult,
|
|
35
|
+
} from "./types.js";
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 key handling.
|
|
3
|
+
*
|
|
4
|
+
* @noble/ed25519 v2 ships only the curve operations and requires a
|
|
5
|
+
* SHA-512 implementation to be plugged in via `etc.sha512Sync`. We use
|
|
6
|
+
* @noble/hashes/sha512 for that.
|
|
7
|
+
*/
|
|
8
|
+
// Polyfill MUST be imported BEFORE @noble/ed25519, which captures
|
|
9
|
+
// globalThis.crypto at module load time.
|
|
10
|
+
import "./_polyfill.js";
|
|
11
|
+
|
|
12
|
+
import * as ed from "@noble/ed25519";
|
|
13
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
14
|
+
|
|
15
|
+
import type { KeyPair } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// Plug SHA-512 into noble-ed25519 (required by v2 API).
|
|
18
|
+
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(ed.etc.concatBytes(...m));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Provide random bytes that work everywhere:
|
|
22
|
+
* - browsers and modern Node: globalThis.crypto.getRandomValues
|
|
23
|
+
* - older Node (18.x ESM): dynamic import of "node:crypto" (top-level await)
|
|
24
|
+
*
|
|
25
|
+
* We override `ed.etc.randomBytes` so noble-ed25519's own RNG path
|
|
26
|
+
* works in older Node, plus expose `randomBytes()` for seal.ts.
|
|
27
|
+
*/
|
|
28
|
+
type RandFn = (n: number) => Uint8Array;
|
|
29
|
+
|
|
30
|
+
async function pickRandomBytes(): Promise<RandFn> {
|
|
31
|
+
const g = globalThis as {
|
|
32
|
+
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array };
|
|
33
|
+
};
|
|
34
|
+
if (g.crypto && typeof g.crypto.getRandomValues === "function") {
|
|
35
|
+
return (n: number) => g.crypto!.getRandomValues!(new Uint8Array(n));
|
|
36
|
+
}
|
|
37
|
+
// Node ESM fallback for environments where globalThis.crypto is missing
|
|
38
|
+
// (Node 18.x without --experimental-global-webcrypto). The dynamic import
|
|
39
|
+
// is a no-op in browsers because the branch above always wins there.
|
|
40
|
+
try {
|
|
41
|
+
const nc = (await import("node:crypto")) as unknown as {
|
|
42
|
+
webcrypto?: { getRandomValues: (a: Uint8Array) => Uint8Array };
|
|
43
|
+
randomBytes?: (n: number) => Uint8Array;
|
|
44
|
+
};
|
|
45
|
+
if (nc.webcrypto?.getRandomValues) {
|
|
46
|
+
return (n: number) => nc.webcrypto!.getRandomValues(new Uint8Array(n));
|
|
47
|
+
}
|
|
48
|
+
if (typeof nc.randomBytes === "function") {
|
|
49
|
+
return (n: number) => new Uint8Array(nc.randomBytes!(n));
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// not in Node — fall through to throw
|
|
53
|
+
}
|
|
54
|
+
throw new Error(
|
|
55
|
+
"no secure RNG available — need WebCrypto or Node 'crypto' module",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const _randomBytes: RandFn = await pickRandomBytes();
|
|
60
|
+
// noble-ed25519's randomBytes signature takes (len?: number); default 32.
|
|
61
|
+
ed.etc.randomBytes = (len?: number) => _randomBytes(len ?? 32);
|
|
62
|
+
|
|
63
|
+
/** Cryptographically-secure random bytes (browser + Node). */
|
|
64
|
+
export function randomBytes(n: number): Uint8Array {
|
|
65
|
+
return _randomBytes(n);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
69
|
+
|
|
70
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
71
|
+
let s = "";
|
|
72
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
73
|
+
s += bytes[i]!.toString(16).padStart(2, "0");
|
|
74
|
+
}
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
79
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/.test(hex)) {
|
|
80
|
+
throw new Error(`invalid hex string of length ${hex.length}`);
|
|
81
|
+
}
|
|
82
|
+
const out = new Uint8Array(hex.length / 2);
|
|
83
|
+
for (let i = 0; i < out.length; i++) {
|
|
84
|
+
out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hexToBytes32(hex: string): Uint8Array {
|
|
90
|
+
if (!HEX64_RE.test(hex)) {
|
|
91
|
+
throw new Error(`expected 64 lowercase hex chars, got ${hex.length} chars`);
|
|
92
|
+
}
|
|
93
|
+
return hexToBytes(hex);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate a fresh Ed25519 key pair.
|
|
98
|
+
* Uses crypto.getRandomValues — works in Node 18+ and modern browsers.
|
|
99
|
+
*/
|
|
100
|
+
export async function generateKey(): Promise<KeyPair> {
|
|
101
|
+
const priv = ed.utils.randomPrivateKey();
|
|
102
|
+
const pub = await ed.getPublicKeyAsync(priv);
|
|
103
|
+
return {
|
|
104
|
+
privateHex: bytesToHex(priv),
|
|
105
|
+
publicHex: bytesToHex(pub),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Synchronous variant of generateKey() — usable when running with the
|
|
111
|
+
* sha512Sync hook installed (Node, Deno, modern browsers).
|
|
112
|
+
*/
|
|
113
|
+
export function generateKeySync(): KeyPair {
|
|
114
|
+
const priv = ed.utils.randomPrivateKey();
|
|
115
|
+
const pub = ed.getPublicKey(priv);
|
|
116
|
+
return {
|
|
117
|
+
privateHex: bytesToHex(priv),
|
|
118
|
+
publicHex: bytesToHex(pub),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Derive the public hex from a private hex (does not mutate caller). */
|
|
123
|
+
export async function publicFromPrivate(privateHex: string): Promise<string> {
|
|
124
|
+
const pub = await ed.getPublicKeyAsync(hexToBytes32(privateHex));
|
|
125
|
+
return bytesToHex(pub);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sign raw bytes with an Ed25519 private key (hex).
|
|
130
|
+
* Returns 64-byte signature as 128 lowercase hex chars.
|
|
131
|
+
*/
|
|
132
|
+
export async function signBytes(
|
|
133
|
+
privateHex: string,
|
|
134
|
+
message: Uint8Array,
|
|
135
|
+
): Promise<string> {
|
|
136
|
+
const sig = await ed.signAsync(message, hexToBytes32(privateHex));
|
|
137
|
+
return bytesToHex(sig);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Verify a signature against bytes and a public key (all hex / Uint8Array). */
|
|
141
|
+
export async function verifyBytes(
|
|
142
|
+
publicHex: string,
|
|
143
|
+
signatureHex: string,
|
|
144
|
+
message: Uint8Array,
|
|
145
|
+
): Promise<boolean> {
|
|
146
|
+
if (signatureHex.length !== 128 || !/^[0-9a-f]{128}$/.test(signatureHex)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (!HEX64_RE.test(publicHex)) return false;
|
|
150
|
+
try {
|
|
151
|
+
return await ed.verifyAsync(
|
|
152
|
+
hexToBytes(signatureHex),
|
|
153
|
+
message,
|
|
154
|
+
hexToBytes32(publicHex),
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { bytesToHex, hexToBytes };
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `register()` — optional opt-in: post a receipt to the Crovia substrate.
|
|
3
|
+
*
|
|
4
|
+
* The substrate accepts the receipt, returns an anchor id and a position
|
|
5
|
+
* in the public continuity graph. This is OPTIONAL — `seal()` and
|
|
6
|
+
* `verify()` work fully offline. Calling `register()` is what causes the
|
|
7
|
+
* receipt to participate in the public substrate's continuity graph.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
Receipt,
|
|
11
|
+
RegisterOptions,
|
|
12
|
+
RegisterResult,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ENDPOINT = "https://croviatrust.com";
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a receipt with the Crovia substrate.
|
|
20
|
+
*
|
|
21
|
+
* @returns A result describing whether the substrate accepted the receipt.
|
|
22
|
+
* Never throws on transport errors — they are returned as fields.
|
|
23
|
+
*/
|
|
24
|
+
export async function register(
|
|
25
|
+
receipt: Receipt,
|
|
26
|
+
opts: RegisterOptions = {},
|
|
27
|
+
): Promise<RegisterResult> {
|
|
28
|
+
const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
|
|
29
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
|
+
const fetchFn = opts.fetch ?? globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
if (typeof fetchFn !== "function") {
|
|
33
|
+
return {
|
|
34
|
+
accepted: false,
|
|
35
|
+
status: 0,
|
|
36
|
+
error: "fetch is not available; pass opts.fetch",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const url = endpoint.replace(/\/+$/, "") + "/api/anchor";
|
|
41
|
+
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetchFn(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
"user-agent": "crovia-seal/0.1.0",
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({ receipt }),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let body: unknown = null;
|
|
57
|
+
try {
|
|
58
|
+
body = await res.json();
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore — body may be empty on errors
|
|
61
|
+
}
|
|
62
|
+
const b = body as { anchor_id?: string; error?: string } | null;
|
|
63
|
+
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
return {
|
|
66
|
+
accepted: true,
|
|
67
|
+
status: res.status,
|
|
68
|
+
anchorId: b?.anchor_id,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
accepted: false,
|
|
73
|
+
status: res.status,
|
|
74
|
+
error: b?.error ?? `HTTP ${res.status}`,
|
|
75
|
+
};
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return {
|
|
78
|
+
accepted: false,
|
|
79
|
+
status: 0,
|
|
80
|
+
error: e instanceof Error ? e.message : String(e),
|
|
81
|
+
};
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/seal.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seal()` — produce a continuity receipt over an arbitrary JSON payload.
|
|
3
|
+
*
|
|
4
|
+
* Receipt format: `crovia.receipt.v1`. See types.ts for the schema.
|
|
5
|
+
*
|
|
6
|
+
* Wire format guarantees:
|
|
7
|
+
* - Canonical JSON via CSC-1 (byte-identical with Python reference)
|
|
8
|
+
* - Ed25519 signatures over `b"CROVIA-RECEIPT-v1\n" || csc1(receipt without sig)`
|
|
9
|
+
* - Domain separator `CROVIA-RECEIPT-v1` distinct from `CROVIA-SEAL-v1`,
|
|
10
|
+
* so a receipt signature cannot be replayed as a Seal v1 signature.
|
|
11
|
+
* - SHA-256 over canonical bytes of `payload` for `payload_hash`.
|
|
12
|
+
*/
|
|
13
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
14
|
+
|
|
15
|
+
import { canonicalize } from "./canonical.js";
|
|
16
|
+
import {
|
|
17
|
+
bytesToHex,
|
|
18
|
+
generateKeySync,
|
|
19
|
+
hexToBytes,
|
|
20
|
+
randomBytes,
|
|
21
|
+
signBytes,
|
|
22
|
+
} from "./keys.js";
|
|
23
|
+
import type { KeyPair, Receipt, SealOptions } from "./types.js";
|
|
24
|
+
|
|
25
|
+
const RECEIPT_VERSION = "crovia.receipt.v1" as const;
|
|
26
|
+
const DOMAIN_STRING = "CROVIA-RECEIPT-v1" as const;
|
|
27
|
+
const DOMAIN_BYTES = new TextEncoder().encode(DOMAIN_STRING + "\n");
|
|
28
|
+
const CANON_ID = "csc-1" as const;
|
|
29
|
+
const SIG_ALG = "ed25519" as const;
|
|
30
|
+
const PAYLOAD_ALG = "sha256" as const;
|
|
31
|
+
const RECEIPT_ID_RE = /^cr_[0-9]{4}_[A-Z2-7]{26}$/;
|
|
32
|
+
|
|
33
|
+
// 16 random bytes encoded as base32 (no padding) gives 26 chars.
|
|
34
|
+
function randomB32(nBytes = 16): string {
|
|
35
|
+
const buf = randomBytes(nBytes);
|
|
36
|
+
// RFC 4648 base32 alphabet, uppercase.
|
|
37
|
+
const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
38
|
+
let out = "";
|
|
39
|
+
let bits = 0;
|
|
40
|
+
let acc = 0;
|
|
41
|
+
for (let i = 0; i < buf.length; i++) {
|
|
42
|
+
acc = (acc << 8) | buf[i]!;
|
|
43
|
+
bits += 8;
|
|
44
|
+
while (bits >= 5) {
|
|
45
|
+
bits -= 5;
|
|
46
|
+
out += ALPHA[(acc >>> bits) & 0x1f];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (bits > 0) {
|
|
50
|
+
out += ALPHA[(acc << (5 - bits)) & 0x1f];
|
|
51
|
+
}
|
|
52
|
+
return out.slice(0, 26);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function newReceiptId(): string {
|
|
56
|
+
const year = new Date().getUTCFullYear();
|
|
57
|
+
return `cr_${year}_${randomB32()}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nowRfc3339Ms(): string {
|
|
61
|
+
const d = new Date();
|
|
62
|
+
// toISOString gives e.g. "2026-05-07T15:43:57.123Z" — exactly the shape
|
|
63
|
+
// the Python reference uses for its emitted_at field.
|
|
64
|
+
return d.toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sha256Prefixed(data: Uint8Array): string {
|
|
68
|
+
return "sha256:" + bytesToHex(sha256(data));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compute the exact bytes that are signed.
|
|
73
|
+
* `payload` here is the receipt object minus its `sig` field.
|
|
74
|
+
*/
|
|
75
|
+
export function computePayload(
|
|
76
|
+
receiptWithoutSig: Omit<Receipt, "sig">,
|
|
77
|
+
): Uint8Array {
|
|
78
|
+
const canonical = canonicalize(receiptWithoutSig);
|
|
79
|
+
const out = new Uint8Array(DOMAIN_BYTES.length + canonical.length);
|
|
80
|
+
out.set(DOMAIN_BYTES, 0);
|
|
81
|
+
out.set(canonical, DOMAIN_BYTES.length);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate the structural shape of a receipt object.
|
|
87
|
+
* Returns `null` on success or an error string on failure.
|
|
88
|
+
*/
|
|
89
|
+
export function validateReceiptShape(r: unknown): string | null {
|
|
90
|
+
if (!r || typeof r !== "object") return "receipt must be an object";
|
|
91
|
+
const o = r as Record<string, unknown>;
|
|
92
|
+
if (o["v"] !== RECEIPT_VERSION) return `v must be "${RECEIPT_VERSION}"`;
|
|
93
|
+
if (typeof o["id"] !== "string" || !RECEIPT_ID_RE.test(o["id"] as string)) {
|
|
94
|
+
return "id must match cr_YYYY_<26 base32 chars>";
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
typeof o["issued_at"] !== "string" ||
|
|
98
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(
|
|
99
|
+
o["issued_at"] as string,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
return "issued_at must be RFC 3339 UTC with ms precision";
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
typeof o["payload_hash"] !== "string" ||
|
|
106
|
+
!/^sha256:[0-9a-f]{64}$/.test(o["payload_hash"] as string)
|
|
107
|
+
) {
|
|
108
|
+
return "payload_hash must be 'sha256:<64 hex>'";
|
|
109
|
+
}
|
|
110
|
+
if (o["payload_alg"] !== PAYLOAD_ALG) {
|
|
111
|
+
return `payload_alg must be "${PAYLOAD_ALG}"`;
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
o["payload_type"] !== undefined &&
|
|
115
|
+
typeof o["payload_type"] !== "string"
|
|
116
|
+
) {
|
|
117
|
+
return "payload_type must be string when present";
|
|
118
|
+
}
|
|
119
|
+
if (o["prev"] !== null) {
|
|
120
|
+
if (
|
|
121
|
+
typeof o["prev"] !== "string" ||
|
|
122
|
+
!RECEIPT_ID_RE.test(o["prev"] as string)
|
|
123
|
+
) {
|
|
124
|
+
return "prev must be null or a valid receipt id";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
typeof o["seq"] !== "number" ||
|
|
129
|
+
!Number.isInteger(o["seq"] as number) ||
|
|
130
|
+
(o["seq"] as number) < 0
|
|
131
|
+
) {
|
|
132
|
+
return "seq must be a non-negative integer";
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
(o["seq"] as number) === 0 &&
|
|
136
|
+
o["prev"] !== null
|
|
137
|
+
) {
|
|
138
|
+
return "seq=0 (genesis) requires prev=null";
|
|
139
|
+
}
|
|
140
|
+
if ((o["seq"] as number) > 0 && o["prev"] === null) {
|
|
141
|
+
return "seq>0 requires prev to be a receipt id";
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
typeof o["signer"] !== "string" ||
|
|
145
|
+
!/^[0-9a-f]{64}$/.test(o["signer"] as string)
|
|
146
|
+
) {
|
|
147
|
+
return "signer must be 64 lowercase hex chars";
|
|
148
|
+
}
|
|
149
|
+
if (o["sig_alg"] !== SIG_ALG) return `sig_alg must be "${SIG_ALG}"`;
|
|
150
|
+
if (o["canon"] !== CANON_ID) return `canon must be "${CANON_ID}"`;
|
|
151
|
+
if (o["domain"] !== DOMAIN_STRING) {
|
|
152
|
+
return `domain must be "${DOMAIN_STRING}"`;
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
typeof o["sig"] !== "string" ||
|
|
156
|
+
!/^[0-9a-f]{128}$/.test(o["sig"] as string)
|
|
157
|
+
) {
|
|
158
|
+
return "sig must be 128 lowercase hex chars";
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Produce a continuity receipt over an arbitrary JSON payload.
|
|
165
|
+
*
|
|
166
|
+
* Default behaviour: generates a fresh ephemeral key for this call.
|
|
167
|
+
* Pass `opts.key` to use your own key (recommended for chains).
|
|
168
|
+
*/
|
|
169
|
+
export async function seal(
|
|
170
|
+
payload: unknown,
|
|
171
|
+
opts: SealOptions = {},
|
|
172
|
+
): Promise<Receipt> {
|
|
173
|
+
const key: KeyPair = opts.key ?? generateKeySync();
|
|
174
|
+
|
|
175
|
+
// Hash the canonical bytes of the user's payload. This is what makes
|
|
176
|
+
// the receipt verifiable WITHOUT carrying the payload itself: anyone
|
|
177
|
+
// with the original bytes can recompute the hash and check it against
|
|
178
|
+
// payload_hash.
|
|
179
|
+
const payloadCanonical = canonicalize(payload);
|
|
180
|
+
const payloadHash = sha256Prefixed(payloadCanonical);
|
|
181
|
+
|
|
182
|
+
const prev: string | null = opts.prevReceipt?.id ?? null;
|
|
183
|
+
const seq: number = opts.prevReceipt
|
|
184
|
+
? opts.prevReceipt.seq + 1
|
|
185
|
+
: 0;
|
|
186
|
+
|
|
187
|
+
const issuedAt =
|
|
188
|
+
opts.issuedAt ??
|
|
189
|
+
nowRfc3339Ms();
|
|
190
|
+
|
|
191
|
+
const unsignedShape: Omit<Receipt, "sig"> = {
|
|
192
|
+
v: RECEIPT_VERSION,
|
|
193
|
+
id: newReceiptId(),
|
|
194
|
+
issued_at: issuedAt,
|
|
195
|
+
payload_hash: payloadHash,
|
|
196
|
+
payload_alg: PAYLOAD_ALG,
|
|
197
|
+
...(opts.payloadType !== undefined && {
|
|
198
|
+
payload_type: opts.payloadType,
|
|
199
|
+
}),
|
|
200
|
+
prev,
|
|
201
|
+
seq,
|
|
202
|
+
signer: key.publicHex,
|
|
203
|
+
sig_alg: SIG_ALG,
|
|
204
|
+
canon: CANON_ID,
|
|
205
|
+
domain: DOMAIN_STRING,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const signingPayload = computePayload(unsignedShape);
|
|
209
|
+
const sigHex = await signBytes(key.privateHex, signingPayload);
|
|
210
|
+
|
|
211
|
+
const receipt: Receipt = {
|
|
212
|
+
...unsignedShape,
|
|
213
|
+
sig: sigHex,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Defense in depth: ensure what we return validates.
|
|
217
|
+
const err = validateReceiptShape(receipt);
|
|
218
|
+
if (err !== null) {
|
|
219
|
+
throw new Error(`internal: produced invalid receipt — ${err}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return receipt;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export {
|
|
226
|
+
bytesToHex,
|
|
227
|
+
hexToBytes,
|
|
228
|
+
RECEIPT_VERSION,
|
|
229
|
+
DOMAIN_STRING,
|
|
230
|
+
DOMAIN_BYTES,
|
|
231
|
+
};
|