@int-loop/core 0.1.0 → 0.2.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.
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import type { LedgerEntry } from "./schema/ledger.ts";
3
+ import type { VerificationFailure } from "./verify.ts";
4
+ export declare const ANCHOR_LEAF_TYPE = "int-loop.anchor-leaf/1";
5
+ export declare const AnchorTimestamp: z.ZodObject<{
6
+ tsa: z.ZodString;
7
+ token: z.ZodString;
8
+ gen_time: z.ZodISODateTime;
9
+ }, z.core.$strict>;
10
+ export type AnchorTimestamp = z.infer<typeof AnchorTimestamp>;
11
+ export declare const AnchorCreatedPayload: z.ZodObject<{
12
+ anchored: z.ZodObject<{
13
+ seq: z.ZodNumber;
14
+ entry_hash: z.ZodString;
15
+ }, z.core.$strict>;
16
+ merkle: z.ZodObject<{
17
+ root: z.ZodString;
18
+ leaf_index: z.ZodNumber;
19
+ tree_size: z.ZodNumber;
20
+ path: z.ZodArray<z.ZodString>;
21
+ }, z.core.$strict>;
22
+ timestamps: z.ZodArray<z.ZodObject<{
23
+ tsa: z.ZodString;
24
+ token: z.ZodString;
25
+ gen_time: z.ZodISODateTime;
26
+ }, z.core.$strict>>;
27
+ }, z.core.$strict>;
28
+ export type AnchorCreatedPayload = z.infer<typeof AnchorCreatedPayload>;
29
+ export type AnchorLeaf = {
30
+ tenant_id: string;
31
+ seq: number;
32
+ entry_hash: string;
33
+ };
34
+ export declare const AnchorLeafSchema: z.ZodObject<{
35
+ tenant_id: z.ZodString;
36
+ seq: z.ZodNumber;
37
+ entry_hash: z.ZodString;
38
+ }, z.core.$strict>;
39
+ export declare function anchorLeafHash(leaf: AnchorLeaf): Promise<Uint8Array<ArrayBuffer>>;
40
+ export declare function merkleRoot(leafHashes: readonly Uint8Array[]): Promise<Uint8Array<ArrayBuffer>>;
41
+ /** The inclusion path for leaf `index`, nearest sibling first. */
42
+ export declare function merklePath(leafHashes: readonly Uint8Array[], index: number): Promise<Uint8Array<ArrayBuffer>[]>;
43
+ /** Recomputes the root from a leaf and its path (RFC 9162 2.1.3.2), or null if the path does not fit the tree. */
44
+ export declare function rootFromPath(leafHash: Uint8Array, index: number, size: number, path: readonly Uint8Array[]): Promise<Uint8Array | null>;
45
+ /**
46
+ * The tree for one anchoring round: one leaf per tenant, in tenant order. Returns the root
47
+ * to stamp, and each tenant's anchor.created payload once the stamps are in.
48
+ */
49
+ export declare function anchorTree(leaves: readonly AnchorLeaf[]): Promise<{
50
+ root: Uint8Array<ArrayBuffer>;
51
+ payload: (tenantId: string, timestamps: AnchorTimestamp[]) => Promise<AnchorCreatedPayload>;
52
+ }>;
53
+ /** How far a TSA's clock may disagree with int-loop's before the verifier objects. */
54
+ export declare const ANCHOR_CLOCK_TOLERANCE_MS: number;
55
+ export type AnchorReport = {
56
+ anchors_checked: number;
57
+ /** The newest anchored entry and the earliest time a TSA vouches it existed. */
58
+ anchored_through: {
59
+ seq: number;
60
+ gen_time: string;
61
+ tsas: string[];
62
+ } | null;
63
+ failures: VerificationFailure[];
64
+ };
65
+ /**
66
+ * Checks every anchor.created entry: the anchored entry is in the ledger with that hash,
67
+ * its leaf leads to the stamped root, and every timestamp token verifies against `roots`
68
+ * for that root, at a time consistent with when the entries were recorded.
69
+ */
70
+ export declare function verifyAnchors(entries: readonly LedgerEntry[], roots: readonly Uint8Array[]): Promise<AnchorReport>;
package/dist/anchor.js ADDED
@@ -0,0 +1,201 @@
1
+ // Anchoring: once an hour, each tenant's latest ledger entry is timestamped by
2
+ // independent RFC 3161 timestamp authorities, so not even int-loop can rewrite
3
+ // history afterwards without it showing.
4
+ //
5
+ // One stamp covers every tenant. The heads are the leaves of a Merkle tree (RFC 9162
6
+ // hashing, domain-separated leaves and nodes); the TSAs stamp the root, and each
7
+ // tenant's anchor.created entry records its own leaf's path to that root. A tenant's
8
+ // evidence pack therefore proves its own chain head existed at the stamped time and
9
+ // reveals nothing about other tenants beyond opaque sibling hashes.
10
+ import { z } from "zod";
11
+ import { canonicalise } from "./jcs.js";
12
+ import { concatBytes, fromBase64Url, fromHex, sha256, toHex, utf8 } from "./hash.js";
13
+ import { Base64Url, Sha256Hex, TenantId, Timestamp } from "./schema/common.js";
14
+ import { verifyTimestampToken } from "./tsp.js";
15
+ export const ANCHOR_LEAF_TYPE = "int-loop.anchor-leaf/1";
16
+ export const AnchorTimestamp = z.strictObject({
17
+ /** Which TSA, e.g. "freetsa". Informational: trust comes from the token's certificate chain. */
18
+ tsa: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/),
19
+ /** The DER TimeStampToken. */
20
+ token: Base64Url,
21
+ /** The time inside the token, repeated for readers; the verifier checks they agree. */
22
+ gen_time: Timestamp,
23
+ });
24
+ export const AnchorCreatedPayload = z.strictObject({
25
+ anchored: z.strictObject({ seq: z.number().int().positive(), entry_hash: Sha256Hex }),
26
+ merkle: z.strictObject({
27
+ root: Sha256Hex,
28
+ leaf_index: z.number().int().nonnegative(),
29
+ tree_size: z.number().int().positive(),
30
+ path: z.array(Sha256Hex).max(64),
31
+ }),
32
+ timestamps: z.array(AnchorTimestamp).min(1).max(8),
33
+ });
34
+ export const AnchorLeafSchema = z.strictObject({ tenant_id: TenantId, seq: z.number().int().positive(), entry_hash: Sha256Hex });
35
+ // ---- Merkle tree (RFC 9162 section 2.1) ----------------------------------------------
36
+ export async function anchorLeafHash(leaf) {
37
+ const body = utf8(canonicalise({ type: ANCHOR_LEAF_TYPE, tenant_id: leaf.tenant_id, seq: leaf.seq, entry_hash: leaf.entry_hash }));
38
+ return sha256(concatBytes(Uint8Array.of(0), body));
39
+ }
40
+ const node = (left, right) => sha256(concatBytes(Uint8Array.of(1), left, right));
41
+ /** The largest power of two smaller than n (n > 1). */
42
+ const split = (n) => 2 ** Math.floor(Math.log2(n - 1));
43
+ export async function merkleRoot(leafHashes) {
44
+ if (leafHashes.length === 0)
45
+ throw new Error("a Merkle tree needs at least one leaf");
46
+ if (leafHashes.length === 1)
47
+ return new Uint8Array(leafHashes[0]);
48
+ const k = split(leafHashes.length);
49
+ return node(await merkleRoot(leafHashes.slice(0, k)), await merkleRoot(leafHashes.slice(k)));
50
+ }
51
+ /** The inclusion path for leaf `index`, nearest sibling first. */
52
+ export async function merklePath(leafHashes, index) {
53
+ if (index < 0 || index >= leafHashes.length)
54
+ throw new Error("leaf index out of range");
55
+ if (leafHashes.length === 1)
56
+ return [];
57
+ const k = split(leafHashes.length);
58
+ return index < k
59
+ ? [...(await merklePath(leafHashes.slice(0, k), index)), await merkleRoot(leafHashes.slice(k))]
60
+ : [...(await merklePath(leafHashes.slice(k), index - k)), await merkleRoot(leafHashes.slice(0, k))];
61
+ }
62
+ /** Recomputes the root from a leaf and its path (RFC 9162 2.1.3.2), or null if the path does not fit the tree. */
63
+ export async function rootFromPath(leafHash, index, size, path) {
64
+ if (index >= size)
65
+ return null;
66
+ let fn = index;
67
+ let sn = size - 1;
68
+ let r = leafHash;
69
+ for (const p of path) {
70
+ if (sn === 0)
71
+ return null;
72
+ if (fn % 2 === 1 || fn === sn) {
73
+ r = await node(p, r);
74
+ while (fn % 2 === 0 && fn !== 0) {
75
+ fn = Math.floor(fn / 2);
76
+ sn = Math.floor(sn / 2);
77
+ }
78
+ }
79
+ else {
80
+ r = await node(r, p);
81
+ }
82
+ fn = Math.floor(fn / 2);
83
+ sn = Math.floor(sn / 2);
84
+ }
85
+ return sn === 0 ? r : null;
86
+ }
87
+ /**
88
+ * The tree for one anchoring round: one leaf per tenant, in tenant order. Returns the root
89
+ * to stamp, and each tenant's anchor.created payload once the stamps are in.
90
+ */
91
+ export async function anchorTree(leaves) {
92
+ const sorted = [...leaves].map((l) => AnchorLeafSchema.parse(l)).sort((a, b) => (a.tenant_id < b.tenant_id ? -1 : a.tenant_id > b.tenant_id ? 1 : 0));
93
+ for (let i = 1; i < sorted.length; i++)
94
+ if (sorted[i].tenant_id === sorted[i - 1].tenant_id)
95
+ throw new Error(`two leaves for ${sorted[i].tenant_id}`);
96
+ const hashes = await Promise.all(sorted.map((l) => anchorLeafHash(l)));
97
+ const root = await merkleRoot(hashes);
98
+ return {
99
+ root,
100
+ payload: async (tenantId, timestamps) => {
101
+ const index = sorted.findIndex((l) => l.tenant_id === tenantId);
102
+ if (index < 0)
103
+ throw new Error(`no leaf for ${tenantId}`);
104
+ const leaf = sorted[index];
105
+ return AnchorCreatedPayload.parse({
106
+ anchored: { seq: leaf.seq, entry_hash: leaf.entry_hash },
107
+ merkle: { root: toHex(root), leaf_index: index, tree_size: sorted.length, path: (await merklePath(hashes, index)).map(toHex) },
108
+ timestamps,
109
+ });
110
+ },
111
+ };
112
+ }
113
+ // ---- Verification -----------------------------------------------------------------------
114
+ /** How far a TSA's clock may disagree with int-loop's before the verifier objects. */
115
+ export const ANCHOR_CLOCK_TOLERANCE_MS = 5 * 60_000;
116
+ /**
117
+ * Checks every anchor.created entry: the anchored entry is in the ledger with that hash,
118
+ * its leaf leads to the stamped root, and every timestamp token verifies against `roots`
119
+ * for that root, at a time consistent with when the entries were recorded.
120
+ */
121
+ export async function verifyAnchors(entries, roots) {
122
+ const failures = [];
123
+ const bySeq = new Map(entries.map((e) => [e.seq, e]));
124
+ let anchors_checked = 0;
125
+ let anchored_through = null;
126
+ for (const entry of entries) {
127
+ if (entry.type !== "anchor.created")
128
+ continue;
129
+ anchors_checked++;
130
+ const fail = (check, message) => failures.push({ seq: entry.seq, check, message });
131
+ try {
132
+ const parsed = AnchorCreatedPayload.safeParse(entry.payload);
133
+ if (!parsed.success) {
134
+ fail("anchor", parsed.error.issues.map((i) => i.message).join("; "));
135
+ continue;
136
+ }
137
+ const p = parsed.data;
138
+ const anchored = bySeq.get(p.anchored.seq);
139
+ if (p.anchored.seq >= entry.seq) {
140
+ fail("anchor", "an anchor can only cover entries recorded before it");
141
+ continue;
142
+ }
143
+ if (!anchored) {
144
+ fail("anchor", `anchored entry ${p.anchored.seq} is not in this ledger`);
145
+ continue;
146
+ }
147
+ if (anchored.entry_hash !== p.anchored.entry_hash) {
148
+ fail("anchor", `anchored hash does not match entry ${p.anchored.seq}`);
149
+ continue;
150
+ }
151
+ const leaf = await anchorLeafHash({ tenant_id: entry.tenant_id, seq: p.anchored.seq, entry_hash: p.anchored.entry_hash });
152
+ const root = await rootFromPath(leaf, p.merkle.leaf_index, p.merkle.tree_size, p.merkle.path.map(fromHex));
153
+ if (root === null || toHex(root) !== p.merkle.root) {
154
+ fail("anchor", "the anchored entry is not in the stamped Merkle tree");
155
+ continue;
156
+ }
157
+ // Labels are the recorder's word; independence is judged by the root each token chains to.
158
+ const tsas = new Set();
159
+ const rootsUsed = new Map();
160
+ const verified = [];
161
+ let earliest = null;
162
+ for (const ts of p.timestamps) {
163
+ const problems = [];
164
+ if (tsas.has(ts.tsa))
165
+ problems.push(`two timestamps from ${ts.tsa}`);
166
+ tsas.add(ts.tsa);
167
+ const result = await verifyTimestampToken(fromBase64Url(ts.token), { digest: fromHex(p.merkle.root), roots });
168
+ if (!result.ok) {
169
+ fail("timestamp", `${ts.tsa}: ${result.reason}`);
170
+ continue;
171
+ }
172
+ const at = Date.parse(result.gen_time);
173
+ if (at !== Date.parse(ts.gen_time))
174
+ problems.push(`recorded time ${ts.gen_time} differs from the token's ${result.gen_time}`);
175
+ if (at < Date.parse(anchored.recorded_at) - ANCHOR_CLOCK_TOLERANCE_MS)
176
+ problems.push(`stamped before entry ${anchored.seq} was recorded`);
177
+ if (at > Date.parse(entry.recorded_at) + ANCHOR_CLOCK_TOLERANCE_MS)
178
+ problems.push("stamped after the anchor itself was recorded");
179
+ const sameRoot = rootsUsed.get(result.root_sha256);
180
+ if (sameRoot !== undefined)
181
+ problems.push(`chains to the same root as ${sameRoot}, so it is not an independent timestamp`);
182
+ rootsUsed.set(result.root_sha256, ts.tsa);
183
+ // Only a timestamp with nothing wrong counts towards what the anchor proves.
184
+ if (problems.length) {
185
+ for (const m of problems)
186
+ fail("timestamp", `${ts.tsa}: ${m}`);
187
+ continue;
188
+ }
189
+ verified.push(ts.tsa);
190
+ earliest = earliest === null ? at : Math.min(earliest, at);
191
+ }
192
+ if (earliest !== null && (anchored_through === null || p.anchored.seq >= anchored_through.seq)) {
193
+ anchored_through = { seq: p.anchored.seq, gen_time: new Date(earliest).toISOString(), tsas: verified };
194
+ }
195
+ }
196
+ catch (err) {
197
+ fail("internal", err instanceof Error ? err.message : String(err));
198
+ }
199
+ }
200
+ return { anchors_checked, anchored_through, failures };
201
+ }
@@ -0,0 +1,12 @@
1
+ import type { Fact } from "./schema/request.ts";
2
+ export type ComprehensionQuestion = {
3
+ question_id: string;
4
+ fact_key: string;
5
+ prompt: string;
6
+ /** Formatted values, in display order. Exactly one is correct. */
7
+ choices: string[];
8
+ answer: string;
9
+ };
10
+ /** Facts a question can be built from: values with plausible wrong answers. */
11
+ export declare function comprehensionFacts(facts: readonly Fact[]): Fact[];
12
+ export declare function comprehensionQuestion(facts: readonly Fact[], seed: string, format: (f: Fact) => string): Promise<ComprehensionQuestion | null>;
@@ -0,0 +1,81 @@
1
+ // L4 comprehension questions. Built deterministically from one structured fact, never
2
+ // from the narrative and never by a model (hard rule 8): "What does the request say
3
+ // for Estimated monthly cost?" with the real value and distractors derived from it.
4
+ //
5
+ // Pure. The seed (request, version, approver) chooses the fact and the order of the
6
+ // choices, so the same inputs always give the same question and anyone holding the
7
+ // facts can rebuild it. Display formatting is passed in, so the choices read exactly
8
+ // as the facts do on the page.
9
+ import { hashCanonical } from "./hash.js";
10
+ const MAX_DISTRACTORS = 3;
11
+ /** Facts a question can be built from: values with plausible wrong answers. */
12
+ export function comprehensionFacts(facts) {
13
+ return facts.filter((f) => f.type === "money" || f.type === "count" || f.type === "date" || (f.type === "enum" && (f.choices ?? []).some((c) => c !== f.value)));
14
+ }
15
+ export async function comprehensionQuestion(facts, seed, format) {
16
+ const eligible = comprehensionFacts(facts);
17
+ if (eligible.length === 0)
18
+ return null;
19
+ const bytes = await hashCanonical({ purpose: "int-loop comprehension", seed });
20
+ // Start at the seeded fact; if formatting leaves it no distinct wrong answers, use the next one.
21
+ for (let k = 0; k < eligible.length; k++) {
22
+ const fact = eligible[(bytes[0] + k) % eligible.length];
23
+ const answer = format(fact);
24
+ const seen = new Set([answer]);
25
+ const distractors = [];
26
+ for (const candidate of variants(fact)) {
27
+ const text = format(candidate);
28
+ if (seen.has(text))
29
+ continue;
30
+ seen.add(text);
31
+ distractors.push(text);
32
+ if (distractors.length === MAX_DISTRACTORS)
33
+ break;
34
+ }
35
+ if (distractors.length > 0)
36
+ return build(fact, answer, distractors, bytes);
37
+ }
38
+ return null;
39
+ }
40
+ function build(fact, answer, distractors, bytes) {
41
+ // Deterministic shuffle (Fisher-Yates driven by the seed's bytes).
42
+ const choices = [answer, ...distractors];
43
+ for (let i = choices.length - 1; i > 0; i--) {
44
+ const j = bytes[1 + i] % (i + 1);
45
+ [choices[i], choices[j]] = [choices[j], choices[i]];
46
+ }
47
+ return {
48
+ question_id: `${fact.key}#${Array.from(bytes.slice(0, 4), (b) => b.toString(16).padStart(2, "0")).join("")}`,
49
+ fact_key: fact.key,
50
+ prompt: `What does the request say for “${fact.label}”?`,
51
+ choices,
52
+ answer,
53
+ };
54
+ }
55
+ /** Wrong values of the same type, nearest-looking first. */
56
+ function variants(f) {
57
+ switch (f.type) {
58
+ case "money": {
59
+ const v = f.value.amount_minor;
60
+ const amounts = v === 0 ? [100, 1_000, 10_000] : [v * 10, Math.round(v / 10), v * 2, Math.round(v / 2), v * 3];
61
+ return amounts.filter((a) => a !== v && a > 0 && Number.isSafeInteger(a)).map((a) => ({ ...f, value: { ...f.value, amount_minor: a } }));
62
+ }
63
+ case "count": {
64
+ const v = f.value;
65
+ const counts = v === 0 ? [1, 10, 100] : [v * 10, Math.floor(v / 10), v * 2, v + 1, v - 1, v * 3];
66
+ return counts.filter((c) => c !== v && c >= 0 && Number.isSafeInteger(c)).map((c) => ({ ...f, value: c }));
67
+ }
68
+ case "date": {
69
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(f.value);
70
+ const base = Date.parse(dateOnly ? `${f.value}T00:00:00Z` : f.value);
71
+ return [7, -7, 30, -30, 365].map((days) => {
72
+ const iso = new Date(base + days * 86_400_000).toISOString();
73
+ return { ...f, value: dateOnly ? iso.slice(0, 10) : iso.replace(".000Z", "Z") };
74
+ });
75
+ }
76
+ case "enum":
77
+ return (f.choices ?? []).filter((c) => c !== f.value).map((c) => ({ ...f, value: c }));
78
+ default:
79
+ return [];
80
+ }
81
+ }
package/dist/der.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ export declare const TAG: {
2
+ readonly BOOLEAN: 1;
3
+ readonly INTEGER: 2;
4
+ readonly BIT_STRING: 3;
5
+ readonly OCTET_STRING: 4;
6
+ readonly NULL: 5;
7
+ readonly OID: 6;
8
+ readonly UTF8_STRING: 12;
9
+ readonly PRINTABLE_STRING: 19;
10
+ readonly IA5_STRING: 22;
11
+ readonly UTC_TIME: 23;
12
+ readonly GENERALIZED_TIME: 24;
13
+ readonly SEQUENCE: 48;
14
+ readonly SET: 49;
15
+ };
16
+ /** One element. `tag` is the whole identifier octet (class, constructed bit and number). */
17
+ export type Der = {
18
+ tag: number;
19
+ /** The complete encoding, header included: what a signature or hash covers. */
20
+ raw: Uint8Array<ArrayBuffer>;
21
+ /** The contents, without the header. */
22
+ content: Uint8Array<ArrayBuffer>;
23
+ };
24
+ export declare const contextTag: (n: number, constructed?: boolean) => number;
25
+ /** Reads exactly one element filling `bytes`. */
26
+ export declare function readDer(bytes: Uint8Array): Der;
27
+ /** The elements inside a constructed element (a SEQUENCE, SET or explicit tag). */
28
+ export declare function children(el: Der): Der[];
29
+ export declare function expectTag(el: Der | undefined, tag: number, what: string): Der;
30
+ /** The elements of a SEQUENCE (or SET), checking its tag. */
31
+ export declare function seq(el: Der | undefined, what: string, tag?: number): Der[];
32
+ export declare function readOid(el: Der | undefined, what?: string): string;
33
+ /** An INTEGER's two's-complement bytes, checked for minimal encoding. */
34
+ export declare function readIntegerBytes(el: Der | undefined, what?: string): Uint8Array<ArrayBuffer>;
35
+ /** A small non-negative INTEGER (a version number, a status). */
36
+ export declare function readSmallInt(el: Der | undefined, what?: string): number;
37
+ export declare function readBoolean(el: Der | undefined, what?: string): boolean;
38
+ export declare function readOctetString(el: Der | undefined, what?: string): Uint8Array<ArrayBuffer>;
39
+ /** A BIT STRING with no unused bits (keys and signatures always are). */
40
+ export declare function readBitStringBytes(el: Der | undefined, what?: string): Uint8Array<ArrayBuffer>;
41
+ /** UTCTime or GeneralizedTime, to milliseconds since the epoch. Only the Z (UTC) forms DER allows. */
42
+ export declare function readTime(el: Der | undefined, what?: string): number;
43
+ export declare function encode(tag: number, ...content: Uint8Array[]): Uint8Array<ArrayBuffer>;
44
+ export declare const encodeSequence: (...items: Uint8Array[]) => Uint8Array<ArrayBuffer>;
45
+ export declare const encodeNull: () => Uint8Array<ArrayBuffer>;
46
+ export declare const encodeBoolean: (value: boolean) => Uint8Array<ArrayBuffer>;
47
+ export declare const encodeOctetString: (bytes: Uint8Array) => Uint8Array<ArrayBuffer>;
48
+ /** A non-negative INTEGER from big-endian unsigned bytes, minimally encoded. */
49
+ export declare function encodeUnsignedInteger(bytes: Uint8Array): Uint8Array<ArrayBuffer>;
50
+ export declare function encodeSmallInt(value: number): Uint8Array<ArrayBuffer>;
51
+ export declare function encodeOid(oid: string): Uint8Array<ArrayBuffer>;
52
+ /** Decodes the base64 body of every PEM block of the given label (default CERTIFICATE). */
53
+ export declare function fromPem(text: string, label?: string): Uint8Array<ArrayBuffer>[];
package/dist/der.js ADDED
@@ -0,0 +1,246 @@
1
+ // A minimal DER (ASN.1 Distinguished Encoding Rules) reader and writer: just enough for
2
+ // RFC 3161 timestamp requests and responses, the CMS signatures inside them and the
3
+ // X.509 certificates that sign them. Written here rather than taken from a library so
4
+ // the Apache-2.0 verifier has no dependencies beyond zod (DECISIONS.md, 2026-11-09).
5
+ //
6
+ // Strict: definite lengths only, minimal length encoding, no trailing bytes. Anything
7
+ // else throws, and every caller turns a throw into a failed verification.
8
+ import { concatBytes } from "./hash.js";
9
+ export const TAG = {
10
+ BOOLEAN: 0x01,
11
+ INTEGER: 0x02,
12
+ BIT_STRING: 0x03,
13
+ OCTET_STRING: 0x04,
14
+ NULL: 0x05,
15
+ OID: 0x06,
16
+ UTF8_STRING: 0x0c,
17
+ PRINTABLE_STRING: 0x13,
18
+ IA5_STRING: 0x16,
19
+ UTC_TIME: 0x17,
20
+ GENERALIZED_TIME: 0x18,
21
+ SEQUENCE: 0x30,
22
+ SET: 0x31,
23
+ };
24
+ export const contextTag = (n, constructed = true) => 0x80 | (constructed ? 0x20 : 0) | n;
25
+ /** Reads exactly one element filling `bytes`. */
26
+ export function readDer(bytes) {
27
+ const { element, end } = readOne(bytes, 0);
28
+ if (end !== bytes.length)
29
+ throw new Error("trailing bytes after DER element");
30
+ return element;
31
+ }
32
+ /** The elements inside a constructed element (a SEQUENCE, SET or explicit tag). */
33
+ export function children(el) {
34
+ if ((el.tag & 0x20) === 0)
35
+ throw new Error(`tag 0x${el.tag.toString(16)} is not constructed`);
36
+ const out = [];
37
+ let pos = 0;
38
+ while (pos < el.content.length) {
39
+ const { element, end } = readOne(el.content, pos);
40
+ out.push(element);
41
+ pos = end;
42
+ }
43
+ return out;
44
+ }
45
+ function readOne(bytes, start) {
46
+ let pos = start;
47
+ const tag = bytes[pos++];
48
+ if (tag === undefined)
49
+ throw new Error("DER element truncated");
50
+ if ((tag & 0x1f) === 0x1f)
51
+ throw new Error("high-numbered DER tags are not supported");
52
+ const first = bytes[pos++];
53
+ if (first === undefined)
54
+ throw new Error("DER length truncated");
55
+ let length;
56
+ if (first < 0x80) {
57
+ length = first;
58
+ }
59
+ else {
60
+ const count = first & 0x7f;
61
+ if (count === 0)
62
+ throw new Error("indefinite DER length");
63
+ if (count > 4)
64
+ throw new Error("DER length too large");
65
+ length = 0;
66
+ for (let i = 0; i < count; i++) {
67
+ const b = bytes[pos++];
68
+ if (b === undefined)
69
+ throw new Error("DER length truncated");
70
+ if (i === 0 && b === 0)
71
+ throw new Error("non-minimal DER length");
72
+ length = length * 256 + b;
73
+ }
74
+ if (length < 0x80)
75
+ throw new Error("non-minimal DER length");
76
+ }
77
+ const end = pos + length;
78
+ if (end > bytes.length)
79
+ throw new Error("DER element truncated");
80
+ return { element: { tag, raw: bytes.slice(start, end), content: bytes.slice(pos, end) }, end };
81
+ }
82
+ export function expectTag(el, tag, what) {
83
+ if (el === undefined)
84
+ throw new Error(`${what} is missing`);
85
+ if (el.tag !== tag)
86
+ throw new Error(`${what}: expected tag 0x${tag.toString(16)}, found 0x${el.tag.toString(16)}`);
87
+ return el;
88
+ }
89
+ /** The elements of a SEQUENCE (or SET), checking its tag. */
90
+ export function seq(el, what, tag = TAG.SEQUENCE) {
91
+ return children(expectTag(el, tag, what));
92
+ }
93
+ // ---- Primitive values ---------------------------------------------------------
94
+ export function readOid(el, what = "object identifier") {
95
+ const c = expectTag(el, TAG.OID, what).content;
96
+ if (c.length === 0)
97
+ throw new Error(`${what} is empty`);
98
+ const parts = [];
99
+ let value = 0;
100
+ for (let i = 0; i < c.length; i++) {
101
+ const b = c[i];
102
+ if (value === 0 && b === 0x80)
103
+ throw new Error(`${what}: non-minimal encoding`);
104
+ value = value * 128 + (b & 0x7f);
105
+ if (value > Number.MAX_SAFE_INTEGER)
106
+ throw new Error(`${what}: arc too large`);
107
+ if ((b & 0x80) === 0) {
108
+ parts.push(value);
109
+ value = 0;
110
+ }
111
+ else if (i === c.length - 1) {
112
+ throw new Error(`${what} truncated`);
113
+ }
114
+ }
115
+ const [head, ...rest] = parts;
116
+ const firstArc = head < 40 ? 0 : head < 80 ? 1 : 2;
117
+ return [firstArc, head - firstArc * 40, ...rest].join(".");
118
+ }
119
+ /** An INTEGER's two's-complement bytes, checked for minimal encoding. */
120
+ export function readIntegerBytes(el, what = "integer") {
121
+ const c = expectTag(el, TAG.INTEGER, what).content;
122
+ if (c.length === 0)
123
+ throw new Error(`${what} is empty`);
124
+ if (c.length > 1 && ((c[0] === 0 && (c[1] & 0x80) === 0) || (c[0] === 0xff && (c[1] & 0x80) !== 0))) {
125
+ throw new Error(`${what}: non-minimal encoding`);
126
+ }
127
+ return c;
128
+ }
129
+ /** A small non-negative INTEGER (a version number, a status). */
130
+ export function readSmallInt(el, what = "integer") {
131
+ const c = readIntegerBytes(el, what);
132
+ if (c.length > 4 || (c[0] & 0x80) !== 0)
133
+ throw new Error(`${what} out of range`);
134
+ return c.reduce((n, b) => n * 256 + b, 0);
135
+ }
136
+ export function readBoolean(el, what = "boolean") {
137
+ const c = expectTag(el, TAG.BOOLEAN, what).content;
138
+ if (c.length !== 1 || (c[0] !== 0 && c[0] !== 0xff))
139
+ throw new Error(`${what}: invalid DER boolean`);
140
+ return c[0] === 0xff;
141
+ }
142
+ export function readOctetString(el, what = "octet string") {
143
+ return expectTag(el, TAG.OCTET_STRING, what).content;
144
+ }
145
+ /** A BIT STRING with no unused bits (keys and signatures always are). */
146
+ export function readBitStringBytes(el, what = "bit string") {
147
+ const c = expectTag(el, TAG.BIT_STRING, what).content;
148
+ if (c.length === 0 || c[0] !== 0)
149
+ throw new Error(`${what}: unused bits are not supported here`);
150
+ return c.slice(1);
151
+ }
152
+ /** UTCTime or GeneralizedTime, to milliseconds since the epoch. Only the Z (UTC) forms DER allows. */
153
+ export function readTime(el, what = "time") {
154
+ if (el === undefined)
155
+ throw new Error(`${what} is missing`);
156
+ const text = new TextDecoder("ascii").decode(el.content);
157
+ let m;
158
+ let year;
159
+ if (el.tag === TAG.UTC_TIME) {
160
+ m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(text);
161
+ if (!m)
162
+ throw new Error(`${what}: invalid UTCTime`);
163
+ const yy = Number(m[1]);
164
+ year = yy >= 50 ? 1900 + yy : 2000 + yy;
165
+ }
166
+ else if (el.tag === TAG.GENERALIZED_TIME) {
167
+ // RFC 3161 allows fractional seconds, with no trailing zeros.
168
+ m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(?:\.(\d*[1-9]))?Z$/.exec(text);
169
+ if (!m)
170
+ throw new Error(`${what}: invalid GeneralizedTime`);
171
+ year = Number(m[1]);
172
+ }
173
+ else {
174
+ throw new Error(`${what}: expected a UTCTime or GeneralizedTime`);
175
+ }
176
+ const fraction = el.tag === TAG.GENERALIZED_TIME && m[7] ? Number(`0.${m[7]}`) : 0;
177
+ const ms = Date.UTC(year, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])) + Math.floor(fraction * 1000);
178
+ const check = new Date(ms);
179
+ if (check.getUTCMonth() !== Number(m[2]) - 1 || check.getUTCDate() !== Number(m[3]) || Number(m[4]) > 23 || Number(m[5]) > 59 || Number(m[6]) > 59) {
180
+ throw new Error(`${what}: not a real date and time`);
181
+ }
182
+ return ms;
183
+ }
184
+ // ---- Writing ------------------------------------------------------------------
185
+ function encodeLength(length) {
186
+ if (length < 0x80)
187
+ return Uint8Array.of(length);
188
+ const bytes = [];
189
+ for (let n = length; n > 0; n = Math.floor(n / 256))
190
+ bytes.unshift(n & 0xff);
191
+ return Uint8Array.of(0x80 | bytes.length, ...bytes);
192
+ }
193
+ export function encode(tag, ...content) {
194
+ const body = concatBytes(...content);
195
+ return concatBytes(Uint8Array.of(tag), encodeLength(body.length), body);
196
+ }
197
+ export const encodeSequence = (...items) => encode(TAG.SEQUENCE, ...items);
198
+ export const encodeNull = () => encode(TAG.NULL);
199
+ export const encodeBoolean = (value) => encode(TAG.BOOLEAN, Uint8Array.of(value ? 0xff : 0));
200
+ export const encodeOctetString = (bytes) => encode(TAG.OCTET_STRING, bytes);
201
+ /** A non-negative INTEGER from big-endian unsigned bytes, minimally encoded. */
202
+ export function encodeUnsignedInteger(bytes) {
203
+ let start = 0;
204
+ while (start < bytes.length - 1 && bytes[start] === 0)
205
+ start++;
206
+ const trimmed = bytes.length === 0 ? Uint8Array.of(0) : bytes.subarray(start);
207
+ return encode(TAG.INTEGER, (trimmed[0] & 0x80) !== 0 ? concatBytes(Uint8Array.of(0), trimmed) : trimmed);
208
+ }
209
+ export function encodeSmallInt(value) {
210
+ if (!Number.isSafeInteger(value) || value < 0)
211
+ throw new Error("expected a non-negative safe integer");
212
+ const bytes = [];
213
+ for (let n = value; n > 0; n = Math.floor(n / 256))
214
+ bytes.unshift(n & 0xff);
215
+ return encodeUnsignedInteger(Uint8Array.from(bytes.length ? bytes : [0]));
216
+ }
217
+ export function encodeOid(oid) {
218
+ const arcs = oid.split(".").map((a) => {
219
+ if (!/^(0|[1-9]\d*)$/.test(a))
220
+ throw new Error(`invalid OID ${oid}`);
221
+ return Number(a);
222
+ });
223
+ if (arcs.length < 2 || arcs[0] > 2 || (arcs[0] < 2 && arcs[1] >= 40))
224
+ throw new Error(`invalid OID ${oid}`);
225
+ const out = [];
226
+ for (const arc of [arcs[0] * 40 + arcs[1], ...arcs.slice(2)]) {
227
+ const chunk = [arc & 0x7f];
228
+ for (let n = Math.floor(arc / 128); n > 0; n = Math.floor(n / 128))
229
+ chunk.unshift(0x80 | (n & 0x7f));
230
+ out.push(...chunk);
231
+ }
232
+ return encode(TAG.OID, Uint8Array.from(out));
233
+ }
234
+ /** Decodes the base64 body of every PEM block of the given label (default CERTIFICATE). */
235
+ export function fromPem(text, label = "CERTIFICATE") {
236
+ const out = [];
237
+ const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`, "g");
238
+ for (const m of text.matchAll(re)) {
239
+ const b64 = m[1].replace(/\s+/g, "");
240
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64))
241
+ throw new Error("invalid PEM base64");
242
+ const bin = atob(b64);
243
+ out.push(Uint8Array.from(bin, (ch) => ch.charCodeAt(0)));
244
+ }
245
+ return out;
246
+ }