@passportsign/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.
Files changed (106) hide show
  1. package/dist/badge.d.ts +5 -0
  2. package/dist/badge.d.ts.map +1 -1
  3. package/dist/badge.js +8 -2
  4. package/dist/badge.js.map +1 -1
  5. package/dist/bind.d.ts.map +1 -1
  6. package/dist/bind.js +2 -8
  7. package/dist/bind.js.map +1 -1
  8. package/dist/bundle-fs.d.ts +16 -0
  9. package/dist/bundle-fs.d.ts.map +1 -0
  10. package/dist/bundle-fs.js +31 -0
  11. package/dist/bundle-fs.js.map +1 -0
  12. package/dist/bundle.d.ts +13 -5
  13. package/dist/bundle.d.ts.map +1 -1
  14. package/dist/bundle.js +18 -20
  15. package/dist/bundle.js.map +1 -1
  16. package/dist/canonical.d.ts.map +1 -1
  17. package/dist/canonical.js +3 -4
  18. package/dist/canonical.js.map +1 -1
  19. package/dist/classify.d.ts +68 -0
  20. package/dist/classify.d.ts.map +1 -0
  21. package/dist/classify.js +117 -0
  22. package/dist/classify.js.map +1 -0
  23. package/dist/dsse-common.d.ts +32 -0
  24. package/dist/dsse-common.d.ts.map +1 -0
  25. package/dist/dsse-common.js +26 -0
  26. package/dist/dsse-common.js.map +1 -0
  27. package/dist/dsse-web.d.ts +28 -0
  28. package/dist/dsse-web.d.ts.map +1 -0
  29. package/dist/dsse-web.js +81 -0
  30. package/dist/dsse-web.js.map +1 -0
  31. package/dist/dsse.d.ts +2 -26
  32. package/dist/dsse.d.ts.map +1 -1
  33. package/dist/dsse.js +2 -19
  34. package/dist/dsse.js.map +1 -1
  35. package/dist/encoding.d.ts +20 -0
  36. package/dist/encoding.d.ts.map +1 -0
  37. package/dist/encoding.js +88 -0
  38. package/dist/encoding.js.map +1 -0
  39. package/dist/github.js +2 -2
  40. package/dist/github.js.map +1 -1
  41. package/dist/index.d.ts +9 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/log/rekor.d.ts +1 -1
  46. package/dist/log/rekor.d.ts.map +1 -1
  47. package/dist/log/rekor.js +7 -10
  48. package/dist/log/rekor.js.map +1 -1
  49. package/dist/lookup.d.ts +46 -0
  50. package/dist/lookup.d.ts.map +1 -0
  51. package/dist/lookup.js +101 -0
  52. package/dist/lookup.js.map +1 -0
  53. package/dist/merkle.js +3 -3
  54. package/dist/merkle.js.map +1 -1
  55. package/dist/nonce.js +1 -1
  56. package/dist/nonce.js.map +1 -1
  57. package/dist/profile-index.d.ts +64 -0
  58. package/dist/profile-index.d.ts.map +1 -0
  59. package/dist/profile-index.js +161 -0
  60. package/dist/profile-index.js.map +1 -0
  61. package/dist/revoke.d.ts +30 -0
  62. package/dist/revoke.d.ts.map +1 -0
  63. package/dist/revoke.js +42 -0
  64. package/dist/revoke.js.map +1 -0
  65. package/dist/sdk-payload.d.ts.map +1 -1
  66. package/dist/sdk-payload.js +4 -6
  67. package/dist/sdk-payload.js.map +1 -1
  68. package/dist/statement.d.ts +41 -0
  69. package/dist/statement.d.ts.map +1 -1
  70. package/dist/statement.js +43 -0
  71. package/dist/statement.js.map +1 -1
  72. package/dist/submit.d.ts +3 -3
  73. package/dist/submit.d.ts.map +1 -1
  74. package/dist/submit.js +3 -14
  75. package/dist/submit.js.map +1 -1
  76. package/dist/verifier.d.ts.map +1 -1
  77. package/dist/verifier.js +4 -14
  78. package/dist/verifier.js.map +1 -1
  79. package/dist/web.d.ts +35 -0
  80. package/dist/web.d.ts.map +1 -0
  81. package/dist/web.js +35 -0
  82. package/dist/web.js.map +1 -0
  83. package/package.json +6 -2
  84. package/src/badge.ts +124 -113
  85. package/src/bind.ts +128 -137
  86. package/src/bundle-fs.ts +40 -0
  87. package/src/bundle.ts +138 -127
  88. package/src/canonical.ts +33 -33
  89. package/src/classify.ts +165 -0
  90. package/src/dsse-common.ts +45 -0
  91. package/src/dsse-web.ts +97 -0
  92. package/src/dsse.ts +63 -91
  93. package/src/encoding.ts +96 -0
  94. package/src/github.ts +196 -196
  95. package/src/index.ts +59 -2
  96. package/src/log/rekor.ts +330 -334
  97. package/src/lookup.ts +175 -0
  98. package/src/merkle.ts +187 -187
  99. package/src/nonce.ts +53 -53
  100. package/src/profile-index.ts +222 -0
  101. package/src/revoke.ts +67 -0
  102. package/src/sdk-payload.ts +60 -62
  103. package/src/statement.ts +203 -119
  104. package/src/submit.ts +38 -54
  105. package/src/verifier.ts +304 -317
  106. package/src/web.ts +175 -0
