@jterrazz/attestation 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/README.md +27 -0
- package/bin/attestation +2 -0
- package/dist/audit.cjs +57 -0
- package/dist/audit.cjs.map +1 -0
- package/dist/audit.js +52 -0
- package/dist/audit.js.map +1 -0
- package/dist/browser.cjs +183 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +111 -0
- package/dist/browser.d.ts +111 -0
- package/dist/browser.js +168 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.cjs +284 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +285 -0
- package/dist/cli.js.map +1 -0
- package/dist/eip712-schema.cjs +96 -0
- package/dist/eip712-schema.cjs.map +1 -0
- package/dist/eip712-schema.d.cts +75 -0
- package/dist/eip712-schema.d.ts +75 -0
- package/dist/eip712-schema.js +73 -0
- package/dist/eip712-schema.js.map +1 -0
- package/dist/index.cjs +21 -0
- package/dist/index.d.cts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +4 -0
- package/dist/node.cjs +279 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +87 -0
- package/dist/node.d.ts +87 -0
- package/dist/node.js +274 -0
- package/dist/node.js.map +1 -0
- package/dist/sign-flow.cjs +323 -0
- package/dist/sign-flow.cjs.map +1 -0
- package/dist/sign-flow.js +277 -0
- package/dist/sign-flow.js.map +1 -0
- package/dist/verify.cjs +276 -0
- package/dist/verify.cjs.map +1 -0
- package/dist/verify.d.cts +105 -0
- package/dist/verify.d.ts +105 -0
- package/dist/verify.js +199 -0
- package/dist/verify.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { n as ATTESTATION_PRIMARY_TYPE, r as ATTESTATION_TYPES_V1, t as ATTESTATION_DOMAIN_V1 } from "./eip712-schema.js";
|
|
2
|
+
import OpenTimestamps from "javascript-opentimestamps";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
//#region src/ots/stamp.ts
|
|
5
|
+
const { DetachedTimestampFile: DetachedTimestampFile$1, Ops: Ops$1 } = OpenTimestamps;
|
|
6
|
+
/**
|
|
7
|
+
* Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.
|
|
8
|
+
*
|
|
9
|
+
* The returned proof initially contains only calendar attestations. Run upgrade()
|
|
10
|
+
* after ~24h to anchor it in a Bitcoin block.
|
|
11
|
+
*/
|
|
12
|
+
async function stampDigest(digest) {
|
|
13
|
+
if (digest.length !== 32) throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);
|
|
14
|
+
const detached = DetachedTimestampFile$1.fromHash(new Ops$1.OpSHA256(), Buffer.from(digest));
|
|
15
|
+
await OpenTimestamps.stamp(detached);
|
|
16
|
+
const bytes = detached.serializeToBytes.call(detached);
|
|
17
|
+
return new Uint8Array(bytes);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.
|
|
21
|
+
*
|
|
22
|
+
* Returns the (possibly upgraded) proof bytes plus a flag indicating whether
|
|
23
|
+
* the upgrade actually attached a Bitcoin attestation this time.
|
|
24
|
+
*/
|
|
25
|
+
async function upgradeProof(otsBytes) {
|
|
26
|
+
const detached = DetachedTimestampFile$1.deserialize(Buffer.from(otsBytes));
|
|
27
|
+
const upgraded = await OpenTimestamps.upgrade(detached);
|
|
28
|
+
const bytes = detached.serializeToBytes.call(detached);
|
|
29
|
+
return {
|
|
30
|
+
bytes: new Uint8Array(bytes),
|
|
31
|
+
upgraded
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/ots/verify.ts
|
|
36
|
+
const { DetachedTimestampFile, Ops } = OpenTimestamps;
|
|
37
|
+
/**
|
|
38
|
+
* Verify an OTS proof attests the given digest, and return the Bitcoin block
|
|
39
|
+
* attestation time if present.
|
|
40
|
+
*
|
|
41
|
+
* Network: this calls a public Bitcoin block-info source via the OTS library
|
|
42
|
+
* to validate the Merkle path. In production-paranoid mode you should run your
|
|
43
|
+
* own Bitcoin node and pass it explicitly (future enhancement).
|
|
44
|
+
*/
|
|
45
|
+
async function verifyOts(digest, otsBytes) {
|
|
46
|
+
if (digest.length !== 32) return {
|
|
47
|
+
details: `Expected 32-byte digest, got ${digest.length}`,
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: "digest-mismatch"
|
|
50
|
+
};
|
|
51
|
+
let detached;
|
|
52
|
+
let original;
|
|
53
|
+
try {
|
|
54
|
+
detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));
|
|
55
|
+
original = DetachedTimestampFile.fromHash(new Ops.OpSHA256(), Buffer.from(digest));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return {
|
|
58
|
+
details: error.message,
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: "invalid-proof"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let attestations;
|
|
64
|
+
try {
|
|
65
|
+
attestations = await OpenTimestamps.verify(detached, original);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
details: error.message,
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "invalid-proof"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const bitcoin = attestations["bitcoin"];
|
|
74
|
+
if (bitcoin === void 0) return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: "pending-bitcoin"
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
bitcoinBlockTime: /* @__PURE__ */ new Date(bitcoin.timestamp * 1e3),
|
|
80
|
+
ok: true
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/eth/sign-page-html.ts
|
|
85
|
+
/**
|
|
86
|
+
* Self-contained HTML page served by the local CLI server. Opens MetaMask
|
|
87
|
+
* (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs
|
|
88
|
+
* the result back to the localhost callback.
|
|
89
|
+
*
|
|
90
|
+
* No bundler, no external script — just a string template. Keeping this
|
|
91
|
+
* pure-string makes it trivial to audit what gets shown to the wallet.
|
|
92
|
+
*/
|
|
93
|
+
function buildSignPageHtml(typedDataJson) {
|
|
94
|
+
return `<!doctype html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8" />
|
|
98
|
+
<title>Sign attestation</title>
|
|
99
|
+
<style>
|
|
100
|
+
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
101
|
+
button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }
|
|
102
|
+
pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }
|
|
103
|
+
.ok { color: #0a7d22; }
|
|
104
|
+
.err { color: #b00020; white-space: pre-wrap; }
|
|
105
|
+
</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<h1>Sign attestation</h1>
|
|
109
|
+
<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>
|
|
110
|
+
<pre id="preview"></pre>
|
|
111
|
+
<p><button id="sign">Sign with wallet</button></p>
|
|
112
|
+
<p id="status"></p>
|
|
113
|
+
<script>
|
|
114
|
+
const TYPED_DATA = JSON.parse(\`${typedDataJson.replace(/\\/g, String.raw`\\`).replace(/`/g, "\\`").replace(/<\/script>/gi, String.raw`<\/script>`)}\`);
|
|
115
|
+
document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);
|
|
116
|
+
|
|
117
|
+
document.getElementById('sign').addEventListener('click', async () => {
|
|
118
|
+
const status = document.getElementById('status');
|
|
119
|
+
status.className = '';
|
|
120
|
+
status.textContent = 'Requesting account…';
|
|
121
|
+
|
|
122
|
+
if (!window.ethereum) {
|
|
123
|
+
status.className = 'err';
|
|
124
|
+
status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
130
|
+
const signer = accounts[0];
|
|
131
|
+
|
|
132
|
+
status.textContent = 'Awaiting wallet confirmation…';
|
|
133
|
+
const signature = await window.ethereum.request({
|
|
134
|
+
method: 'eth_signTypedData_v4',
|
|
135
|
+
params: [signer, JSON.stringify(TYPED_DATA)],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
status.textContent = 'Submitting signature…';
|
|
139
|
+
const res = await fetch('/done', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ signature, signerAddress: signer }),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);
|
|
145
|
+
|
|
146
|
+
status.className = 'ok';
|
|
147
|
+
status.textContent = 'Signed. You can close this tab.';
|
|
148
|
+
document.getElementById('sign').disabled = true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
status.className = 'err';
|
|
151
|
+
status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
<\/script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>`;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/eth/sign-flow.ts
|
|
160
|
+
const DEFAULT_TIMEOUT_MS = 300 * 1e3;
|
|
161
|
+
/**
|
|
162
|
+
* Spin up a one-shot localhost HTTP server, open the browser, wait for the user
|
|
163
|
+
* to sign with their wallet, return the signature.
|
|
164
|
+
*
|
|
165
|
+
* The local server only accepts loopback requests (127.0.0.1).
|
|
166
|
+
*/
|
|
167
|
+
async function signViaBrowser(opts) {
|
|
168
|
+
const message = opts.message;
|
|
169
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
170
|
+
const typedData = {
|
|
171
|
+
domain: ATTESTATION_DOMAIN_V1,
|
|
172
|
+
message: serializeMessageForBrowser(message),
|
|
173
|
+
primaryType: ATTESTATION_PRIMARY_TYPE,
|
|
174
|
+
types: {
|
|
175
|
+
EIP712Domain: domainTypes(),
|
|
176
|
+
...ATTESTATION_TYPES_V1
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const html = buildSignPageHtml(JSON.stringify(typedData));
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const server = createServer((req, res) => {
|
|
182
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
|
|
183
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
184
|
+
res.end(html);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (req.method === "POST" && req.url === "/done") {
|
|
188
|
+
let body = "";
|
|
189
|
+
req.on("data", (chunk) => {
|
|
190
|
+
body += chunk.toString("utf8");
|
|
191
|
+
});
|
|
192
|
+
req.on("end", () => {
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(body);
|
|
196
|
+
} catch {
|
|
197
|
+
res.writeHead(400);
|
|
198
|
+
res.end("Parse error");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (typeof parsed.signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) || typeof parsed.signerAddress !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)) {
|
|
202
|
+
res.writeHead(400);
|
|
203
|
+
res.end("Invalid payload");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
207
|
+
res.end("OK");
|
|
208
|
+
res.on("finish", () => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
server.close();
|
|
211
|
+
resolve(parsed);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
res.writeHead(404);
|
|
217
|
+
res.end("Not found");
|
|
218
|
+
});
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
server.close();
|
|
221
|
+
reject(/* @__PURE__ */ new Error(`Sign flow timed out after ${timeoutMs}ms`));
|
|
222
|
+
}, timeoutMs);
|
|
223
|
+
server.on("error", (err) => {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
reject(err);
|
|
226
|
+
});
|
|
227
|
+
server.listen(0, "127.0.0.1", () => {
|
|
228
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
229
|
+
const ready = opts.onUrlReady ?? defaultOpenInBrowser;
|
|
230
|
+
Promise.resolve(ready(url)).catch((error) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
server.close();
|
|
233
|
+
reject(error);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async function defaultOpenInBrowser(url) {
|
|
239
|
+
const { default: open } = await import("open");
|
|
240
|
+
await open(url);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable
|
|
244
|
+
* form for the browser. EIP-712 v4 expects numeric strings for uint64.
|
|
245
|
+
*/
|
|
246
|
+
function serializeMessageForBrowser(message) {
|
|
247
|
+
return {
|
|
248
|
+
claims: {
|
|
249
|
+
priorAttestation: message.claims.priorAttestation,
|
|
250
|
+
publishedAt: message.claims.publishedAt.toString(),
|
|
251
|
+
revision: message.claims.revision,
|
|
252
|
+
slug: message.claims.slug
|
|
253
|
+
},
|
|
254
|
+
schemaVersion: message.schemaVersion,
|
|
255
|
+
subject: message.subject
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function domainTypes() {
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
name: "name",
|
|
262
|
+
type: "string"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "version",
|
|
266
|
+
type: "string"
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "chainId",
|
|
270
|
+
type: "uint256"
|
|
271
|
+
}
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
export { upgradeProof as i, verifyOts as n, stampDigest as r, signViaBrowser as t };
|
|
276
|
+
|
|
277
|
+
//# sourceMappingURL=sign-flow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign-flow.js","names":["DetachedTimestampFile","Ops","createHttpServer"],"sources":["../src/ots/stamp.ts","../src/ots/verify.ts","../src/eth/sign-page-html.ts","../src/eth/sign-flow.ts"],"sourcesContent":["import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n stamp: (detached: unknown) => Promise<void>;\n upgrade: (detached: unknown) => Promise<boolean>;\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.\n *\n * The returned proof initially contains only calendar attestations. Run upgrade()\n * after ~24h to anchor it in a Bitcoin block.\n */\nexport async function stampDigest(digest: Uint8Array): Promise<Uint8Array> {\n if (digest.length !== 32) {\n throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);\n }\n\n const detached = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n await (OpenTimestamps as unknown as { stamp: (d: unknown) => Promise<void> }).stamp(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return new Uint8Array(bytes as Buffer);\n}\n\n/**\n * Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.\n *\n * Returns the (possibly upgraded) proof bytes plus a flag indicating whether\n * the upgrade actually attached a Bitcoin attestation this time.\n */\nexport async function upgradeProof(\n otsBytes: Uint8Array,\n): Promise<{ bytes: Uint8Array; upgraded: boolean }> {\n const detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n const upgraded = await (\n OpenTimestamps as unknown as { upgrade: (d: unknown) => Promise<boolean> }\n ).upgrade(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return { bytes: new Uint8Array(bytes as Buffer), upgraded };\n}\n","import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\nexport type OtsVerifyOk = {\n ok: true;\n bitcoinBlockTime: Date;\n};\n\nexport type OtsVerifyFail = {\n ok: false;\n reason: 'digest-mismatch' | 'invalid-proof' | 'pending-bitcoin';\n details?: string;\n};\n\nexport type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;\n\n/**\n * Verify an OTS proof attests the given digest, and return the Bitcoin block\n * attestation time if present.\n *\n * Network: this calls a public Bitcoin block-info source via the OTS library\n * to validate the Merkle path. In production-paranoid mode you should run your\n * own Bitcoin node and pass it explicitly (future enhancement).\n */\nexport async function verifyOts(\n digest: Uint8Array,\n otsBytes: Uint8Array,\n): Promise<OtsVerifyResult> {\n if (digest.length !== 32) {\n return {\n details: `Expected 32-byte digest, got ${digest.length}`,\n ok: false,\n reason: 'digest-mismatch',\n };\n }\n\n let detached: unknown;\n let original: unknown;\n try {\n detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n original = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n let attestations: Record<string, { timestamp: number }>;\n try {\n attestations = await (\n OpenTimestamps as unknown as {\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n }\n ).verify(detached, original);\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n const bitcoin = attestations['bitcoin'];\n if (bitcoin === undefined) {\n return { ok: false, reason: 'pending-bitcoin' };\n }\n\n return {\n bitcoinBlockTime: new Date(bitcoin.timestamp * 1000),\n ok: true,\n };\n}\n","/**\n * Self-contained HTML page served by the local CLI server. Opens MetaMask\n * (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs\n * the result back to the localhost callback.\n *\n * No bundler, no external script — just a string template. Keeping this\n * pure-string makes it trivial to audit what gets shown to the wallet.\n */\nexport function buildSignPageHtml(typedDataJson: string): string {\n // TypedDataJson MUST already be a JSON string of EIP-712 v4 typed data\n // (with `domain`, `types`, `primaryType`, `message`).\n const escaped = typedDataJson\n .replace(/\\\\/g, String.raw`\\\\`)\n .replace(/`/g, '\\\\`')\n .replace(/<\\/script>/gi, String.raw`<\\/script>`);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>Sign attestation</title>\n<style>\n body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }\n button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }\n pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }\n .ok { color: #0a7d22; }\n .err { color: #b00020; white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<h1>Sign attestation</h1>\n<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>\n<pre id=\"preview\"></pre>\n<p><button id=\"sign\">Sign with wallet</button></p>\n<p id=\"status\"></p>\n<script>\n const TYPED_DATA = JSON.parse(\\`${escaped}\\`);\n document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);\n\n document.getElementById('sign').addEventListener('click', async () => {\n const status = document.getElementById('status');\n status.className = '';\n status.textContent = 'Requesting account…';\n\n if (!window.ethereum) {\n status.className = 'err';\n status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';\n return;\n }\n\n try {\n const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });\n const signer = accounts[0];\n\n status.textContent = 'Awaiting wallet confirmation…';\n const signature = await window.ethereum.request({\n method: 'eth_signTypedData_v4',\n params: [signer, JSON.stringify(TYPED_DATA)],\n });\n\n status.textContent = 'Submitting signature…';\n const res = await fetch('/done', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ signature, signerAddress: signer }),\n });\n if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);\n\n status.className = 'ok';\n status.textContent = 'Signed. You can close this tab.';\n document.getElementById('sign').disabled = true;\n } catch (err) {\n status.className = 'err';\n status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));\n }\n });\n</script>\n</body>\n</html>`;\n}\n","import { createServer as createHttpServer } from 'node:http';\nimport { type AddressInfo, createServer } from 'node:net';\n\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n} from '../core/eip712-schema.js';\nimport { buildSignPageHtml } from './sign-page-html.js';\n\nexport type SignFlowOptions = {\n message: AttestationMessage;\n onUrlReady?: (url: string) => Promise<void> | void;\n timeoutMs?: number;\n};\n\nexport type SignFlowResult = {\n signature: `0x${string}`;\n signerAddress: `0x${string}`;\n};\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * Spin up a one-shot localhost HTTP server, open the browser, wait for the user\n * to sign with their wallet, return the signature.\n *\n * The local server only accepts loopback requests (127.0.0.1).\n */\nexport async function signViaBrowser(opts: SignFlowOptions): Promise<SignFlowResult> {\n const message = opts.message;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const typedData = {\n domain: ATTESTATION_DOMAIN_V1,\n message: serializeMessageForBrowser(message),\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: { EIP712Domain: domainTypes(), ...ATTESTATION_TYPES_V1 },\n };\n const html = buildSignPageHtml(JSON.stringify(typedData));\n\n return new Promise<SignFlowResult>((resolve, reject) => {\n const server = createHttpServer((req, res) => {\n if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n res.end(html);\n return;\n }\n\n if (req.method === 'POST' && req.url === '/done') {\n let body = '';\n req.on('data', (chunk: Buffer) => {\n body += chunk.toString('utf8');\n });\n req.on('end', () => {\n let parsed: { signature: `0x${string}`; signerAddress: `0x${string}` };\n try {\n parsed = JSON.parse(body) as typeof parsed;\n } catch {\n res.writeHead(400);\n res.end('Parse error');\n return; // Keep server running so user can retry\n }\n if (\n typeof parsed.signature !== 'string' ||\n !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) ||\n typeof parsed.signerAddress !== 'string' ||\n !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)\n ) {\n res.writeHead(400);\n res.end('Invalid payload');\n return; // Keep server running so user can retry\n }\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end('OK');\n res.on('finish', () => {\n clearTimeout(timer);\n server.close();\n resolve(parsed);\n });\n });\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n });\n\n const timer = setTimeout(() => {\n server.close();\n reject(new Error(`Sign flow timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n server.on('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n\n server.listen(0, '127.0.0.1', () => {\n const addr = server.address() as AddressInfo;\n const url = `http://127.0.0.1:${addr.port}/`;\n const ready = opts.onUrlReady ?? defaultOpenInBrowser;\n void Promise.resolve(ready(url)).catch((error: unknown) => {\n clearTimeout(timer);\n server.close();\n reject(error);\n });\n });\n });\n}\n\nasync function defaultOpenInBrowser(url: string): Promise<void> {\n const { default: open } = await import('open');\n await open(url);\n}\n\n/**\n * Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable\n * form for the browser. EIP-712 v4 expects numeric strings for uint64.\n */\nfunction serializeMessageForBrowser(message: AttestationMessage): Record<string, unknown> {\n return {\n claims: {\n priorAttestation: message.claims.priorAttestation,\n publishedAt: message.claims.publishedAt.toString(),\n revision: message.claims.revision,\n slug: message.claims.slug,\n },\n schemaVersion: message.schemaVersion,\n subject: message.subject,\n };\n}\n\nfunction domainTypes(): Array<{ name: string; type: string }> {\n return [\n { name: 'name', type: 'string' },\n { name: 'version', type: 'string' },\n { name: 'chainId', type: 'uint256' },\n ];\n}\n\n// Suppress unused-import lint: createServer kept for future net-level helpers.\nvoid createServer;\n"],"mappings":"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"}
|
package/dist/verify.cjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const require_eip712_schema = require("./eip712-schema.cjs");
|
|
2
|
+
let _noble_hashes_sha2_js = require("@noble/hashes/sha2.js");
|
|
3
|
+
let viem = require("viem");
|
|
4
|
+
//#region src/version.ts
|
|
5
|
+
const SCHEMA_VERSION = 1;
|
|
6
|
+
const CANONICAL_VERSION = 1;
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/core/canonicalize.ts
|
|
9
|
+
/**
|
|
10
|
+
* Canonicalize an article body for attestation v1.
|
|
11
|
+
*
|
|
12
|
+
* FROZEN CONTRACT — v1 rules below must NEVER change. Any modification requires
|
|
13
|
+
* publishing a v2 alongside (and v1 verifiers stay alive forever).
|
|
14
|
+
*
|
|
15
|
+
* Rules applied in order:
|
|
16
|
+
* 1. Reject unpaired UTF-16 surrogates (corrupt input).
|
|
17
|
+
* 2. Strip a leading BOM (U+FEFF) if present.
|
|
18
|
+
* 3. Apply Unicode NFC normalization.
|
|
19
|
+
* 4. Convert CRLF and stray CR to LF.
|
|
20
|
+
* 5. Trim trailing whitespace and append exactly one LF.
|
|
21
|
+
* 6. Encode as UTF-8 bytes.
|
|
22
|
+
*
|
|
23
|
+
* No markdown parsing. No per-line whitespace trim (would break " \n" soft breaks).
|
|
24
|
+
* No tab → space substitution (would break code blocks).
|
|
25
|
+
* No interior whitespace collapsing.
|
|
26
|
+
*/
|
|
27
|
+
function canonicalize(input) {
|
|
28
|
+
assertValidUtf16(input);
|
|
29
|
+
let s = input;
|
|
30
|
+
if (s.charCodeAt(0) === 65279) s = s.slice(1);
|
|
31
|
+
s = s.normalize("NFC");
|
|
32
|
+
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
33
|
+
s = `${s.replace(/\s+$/u, "")}\n`;
|
|
34
|
+
return new TextEncoder().encode(s);
|
|
35
|
+
}
|
|
36
|
+
var InvalidContentError = class extends Error {
|
|
37
|
+
name = "InvalidContentError";
|
|
38
|
+
};
|
|
39
|
+
function assertValidUtf16(s) {
|
|
40
|
+
for (let i = 0; i < s.length; i++) {
|
|
41
|
+
const code = s.charCodeAt(i);
|
|
42
|
+
if (code >= 55296 && code <= 56319) {
|
|
43
|
+
const next = s.charCodeAt(i + 1);
|
|
44
|
+
if (!(next >= 56320 && next <= 57343)) throw new InvalidContentError(`Unpaired high surrogate at index ${i}`);
|
|
45
|
+
i++;
|
|
46
|
+
} else if (code >= 56320 && code <= 57343) throw new InvalidContentError(`Unpaired low surrogate at index ${i}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/core/sha256.ts
|
|
51
|
+
/**
|
|
52
|
+
* SHA-256 of bytes, returned as bare hex (no 0x prefix).
|
|
53
|
+
*
|
|
54
|
+
* Uses @noble/hashes — pure JS, sync, audited, runs identically in Node, Bun,
|
|
55
|
+
* Deno, and every modern browser. This is the only hash primitive used by the
|
|
56
|
+
* package, so swapping the implementation here is the single point of change.
|
|
57
|
+
*/
|
|
58
|
+
function sha256Hex(bytes) {
|
|
59
|
+
const out = (0, _noble_hashes_sha2_js.sha256)(bytes);
|
|
60
|
+
let hex = "";
|
|
61
|
+
for (const byte of out) hex += byte.toString(16).padStart(2, "0");
|
|
62
|
+
return hex;
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/attestation/create.ts
|
|
66
|
+
function buildAttestationMessage(input) {
|
|
67
|
+
const digest = sha256Hex(canonicalize(input.content));
|
|
68
|
+
return {
|
|
69
|
+
claims: {
|
|
70
|
+
priorAttestation: input.priorAttestation ?? "0x0000000000000000000000000000000000000000000000000000000000000000",
|
|
71
|
+
publishedAt: toUnixSeconds(input.publishedAt),
|
|
72
|
+
revision: input.revision ?? 1,
|
|
73
|
+
slug: input.slug
|
|
74
|
+
},
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
subject: {
|
|
77
|
+
contentDigest: `0x${digest}`,
|
|
78
|
+
locale: input.locale,
|
|
79
|
+
title: input.title
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function signAttestation(message, account) {
|
|
84
|
+
const signature = await account.signTypedData({
|
|
85
|
+
domain: require_eip712_schema.ATTESTATION_DOMAIN_V1,
|
|
86
|
+
message,
|
|
87
|
+
primaryType: require_eip712_schema.ATTESTATION_PRIMARY_TYPE,
|
|
88
|
+
types: require_eip712_schema.ATTESTATION_TYPES_V1
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
...message,
|
|
92
|
+
signature,
|
|
93
|
+
signerAddress: account.address
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function createAttestation(input, account) {
|
|
97
|
+
return signAttestation(buildAttestationMessage(input), account);
|
|
98
|
+
}
|
|
99
|
+
function toUnixSeconds(value) {
|
|
100
|
+
if (typeof value === "bigint") return value;
|
|
101
|
+
if (typeof value === "number") return BigInt(Math.floor(value));
|
|
102
|
+
return BigInt(Math.floor(value.getTime() / 1e3));
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/attestation/serialize.ts
|
|
106
|
+
function toStored(att) {
|
|
107
|
+
return {
|
|
108
|
+
claims: {
|
|
109
|
+
priorAttestation: att.claims.priorAttestation,
|
|
110
|
+
publishedAt: att.claims.publishedAt.toString(),
|
|
111
|
+
revision: att.claims.revision,
|
|
112
|
+
slug: att.claims.slug
|
|
113
|
+
},
|
|
114
|
+
schemaVersion: att.schemaVersion,
|
|
115
|
+
signature: att.signature,
|
|
116
|
+
signerAddress: att.signerAddress,
|
|
117
|
+
subject: att.subject
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function fromStored(stored) {
|
|
121
|
+
return {
|
|
122
|
+
claims: {
|
|
123
|
+
priorAttestation: stored.claims.priorAttestation,
|
|
124
|
+
publishedAt: BigInt(stored.claims.publishedAt),
|
|
125
|
+
revision: stored.claims.revision,
|
|
126
|
+
slug: stored.claims.slug
|
|
127
|
+
},
|
|
128
|
+
schemaVersion: stored.schemaVersion,
|
|
129
|
+
signature: stored.signature,
|
|
130
|
+
signerAddress: stored.signerAddress,
|
|
131
|
+
subject: stored.subject
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function stringify(att) {
|
|
135
|
+
return `${JSON.stringify(toStored(att), null, 2)}\n`;
|
|
136
|
+
}
|
|
137
|
+
function parse(json) {
|
|
138
|
+
return fromStored(JSON.parse(json));
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/attestation/verify.ts
|
|
142
|
+
async function verifyAttestation(input) {
|
|
143
|
+
const att = input.attestation;
|
|
144
|
+
if (att.schemaVersion !== 1) return {
|
|
145
|
+
error: {
|
|
146
|
+
kind: "schema-version-unsupported",
|
|
147
|
+
version: att.schemaVersion
|
|
148
|
+
},
|
|
149
|
+
ok: false
|
|
150
|
+
};
|
|
151
|
+
const expected = att.subject.contentDigest;
|
|
152
|
+
const actual = `0x${sha256Hex(canonicalize(input.content))}`;
|
|
153
|
+
if (actual !== expected) return {
|
|
154
|
+
error: {
|
|
155
|
+
actualDigest: actual,
|
|
156
|
+
expectedDigest: expected,
|
|
157
|
+
kind: "content-mismatch"
|
|
158
|
+
},
|
|
159
|
+
ok: false
|
|
160
|
+
};
|
|
161
|
+
let recovered;
|
|
162
|
+
try {
|
|
163
|
+
recovered = await (0, viem.recoverTypedDataAddress)({
|
|
164
|
+
domain: require_eip712_schema.ATTESTATION_DOMAIN_V1,
|
|
165
|
+
message: {
|
|
166
|
+
claims: att.claims,
|
|
167
|
+
schemaVersion: att.schemaVersion,
|
|
168
|
+
subject: att.subject
|
|
169
|
+
},
|
|
170
|
+
primaryType: require_eip712_schema.ATTESTATION_PRIMARY_TYPE,
|
|
171
|
+
signature: att.signature,
|
|
172
|
+
types: require_eip712_schema.ATTESTATION_TYPES_V1
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return {
|
|
176
|
+
error: {
|
|
177
|
+
kind: "invalid-signature",
|
|
178
|
+
reason: error.message
|
|
179
|
+
},
|
|
180
|
+
ok: false
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (recovered.toLowerCase() !== att.signerAddress.toLowerCase()) return {
|
|
184
|
+
error: {
|
|
185
|
+
declared: att.signerAddress,
|
|
186
|
+
kind: "signer-mismatch",
|
|
187
|
+
recovered
|
|
188
|
+
},
|
|
189
|
+
ok: false
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
signerAddress: recovered
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
Object.defineProperty(exports, "CANONICAL_VERSION", {
|
|
198
|
+
enumerable: true,
|
|
199
|
+
get: function() {
|
|
200
|
+
return CANONICAL_VERSION;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
Object.defineProperty(exports, "InvalidContentError", {
|
|
204
|
+
enumerable: true,
|
|
205
|
+
get: function() {
|
|
206
|
+
return InvalidContentError;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
Object.defineProperty(exports, "SCHEMA_VERSION", {
|
|
210
|
+
enumerable: true,
|
|
211
|
+
get: function() {
|
|
212
|
+
return SCHEMA_VERSION;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
Object.defineProperty(exports, "buildAttestationMessage", {
|
|
216
|
+
enumerable: true,
|
|
217
|
+
get: function() {
|
|
218
|
+
return buildAttestationMessage;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
Object.defineProperty(exports, "canonicalize", {
|
|
222
|
+
enumerable: true,
|
|
223
|
+
get: function() {
|
|
224
|
+
return canonicalize;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
Object.defineProperty(exports, "createAttestation", {
|
|
228
|
+
enumerable: true,
|
|
229
|
+
get: function() {
|
|
230
|
+
return createAttestation;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
Object.defineProperty(exports, "fromStored", {
|
|
234
|
+
enumerable: true,
|
|
235
|
+
get: function() {
|
|
236
|
+
return fromStored;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
Object.defineProperty(exports, "parse", {
|
|
240
|
+
enumerable: true,
|
|
241
|
+
get: function() {
|
|
242
|
+
return parse;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
Object.defineProperty(exports, "sha256Hex", {
|
|
246
|
+
enumerable: true,
|
|
247
|
+
get: function() {
|
|
248
|
+
return sha256Hex;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
Object.defineProperty(exports, "signAttestation", {
|
|
252
|
+
enumerable: true,
|
|
253
|
+
get: function() {
|
|
254
|
+
return signAttestation;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
Object.defineProperty(exports, "stringify", {
|
|
258
|
+
enumerable: true,
|
|
259
|
+
get: function() {
|
|
260
|
+
return stringify;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
Object.defineProperty(exports, "toStored", {
|
|
264
|
+
enumerable: true,
|
|
265
|
+
get: function() {
|
|
266
|
+
return toStored;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
Object.defineProperty(exports, "verifyAttestation", {
|
|
270
|
+
enumerable: true,
|
|
271
|
+
get: function() {
|
|
272
|
+
return verifyAttestation;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
//# sourceMappingURL=verify.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.cjs","names":["ATTESTATION_DOMAIN_V1","ATTESTATION_PRIMARY_TYPE","ATTESTATION_TYPES_V1","ATTESTATION_DOMAIN_V1","ATTESTATION_PRIMARY_TYPE","ATTESTATION_TYPES_V1"],"sources":["../src/version.ts","../src/core/canonicalize.ts","../src/core/sha256.ts","../src/attestation/create.ts","../src/attestation/serialize.ts","../src/attestation/verify.ts"],"sourcesContent":["// Frozen on first published attestation. Bump only by adding v2 alongside v1.\nexport const SCHEMA_VERSION = 1 as const;\nexport const CANONICAL_VERSION = 1 as const;\n","/**\n * Canonicalize an article body for attestation v1.\n *\n * FROZEN CONTRACT — v1 rules below must NEVER change. Any modification requires\n * publishing a v2 alongside (and v1 verifiers stay alive forever).\n *\n * Rules applied in order:\n * 1. Reject unpaired UTF-16 surrogates (corrupt input).\n * 2. Strip a leading BOM (U+FEFF) if present.\n * 3. Apply Unicode NFC normalization.\n * 4. Convert CRLF and stray CR to LF.\n * 5. Trim trailing whitespace and append exactly one LF.\n * 6. Encode as UTF-8 bytes.\n *\n * No markdown parsing. No per-line whitespace trim (would break \" \\n\" soft breaks).\n * No tab → space substitution (would break code blocks).\n * No interior whitespace collapsing.\n */\nexport function canonicalize(input: string): Uint8Array {\n assertValidUtf16(input);\n\n let s = input;\n if (s.charCodeAt(0) === 0xfeff) {\n s = s.slice(1);\n }\n s = s.normalize('NFC');\n s = s.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n // \\s with /u flag covers all ECMAScript whitespace, including U+00A0 and U+FEFF.\n s = `${s.replace(/\\s+$/u, '')}\\n`;\n\n return new TextEncoder().encode(s);\n}\n\n/**\n * Version of the canonicalization algorithm that produced bytes.\n * Embedded into every attestation so future verifiers can dispatch correctly.\n */\nexport { CANONICAL_VERSION as canonicalVersion } from '../version.js';\n\nexport class InvalidContentError extends Error {\n override name = 'InvalidContentError';\n}\n\nfunction assertValidUtf16(s: string): void {\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code >= 0xd800 && code <= 0xdbff) {\n const next = s.charCodeAt(i + 1);\n if (!(next >= 0xdc00 && next <= 0xdfff)) {\n throw new InvalidContentError(`Unpaired high surrogate at index ${i}`);\n }\n i++;\n } else if (code >= 0xdc00 && code <= 0xdfff) {\n throw new InvalidContentError(`Unpaired low surrogate at index ${i}`);\n }\n }\n}\n","import { sha256 } from '@noble/hashes/sha2.js';\n\n/**\n * SHA-256 of bytes, returned as bare hex (no 0x prefix).\n *\n * Uses @noble/hashes — pure JS, sync, audited, runs identically in Node, Bun,\n * Deno, and every modern browser. This is the only hash primitive used by the\n * package, so swapping the implementation here is the single point of change.\n */\nexport function sha256Hex(bytes: Uint8Array): string {\n const out = sha256(bytes);\n let hex = '';\n for (const byte of out) {\n hex += byte.toString(16).padStart(2, '0');\n }\n return hex;\n}\n","import { type LocalAccount } from 'viem';\n\nimport { canonicalize } from '../core/canonicalize.js';\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n NO_PRIOR_ATTESTATION,\n} from '../core/eip712-schema.js';\nimport { sha256Hex } from '../core/sha256.js';\nimport { SCHEMA_VERSION } from '../version.js';\nimport { type SignedAttestation } from './types.js';\n\nexport type CreateAttestationInput = {\n content: string;\n title: string;\n slug: string;\n locale: string;\n publishedAt: bigint | Date | number;\n revision?: number;\n priorAttestation?: `0x${string}`;\n};\n\nexport function buildAttestationMessage(input: CreateAttestationInput): AttestationMessage {\n const bytes = canonicalize(input.content);\n const digest = sha256Hex(bytes);\n\n return {\n claims: {\n priorAttestation: input.priorAttestation ?? NO_PRIOR_ATTESTATION,\n publishedAt: toUnixSeconds(input.publishedAt),\n revision: input.revision ?? 1,\n slug: input.slug,\n },\n schemaVersion: SCHEMA_VERSION,\n subject: {\n contentDigest: `0x${digest}` as const,\n locale: input.locale,\n title: input.title,\n },\n };\n}\n\nexport async function signAttestation(\n message: AttestationMessage,\n account: LocalAccount,\n): Promise<SignedAttestation> {\n const signature = await account.signTypedData({\n domain: ATTESTATION_DOMAIN_V1,\n message,\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: ATTESTATION_TYPES_V1,\n });\n\n return {\n ...message,\n signature,\n signerAddress: account.address,\n };\n}\n\nexport async function createAttestation(\n input: CreateAttestationInput,\n account: LocalAccount,\n): Promise<SignedAttestation> {\n return signAttestation(buildAttestationMessage(input), account);\n}\n\nfunction toUnixSeconds(value: bigint | Date | number): bigint {\n if (typeof value === 'bigint') {\n return value;\n }\n if (typeof value === 'number') {\n return BigInt(Math.floor(value));\n }\n return BigInt(Math.floor(value.getTime() / 1000));\n}\n","import { type SignedAttestation, type StoredAttestation } from './types.js';\n\nexport function toStored(att: SignedAttestation): StoredAttestation {\n return {\n claims: {\n priorAttestation: att.claims.priorAttestation,\n publishedAt: att.claims.publishedAt.toString(),\n revision: att.claims.revision,\n slug: att.claims.slug,\n },\n schemaVersion: att.schemaVersion,\n signature: att.signature,\n signerAddress: att.signerAddress,\n subject: att.subject,\n };\n}\n\nexport function fromStored(stored: StoredAttestation): SignedAttestation {\n return {\n claims: {\n priorAttestation: stored.claims.priorAttestation,\n publishedAt: BigInt(stored.claims.publishedAt),\n revision: stored.claims.revision,\n slug: stored.claims.slug,\n },\n schemaVersion: stored.schemaVersion,\n signature: stored.signature,\n signerAddress: stored.signerAddress,\n subject: stored.subject,\n };\n}\n\nexport function stringify(att: SignedAttestation): string {\n return `${JSON.stringify(toStored(att), null, 2)}\\n`;\n}\n\nexport function parse(json: string): SignedAttestation {\n const parsed = JSON.parse(json) as StoredAttestation;\n return fromStored(parsed);\n}\n","import { recoverTypedDataAddress } from 'viem';\n\nimport { canonicalize } from '../core/canonicalize.js';\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n} from '../core/eip712-schema.js';\nimport { sha256Hex } from '../core/sha256.js';\nimport { SCHEMA_VERSION } from '../version.js';\nimport { type SignedAttestation, type VerifyResult } from './types.js';\n\nexport type VerifyInput = {\n content: string;\n attestation: SignedAttestation;\n};\n\nexport async function verifyAttestation(input: VerifyInput): Promise<VerifyResult> {\n const att = input.attestation;\n\n if (att.schemaVersion !== SCHEMA_VERSION) {\n return {\n error: { kind: 'schema-version-unsupported', version: att.schemaVersion },\n ok: false,\n };\n }\n\n const expected = att.subject.contentDigest;\n const actual = `0x${sha256Hex(canonicalize(input.content))}` as const;\n if (actual !== expected) {\n return {\n error: { actualDigest: actual, expectedDigest: expected, kind: 'content-mismatch' },\n ok: false,\n };\n }\n\n let recovered: `0x${string}`;\n try {\n recovered = await recoverTypedDataAddress({\n domain: ATTESTATION_DOMAIN_V1,\n message: { claims: att.claims, schemaVersion: att.schemaVersion, subject: att.subject },\n primaryType: ATTESTATION_PRIMARY_TYPE,\n signature: att.signature,\n types: ATTESTATION_TYPES_V1,\n });\n } catch (error) {\n return {\n error: { kind: 'invalid-signature', reason: (error as Error).message },\n ok: false,\n };\n }\n\n if (recovered.toLowerCase() !== att.signerAddress.toLowerCase()) {\n return {\n error: { declared: att.signerAddress, kind: 'signer-mismatch', recovered },\n ok: false,\n };\n }\n\n return { ok: true, signerAddress: recovered };\n}\n"],"mappings":"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"}
|