@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/src/statement.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* in-toto Statement v1 builder for passportsign attestations.
|
|
3
|
+
*
|
|
4
|
+
* The statement's canonical JCS bytes are what gets hashed into the
|
|
5
|
+
* Rekor entry, so this module is the authoritative source for the
|
|
6
|
+
* statement shape. Test vectors in
|
|
7
|
+
* `test/fixtures/canonical-vectors.json` pin the canonicalization
|
|
8
|
+
* output for representative statements built here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const IN_TOTO_STATEMENT_TYPE = 'https://in-toto.io/Statement/v1' as const;
|
|
12
|
+
export const PASSPORTSIGN_PREDICATE_TYPE =
|
|
13
|
+
'https://passportsign.dev/personhood/v1' as const;
|
|
14
|
+
|
|
15
|
+
export type DisclosureLevel = 'personhood' | 'personhood+country';
|
|
16
|
+
|
|
17
|
+
export interface PassportsignPredicate {
|
|
18
|
+
/** From the zkPassport SDK — deterministic for (passport, domain, scope). */
|
|
19
|
+
unique_identifier: string;
|
|
20
|
+
/** ICAO 3-letter code if disclosed, else null. Pass through as-returned by SDK. */
|
|
21
|
+
issuing_country: string | null;
|
|
22
|
+
/** Derived from issuing_country (null → personhood, set → personhood+country). */
|
|
23
|
+
disclosure_level: DisclosureLevel;
|
|
24
|
+
/** Lowercase hex SHA-256 of the proof blob bytes. */
|
|
25
|
+
proof_blob_sha256: string;
|
|
26
|
+
/** Public gist URL captured at binding time. */
|
|
27
|
+
gist_url: string;
|
|
28
|
+
/** Lowercase hex SHA-256 of the gist's content bytes. Also the subject digest. */
|
|
29
|
+
gist_content_sha256: string;
|
|
30
|
+
/** zkPassport scope (e.g. "passportsign.dev:nationality-disclose:1"). */
|
|
31
|
+
scope: string;
|
|
32
|
+
/** Version string from the zkPassport SDK that produced the proof. */
|
|
33
|
+
zkpassport_sdk_version: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PassportsignStatement {
|
|
37
|
+
_type: typeof IN_TOTO_STATEMENT_TYPE;
|
|
38
|
+
subject: Array<{
|
|
39
|
+
name: string;
|
|
40
|
+
digest: { sha256: string };
|
|
41
|
+
}>;
|
|
42
|
+
predicateType: typeof PASSPORTSIGN_PREDICATE_TYPE;
|
|
43
|
+
predicate: PassportsignPredicate;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BuildStatementInput {
|
|
47
|
+
github_username: string;
|
|
48
|
+
unique_identifier: string;
|
|
49
|
+
issuing_country: string | null;
|
|
50
|
+
proof_blob_sha256: string;
|
|
51
|
+
gist_url: string;
|
|
52
|
+
gist_content_sha256: string;
|
|
53
|
+
scope: string;
|
|
54
|
+
zkpassport_sdk_version: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
58
|
+
|
|
59
|
+
function assertSha256Hex(value: string, field: string): void {
|
|
60
|
+
if (!SHA256_HEX.test(value)) {
|
|
61
|
+
throw new TypeError(
|
|
62
|
+
`${field}: expected lowercase 64-char hex SHA-256, got ${JSON.stringify(value)}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertNonEmpty(value: string, field: string): void {
|
|
68
|
+
if (value.length === 0) {
|
|
69
|
+
throw new TypeError(`${field}: must be non-empty`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a passportsign in-toto Statement v1.
|
|
75
|
+
*
|
|
76
|
+
* Invariants enforced here (so the canonical bytes are always well-formed):
|
|
77
|
+
* - `proof_blob_sha256` and `gist_content_sha256` are lowercase 64-char hex.
|
|
78
|
+
* - `github_username`, `unique_identifier`, `gist_url`, `scope`,
|
|
79
|
+
* `zkpassport_sdk_version` are non-empty.
|
|
80
|
+
* - `subject[0].digest.sha256 === gist_content_sha256` — the subject digest
|
|
81
|
+
* is the artifact whose control was demonstrated (the gist content).
|
|
82
|
+
* - `disclosure_level` is derived from `issuing_country` and is never
|
|
83
|
+
* accepted from the caller.
|
|
84
|
+
* - There is no `bound_at` field — the Rekor inclusion timestamp is the
|
|
85
|
+
* authoritative time of binding.
|
|
86
|
+
*/
|
|
87
|
+
export function buildStatement(input: BuildStatementInput): PassportsignStatement {
|
|
88
|
+
assertSha256Hex(input.proof_blob_sha256, 'proof_blob_sha256');
|
|
89
|
+
assertSha256Hex(input.gist_content_sha256, 'gist_content_sha256');
|
|
90
|
+
assertNonEmpty(input.github_username, 'github_username');
|
|
91
|
+
assertNonEmpty(input.unique_identifier, 'unique_identifier');
|
|
92
|
+
assertNonEmpty(input.gist_url, 'gist_url');
|
|
93
|
+
assertNonEmpty(input.scope, 'scope');
|
|
94
|
+
assertNonEmpty(input.zkpassport_sdk_version, 'zkpassport_sdk_version');
|
|
95
|
+
|
|
96
|
+
const disclosure_level: DisclosureLevel =
|
|
97
|
+
input.issuing_country === null ? 'personhood' : 'personhood+country';
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
_type: IN_TOTO_STATEMENT_TYPE,
|
|
101
|
+
subject: [
|
|
102
|
+
{
|
|
103
|
+
name: `github.com/${input.github_username}`,
|
|
104
|
+
digest: { sha256: input.gist_content_sha256 },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
predicateType: PASSPORTSIGN_PREDICATE_TYPE,
|
|
108
|
+
predicate: {
|
|
109
|
+
unique_identifier: input.unique_identifier,
|
|
110
|
+
issuing_country: input.issuing_country,
|
|
111
|
+
disclosure_level,
|
|
112
|
+
proof_blob_sha256: input.proof_blob_sha256,
|
|
113
|
+
gist_url: input.gist_url,
|
|
114
|
+
gist_content_sha256: input.gist_content_sha256,
|
|
115
|
+
scope: input.scope,
|
|
116
|
+
zkpassport_sdk_version: input.zkpassport_sdk_version,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite cache mirroring spec §5's `bindings` table.
|
|
3
|
+
*
|
|
4
|
+
* The cache is **non-authoritative** by design — losing it is an
|
|
5
|
+
* availability incident, not a security one. The canonical state lives
|
|
6
|
+
* in Rekor, and `passportsign rebuild` reconstructs the cache from log
|
|
7
|
+
* entries.
|
|
8
|
+
*
|
|
9
|
+
* Uses Node's built-in `node:sqlite` (experimental but functional in
|
|
10
|
+
* Node 22.5+; stable enough for our single-user CLI use). Avoids the
|
|
11
|
+
* `better-sqlite3` native-build dependency, which requires Visual
|
|
12
|
+
* Studio C++ tools on Windows.
|
|
13
|
+
*
|
|
14
|
+
* Usernames are normalized to lowercase on insert and read per spec §10
|
|
15
|
+
* row 7. Display casing is the caller's responsibility.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
19
|
+
|
|
20
|
+
export type Status = 'active' | 'stale' | 'revoked';
|
|
21
|
+
export type DisclosureLevel = 'personhood' | 'personhood+country';
|
|
22
|
+
|
|
23
|
+
export interface BindingRow {
|
|
24
|
+
github_username: string; // lowercase
|
|
25
|
+
unique_identifier: string;
|
|
26
|
+
issuing_country: string | null;
|
|
27
|
+
disclosure_level: DisclosureLevel;
|
|
28
|
+
scope: string;
|
|
29
|
+
zkpassport_sdk_ver: string;
|
|
30
|
+
proof_blob: Uint8Array;
|
|
31
|
+
gist_url: string;
|
|
32
|
+
gist_content_sha256: string;
|
|
33
|
+
bound_at: string; // ISO 8601, local-cache only
|
|
34
|
+
log_entry_hash: string;
|
|
35
|
+
log_inclusion_proof: unknown; // serialized as JSON in the DB
|
|
36
|
+
log_root_at_submission: string;
|
|
37
|
+
last_checked_at: string; // ISO 8601
|
|
38
|
+
status: Status;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PassportsignCache {
|
|
42
|
+
upsertBinding(row: BindingRow): void;
|
|
43
|
+
getByUsername(username: string): BindingRow | null;
|
|
44
|
+
getByUniqueIdentifier(uid: string): BindingRow[];
|
|
45
|
+
setStatus(username: string, status: Status): void;
|
|
46
|
+
setLastChecked(username: string, when: Date): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SCHEMA = `
|
|
51
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
52
|
+
github_username TEXT PRIMARY KEY,
|
|
53
|
+
unique_identifier TEXT NOT NULL,
|
|
54
|
+
issuing_country TEXT,
|
|
55
|
+
disclosure_level TEXT NOT NULL CHECK (disclosure_level IN ('personhood','personhood+country')),
|
|
56
|
+
scope TEXT NOT NULL,
|
|
57
|
+
zkpassport_sdk_ver TEXT NOT NULL,
|
|
58
|
+
proof_blob BLOB NOT NULL,
|
|
59
|
+
gist_url TEXT NOT NULL,
|
|
60
|
+
gist_content_sha256 TEXT NOT NULL,
|
|
61
|
+
bound_at TEXT NOT NULL,
|
|
62
|
+
log_entry_hash TEXT NOT NULL UNIQUE,
|
|
63
|
+
log_inclusion_proof TEXT NOT NULL,
|
|
64
|
+
log_root_at_submission TEXT NOT NULL,
|
|
65
|
+
last_checked_at TEXT NOT NULL,
|
|
66
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','stale','revoked'))
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS bindings_unique_identifier ON bindings(unique_identifier);
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
interface DbRow {
|
|
72
|
+
github_username: string;
|
|
73
|
+
unique_identifier: string;
|
|
74
|
+
issuing_country: string | null;
|
|
75
|
+
disclosure_level: DisclosureLevel;
|
|
76
|
+
scope: string;
|
|
77
|
+
zkpassport_sdk_ver: string;
|
|
78
|
+
proof_blob: Uint8Array;
|
|
79
|
+
gist_url: string;
|
|
80
|
+
gist_content_sha256: string;
|
|
81
|
+
bound_at: string;
|
|
82
|
+
log_entry_hash: string;
|
|
83
|
+
log_inclusion_proof: string;
|
|
84
|
+
log_root_at_submission: string;
|
|
85
|
+
last_checked_at: string;
|
|
86
|
+
status: Status;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rowFromDb(r: DbRow): BindingRow {
|
|
90
|
+
return {
|
|
91
|
+
github_username: r.github_username,
|
|
92
|
+
unique_identifier: r.unique_identifier,
|
|
93
|
+
issuing_country: r.issuing_country,
|
|
94
|
+
disclosure_level: r.disclosure_level,
|
|
95
|
+
scope: r.scope,
|
|
96
|
+
zkpassport_sdk_ver: r.zkpassport_sdk_ver,
|
|
97
|
+
proof_blob: new Uint8Array(r.proof_blob),
|
|
98
|
+
gist_url: r.gist_url,
|
|
99
|
+
gist_content_sha256: r.gist_content_sha256,
|
|
100
|
+
bound_at: r.bound_at,
|
|
101
|
+
log_entry_hash: r.log_entry_hash,
|
|
102
|
+
log_inclusion_proof: JSON.parse(r.log_inclusion_proof),
|
|
103
|
+
log_root_at_submission: r.log_root_at_submission,
|
|
104
|
+
last_checked_at: r.last_checked_at,
|
|
105
|
+
status: r.status,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function openCache(path: string): PassportsignCache {
|
|
110
|
+
const db = new DatabaseSync(path);
|
|
111
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
112
|
+
db.exec(SCHEMA);
|
|
113
|
+
|
|
114
|
+
const upsertStmt = db.prepare(`
|
|
115
|
+
INSERT INTO bindings (
|
|
116
|
+
github_username, unique_identifier, issuing_country, disclosure_level,
|
|
117
|
+
scope, zkpassport_sdk_ver, proof_blob, gist_url, gist_content_sha256,
|
|
118
|
+
bound_at, log_entry_hash, log_inclusion_proof, log_root_at_submission,
|
|
119
|
+
last_checked_at, status
|
|
120
|
+
) VALUES (
|
|
121
|
+
:github_username, :unique_identifier, :issuing_country, :disclosure_level,
|
|
122
|
+
:scope, :zkpassport_sdk_ver, :proof_blob, :gist_url, :gist_content_sha256,
|
|
123
|
+
:bound_at, :log_entry_hash, :log_inclusion_proof, :log_root_at_submission,
|
|
124
|
+
:last_checked_at, :status
|
|
125
|
+
)
|
|
126
|
+
ON CONFLICT(github_username) DO UPDATE SET
|
|
127
|
+
unique_identifier = excluded.unique_identifier,
|
|
128
|
+
issuing_country = excluded.issuing_country,
|
|
129
|
+
disclosure_level = excluded.disclosure_level,
|
|
130
|
+
scope = excluded.scope,
|
|
131
|
+
zkpassport_sdk_ver = excluded.zkpassport_sdk_ver,
|
|
132
|
+
proof_blob = excluded.proof_blob,
|
|
133
|
+
gist_url = excluded.gist_url,
|
|
134
|
+
gist_content_sha256 = excluded.gist_content_sha256,
|
|
135
|
+
bound_at = excluded.bound_at,
|
|
136
|
+
log_entry_hash = excluded.log_entry_hash,
|
|
137
|
+
log_inclusion_proof = excluded.log_inclusion_proof,
|
|
138
|
+
log_root_at_submission = excluded.log_root_at_submission,
|
|
139
|
+
last_checked_at = excluded.last_checked_at,
|
|
140
|
+
status = excluded.status
|
|
141
|
+
`);
|
|
142
|
+
const getByUsernameStmt = db.prepare(`SELECT * FROM bindings WHERE github_username = ?`);
|
|
143
|
+
const getByUniqueIdStmt = db.prepare(`SELECT * FROM bindings WHERE unique_identifier = ? ORDER BY bound_at`);
|
|
144
|
+
const setStatusStmt = db.prepare(`UPDATE bindings SET status = ? WHERE github_username = ?`);
|
|
145
|
+
const setLastCheckedStmt = db.prepare(`UPDATE bindings SET last_checked_at = ? WHERE github_username = ?`);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
upsertBinding(row) {
|
|
149
|
+
upsertStmt.run({
|
|
150
|
+
github_username: row.github_username.toLowerCase(),
|
|
151
|
+
unique_identifier: row.unique_identifier,
|
|
152
|
+
issuing_country: row.issuing_country,
|
|
153
|
+
disclosure_level: row.disclosure_level,
|
|
154
|
+
scope: row.scope,
|
|
155
|
+
zkpassport_sdk_ver: row.zkpassport_sdk_ver,
|
|
156
|
+
proof_blob: row.proof_blob,
|
|
157
|
+
gist_url: row.gist_url,
|
|
158
|
+
gist_content_sha256: row.gist_content_sha256,
|
|
159
|
+
bound_at: row.bound_at,
|
|
160
|
+
log_entry_hash: row.log_entry_hash,
|
|
161
|
+
log_inclusion_proof: JSON.stringify(row.log_inclusion_proof),
|
|
162
|
+
log_root_at_submission: row.log_root_at_submission,
|
|
163
|
+
last_checked_at: row.last_checked_at,
|
|
164
|
+
status: row.status,
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
getByUsername(username) {
|
|
168
|
+
const r = getByUsernameStmt.get(username.toLowerCase()) as unknown as DbRow | undefined;
|
|
169
|
+
return r ? rowFromDb(r) : null;
|
|
170
|
+
},
|
|
171
|
+
getByUniqueIdentifier(uid) {
|
|
172
|
+
const rows = getByUniqueIdStmt.all(uid) as unknown as DbRow[];
|
|
173
|
+
return rows.map(rowFromDb);
|
|
174
|
+
},
|
|
175
|
+
setStatus(username, status) {
|
|
176
|
+
setStatusStmt.run(status, username.toLowerCase());
|
|
177
|
+
},
|
|
178
|
+
setLastChecked(username, when) {
|
|
179
|
+
setLastCheckedStmt.run(when.toISOString(), username.toLowerCase());
|
|
180
|
+
},
|
|
181
|
+
close() {
|
|
182
|
+
db.close();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit a {@link PreparedBinding} to a Rekor log and assemble the
|
|
3
|
+
* resulting {@link PassportsignBundle}.
|
|
4
|
+
*
|
|
5
|
+
* Composes the DSSE envelope step (with ephemeral ECDSA P-256 key)
|
|
6
|
+
* with a {@link RekorClient}. Day 7 calls this to turn a real-passport
|
|
7
|
+
* bind into a public-log entry plus a portable bundle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type PreparedBinding } from './bind.js';
|
|
11
|
+
import {
|
|
12
|
+
BUNDLE_FORMAT_VERSION,
|
|
13
|
+
type PassportsignBundle,
|
|
14
|
+
validateBundle,
|
|
15
|
+
} from './bundle.js';
|
|
16
|
+
import { IN_TOTO_PAYLOAD_TYPE, signEnvelope } from './dsse.js';
|
|
17
|
+
import { type RekorClient, type RekorEntryResponse } from './log/rekor.js';
|
|
18
|
+
|
|
19
|
+
export interface SubmitBindingDeps {
|
|
20
|
+
rekor: RekorClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SubmitBindingResult {
|
|
24
|
+
bundle: PassportsignBundle;
|
|
25
|
+
rekorEntry: RekorEntryResponse;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sign the canonical statement bytes with an ephemeral ECDSA P-256 key,
|
|
30
|
+
* submit the in-toto entry to Rekor, and assemble the bundle. Throws
|
|
31
|
+
* `PassportsignError('log_submission_failed', …)` (from the client) on
|
|
32
|
+
* any Rekor failure.
|
|
33
|
+
*/
|
|
34
|
+
export async function submitBinding(
|
|
35
|
+
prepared: PreparedBinding,
|
|
36
|
+
deps: SubmitBindingDeps,
|
|
37
|
+
): Promise<SubmitBindingResult> {
|
|
38
|
+
const { envelope } = signEnvelope(prepared.statement_canonical, IN_TOTO_PAYLOAD_TYPE);
|
|
39
|
+
const rekorEntry = await deps.rekor.submitIntoto(envelope);
|
|
40
|
+
|
|
41
|
+
const bundle: PassportsignBundle = {
|
|
42
|
+
bundle_format_version: BUNDLE_FORMAT_VERSION,
|
|
43
|
+
statement: Buffer.from(prepared.statement_canonical).toString('hex'),
|
|
44
|
+
proof_blob: prepared.proof_blob_b64,
|
|
45
|
+
rekor: {
|
|
46
|
+
log_entry_hash: rekorEntry.uuid,
|
|
47
|
+
inclusion_proof: rekorEntry.verification.inclusionProof,
|
|
48
|
+
log_root_at_submission: rekorEntry.verification.inclusionProof.rootHash,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
validateBundle(bundle);
|
|
52
|
+
|
|
53
|
+
return { bundle, rekorEntry };
|
|
54
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// `@truestamp/canonify`'s package.json defines `exports` but doesn't expose
|
|
2
|
+
// types via a `"types"` condition. NodeNext module resolution can't reach
|
|
3
|
+
// the bundled `dist/mod.d.ts` through the exports map. Declare the surface
|
|
4
|
+
// locally so `tsc --noEmit` passes.
|
|
5
|
+
declare module '@truestamp/canonify' {
|
|
6
|
+
export default function canonify(object: unknown): string | undefined;
|
|
7
|
+
}
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
import { type PassportsignBundle, validateBundle } from './bundle.js';
|
|
18
|
+
import { type RekorClient } from './log/rekor.js';
|
|
19
|
+
import { hashLeaf, verifyConsistency, verifyInclusion } from './merkle.js';
|
|
20
|
+
import { unpackSdkPayload } from './sdk-payload.js';
|
|
21
|
+
|
|
22
|
+
export type CheckResult = 'pass' | 'fail' | 'skipped';
|
|
23
|
+
|
|
24
|
+
export interface BundleVerifyResult {
|
|
25
|
+
/**
|
|
26
|
+
* Statement bytes in the bundle hash to the `payloadHash` Rekor recorded
|
|
27
|
+
* for the entry.
|
|
28
|
+
*/
|
|
29
|
+
hash_match: CheckResult;
|
|
30
|
+
/**
|
|
31
|
+
* The captured inclusion proof verifies the Rekor entry's leaf hash
|
|
32
|
+
* against the captured root.
|
|
33
|
+
*/
|
|
34
|
+
inclusion_proof: CheckResult;
|
|
35
|
+
/**
|
|
36
|
+
* The captured root is a prefix of the current witnessed root (the log
|
|
37
|
+
* has not been rewritten in a way that orphans our entry). Skipped when
|
|
38
|
+
* no rekor client is provided.
|
|
39
|
+
*/
|
|
40
|
+
root_consistency: CheckResult;
|
|
41
|
+
/**
|
|
42
|
+
* SDK proof verification. `'skipped'` when no SDK verifier is injected.
|
|
43
|
+
* `'pass'` when the SDK validates the proofs AND the returned
|
|
44
|
+
* uniqueIdentifier matches the statement's predicate.
|
|
45
|
+
*/
|
|
46
|
+
sdk_proof: CheckResult;
|
|
47
|
+
/**
|
|
48
|
+
* `'pass'` only when every enabled check passes; `'fail'` if any check
|
|
49
|
+
* fails; `'pending'` when one or more checks are `'skipped'`.
|
|
50
|
+
*/
|
|
51
|
+
overall: 'pass' | 'fail' | 'pending';
|
|
52
|
+
errors: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SdkVerifyInput {
|
|
56
|
+
proofs: unknown[];
|
|
57
|
+
originalQuery: unknown;
|
|
58
|
+
queryResult: unknown;
|
|
59
|
+
scope?: string;
|
|
60
|
+
devMode?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SdkVerifyResult {
|
|
64
|
+
verified: boolean;
|
|
65
|
+
uniqueIdentifier: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SdkVerifier {
|
|
69
|
+
verify(input: SdkVerifyInput): Promise<SdkVerifyResult>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface VerifyBundleDeps {
|
|
73
|
+
/** Inject a Rekor client to enable hash_match / inclusion / consistency checks. */
|
|
74
|
+
rekor?: RekorClient;
|
|
75
|
+
/** Inject a zkPassport SDK verifier to enable the sdk_proof check. */
|
|
76
|
+
sdkVerifier?: SdkVerifier;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
80
|
+
const out = new Uint8Array(hex.length / 2);
|
|
81
|
+
for (let i = 0; i < out.length; i++) {
|
|
82
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
88
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ParsedEntryBody {
|
|
92
|
+
payloadHashHex: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseEntryBody(bodyBase64: string): ParsedEntryBody {
|
|
96
|
+
const bytes = Buffer.from(bodyBase64, 'base64').toString('utf8');
|
|
97
|
+
const body = JSON.parse(bytes) as Record<string, unknown>;
|
|
98
|
+
const spec = body['spec'] as Record<string, unknown> | undefined;
|
|
99
|
+
const content = spec?.['content'] as Record<string, unknown> | undefined;
|
|
100
|
+
const payloadHash = content?.['payloadHash'] as Record<string, unknown> | undefined;
|
|
101
|
+
const value = payloadHash?.['value'];
|
|
102
|
+
if (typeof value !== 'string') {
|
|
103
|
+
throw new Error('Rekor entry body missing spec.content.payloadHash.value');
|
|
104
|
+
}
|
|
105
|
+
return { payloadHashHex: value };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Verify a passportsign bundle. Online checks (hash_match, inclusion_proof,
|
|
110
|
+
* root_consistency) require a {@link RekorClient}; without one they are
|
|
111
|
+
* marked `'skipped'`. SDK proof verification is Day 7 work and currently
|
|
112
|
+
* always returns `'pending_day_7'`.
|
|
113
|
+
*/
|
|
114
|
+
export async function verifyBundle(
|
|
115
|
+
bundle: PassportsignBundle,
|
|
116
|
+
deps: VerifyBundleDeps = {},
|
|
117
|
+
): Promise<BundleVerifyResult> {
|
|
118
|
+
validateBundle(bundle);
|
|
119
|
+
|
|
120
|
+
const result: BundleVerifyResult = {
|
|
121
|
+
hash_match: 'skipped',
|
|
122
|
+
inclusion_proof: 'skipped',
|
|
123
|
+
root_consistency: 'skipped',
|
|
124
|
+
sdk_proof: 'skipped',
|
|
125
|
+
overall: 'pending',
|
|
126
|
+
errors: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// SDK proof verification (independent of rekor) — runs first because
|
|
130
|
+
// it can be done purely from the bundle.
|
|
131
|
+
if (deps.sdkVerifier) {
|
|
132
|
+
result.sdk_proof = await runSdkVerification(bundle, deps.sdkVerifier, result.errors);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!deps.rekor) {
|
|
136
|
+
result.overall = computeOverall(result);
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1. Fetch the entry from Rekor (any operator's Rekor mirror would do).
|
|
141
|
+
let entry;
|
|
142
|
+
try {
|
|
143
|
+
entry = await deps.rekor.getEntry(bundle.rekor.log_entry_hash);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
result.errors.push(
|
|
146
|
+
`failed to fetch Rekor entry: ${err instanceof Error ? err.message : String(err)}`,
|
|
147
|
+
);
|
|
148
|
+
result.overall = 'fail';
|
|
149
|
+
result.hash_match = 'fail';
|
|
150
|
+
result.inclusion_proof = 'fail';
|
|
151
|
+
result.root_consistency = 'fail';
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. hash_match: bundle.statement bytes' sha256 must equal entry.body's payloadHash.
|
|
156
|
+
const statementBytes = hexToBytes(bundle.statement);
|
|
157
|
+
const expectedPayloadHash = sha256Hex(statementBytes);
|
|
158
|
+
let entryPayloadHash: string;
|
|
159
|
+
try {
|
|
160
|
+
entryPayloadHash = parseEntryBody(entry.body).payloadHashHex;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
result.errors.push(
|
|
163
|
+
`failed to parse Rekor entry body: ${err instanceof Error ? err.message : String(err)}`,
|
|
164
|
+
);
|
|
165
|
+
result.hash_match = 'fail';
|
|
166
|
+
result.inclusion_proof = 'fail';
|
|
167
|
+
result.root_consistency = 'fail';
|
|
168
|
+
result.overall = 'fail';
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
result.hash_match = expectedPayloadHash === entryPayloadHash ? 'pass' : 'fail';
|
|
172
|
+
if (result.hash_match === 'fail') {
|
|
173
|
+
result.errors.push(
|
|
174
|
+
`payloadHash mismatch: bundle says ${expectedPayloadHash}, Rekor entry has ${entryPayloadHash}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. inclusion_proof: leaf hash = sha256(0x00 || decoded-body-bytes); verify against captured root.
|
|
179
|
+
const bodyBytes = new Uint8Array(Buffer.from(entry.body, 'base64'));
|
|
180
|
+
const leaf = hashLeaf(bodyBytes);
|
|
181
|
+
const captured = bundle.rekor.inclusion_proof as {
|
|
182
|
+
hashes: string[];
|
|
183
|
+
logIndex: number;
|
|
184
|
+
treeSize: number;
|
|
185
|
+
rootHash: string;
|
|
186
|
+
};
|
|
187
|
+
const proofHashes = captured.hashes.map(hexToBytes);
|
|
188
|
+
const rootBytes = hexToBytes(captured.rootHash);
|
|
189
|
+
result.inclusion_proof = verifyInclusion(
|
|
190
|
+
leaf,
|
|
191
|
+
captured.logIndex,
|
|
192
|
+
captured.treeSize,
|
|
193
|
+
proofHashes,
|
|
194
|
+
rootBytes,
|
|
195
|
+
)
|
|
196
|
+
? 'pass'
|
|
197
|
+
: 'fail';
|
|
198
|
+
if (result.inclusion_proof === 'fail') {
|
|
199
|
+
result.errors.push('inclusion proof does not verify against captured root');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 4. root_consistency: captured root must be a prefix of current witnessed root.
|
|
203
|
+
try {
|
|
204
|
+
const logInfo = await deps.rekor.getLogInfo();
|
|
205
|
+
if (logInfo.treeSize < captured.treeSize) {
|
|
206
|
+
result.errors.push(
|
|
207
|
+
`current tree size ${logInfo.treeSize} is smaller than captured ${captured.treeSize} — log may have been rewound`,
|
|
208
|
+
);
|
|
209
|
+
result.root_consistency = 'fail';
|
|
210
|
+
} else if (logInfo.treeSize === captured.treeSize) {
|
|
211
|
+
// Same tree, just compare roots
|
|
212
|
+
result.root_consistency =
|
|
213
|
+
logInfo.rootHash === captured.rootHash ? 'pass' : 'fail';
|
|
214
|
+
if (result.root_consistency === 'fail') {
|
|
215
|
+
result.errors.push(
|
|
216
|
+
`root mismatch at same treeSize: captured ${captured.rootHash}, current ${logInfo.rootHash}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const proof = await deps.rekor.getConsistencyProof(
|
|
221
|
+
captured.treeSize,
|
|
222
|
+
logInfo.treeSize,
|
|
223
|
+
);
|
|
224
|
+
const proofBytes = proof.hashes.map(hexToBytes);
|
|
225
|
+
result.root_consistency = verifyConsistency(
|
|
226
|
+
captured.treeSize,
|
|
227
|
+
logInfo.treeSize,
|
|
228
|
+
rootBytes,
|
|
229
|
+
hexToBytes(logInfo.rootHash),
|
|
230
|
+
proofBytes,
|
|
231
|
+
)
|
|
232
|
+
? 'pass'
|
|
233
|
+
: 'fail';
|
|
234
|
+
if (result.root_consistency === 'fail') {
|
|
235
|
+
result.errors.push(
|
|
236
|
+
'consistency proof does not verify — captured root is not an ancestor of current root',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
result.errors.push(
|
|
242
|
+
`consistency check error: ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
);
|
|
244
|
+
result.root_consistency = 'fail';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
result.overall = computeOverall(result);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runSdkVerification(
|
|
252
|
+
bundle: PassportsignBundle,
|
|
253
|
+
sdkVerifier: SdkVerifier,
|
|
254
|
+
errors: string[],
|
|
255
|
+
): Promise<CheckResult> {
|
|
256
|
+
let payload;
|
|
257
|
+
try {
|
|
258
|
+
payload = unpackSdkPayload(bundle.proof_blob);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
errors.push(
|
|
261
|
+
`failed to unpack SDK payload from bundle.proof_blob: ${
|
|
262
|
+
err instanceof Error ? err.message : String(err)
|
|
263
|
+
}`,
|
|
264
|
+
);
|
|
265
|
+
return 'fail';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let sdkResult: SdkVerifyResult;
|
|
269
|
+
try {
|
|
270
|
+
sdkResult = await sdkVerifier.verify({
|
|
271
|
+
proofs: payload.proofs,
|
|
272
|
+
originalQuery: payload.original_query,
|
|
273
|
+
queryResult: payload.query_result,
|
|
274
|
+
devMode: payload.dev_mode,
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
errors.push(`SDK verify threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
return 'fail';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (sdkResult.verified !== true) {
|
|
282
|
+
errors.push(`SDK reported verified=${String(sdkResult.verified)}`);
|
|
283
|
+
return 'fail';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The returned uniqueIdentifier must match the statement's predicate.
|
|
287
|
+
let statementUniqueId: string | undefined;
|
|
288
|
+
try {
|
|
289
|
+
const statementBytes = Buffer.from(bundle.statement, 'hex').toString('utf8');
|
|
290
|
+
const parsed = JSON.parse(statementBytes) as {
|
|
291
|
+
predicate?: { unique_identifier?: string };
|
|
292
|
+
};
|
|
293
|
+
statementUniqueId = parsed.predicate?.unique_identifier;
|
|
294
|
+
} catch {
|
|
295
|
+
statementUniqueId = undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!statementUniqueId) {
|
|
299
|
+
errors.push('could not extract unique_identifier from bundle.statement');
|
|
300
|
+
return 'fail';
|
|
301
|
+
}
|
|
302
|
+
if (sdkResult.uniqueIdentifier !== statementUniqueId) {
|
|
303
|
+
errors.push(
|
|
304
|
+
`SDK uniqueIdentifier ${String(sdkResult.uniqueIdentifier)} does not match statement.predicate.unique_identifier ${statementUniqueId}`,
|
|
305
|
+
);
|
|
306
|
+
return 'fail';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return 'pass';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function computeOverall(r: BundleVerifyResult): 'pass' | 'fail' | 'pending' {
|
|
313
|
+
const all = [r.hash_match, r.inclusion_proof, r.root_consistency, r.sdk_proof];
|
|
314
|
+
if (all.some((s) => s === 'fail')) return 'fail';
|
|
315
|
+
if (all.every((s) => s === 'pass')) return 'pass';
|
|
316
|
+
return 'pending';
|
|
317
|
+
}
|