package/src/lookup.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Resolve a user's published bindings: index file → Rekor entries →
3
+ * integrity + sanity checks → state classification.
4
+ *
5
+ * This is the shared read pipeline behind `passportsign list` and the
6
+ * hosted badge service. The index file is user-controlled, so nothing
7
+ * from it is trusted: every referenced entry is fetched from the log,
8
+ * its attestation integrity-checked ({@link parseIntotoEntry}), its
9
+ * inclusion proof verified, and its subject/predicateType checked
10
+ * against what the index claimed it was.
11
+ */
12
+
13
+ import {
14
+ PASSPORTSIGN_PREDICATE_TYPE,
15
+ PASSPORTSIGN_REVOCATION_PREDICATE_TYPE,
16
+ } from './statement.js';
17
+ import {
18
+ classifyBindings,
19
+ parseIntotoEntry,
20
+ type ClassifiedBinding,
21
+ type ParsedIntotoEntry,
22
+ } from './classify.js';
23
+ import { base64ToBytes, hexToBytes } from './encoding.js';
24
+ import { hashLeaf, verifyInclusion } from './merkle.js';
25
+ import { fetchProfileIndex, type ProfileIndex } from './profile-index.js';
26
+ import { type RekorClient, type RekorEntryResponse } from './log/rekor.js';
27
+
28
+ export interface LookupDeps {
29
+ rekor: RekorClient;
30
+ /** Epoch ms for staleness classification; defaults to the current time. */
31
+ now?: number;
32
+ }
33
+
34
+ export interface LookupEntryProblem {
35
+ uuid: string;
36
+ error: string;
37
+ }
38
+
39
+ export interface LookupResult {
40
+ index: ProfileIndex | null;
41
+ classified: ClassifiedBinding[];
42
+ /** Entries the log could not return (network, 404). */
43
+ unreachable: LookupEntryProblem[];
44
+ /** Entries that failed integrity or sanity checks — treat as hostile index content. */
45
+ invalid: LookupEntryProblem[];
46
+ }
47
+
48
+ function verifyEntryInclusion(entry: RekorEntryResponse): boolean {
49
+ const proof = entry.verification.inclusionProof;
50
+ const leaf = hashLeaf(base64ToBytes(entry.body));
51
+ return verifyInclusion(
52
+ leaf,
53
+ proof.logIndex,
54
+ proof.treeSize,
55
+ proof.hashes.map(hexToBytes),
56
+ hexToBytes(proof.rootHash),
57
+ );
58
+ }
59
+
60
+ interface FetchedSet {
61
+ parsed: ParsedIntotoEntry[];
62
+ unreachable: LookupEntryProblem[];
63
+ invalid: LookupEntryProblem[];
64
+ }
65
+
66
+ async function fetchAndCheck(
67
+ uuids: string[],
68
+ expectedPredicateType: string,
69
+ githubUsername: string,
70
+ rekor: RekorClient,
71
+ ): Promise<FetchedSet> {
72
+ const out: FetchedSet = { parsed: [], unreachable: [], invalid: [] };
73
+ const results = await Promise.allSettled(uuids.map((uuid) => rekor.getEntry(uuid)));
74
+
75
+ results.forEach((result, i) => {
76
+ const uuid = uuids[i]!;
77
+ if (result.status === 'rejected') {
78
+ const reason = result.reason;
79
+ out.unreachable.push({
80
+ uuid,
81
+ error: reason instanceof Error ? reason.message : String(reason),
82
+ });
83
+ return;
84
+ }
85
+ try {
86
+ const entry = result.value;
87
+ if (!verifyEntryInclusion(entry)) {
88
+ out.invalid.push({ uuid, error: 'inclusion proof does not verify' });
89
+ return;
90
+ }
91
+ const parsed = parseIntotoEntry(entry);
92
+ if (parsed.predicateType !== expectedPredicateType) {
93
+ out.invalid.push({
94
+ uuid,
95
+ error: `predicateType ${parsed.predicateType} != expected ${expectedPredicateType}`,
96
+ });
97
+ return;
98
+ }
99
+ const subject = parsed.statement.subject[0]?.name ?? '';
100
+ if (subject.toLowerCase() !== `github.com/${githubUsername}`.toLowerCase()) {
101
+ out.invalid.push({
102
+ uuid,
103
+ error: `subject ${subject} does not match github.com/${githubUsername}`,
104
+ });
105
+ return;
106
+ }
107
+ out.parsed.push(parsed);
108
+ } catch (err) {
109
+ out.invalid.push({ uuid, error: err instanceof Error ? err.message : String(err) });
110
+ }
111
+ });
112
+
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Run the lookup pipeline over an already-obtained index (e.g. the
118
+ * user's file merged with an operator overlay).
119
+ */
120
+ export async function lookupFromIndex(
121
+ index: ProfileIndex,
122
+ deps: LookupDeps,
123
+ ): Promise<LookupResult> {
124
+ const username = index.github_username;
125
+
126
+ const [bindings, revocations] = await Promise.all([
127
+ fetchAndCheck(
128
+ index.bindings.map((b) => b.rekor_entry_hash),
129
+ PASSPORTSIGN_PREDICATE_TYPE,
130
+ username,
131
+ deps.rekor,
132
+ ),
133
+ fetchAndCheck(
134
+ index.revocations.map((r) => r.rekor_entry_hash),
135
+ PASSPORTSIGN_REVOCATION_PREDICATE_TYPE,
136
+ username,
137
+ deps.rekor,
138
+ ),
139
+ ]);
140
+
141
+ const classified = classifyBindings({
142
+ bindings: bindings.parsed,
143
+ revocations: revocations.parsed,
144
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
145
+ });
146
+
147
+ return {
148
+ index,
149
+ classified,
150
+ unreachable: [...bindings.unreachable, ...revocations.unreachable],
151
+ invalid: [...bindings.invalid, ...revocations.invalid],
152
+ };
153
+ }
154
+
155
+ export interface LookupBindingsDeps extends LookupDeps {
156
+ /** Injectable fetch for the index file request. */
157
+ fetch?: typeof fetch;
158
+ }
159
+
160
+ /**
161
+ * Fetch the user's published `passportsign-index.json` and resolve it.
162
+ * `index: null` in the result means the user has not published one.
163
+ */
164
+ export async function lookupBindings(
165
+ githubUsername: string,
166
+ deps: LookupBindingsDeps,
167
+ ): Promise<LookupResult> {
168
+ const index = await fetchProfileIndex(githubUsername, {
169
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
170
+ });
171
+ if (index === null) {
172
+ return { index: null, classified: [], unreachable: [], invalid: [] };
173
+ }
174
+ return lookupFromIndex(index, deps);
175
+ }
package/src/merkle.ts CHANGED
@@ -1,187 +1,187 @@
1
- /**
2
- * RFC 6962 Merkle tree primitives — the math underneath Rekor's
3
- * inclusion and consistency proofs.
4
- *
5
- * Algorithm ported from
6
- * https://github.com/google/certificate-transparency-go/blob/master/merkle/log_verifier.go
7
- * (the canonical reference implementation Sigstore tracks).
8
- *
9
- * Leaf hash: sha256(0x00 || leaf-bytes)
10
- * Inner hash: sha256(0x01 || left-hash || right-hash)
11
- *
12
- * A proof of length n+m has n "inner" hashes (siblings along the path
13
- * from leaf up to the highest common ancestor with the rightmost
14
- * leaf) followed by m "border" hashes (always left-leaning siblings
15
- * above that ancestor). The split is determined by bit-decomposition
16
- * of leafIndex against treeSize - 1.
17
- */
18
-
19
- import { createHash } from 'node:crypto';
20
-
21
- export function hashLeaf(data: Uint8Array): Uint8Array {
22
- const buf = new Uint8Array(1 + data.length);
23
- buf[0] = 0x00;
24
- buf.set(data, 1);
25
- return new Uint8Array(createHash('sha256').update(buf).digest());
26
- }
27
-
28
- export function hashPair(left: Uint8Array, right: Uint8Array): Uint8Array {
29
- const buf = new Uint8Array(1 + left.length + right.length);
30
- buf[0] = 0x01;
31
- buf.set(left, 1);
32
- buf.set(right, 1 + left.length);
33
- return new Uint8Array(createHash('sha256').update(buf).digest());
34
- }
35
-
36
- function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
37
- if (a.length !== b.length) return false;
38
- for (let i = 0; i < a.length; i++) {
39
- if (a[i] !== b[i]) return false;
40
- }
41
- return true;
42
- }
43
-
44
- function bitLength(n: number): number {
45
- let len = 0;
46
- while (n > 0) {
47
- len++;
48
- n = Math.floor(n / 2);
49
- }
50
- return len;
51
- }
52
-
53
- function popcount(n: number): number {
54
- let count = 0;
55
- while (n > 0) {
56
- count += n & 1;
57
- n = Math.floor(n / 2);
58
- }
59
- return count;
60
- }
61
-
62
- function trailingZeros(n: number): number {
63
- if (n === 0) return 64;
64
- let count = 0;
65
- while ((n & 1) === 0) {
66
- count++;
67
- n = Math.floor(n / 2);
68
- }
69
- return count;
70
- }
71
-
72
- interface ProofDecomposition {
73
- inner: number;
74
- border: number;
75
- }
76
-
77
- function decompInclProof(leafIndex: number, treeSize: number): ProofDecomposition {
78
- const inner = bitLength(leafIndex ^ (treeSize - 1));
79
- const border = popcount(Math.floor(leafIndex / Math.pow(2, inner)));
80
- return { inner, border };
81
- }
82
-
83
- function chainInner(seed: Uint8Array, proof: Uint8Array[], leafIndex: number): Uint8Array {
84
- let res = seed;
85
- for (let i = 0; i < proof.length; i++) {
86
- const bit = (Math.floor(leafIndex / Math.pow(2, i))) & 1;
87
- res = bit === 0 ? hashPair(res, proof[i]!) : hashPair(proof[i]!, res);
88
- }
89
- return res;
90
- }
91
-
92
- function chainInnerRight(seed: Uint8Array, proof: Uint8Array[], leafIndex: number): Uint8Array {
93
- let res = seed;
94
- for (let i = 0; i < proof.length; i++) {
95
- const bit = (Math.floor(leafIndex / Math.pow(2, i))) & 1;
96
- if (bit === 1) {
97
- res = hashPair(proof[i]!, res);
98
- }
99
- }
100
- return res;
101
- }
102
-
103
- function chainBorderRight(seed: Uint8Array, proof: Uint8Array[]): Uint8Array {
104
- let res = seed;
105
- for (const p of proof) {
106
- res = hashPair(p, res);
107
- }
108
- return res;
109
- }
110
-
111
- /**
112
- * Verify an RFC 6962 inclusion proof: prove that a leaf with hash
113
- * `leafHash` at position `leafIndex` is included in a tree of size
114
- * `treeSize` with root `rootHash`, using the supplied path of
115
- * sibling hashes.
116
- */
117
- export function verifyInclusion(
118
- leafHash: Uint8Array,
119
- leafIndex: number,
120
- treeSize: number,
121
- proof: Uint8Array[],
122
- rootHash: Uint8Array,
123
- ): boolean {
124
- if (leafIndex < 0 || treeSize < 0 || leafIndex >= treeSize) return false;
125
-
126
- const { inner, border } = decompInclProof(leafIndex, treeSize);
127
- if (proof.length !== inner + border) return false;
128
-
129
- let res = chainInner(leafHash, proof.slice(0, inner), leafIndex);
130
- res = chainBorderRight(res, proof.slice(inner));
131
- return bytesEqual(res, rootHash);
132
- }
133
-
134
- /**
135
- * Verify an RFC 6962 consistency proof: prove that the tree of size
136
- * `firstSize` with root `firstRoot` is a prefix of the tree of size
137
- * `secondSize` with root `secondRoot`. Used to detect log rewrites —
138
- * if our captured root is no longer an ancestor of the current root,
139
- * the log has been tampered with.
140
- *
141
- * Algorithm port of certificate-transparency-go's `VerifyConsistencyProof`.
142
- */
143
- export function verifyConsistency(
144
- firstSize: number,
145
- secondSize: number,
146
- firstRoot: Uint8Array,
147
- secondRoot: Uint8Array,
148
- proof: Uint8Array[],
149
- ): boolean {
150
- if (firstSize < 0 || secondSize < firstSize) return false;
151
- if (firstSize === secondSize) {
152
- return proof.length === 0 && bytesEqual(firstRoot, secondRoot);
153
- }
154
- if (firstSize === 0) {
155
- return proof.length === 0;
156
- }
157
-
158
- let { inner, border } = decompInclProof(firstSize - 1, secondSize);
159
- const shift = trailingZeros(firstSize);
160
- inner -= shift;
161
-
162
- let seed: Uint8Array;
163
- let start: number;
164
- if ((firstSize & (firstSize - 1)) !== 0) {
165
- if (proof.length === 0) return false;
166
- seed = proof[0]!;
167
- start = 1;
168
- } else {
169
- seed = firstRoot;
170
- start = 0;
171
- }
172
-
173
- if (proof.length !== start + inner + border) return false;
174
- const subProof = proof.slice(start);
175
-
176
- const mask = Math.floor((firstSize - 1) / Math.pow(2, shift));
177
-
178
- let hash1 = chainInnerRight(seed, subProof.slice(0, inner), mask);
179
- hash1 = chainBorderRight(hash1, subProof.slice(inner));
180
- if (!bytesEqual(hash1, firstRoot)) return false;
181
-
182
- let hash2 = chainInner(seed, subProof.slice(0, inner), mask);
183
- hash2 = chainBorderRight(hash2, subProof.slice(inner));
184
- if (!bytesEqual(hash2, secondRoot)) return false;
185
-
186
- return true;
187
- }
1
+ /**
2
+ * RFC 6962 Merkle tree primitives — the math underneath Rekor's
3
+ * inclusion and consistency proofs.
4
+ *
5
+ * Algorithm ported from
6
+ * https://github.com/google/certificate-transparency-go/blob/master/merkle/log_verifier.go
7
+ * (the canonical reference implementation Sigstore tracks).
8
+ *
9
+ * Leaf hash: sha256(0x00 || leaf-bytes)
10
+ * Inner hash: sha256(0x01 || left-hash || right-hash)
11
+ *
12
+ * A proof of length n+m has n "inner" hashes (siblings along the path
13
+ * from leaf up to the highest common ancestor with the rightmost
14
+ * leaf) followed by m "border" hashes (always left-leaning siblings
15
+ * above that ancestor). The split is determined by bit-decomposition
16
+ * of leafIndex against treeSize - 1.
17
+ */
18
+
19
+ import { sha256Bytes } from './encoding.js';
20
+
21
+ export function hashLeaf(data: Uint8Array): Uint8Array {
22
+ const buf = new Uint8Array(1 + data.length);
23
+ buf[0] = 0x00;
24
+ buf.set(data, 1);
25
+ return sha256Bytes(buf);
26
+ }
27
+
28
+ export function hashPair(left: Uint8Array, right: Uint8Array): Uint8Array {
29
+ const buf = new Uint8Array(1 + left.length + right.length);
30
+ buf[0] = 0x01;
31
+ buf.set(left, 1);
32
+ buf.set(right, 1 + left.length);
33
+ return sha256Bytes(buf);
34
+ }
35
+
36
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
37
+ if (a.length !== b.length) return false;
38
+ for (let i = 0; i < a.length; i++) {
39
+ if (a[i] !== b[i]) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ function bitLength(n: number): number {
45
+ let len = 0;
46
+ while (n > 0) {
47
+ len++;
48
+ n = Math.floor(n / 2);
49
+ }
50
+ return len;
51
+ }
52
+
53
+ function popcount(n: number): number {
54
+ let count = 0;
55
+ while (n > 0) {
56
+ count += n & 1;
57
+ n = Math.floor(n / 2);
58
+ }
59
+ return count;
60
+ }
61
+
62
+ function trailingZeros(n: number): number {
63
+ if (n === 0) return 64;
64
+ let count = 0;
65
+ while ((n & 1) === 0) {
66
+ count++;
67
+ n = Math.floor(n / 2);
68
+ }
69
+ return count;
70
+ }
71
+
72
+ interface ProofDecomposition {
73
+ inner: number;
74
+ border: number;
75
+ }
76
+
77
+ function decompInclProof(leafIndex: number, treeSize: number): ProofDecomposition {
78
+ const inner = bitLength(leafIndex ^ (treeSize - 1));
79
+ const border = popcount(Math.floor(leafIndex / Math.pow(2, inner)));
80
+ return { inner, border };
81
+ }
82
+
83
+ function chainInner(seed: Uint8Array, proof: Uint8Array[], leafIndex: number): Uint8Array {
84
+ let res = seed;
85
+ for (let i = 0; i < proof.length; i++) {
86
+ const bit = (Math.floor(leafIndex / Math.pow(2, i))) & 1;
87
+ res = bit === 0 ? hashPair(res, proof[i]!) : hashPair(proof[i]!, res);
88
+ }
89
+ return res;
90
+ }
91
+
92
+ function chainInnerRight(seed: Uint8Array, proof: Uint8Array[], leafIndex: number): Uint8Array {
93
+ let res = seed;
94
+ for (let i = 0; i < proof.length; i++) {
95
+ const bit = (Math.floor(leafIndex / Math.pow(2, i))) & 1;
96
+ if (bit === 1) {
97
+ res = hashPair(proof[i]!, res);
98
+ }
99
+ }
100
+ return res;
101
+ }
102
+
103
+ function chainBorderRight(seed: Uint8Array, proof: Uint8Array[]): Uint8Array {
104
+ let res = seed;
105
+ for (const p of proof) {
106
+ res = hashPair(p, res);
107
+ }
108
+ return res;
109
+ }
110
+
111
+ /**
112
+ * Verify an RFC 6962 inclusion proof: prove that a leaf with hash
113
+ * `leafHash` at position `leafIndex` is included in a tree of size
114
+ * `treeSize` with root `rootHash`, using the supplied path of
115
+ * sibling hashes.
116
+ */
117
+ export function verifyInclusion(
118
+ leafHash: Uint8Array,
119
+ leafIndex: number,
120
+ treeSize: number,
121
+ proof: Uint8Array[],
122
+ rootHash: Uint8Array,
123
+ ): boolean {
124
+ if (leafIndex < 0 || treeSize < 0 || leafIndex >= treeSize) return false;
125
+
126
+ const { inner, border } = decompInclProof(leafIndex, treeSize);
127
+ if (proof.length !== inner + border) return false;
128
+
129
+ let res = chainInner(leafHash, proof.slice(0, inner), leafIndex);
130
+ res = chainBorderRight(res, proof.slice(inner));
131
+ return bytesEqual(res, rootHash);
132
+ }
133
+
134
+ /**
135
+ * Verify an RFC 6962 consistency proof: prove that the tree of size
136
+ * `firstSize` with root `firstRoot` is a prefix of the tree of size
137
+ * `secondSize` with root `secondRoot`. Used to detect log rewrites —
138
+ * if our captured root is no longer an ancestor of the current root,
139
+ * the log has been tampered with.
140
+ *
141
+ * Algorithm port of certificate-transparency-go's `VerifyConsistencyProof`.
142
+ */
143
+ export function verifyConsistency(
144
+ firstSize: number,
145
+ secondSize: number,
146
+ firstRoot: Uint8Array,
147
+ secondRoot: Uint8Array,
148
+ proof: Uint8Array[],
149
+ ): boolean {
150
+ if (firstSize < 0 || secondSize < firstSize) return false;
151
+ if (firstSize === secondSize) {
152
+ return proof.length === 0 && bytesEqual(firstRoot, secondRoot);
153
+ }
154
+ if (firstSize === 0) {
155
+ return proof.length === 0;
156
+ }
157
+
158
+ let { inner, border } = decompInclProof(firstSize - 1, secondSize);
159
+ const shift = trailingZeros(firstSize);
160
+ inner -= shift;
161
+
162
+ let seed: Uint8Array;
163
+ let start: number;
164
+ if ((firstSize & (firstSize - 1)) !== 0) {
165
+ if (proof.length === 0) return false;
166
+ seed = proof[0]!;
167
+ start = 1;
168
+ } else {
169
+ seed = firstRoot;
170
+ start = 0;
171
+ }
172
+
173
+ if (proof.length !== start + inner + border) return false;
174
+ const subProof = proof.slice(start);
175
+
176
+ const mask = Math.floor((firstSize - 1) / Math.pow(2, shift));
177
+
178
+ let hash1 = chainInnerRight(seed, subProof.slice(0, inner), mask);
179
+ hash1 = chainBorderRight(hash1, subProof.slice(inner));
180
+ if (!bytesEqual(hash1, firstRoot)) return false;
181
+
182
+ let hash2 = chainInner(seed, subProof.slice(0, inner), mask);
183
+ hash2 = chainBorderRight(hash2, subProof.slice(inner));
184
+ if (!bytesEqual(hash2, secondRoot)) return false;
185
+
186
+ return true;
187
+ }
package/src/nonce.ts CHANGED
@@ -1,53 +1,53 @@
1
- /**
2
- * Binding nonce per spec §3 step 1: cryptographically random, ≥128 bits,
3
- * base32-encoded for gist-friendliness, prefixed with the service
4
- * namespace ("zkm-") and the username for human readability.
5
- *
6
- * Format: `zkm-{username}-{base32}`
7
- * Entropy: 160 bits (20 bytes → 32 base32 chars).
8
- */
9
-
10
- import { randomBytes } from 'node:crypto';
11
-
12
- const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
13
- export const NONCE_BYTES = 20;
14
- export const NONCE_BASE32_LENGTH = 32; // (20 * 8) / 5
15
-
16
- /**
17
- * RFC 4648 base32 (lowercase, no padding) of the input bytes.
18
- *
19
- * Uses BigInt to avoid 32-bit overflow when accumulating ≥4 bytes.
20
- */
21
- export function base32Encode(bytes: Uint8Array): string {
22
- let bits = 0n;
23
- let bitCount = 0;
24
- let result = '';
25
- for (const byte of bytes) {
26
- bits = (bits << 8n) | BigInt(byte);
27
- bitCount += 8;
28
- while (bitCount >= 5) {
29
- bitCount -= 5;
30
- const idx = Number((bits >> BigInt(bitCount)) & 0x1fn);
31
- result += BASE32_ALPHABET[idx];
32
- }
33
- }
34
- if (bitCount > 0) {
35
- const idx = Number((bits << BigInt(5 - bitCount)) & 0x1fn);
36
- result += BASE32_ALPHABET[idx];
37
- }
38
- return result;
39
- }
40
-
41
- /**
42
- * Generate a fresh nonce for a binding session.
43
- *
44
- * @param username - GitHub username to embed in the nonce (case preserved).
45
- * @returns `zkm-<username>-<32 base32 chars>` (length-stable for valid usernames).
46
- */
47
- export function generateNonce(username: string): string {
48
- if (username.length === 0) {
49
- throw new TypeError('generateNonce: username must be non-empty');
50
- }
51
- const bytes = randomBytes(NONCE_BYTES);
52
- return `zkm-${username}-${base32Encode(bytes)}`;
53
- }
1
+ /**
2
+ * Binding nonce per spec §3 step 1: cryptographically random, ≥128 bits,
3
+ * base32-encoded for gist-friendliness, prefixed with the service
4
+ * namespace ("zkm-") and the username for human readability.
5
+ *
6
+ * Format: `zkm-{username}-{base32}`
7
+ * Entropy: 160 bits (20 bytes → 32 base32 chars).
8
+ */
9
+
10
+ import { randomBytes } from './encoding.js';
11
+
12
+ const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
13
+ export const NONCE_BYTES = 20;
14
+ export const NONCE_BASE32_LENGTH = 32; // (20 * 8) / 5
15
+
16
+ /**
17
+ * RFC 4648 base32 (lowercase, no padding) of the input bytes.
18
+ *
19
+ * Uses BigInt to avoid 32-bit overflow when accumulating ≥4 bytes.
20
+ */
21
+ export function base32Encode(bytes: Uint8Array): string {
22
+ let bits = 0n;
23
+ let bitCount = 0;
24
+ let result = '';
25
+ for (const byte of bytes) {
26
+ bits = (bits << 8n) | BigInt(byte);
27
+ bitCount += 8;
28
+ while (bitCount >= 5) {
29
+ bitCount -= 5;
30
+ const idx = Number((bits >> BigInt(bitCount)) & 0x1fn);
31
+ result += BASE32_ALPHABET[idx];
32
+ }
33
+ }
34
+ if (bitCount > 0) {
35
+ const idx = Number((bits << BigInt(5 - bitCount)) & 0x1fn);
36
+ result += BASE32_ALPHABET[idx];
37
+ }
38
+ return result;
39
+ }
40
+
41
+ /**
42
+ * Generate a fresh nonce for a binding session.
43
+ *
44
+ * @param username - GitHub username to embed in the nonce (case preserved).
45
+ * @returns `zkm-<username>-<32 base32 chars>` (length-stable for valid usernames).
46
+ */
47
+ export function generateNonce(username: string): string {
48
+ if (username.length === 0) {
49
+ throw new TypeError('generateNonce: username must be non-empty');
50
+ }
51
+ const bytes = randomBytes(NONCE_BYTES);
52
+ return `zkm-${username}-${base32Encode(bytes)}`;
53
+ }