@passportsign/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +36 -0
- package/dist/badge.d.ts +37 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/badge.js +94 -0
- package/dist/badge.js.map +1 -0
- package/dist/bind.d.ts +61 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +79 -0
- package/dist/bind.js.map +1 -0
- package/dist/bundle.d.ts +47 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +95 -0
- package/dist/bundle.js.map +1 -0
- package/dist/canonical.d.ts +19 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +30 -0
- package/dist/canonical.js.map +1 -0
- package/dist/dsse.d.ts +55 -0
- package/dist/dsse.d.ts.map +1 -0
- package/dist/dsse.js +64 -0
- package/dist/dsse.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +33 -0
- package/dist/errors.js.map +1 -0
- package/dist/github.d.ts +28 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +113 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/log/rekor.d.ts +91 -0
- package/dist/log/rekor.d.ts.map +1 -0
- package/dist/log/rekor.js +218 -0
- package/dist/log/rekor.js.map +1 -0
- package/dist/merkle.d.ts +37 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +160 -0
- package/dist/merkle.js.map +1 -0
- package/dist/nonce.d.ts +24 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +50 -0
- package/dist/nonce.js.map +1 -0
- package/dist/sdk-payload.d.ts +33 -0
- package/dist/sdk-payload.d.ts.map +1 -0
- package/dist/sdk-payload.js +36 -0
- package/dist/sdk-payload.js.map +1 -0
- package/dist/statement.d.ts +67 -0
- package/dist/statement.d.ts.map +1 -0
- package/dist/statement.js +67 -0
- package/dist/statement.js.map +1 -0
- package/dist/storage/sqlite.d.ts +45 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +132 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/submit.d.ts +26 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/submit.js +35 -0
- package/dist/submit.js.map +1 -0
- package/dist/verifier.d.ts +74 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +197 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +60 -0
- package/src/badge.ts +113 -0
- package/src/bind.ts +137 -0
- package/src/bundle.ts +127 -0
- package/src/canonical.ts +33 -0
- package/src/dsse.ts +91 -0
- package/src/errors.ts +37 -0
- package/src/github.ts +196 -0
- package/src/index.ts +121 -0
- package/src/log/rekor.ts +334 -0
- package/src/merkle.ts +187 -0
- package/src/nonce.ts +53 -0
- package/src/sdk-payload.ts +62 -0
- package/src/statement.ts +119 -0
- package/src/storage/sqlite.ts +185 -0
- package/src/submit.ts +54 -0
- package/src/truestamp-canonify.d.ts +7 -0
- package/src/verifier.ts +317 -0
package/dist/verifier.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle verifier — the trust anchor for everyone who is not us.
|
|
3
|
+
*
|
|
4
|
+
* Day 6 scope: structural integrity (statement hash matches Rekor's
|
|
5
|
+
* recorded payloadHash), Merkle inclusion proof against the captured
|
|
6
|
+
* root, and log-root consistency between the captured root and the
|
|
7
|
+
* current witnessed root.
|
|
8
|
+
*
|
|
9
|
+
* Day 7 scope (deferred): SDK proof verification. Requires a
|
|
10
|
+
* bundle-schema extension to carry SDK inputs (proofs array,
|
|
11
|
+
* originalQuery, queryResult). For now `sdk_proof` reports
|
|
12
|
+
* `'pending_day_7'`.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { validateBundle } from './bundle.js';
|
|
16
|
+
import {} from './log/rekor.js';
|
|
17
|
+
import { hashLeaf, verifyConsistency, verifyInclusion } from './merkle.js';
|
|
18
|
+
import { unpackSdkPayload } from './sdk-payload.js';
|
|
19
|
+
function hexToBytes(hex) {
|
|
20
|
+
const out = new Uint8Array(hex.length / 2);
|
|
21
|
+
for (let i = 0; i < out.length; i++) {
|
|
22
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function sha256Hex(bytes) {
|
|
27
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
function parseEntryBody(bodyBase64) {
|
|
30
|
+
const bytes = Buffer.from(bodyBase64, 'base64').toString('utf8');
|
|
31
|
+
const body = JSON.parse(bytes);
|
|
32
|
+
const spec = body['spec'];
|
|
33
|
+
const content = spec?.['content'];
|
|
34
|
+
const payloadHash = content?.['payloadHash'];
|
|
35
|
+
const value = payloadHash?.['value'];
|
|
36
|
+
if (typeof value !== 'string') {
|
|
37
|
+
throw new Error('Rekor entry body missing spec.content.payloadHash.value');
|
|
38
|
+
}
|
|
39
|
+
return { payloadHashHex: value };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Verify a passportsign bundle. Online checks (hash_match, inclusion_proof,
|
|
43
|
+
* root_consistency) require a {@link RekorClient}; without one they are
|
|
44
|
+
* marked `'skipped'`. SDK proof verification is Day 7 work and currently
|
|
45
|
+
* always returns `'pending_day_7'`.
|
|
46
|
+
*/
|
|
47
|
+
export async function verifyBundle(bundle, deps = {}) {
|
|
48
|
+
validateBundle(bundle);
|
|
49
|
+
const result = {
|
|
50
|
+
hash_match: 'skipped',
|
|
51
|
+
inclusion_proof: 'skipped',
|
|
52
|
+
root_consistency: 'skipped',
|
|
53
|
+
sdk_proof: 'skipped',
|
|
54
|
+
overall: 'pending',
|
|
55
|
+
errors: [],
|
|
56
|
+
};
|
|
57
|
+
// SDK proof verification (independent of rekor) — runs first because
|
|
58
|
+
// it can be done purely from the bundle.
|
|
59
|
+
if (deps.sdkVerifier) {
|
|
60
|
+
result.sdk_proof = await runSdkVerification(bundle, deps.sdkVerifier, result.errors);
|
|
61
|
+
}
|
|
62
|
+
if (!deps.rekor) {
|
|
63
|
+
result.overall = computeOverall(result);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
// 1. Fetch the entry from Rekor (any operator's Rekor mirror would do).
|
|
67
|
+
let entry;
|
|
68
|
+
try {
|
|
69
|
+
entry = await deps.rekor.getEntry(bundle.rekor.log_entry_hash);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
result.errors.push(`failed to fetch Rekor entry: ${err instanceof Error ? err.message : String(err)}`);
|
|
73
|
+
result.overall = 'fail';
|
|
74
|
+
result.hash_match = 'fail';
|
|
75
|
+
result.inclusion_proof = 'fail';
|
|
76
|
+
result.root_consistency = 'fail';
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
// 2. hash_match: bundle.statement bytes' sha256 must equal entry.body's payloadHash.
|
|
80
|
+
const statementBytes = hexToBytes(bundle.statement);
|
|
81
|
+
const expectedPayloadHash = sha256Hex(statementBytes);
|
|
82
|
+
let entryPayloadHash;
|
|
83
|
+
try {
|
|
84
|
+
entryPayloadHash = parseEntryBody(entry.body).payloadHashHex;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
result.errors.push(`failed to parse Rekor entry body: ${err instanceof Error ? err.message : String(err)}`);
|
|
88
|
+
result.hash_match = 'fail';
|
|
89
|
+
result.inclusion_proof = 'fail';
|
|
90
|
+
result.root_consistency = 'fail';
|
|
91
|
+
result.overall = 'fail';
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
result.hash_match = expectedPayloadHash === entryPayloadHash ? 'pass' : 'fail';
|
|
95
|
+
if (result.hash_match === 'fail') {
|
|
96
|
+
result.errors.push(`payloadHash mismatch: bundle says ${expectedPayloadHash}, Rekor entry has ${entryPayloadHash}`);
|
|
97
|
+
}
|
|
98
|
+
// 3. inclusion_proof: leaf hash = sha256(0x00 || decoded-body-bytes); verify against captured root.
|
|
99
|
+
const bodyBytes = new Uint8Array(Buffer.from(entry.body, 'base64'));
|
|
100
|
+
const leaf = hashLeaf(bodyBytes);
|
|
101
|
+
const captured = bundle.rekor.inclusion_proof;
|
|
102
|
+
const proofHashes = captured.hashes.map(hexToBytes);
|
|
103
|
+
const rootBytes = hexToBytes(captured.rootHash);
|
|
104
|
+
result.inclusion_proof = verifyInclusion(leaf, captured.logIndex, captured.treeSize, proofHashes, rootBytes)
|
|
105
|
+
? 'pass'
|
|
106
|
+
: 'fail';
|
|
107
|
+
if (result.inclusion_proof === 'fail') {
|
|
108
|
+
result.errors.push('inclusion proof does not verify against captured root');
|
|
109
|
+
}
|
|
110
|
+
// 4. root_consistency: captured root must be a prefix of current witnessed root.
|
|
111
|
+
try {
|
|
112
|
+
const logInfo = await deps.rekor.getLogInfo();
|
|
113
|
+
if (logInfo.treeSize < captured.treeSize) {
|
|
114
|
+
result.errors.push(`current tree size ${logInfo.treeSize} is smaller than captured ${captured.treeSize} — log may have been rewound`);
|
|
115
|
+
result.root_consistency = 'fail';
|
|
116
|
+
}
|
|
117
|
+
else if (logInfo.treeSize === captured.treeSize) {
|
|
118
|
+
// Same tree, just compare roots
|
|
119
|
+
result.root_consistency =
|
|
120
|
+
logInfo.rootHash === captured.rootHash ? 'pass' : 'fail';
|
|
121
|
+
if (result.root_consistency === 'fail') {
|
|
122
|
+
result.errors.push(`root mismatch at same treeSize: captured ${captured.rootHash}, current ${logInfo.rootHash}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const proof = await deps.rekor.getConsistencyProof(captured.treeSize, logInfo.treeSize);
|
|
127
|
+
const proofBytes = proof.hashes.map(hexToBytes);
|
|
128
|
+
result.root_consistency = verifyConsistency(captured.treeSize, logInfo.treeSize, rootBytes, hexToBytes(logInfo.rootHash), proofBytes)
|
|
129
|
+
? 'pass'
|
|
130
|
+
: 'fail';
|
|
131
|
+
if (result.root_consistency === 'fail') {
|
|
132
|
+
result.errors.push('consistency proof does not verify — captured root is not an ancestor of current root');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
result.errors.push(`consistency check error: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
+
result.root_consistency = 'fail';
|
|
139
|
+
}
|
|
140
|
+
result.overall = computeOverall(result);
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
async function runSdkVerification(bundle, sdkVerifier, errors) {
|
|
144
|
+
let payload;
|
|
145
|
+
try {
|
|
146
|
+
payload = unpackSdkPayload(bundle.proof_blob);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
errors.push(`failed to unpack SDK payload from bundle.proof_blob: ${err instanceof Error ? err.message : String(err)}`);
|
|
150
|
+
return 'fail';
|
|
151
|
+
}
|
|
152
|
+
let sdkResult;
|
|
153
|
+
try {
|
|
154
|
+
sdkResult = await sdkVerifier.verify({
|
|
155
|
+
proofs: payload.proofs,
|
|
156
|
+
originalQuery: payload.original_query,
|
|
157
|
+
queryResult: payload.query_result,
|
|
158
|
+
devMode: payload.dev_mode,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
errors.push(`SDK verify threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
return 'fail';
|
|
164
|
+
}
|
|
165
|
+
if (sdkResult.verified !== true) {
|
|
166
|
+
errors.push(`SDK reported verified=${String(sdkResult.verified)}`);
|
|
167
|
+
return 'fail';
|
|
168
|
+
}
|
|
169
|
+
// The returned uniqueIdentifier must match the statement's predicate.
|
|
170
|
+
let statementUniqueId;
|
|
171
|
+
try {
|
|
172
|
+
const statementBytes = Buffer.from(bundle.statement, 'hex').toString('utf8');
|
|
173
|
+
const parsed = JSON.parse(statementBytes);
|
|
174
|
+
statementUniqueId = parsed.predicate?.unique_identifier;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
statementUniqueId = undefined;
|
|
178
|
+
}
|
|
179
|
+
if (!statementUniqueId) {
|
|
180
|
+
errors.push('could not extract unique_identifier from bundle.statement');
|
|
181
|
+
return 'fail';
|
|
182
|
+
}
|
|
183
|
+
if (sdkResult.uniqueIdentifier !== statementUniqueId) {
|
|
184
|
+
errors.push(`SDK uniqueIdentifier ${String(sdkResult.uniqueIdentifier)} does not match statement.predicate.unique_identifier ${statementUniqueId}`);
|
|
185
|
+
return 'fail';
|
|
186
|
+
}
|
|
187
|
+
return 'pass';
|
|
188
|
+
}
|
|
189
|
+
function computeOverall(r) {
|
|
190
|
+
const all = [r.hash_match, r.inclusion_proof, r.root_consistency, r.sdk_proof];
|
|
191
|
+
if (all.some((s) => s === 'fail'))
|
|
192
|
+
return 'fail';
|
|
193
|
+
if (all.every((s) => s === 'pass'))
|
|
194
|
+
return 'pass';
|
|
195
|
+
return 'pending';
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=verifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verifier.js","sourceRoot":"","sources":["../src/verifier.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@passportsign/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core primitives for passportsign: canonical serialization, in-toto Statement v1 builder, binding bundle format, GitHub gist check, Rekor client, RFC 6962 Merkle, DSSE envelope, bundle verifier. Apache-2.0.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"passportsign",
|
|
7
|
+
"sigstore",
|
|
8
|
+
"rekor",
|
|
9
|
+
"in-toto",
|
|
10
|
+
"zkpassport",
|
|
11
|
+
"personhood",
|
|
12
|
+
"attestation",
|
|
13
|
+
"identity"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://passportsign.dev",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/debugmcp/passportsign.git",
|
|
19
|
+
"directory": "packages/core"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/debugmcp/passportsign/issues"
|
|
23
|
+
},
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"author": "JF <https://passportsign.dev>",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./storage/sqlite": {
|
|
35
|
+
"types": "./dist/storage/sqlite.d.ts",
|
|
36
|
+
"default": "./dist/storage/sqlite.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.5"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@truestamp/canonify": "1.0.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc -p tsconfig.json",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/badge.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained inline SVG badge generator for passportsign.
|
|
3
|
+
*
|
|
4
|
+
* v0 ships without a hosted badge service. Maintainers commit the
|
|
5
|
+
* generated `passportsign-badge.svg` to a public repo (typically the
|
|
6
|
+
* `username/username` profile repo) and reference it from their
|
|
7
|
+
* README. GitHub's image proxy renders the SVG; the badge wraps a
|
|
8
|
+
* link to the Rekor entry for click-through verification.
|
|
9
|
+
*
|
|
10
|
+
* Visual: shields.io-style pill, two segments, ~190-280px wide
|
|
11
|
+
* depending on text. Renders with the 10x scale + transform=scale(.1)
|
|
12
|
+
* trick used by shields.io for crisper text rendering.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface BadgeInput {
|
|
16
|
+
github_username: string;
|
|
17
|
+
issuing_country: string | null;
|
|
18
|
+
bound_at: string; // ISO 8601 timestamp
|
|
19
|
+
/** Rekor entry UUID for the `<title>` tooltip. */
|
|
20
|
+
log_entry_hash?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LABEL = 'passportsign';
|
|
24
|
+
const CHAR_WIDTH_PX = 7; // Approx Verdana 11pt character width
|
|
25
|
+
const SIDE_PADDING_PX = 8;
|
|
26
|
+
|
|
27
|
+
function escapeXml(s: string): string {
|
|
28
|
+
return s.replace(/[<>&"']/g, (c) => {
|
|
29
|
+
switch (c) {
|
|
30
|
+
case '<': return '<';
|
|
31
|
+
case '>': return '>';
|
|
32
|
+
case '&': return '&';
|
|
33
|
+
case '"': return '"';
|
|
34
|
+
case "'": return ''';
|
|
35
|
+
default: return c;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dateStringFor(isoTimestamp: string): string {
|
|
41
|
+
// Render as YYYY-MM-DD for the badge.
|
|
42
|
+
const d = new Date(isoTimestamp);
|
|
43
|
+
if (Number.isNaN(d.getTime())) return isoTimestamp.slice(0, 10);
|
|
44
|
+
const y = d.getUTCFullYear();
|
|
45
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
46
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
47
|
+
return `${y}-${m}-${day}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render the badge SVG. The output is intentionally a string (not a
|
|
52
|
+
* DOM tree or stream) so callers can write it to disk directly with
|
|
53
|
+
* `writeFileSync`.
|
|
54
|
+
*/
|
|
55
|
+
export function renderBadgeSvg(input: BadgeInput): string {
|
|
56
|
+
const date = dateStringFor(input.bound_at);
|
|
57
|
+
const valueParts = ['verified human'];
|
|
58
|
+
if (input.issuing_country) valueParts.push(input.issuing_country);
|
|
59
|
+
valueParts.push(date);
|
|
60
|
+
const valueText = valueParts.join(' · '); // middle dot ·
|
|
61
|
+
|
|
62
|
+
const labelEsc = escapeXml(LABEL);
|
|
63
|
+
const valueEsc = escapeXml(valueText);
|
|
64
|
+
|
|
65
|
+
const labelW = LABEL.length * CHAR_WIDTH_PX + 2 * SIDE_PADDING_PX;
|
|
66
|
+
const valueW = valueText.length * CHAR_WIDTH_PX + 2 * SIDE_PADDING_PX;
|
|
67
|
+
const totalW = labelW + valueW;
|
|
68
|
+
|
|
69
|
+
const labelCx10 = labelW * 5; // centre of label segment, scaled x10
|
|
70
|
+
const valueCx10 = (labelW + valueW / 2) * 10;
|
|
71
|
+
const ariaLabel = `${labelEsc}: ${valueEsc}`;
|
|
72
|
+
const tooltipExtra = input.log_entry_hash
|
|
73
|
+
? ` (rekor entry ${escapeXml(input.log_entry_hash.slice(0, 16))}…)`
|
|
74
|
+
: '';
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${ariaLabel}">`,
|
|
78
|
+
`<title>${ariaLabel}${tooltipExtra}</title>`,
|
|
79
|
+
`<linearGradient id="s" x2="0" y2="100%">`,
|
|
80
|
+
`<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>`,
|
|
81
|
+
`<stop offset="1" stop-opacity=".1"/>`,
|
|
82
|
+
`</linearGradient>`,
|
|
83
|
+
`<clipPath id="r"><rect width="${totalW}" height="20" rx="3" fill="#fff"/></clipPath>`,
|
|
84
|
+
`<g clip-path="url(#r)">`,
|
|
85
|
+
`<rect width="${labelW}" height="20" fill="#555"/>`,
|
|
86
|
+
`<rect x="${labelW}" width="${valueW}" height="20" fill="#4c1"/>`,
|
|
87
|
+
`<rect width="${totalW}" height="20" fill="url(#s)"/>`,
|
|
88
|
+
`</g>`,
|
|
89
|
+
`<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">`,
|
|
90
|
+
`<text x="${labelCx10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">${labelEsc}</text>`,
|
|
91
|
+
`<text x="${labelCx10}" y="140" transform="scale(.1)">${labelEsc}</text>`,
|
|
92
|
+
`<text x="${valueCx10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)">${valueEsc}</text>`,
|
|
93
|
+
`<text x="${valueCx10}" y="140" transform="scale(.1)">${valueEsc}</text>`,
|
|
94
|
+
`</g>`,
|
|
95
|
+
`</svg>`,
|
|
96
|
+
].join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render the Markdown snippet that wraps the badge in a click-through
|
|
101
|
+
* link to the Rekor entry. Suitable for pasting into a README.
|
|
102
|
+
*/
|
|
103
|
+
export function renderBadgeMarkdown(input: {
|
|
104
|
+
badge_path: string; // relative path the user will commit
|
|
105
|
+
log_entry_hash: string; // Rekor entry UUID
|
|
106
|
+
rekor_base_url?: string; // default: https://rekor.sigstore.dev
|
|
107
|
+
alt_text?: string;
|
|
108
|
+
}): string {
|
|
109
|
+
const base = input.rekor_base_url ?? 'https://rekor.sigstore.dev';
|
|
110
|
+
const url = `${base}/api/v1/log/entries/${input.log_entry_hash}`;
|
|
111
|
+
const alt = input.alt_text ?? 'passportsign verified';
|
|
112
|
+
return `[](${url})`;
|
|
113
|
+
}
|
package/src/bind.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind-flow orchestrator (no Rekor yet).
|
|
3
|
+
*
|
|
4
|
+
* Composes the pieces from `github.ts`, `statement.ts`, and `canonical.ts`
|
|
5
|
+
* into a single "given these inputs, produce a ready-to-submit binding"
|
|
6
|
+
* function. Day 5 will chain this with the Rekor submission and bundle
|
|
7
|
+
* write to deliver the full `passportsign bind` CLI command.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately does **not** call the zkPassport SDK directly — the proof
|
|
10
|
+
* blob and SDK-derived metadata come in as plain data. The CLI's bind
|
|
11
|
+
* command is the producer of that data (it drives the SDK + UI),
|
|
12
|
+
* keeping this module pure and unit-testable without the SDK.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
import { canonicalize, canonicalSha256Hex } from './canonical.js';
|
|
18
|
+
import { PassportsignError } from './errors.js';
|
|
19
|
+
import { checkGistControl, type GistEvidence } from './github.js';
|
|
20
|
+
import { buildStatement, type PassportsignStatement } from './statement.js';
|
|
21
|
+
|
|
22
|
+
export interface PrepareBindingInput {
|
|
23
|
+
github_username: string;
|
|
24
|
+
/** Base64-encoded zkPassport proof blob (the SDK callback's serialized output). */
|
|
25
|
+
proof_blob_b64: string;
|
|
26
|
+
/** From the SDK's `onResult` callback. Deterministic for this passport + scope. */
|
|
27
|
+
unique_identifier: string;
|
|
28
|
+
/** ICAO 3-letter code if disclosed, else null. Pass through as-returned by SDK. */
|
|
29
|
+
issuing_country: string | null;
|
|
30
|
+
/** Per-binding nonce that was placed in the user's gist for the control check. */
|
|
31
|
+
nonce: string;
|
|
32
|
+
/** Full scope string (e.g. "passportsign.dev:nationality-disclose:1"). */
|
|
33
|
+
scope: string;
|
|
34
|
+
/** Version string from the zkPassport SDK that produced the proof. */
|
|
35
|
+
zkpassport_sdk_version: string;
|
|
36
|
+
/** Optional GitHub token for the gist check (rate limits only — no special access). */
|
|
37
|
+
github_token?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PrepareBindingInit {
|
|
41
|
+
/** Init timestamp — gist `updated_at` must be on or after this. */
|
|
42
|
+
issuedAt: Date;
|
|
43
|
+
/** Filename to look for in the user's gists. Defaults to `passportsign.txt`. */
|
|
44
|
+
gistFilename?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PrepareBindingDeps {
|
|
48
|
+
/** Inject for tests. Defaults to {@link checkGistControl}. */
|
|
49
|
+
github?: typeof checkGistControl;
|
|
50
|
+
/** Inject a fetch (forwarded to github). */
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PreparedBinding {
|
|
55
|
+
statement: PassportsignStatement;
|
|
56
|
+
statement_canonical: Uint8Array;
|
|
57
|
+
statement_sha256_hex: string;
|
|
58
|
+
proof_blob_b64: string;
|
|
59
|
+
proof_blob_sha256_hex: string;
|
|
60
|
+
gist: GistEvidence;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_GIST_FILENAME = 'passportsign.txt';
|
|
64
|
+
|
|
65
|
+
function decodeBase64(b64: string): Uint8Array {
|
|
66
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
70
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run the GitHub gist control check, then build the in-toto statement and
|
|
75
|
+
* compute canonical bytes + hashes for the Rekor handoff.
|
|
76
|
+
*
|
|
77
|
+
* Throws {@link PassportsignError} with the matching §4 code on any
|
|
78
|
+
* failure path.
|
|
79
|
+
*/
|
|
80
|
+
export async function prepareBinding(
|
|
81
|
+
input: PrepareBindingInput,
|
|
82
|
+
init: PrepareBindingInit,
|
|
83
|
+
deps: PrepareBindingDeps = {},
|
|
84
|
+
): Promise<PreparedBinding> {
|
|
85
|
+
const githubImpl = deps.github ?? checkGistControl;
|
|
86
|
+
|
|
87
|
+
// 1. GitHub gist control check (throws PassportsignError on any §4 path).
|
|
88
|
+
const gist = await githubImpl({
|
|
89
|
+
username: input.github_username,
|
|
90
|
+
expected_filename: init.gistFilename ?? DEFAULT_GIST_FILENAME,
|
|
91
|
+
expected_content: input.nonce,
|
|
92
|
+
not_before: init.issuedAt,
|
|
93
|
+
...(input.github_token ? { token: input.github_token } : {}),
|
|
94
|
+
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 2. Derive proof_blob sha256 from the base64 input.
|
|
98
|
+
let proofBytes: Uint8Array;
|
|
99
|
+
try {
|
|
100
|
+
proofBytes = decodeBase64(input.proof_blob_b64);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new PassportsignError(
|
|
103
|
+
'proof_invalid',
|
|
104
|
+
`proof_blob_b64 is not valid base64`,
|
|
105
|
+
err,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (proofBytes.length === 0) {
|
|
109
|
+
throw new PassportsignError('proof_invalid', 'proof_blob_b64 decoded to zero bytes');
|
|
110
|
+
}
|
|
111
|
+
const proof_blob_sha256_hex = sha256Hex(proofBytes);
|
|
112
|
+
|
|
113
|
+
// 3. Build the in-toto statement (enforces hex / non-empty invariants).
|
|
114
|
+
const statement = buildStatement({
|
|
115
|
+
github_username: input.github_username,
|
|
116
|
+
unique_identifier: input.unique_identifier,
|
|
117
|
+
issuing_country: input.issuing_country,
|
|
118
|
+
proof_blob_sha256: proof_blob_sha256_hex,
|
|
119
|
+
gist_url: gist.url,
|
|
120
|
+
gist_content_sha256: gist.content_sha256,
|
|
121
|
+
scope: input.scope,
|
|
122
|
+
zkpassport_sdk_version: input.zkpassport_sdk_version,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// 4. Canonical bytes + sha256 for the Rekor entry.
|
|
126
|
+
const statement_canonical = canonicalize(statement);
|
|
127
|
+
const statement_sha256_hex = canonicalSha256Hex(statement);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
statement,
|
|
131
|
+
statement_canonical,
|
|
132
|
+
statement_sha256_hex,
|
|
133
|
+
proof_blob_b64: input.proof_blob_b64,
|
|
134
|
+
proof_blob_sha256_hex,
|
|
135
|
+
gist,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `binding.passportsign.json` bundle format — the portable unit of
|
|
3
|
+
* verification.
|
|
4
|
+
*
|
|
5
|
+
* Rekor stores hashes, not artifacts. To verify a binding, a third party
|
|
6
|
+
* needs both the Rekor entry (hash + inclusion proof) and the artifacts
|
|
7
|
+
* that were hashed. The bundle carries both: the canonical statement bytes
|
|
8
|
+
* (hex), the proof blob (base64), and the Rekor metadata.
|
|
9
|
+
*
|
|
10
|
+
* Shape follows the Sigstore verification-bundle pattern. The
|
|
11
|
+
* `rekor.inclusion_proof` field is intentionally `unknown` for now — its
|
|
12
|
+
* shape gets pinned in Day 5 after we've smoke-tested the public Sigstore
|
|
13
|
+
* Rekor response format.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
export const BUNDLE_FORMAT_VERSION = 1 as const;
|
|
19
|
+
|
|
20
|
+
export interface RekorBundleFields {
|
|
21
|
+
log_entry_hash: string;
|
|
22
|
+
inclusion_proof: unknown;
|
|
23
|
+
log_root_at_submission: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PassportsignBundle {
|
|
27
|
+
bundle_format_version: typeof BUNDLE_FORMAT_VERSION;
|
|
28
|
+
/** Hex-encoded canonical JCS bytes of the in-toto statement. */
|
|
29
|
+
statement: string;
|
|
30
|
+
/** Base64-encoded zkPassport proof blob. */
|
|
31
|
+
proof_blob: string;
|
|
32
|
+
rekor: RekorBundleFields;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HEX_EVEN = /^(?:[0-9a-f]{2})+$/;
|
|
36
|
+
// Standard base64: A-Z, a-z, 0-9, +, /, with 0-2 trailing '=' for padding.
|
|
37
|
+
// Length must be multiple of 4.
|
|
38
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
39
|
+
|
|
40
|
+
export class BundleValidationError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly path: string,
|
|
43
|
+
message: string,
|
|
44
|
+
) {
|
|
45
|
+
super(`${path}: ${message}`);
|
|
46
|
+
this.name = 'BundleValidationError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fail(path: string, message: string): never {
|
|
51
|
+
throw new BundleValidationError(path, message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
55
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Type-guard validator for `PassportsignBundle`. Throws
|
|
60
|
+
* `BundleValidationError` with a structured path on the first issue.
|
|
61
|
+
*/
|
|
62
|
+
export function validateBundle(value: unknown): asserts value is PassportsignBundle {
|
|
63
|
+
if (!isObject(value)) fail('$', 'bundle must be a JSON object');
|
|
64
|
+
|
|
65
|
+
if (value['bundle_format_version'] !== BUNDLE_FORMAT_VERSION) {
|
|
66
|
+
fail(
|
|
67
|
+
'$.bundle_format_version',
|
|
68
|
+
`expected ${BUNDLE_FORMAT_VERSION}, got ${JSON.stringify(value['bundle_format_version'])}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const statement = value['statement'];
|
|
73
|
+
if (typeof statement !== 'string') fail('$.statement', 'must be a string');
|
|
74
|
+
if (!HEX_EVEN.test(statement)) {
|
|
75
|
+
fail('$.statement', 'must be lowercase even-length hex (canonical JCS bytes)');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const proofBlob = value['proof_blob'];
|
|
79
|
+
if (typeof proofBlob !== 'string') fail('$.proof_blob', 'must be a string');
|
|
80
|
+
if (!BASE64.test(proofBlob)) {
|
|
81
|
+
fail('$.proof_blob', 'must be standard base64 (A-Z, a-z, 0-9, +, /, = padding)');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rekor = value['rekor'];
|
|
85
|
+
if (!isObject(rekor)) fail('$.rekor', 'must be an object');
|
|
86
|
+
|
|
87
|
+
const logEntryHash = rekor['log_entry_hash'];
|
|
88
|
+
if (typeof logEntryHash !== 'string' || logEntryHash.length === 0) {
|
|
89
|
+
fail('$.rekor.log_entry_hash', 'must be a non-empty string');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!('inclusion_proof' in rekor)) {
|
|
93
|
+
fail('$.rekor.inclusion_proof', 'is required (shape pinned in Day 5)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const logRootAtSubmission = rekor['log_root_at_submission'];
|
|
97
|
+
if (typeof logRootAtSubmission !== 'string' || logRootAtSubmission.length === 0) {
|
|
98
|
+
fail('$.rekor.log_root_at_submission', 'must be a non-empty string');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read and validate a `binding.passportsign.json` file. Throws on
|
|
104
|
+
* invalid JSON or schema violations.
|
|
105
|
+
*/
|
|
106
|
+
export function readBundle(path: string): PassportsignBundle {
|
|
107
|
+
const raw = readFileSync(path, 'utf8');
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new BundleValidationError(
|
|
113
|
+
'$',
|
|
114
|
+
`invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
validateBundle(parsed);
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate and write a `binding.passportsign.json` file (pretty-printed).
|
|
123
|
+
*/
|
|
124
|
+
export function writeBundle(path: string, bundle: PassportsignBundle): void {
|
|
125
|
+
validateBundle(bundle);
|
|
126
|
+
writeFileSync(path, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
|
127
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import canonify from '@truestamp/canonify';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RFC 8785 JCS-canonical UTF-8 bytes for a JSON-serializable value.
|
|
6
|
+
*
|
|
7
|
+
* Wraps `@truestamp/canonify` (pinned at exact 1.0.3) and UTF-8 encodes the
|
|
8
|
+
* resulting string. The fixture-pinned drift test in
|
|
9
|
+
* `test/canonical.test.ts` guards against silent behavior changes in the
|
|
10
|
+
* underlying library — JCS implementations have had subtle bugs and this
|
|
11
|
+
* function's output is the most security-critical artifact in the repo.
|
|
12
|
+
*
|
|
13
|
+
* Throws `TypeError` if the value cannot be canonicalized (e.g. undefined,
|
|
14
|
+
* cycles, non-JSON-serializable types).
|
|
15
|
+
*/
|
|
16
|
+
export function canonicalize(value: unknown): Uint8Array {
|
|
17
|
+
const canonical = canonify(value);
|
|
18
|
+
if (canonical === undefined) {
|
|
19
|
+
throw new TypeError(
|
|
20
|
+
'canonicalize: value cannot be JCS-canonicalized (undefined / cycle / non-JSON)',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return new TextEncoder().encode(canonical);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lowercase-hex SHA-256 of `canonicalize(value)`. Used to derive the
|
|
28
|
+
* Rekor entry hash for the in-toto statement.
|
|
29
|
+
*/
|
|
30
|
+
export function canonicalSha256Hex(value: unknown): string {
|
|
31
|
+
const bytes = canonicalize(value);
|
|
32
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
33
|
+
}
|