can2cup 0.10.2 → 0.10.4

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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RFC 3161 trusted timestamps over the transcript head.
3
+ *
4
+ * The problem this closes: the relay signs the head, so a client that pinned the
5
+ * relay key can prove a fork after the fact — but only against a relay that keeps
6
+ * its key honest. The operator holds that key. For a transcript meant to be cited
7
+ * later, "trust the operator's clock" is exactly the objection the reader will
8
+ * raise. A Time Stamping Authority is a neutral third party that signs
9
+ * "this digest existed at this time", and it is the primitive eIDAS/PAdES/CAdES
10
+ * already build on, so the attestation is one a lawyer recognises.
11
+ *
12
+ * Scope, deliberately: we BUILD the TimeStampReq and STORE the TimeStampResp as
13
+ * an opaque DER blob. We do not verify the CMS SignedData or walk the TSA's X.509
14
+ * chain here — doing that properly needs a real PKI stack, and a half-verified
15
+ * token is worse than an unverified one. Verification belongs where the tooling
16
+ * exists: `openssl ts -verify`. The relay only checks PKIStatus so it never
17
+ * stores a rejection as if it were evidence.
18
+ *
19
+ * Disabled unless TSA_URL is set. An unset relay answers "not configured" rather
20
+ * than silently skipping, so nobody believes they have an anchor they do not have.
21
+ */
22
+
23
+ // ------------------------------------------------------------------ DER ---
24
+
25
+ const tag = (t: number, body: Uint8Array): Uint8Array => {
26
+ const len = body.length;
27
+ let header: number[];
28
+ if (len < 0x80) header = [t, len];
29
+ else {
30
+ const bytes: number[] = [];
31
+ for (let n = len; n > 0; n = Math.floor(n / 256)) bytes.unshift(n % 256);
32
+ header = [t, 0x80 | bytes.length, ...bytes];
33
+ }
34
+ const out = new Uint8Array(header.length + len);
35
+ out.set(header, 0);
36
+ out.set(body, header.length);
37
+ return out;
38
+ };
39
+
40
+ const concat = (...parts: Uint8Array[]): Uint8Array => {
41
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
42
+ let at = 0;
43
+ for (const p of parts) { out.set(p, at); at += p.length; }
44
+ return out;
45
+ };
46
+
47
+ const SEQUENCE = 0x30, INTEGER = 0x02, OCTET_STRING = 0x04, NULL = 0x05, BOOLEAN = 0x01, OID = 0x06;
48
+
49
+ /** 2.16.840.1.101.3.4.2.1 — id-sha256 */
50
+ const OID_SHA256 = new Uint8Array([0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
51
+
52
+ export function hexToBytes(hex: string): Uint8Array {
53
+ if (!/^[0-9a-f]*$/i.test(hex) || hex.length % 2) throw new Error("not hex");
54
+ const out = new Uint8Array(hex.length / 2);
55
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
56
+ return out;
57
+ }
58
+
59
+ export const bytesToB64 = (b: Uint8Array): string => {
60
+ let s = "";
61
+ for (const x of b) s += String.fromCharCode(x);
62
+ return btoa(s);
63
+ };
64
+
65
+ /**
66
+ * TimeStampReq ::= SEQUENCE {
67
+ * version INTEGER {v1(1)}, messageImprint MessageImprint,
68
+ * reqPolicy TSAPolicyId OPTIONAL, nonce INTEGER OPTIONAL,
69
+ * certReq BOOLEAN DEFAULT FALSE, extensions [0] IMPLICIT Extensions OPTIONAL }
70
+ *
71
+ * `digest` is the sha256 the TSA will attest to — for us, the chain head hash,
72
+ * which is already a sha256 over the canonical envelope.
73
+ */
74
+ export function buildTimeStampReq(digest: Uint8Array, nonce: Uint8Array): Uint8Array {
75
+ if (digest.length !== 32) throw new Error("sha256 digest must be 32 bytes");
76
+ const algId = tag(SEQUENCE, concat(tag(OID, OID_SHA256), tag(NULL, new Uint8Array(0))));
77
+ const messageImprint = tag(SEQUENCE, concat(algId, tag(OCTET_STRING, digest)));
78
+ // DER INTEGER is signed: a leading bit of 1 would read as negative.
79
+ const n = nonce[0] & 0x80 ? concat(new Uint8Array([0]), nonce) : nonce;
80
+ return tag(SEQUENCE, concat(
81
+ tag(INTEGER, new Uint8Array([1])),
82
+ messageImprint,
83
+ tag(INTEGER, n),
84
+ tag(BOOLEAN, new Uint8Array([0xff])), // certReq: ask the TSA to include its cert
85
+ ));
86
+ }
87
+
88
+ /**
89
+ * Read PKIStatus out of a TimeStampResp. Structure:
90
+ * TimeStampResp ::= SEQUENCE { status PKIStatusInfo, timeStampToken TST OPTIONAL }
91
+ * PKIStatusInfo ::= SEQUENCE { status INTEGER, ... }
92
+ * 0 = granted, 1 = grantedWithMods; anything else is a rejection.
93
+ */
94
+ export function readPkiStatus(resp: Uint8Array): number | undefined {
95
+ let i = 0;
96
+ const readLen = (): number => {
97
+ let len = resp[i++];
98
+ if (len & 0x80) {
99
+ const n = len & 0x7f;
100
+ len = 0;
101
+ for (let k = 0; k < n; k++) len = len * 256 + resp[i++];
102
+ }
103
+ return len;
104
+ };
105
+ if (resp[i++] !== SEQUENCE) return undefined; // TimeStampResp
106
+ readLen();
107
+ if (resp[i++] !== SEQUENCE) return undefined; // PKIStatusInfo
108
+ readLen();
109
+ if (resp[i++] !== INTEGER) return undefined;
110
+ const len = readLen();
111
+ let v = 0;
112
+ for (let k = 0; k < len; k++) v = v * 256 + resp[i++];
113
+ return v;
114
+ }
115
+
116
+ // ------------------------------------------------------------- request ---
117
+
118
+ export interface Anchor {
119
+ seq: number;
120
+ hash: string; // the transcript head this attests to
121
+ requestedAt: string; // relay clock — informational only; the TSA's clock is inside the token
122
+ tsa: string;
123
+ status: number; // PKIStatus: 0 granted, 1 grantedWithMods
124
+ token: string; // base64 DER TimeStampResp — verify with `openssl ts -verify`
125
+ }
126
+
127
+ export class AnchorError extends Error {}
128
+
129
+ /** POST an RFC 3161 query and return the stored anchor. Throws AnchorError on any
130
+ * outcome that is not a granted token, so a failure is never stored as evidence. */
131
+ export async function requestTimestamp(
132
+ tsaUrl: string, seq: number, headHash: string, nonceHex: string,
133
+ ): Promise<Anchor> {
134
+ const req = buildTimeStampReq(hexToBytes(headHash), hexToBytes(nonceHex));
135
+ let res: Response;
136
+ try {
137
+ res = await fetch(tsaUrl, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/timestamp-query" },
140
+ // workers-types' BodyInit predates TS's generic Uint8Array<ArrayBufferLike>; the
141
+ // runtime accepts a typed array here. Narrow cast rather than copying the buffer.
142
+ body: req as unknown as BodyInit,
143
+ });
144
+ } catch (e) {
145
+ throw new AnchorError(`TSA unreachable: ${(e as Error).message}`);
146
+ }
147
+ if (!res.ok) throw new AnchorError(`TSA returned HTTP ${res.status}`);
148
+
149
+ const body = new Uint8Array(await res.arrayBuffer());
150
+ if (body.length === 0) throw new AnchorError("TSA returned an empty body");
151
+ const status = readPkiStatus(body);
152
+ if (status === undefined) throw new AnchorError("TSA response is not a TimeStampResp");
153
+ if (status !== 0 && status !== 1) throw new AnchorError(`TSA rejected the request (PKIStatus ${status})`);
154
+
155
+ return {
156
+ seq, hash: headHash, requestedAt: new Date().toISOString(),
157
+ tsa: tsaUrl, status, token: bytesToB64(body),
158
+ };
159
+ }