@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.
Files changed (84) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +36 -0
  3. package/dist/badge.d.ts +37 -0
  4. package/dist/badge.d.ts.map +1 -0
  5. package/dist/badge.js +94 -0
  6. package/dist/badge.js.map +1 -0
  7. package/dist/bind.d.ts +61 -0
  8. package/dist/bind.d.ts.map +1 -0
  9. package/dist/bind.js +79 -0
  10. package/dist/bind.js.map +1 -0
  11. package/dist/bundle.d.ts +47 -0
  12. package/dist/bundle.d.ts.map +1 -0
  13. package/dist/bundle.js +95 -0
  14. package/dist/bundle.js.map +1 -0
  15. package/dist/canonical.d.ts +19 -0
  16. package/dist/canonical.d.ts.map +1 -0
  17. package/dist/canonical.js +30 -0
  18. package/dist/canonical.js.map +1 -0
  19. package/dist/dsse.d.ts +55 -0
  20. package/dist/dsse.d.ts.map +1 -0
  21. package/dist/dsse.js +64 -0
  22. package/dist/dsse.js.map +1 -0
  23. package/dist/errors.d.ts +17 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +33 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/github.d.ts +28 -0
  28. package/dist/github.d.ts.map +1 -0
  29. package/dist/github.js +113 -0
  30. package/dist/github.js.map +1 -0
  31. package/dist/index.d.ts +22 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +27 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/log/rekor.d.ts +91 -0
  36. package/dist/log/rekor.d.ts.map +1 -0
  37. package/dist/log/rekor.js +218 -0
  38. package/dist/log/rekor.js.map +1 -0
  39. package/dist/merkle.d.ts +37 -0
  40. package/dist/merkle.d.ts.map +1 -0
  41. package/dist/merkle.js +160 -0
  42. package/dist/merkle.js.map +1 -0
  43. package/dist/nonce.d.ts +24 -0
  44. package/dist/nonce.d.ts.map +1 -0
  45. package/dist/nonce.js +50 -0
  46. package/dist/nonce.js.map +1 -0
  47. package/dist/sdk-payload.d.ts +33 -0
  48. package/dist/sdk-payload.d.ts.map +1 -0
  49. package/dist/sdk-payload.js +36 -0
  50. package/dist/sdk-payload.js.map +1 -0
  51. package/dist/statement.d.ts +67 -0
  52. package/dist/statement.d.ts.map +1 -0
  53. package/dist/statement.js +67 -0
  54. package/dist/statement.js.map +1 -0
  55. package/dist/storage/sqlite.d.ts +45 -0
  56. package/dist/storage/sqlite.d.ts.map +1 -0
  57. package/dist/storage/sqlite.js +132 -0
  58. package/dist/storage/sqlite.js.map +1 -0
  59. package/dist/submit.d.ts +26 -0
  60. package/dist/submit.d.ts.map +1 -0
  61. package/dist/submit.js +35 -0
  62. package/dist/submit.js.map +1 -0
  63. package/dist/verifier.d.ts +74 -0
  64. package/dist/verifier.d.ts.map +1 -0
  65. package/dist/verifier.js +197 -0
  66. package/dist/verifier.js.map +1 -0
  67. package/package.json +60 -0
  68. package/src/badge.ts +113 -0
  69. package/src/bind.ts +137 -0
  70. package/src/bundle.ts +127 -0
  71. package/src/canonical.ts +33 -0
  72. package/src/dsse.ts +91 -0
  73. package/src/errors.ts +37 -0
  74. package/src/github.ts +196 -0
  75. package/src/index.ts +121 -0
  76. package/src/log/rekor.ts +334 -0
  77. package/src/merkle.ts +187 -0
  78. package/src/nonce.ts +53 -0
  79. package/src/sdk-payload.ts +62 -0
  80. package/src/statement.ts +119 -0
  81. package/src/storage/sqlite.ts +185 -0
  82. package/src/submit.ts +54 -0
  83. package/src/truestamp-canonify.d.ts +7 -0
  84. package/src/verifier.ts +317 -0
package/src/dsse.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * DSSE (Dead Simple Signing Envelope) envelope builder.
3
+ *
4
+ * Per-binding ephemeral ECDSA P-256 key — the private key is discarded
5
+ * after signing. The DSSE signature is a Rekor schema requirement, not
6
+ * a trust mechanism. The actual authentication for passportsign comes
7
+ * from the zkPassport proof + GitHub gist evidence carried inside the
8
+ * statement's predicate, not from this signature.
9
+ *
10
+ * Spec: https://github.com/secure-systems-lab/dsse/blob/master/protocol.md
11
+ *
12
+ * Note on key algorithm choice: ECDSA P-256 over SHA-256 is what
13
+ * Rekor's public instance accepts for intoto v0.0.2 entries. Ed25519
14
+ * is in the DSSE spec but the public Rekor's verification path rejected
15
+ * it during the Day 5 smoke test (500 "error generating canonicalized
16
+ * entry"). See `docs/v0-acceptance.md` Day 5 evidence.
17
+ */
18
+
19
+ import { createSign, generateKeyPairSync } from 'node:crypto';
20
+
21
+ export const DSSE_VERSION = 'DSSEv1';
22
+ export const IN_TOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
23
+
24
+ export interface DsseSignature {
25
+ /** Single-base64 of the raw signature bytes. */
26
+ sig: string;
27
+ /** PEM-encoded SubjectPublicKeyInfo. */
28
+ publicKey: string;
29
+ /** Optional key identifier. Omit (don't pass empty string) when not set. */
30
+ keyid?: string;
31
+ }
32
+
33
+ export interface DsseEnvelope {
34
+ /** Media type of the payload (e.g. `application/vnd.in-toto+json`). */
35
+ payloadType: string;
36
+ /** Single-base64 of the raw payload bytes. */
37
+ payload: string;
38
+ signatures: DsseSignature[];
39
+ }
40
+
41
+ /**
42
+ * DSSE Pre-Authentication Encoding (PAE):
43
+ *
44
+ * "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
45
+ *
46
+ * Where SP is a single 0x20 space, LEN is the ASCII-decimal length of
47
+ * the following byte string.
48
+ */
49
+ export function pae(type: string, body: Uint8Array): Uint8Array {
50
+ const typeBytes = new TextEncoder().encode(type);
51
+ const prefix = `${DSSE_VERSION} ${typeBytes.length} ${type} ${body.length} `;
52
+ const prefixBytes = new TextEncoder().encode(prefix);
53
+ const out = new Uint8Array(prefixBytes.length + body.length);
54
+ out.set(prefixBytes);
55
+ out.set(body, prefixBytes.length);
56
+ return out;
57
+ }
58
+
59
+ export interface SignEnvelopeResult {
60
+ envelope: DsseEnvelope;
61
+ /** PEM of the ephemeral public key (also embedded in envelope.signatures[0].publicKey). */
62
+ publicKeyPem: string;
63
+ }
64
+
65
+ /**
66
+ * Generate an ephemeral ECDSA P-256 keypair, sign PAE(payloadType,
67
+ * payload), and return a DSSE envelope. The private key is discarded
68
+ * before return.
69
+ */
70
+ export function signEnvelope(payload: Uint8Array, payloadType: string): SignEnvelopeResult {
71
+ const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
72
+ const paeBytes = pae(payloadType, payload);
73
+ const signer = createSign('SHA256');
74
+ signer.update(Buffer.from(paeBytes));
75
+ const sigBuf = signer.sign(privateKey);
76
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
77
+
78
+ return {
79
+ envelope: {
80
+ payloadType,
81
+ payload: Buffer.from(payload).toString('base64'),
82
+ signatures: [
83
+ {
84
+ sig: sigBuf.toString('base64'),
85
+ publicKey: publicKeyPem,
86
+ },
87
+ ],
88
+ },
89
+ publicKeyPem,
90
+ };
91
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * §4 error vocabulary — the set of failure codes a bind/verify call can
3
+ * surface to a caller. Verbatim from `docs/passportsign.md` §4.
4
+ *
5
+ * Callers should `catch` `PassportsignError` and branch on `.code`. The
6
+ * `cause` field carries the underlying error (HTTP response body, SDK
7
+ * error, etc.) for logging — never for client display, since it may
8
+ * include identifying data.
9
+ */
10
+
11
+ export const ERROR_CODES = [
12
+ 'username_invalid',
13
+ 'binding_pending_expired',
14
+ 'gist_not_found',
15
+ 'gist_wrong_content',
16
+ 'gist_wrong_owner',
17
+ 'gist_predates_init',
18
+ 'proof_invalid',
19
+ 'proof_scope_mismatch',
20
+ 'proof_missing_personhood',
21
+ 'log_submission_failed',
22
+ 'internal_error',
23
+ ] as const;
24
+
25
+ export type ErrorCode = (typeof ERROR_CODES)[number];
26
+
27
+ export class PassportsignError extends Error {
28
+ readonly code: ErrorCode;
29
+ override readonly cause: unknown;
30
+
31
+ constructor(code: ErrorCode, message: string, cause?: unknown) {
32
+ super(message);
33
+ this.name = 'PassportsignError';
34
+ this.code = code;
35
+ this.cause = cause;
36
+ }
37
+ }
package/src/github.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * GitHub gist control check per spec §3 step 5 and §14 "The gist control check."
3
+ *
4
+ * The honest semantic claim is: at the moment we checked, the named user
5
+ * controlled a public gist with the expected filename and content. We
6
+ * capture `html_url`, `updated_at`, and a SHA-256 of the content so the
7
+ * evidence is independently re-checkable later (e.g. via the Wayback
8
+ * Machine).
9
+ *
10
+ * The optional `token` is **purely for rate-limit headroom** — it
11
+ * carries zero special access. Unauth'd: 60 req/hr; with token: 5000.
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { PassportsignError } from './errors.js';
16
+
17
+ export interface GistEvidence {
18
+ url: string;
19
+ content_sha256: string;
20
+ updated_at: string;
21
+ }
22
+
23
+ export interface CheckGistOptions {
24
+ username: string;
25
+ expected_filename: string;
26
+ expected_content: string;
27
+ not_before: Date;
28
+ token?: string;
29
+ fetch?: typeof fetch;
30
+ baseUrl?: string;
31
+ }
32
+
33
+ interface GistFile {
34
+ filename?: string;
35
+ content?: string;
36
+ }
37
+
38
+ interface GistSummary {
39
+ id: string;
40
+ html_url: string;
41
+ updated_at: string;
42
+ owner?: { login?: string } | null;
43
+ files: Record<string, GistFile>;
44
+ }
45
+
46
+ const DEFAULT_BASE_URL = 'https://api.github.com';
47
+ const GIST_LIST_PER_PAGE = 100;
48
+
49
+ function sha256Hex(content: string): string {
50
+ return createHash('sha256').update(content, 'utf8').digest('hex');
51
+ }
52
+
53
+ function authHeaders(token: string | undefined): Record<string, string> {
54
+ return {
55
+ Accept: 'application/vnd.github+json',
56
+ 'X-GitHub-Api-Version': '2022-11-28',
57
+ 'User-Agent': 'passportsign-cli',
58
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
59
+ };
60
+ }
61
+
62
+ export async function checkGistControl(opts: CheckGistOptions): Promise<GistEvidence> {
63
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
64
+ const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
65
+ const headers = authHeaders(opts.token);
66
+
67
+ if (opts.username.length === 0) {
68
+ throw new PassportsignError('username_invalid', 'username must be non-empty');
69
+ }
70
+
71
+ // Step 1: list the user's gists, filter by filename.
72
+ const listUrl = `${baseUrl}/users/${encodeURIComponent(opts.username)}/gists?per_page=${GIST_LIST_PER_PAGE}`;
73
+ let listResponse: Response;
74
+ try {
75
+ listResponse = await fetchImpl(listUrl, { headers });
76
+ } catch (err) {
77
+ throw new PassportsignError(
78
+ 'internal_error',
79
+ `GitHub list-gists request failed: ${err instanceof Error ? err.message : String(err)}`,
80
+ err,
81
+ );
82
+ }
83
+
84
+ if (listResponse.status === 404) {
85
+ throw new PassportsignError(
86
+ 'username_invalid',
87
+ `GitHub user '${opts.username}' not found`,
88
+ );
89
+ }
90
+ if (!listResponse.ok) {
91
+ throw new PassportsignError(
92
+ 'internal_error',
93
+ `GitHub list-gists returned HTTP ${listResponse.status}`,
94
+ );
95
+ }
96
+
97
+ let listBody: unknown;
98
+ try {
99
+ listBody = await listResponse.json();
100
+ } catch (err) {
101
+ throw new PassportsignError(
102
+ 'internal_error',
103
+ 'GitHub list-gists returned non-JSON',
104
+ err,
105
+ );
106
+ }
107
+ if (!Array.isArray(listBody)) {
108
+ throw new PassportsignError(
109
+ 'internal_error',
110
+ 'GitHub list-gists did not return an array',
111
+ );
112
+ }
113
+
114
+ const matches = (listBody as GistSummary[])
115
+ .filter((g) => g && typeof g === 'object' && opts.expected_filename in (g.files ?? {}))
116
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
117
+
118
+ const match = matches[0];
119
+ if (!match) {
120
+ throw new PassportsignError(
121
+ 'gist_not_found',
122
+ `no public gist owned by '${opts.username}' contains file '${opts.expected_filename}'`,
123
+ );
124
+ }
125
+
126
+ // Step 2: re-fetch the gist by id to get the full content.
127
+ let detailResponse: Response;
128
+ try {
129
+ detailResponse = await fetchImpl(`${baseUrl}/gists/${match.id}`, { headers });
130
+ } catch (err) {
131
+ throw new PassportsignError(
132
+ 'internal_error',
133
+ `GitHub get-gist request failed: ${err instanceof Error ? err.message : String(err)}`,
134
+ err,
135
+ );
136
+ }
137
+ if (!detailResponse.ok) {
138
+ throw new PassportsignError(
139
+ 'internal_error',
140
+ `GitHub get-gist returned HTTP ${detailResponse.status}`,
141
+ );
142
+ }
143
+
144
+ let detail: GistSummary;
145
+ try {
146
+ detail = (await detailResponse.json()) as GistSummary;
147
+ } catch (err) {
148
+ throw new PassportsignError('internal_error', 'GitHub get-gist returned non-JSON', err);
149
+ }
150
+
151
+ // Step 3: owner check (case-insensitive per spec §10 row 7).
152
+ const ownerLogin = detail.owner?.login;
153
+ if (!ownerLogin || ownerLogin.toLowerCase() !== opts.username.toLowerCase()) {
154
+ throw new PassportsignError(
155
+ 'gist_wrong_owner',
156
+ `gist ${match.id} owner '${ownerLogin ?? 'unknown'}' does not match expected '${opts.username}'`,
157
+ );
158
+ }
159
+
160
+ // Step 4: content exact match.
161
+ const file = (detail.files ?? {})[opts.expected_filename];
162
+ const content = file?.content;
163
+ if (typeof content !== 'string') {
164
+ throw new PassportsignError(
165
+ 'gist_wrong_content',
166
+ `gist ${match.id} has no readable content for '${opts.expected_filename}'`,
167
+ );
168
+ }
169
+ if (content !== opts.expected_content) {
170
+ throw new PassportsignError(
171
+ 'gist_wrong_content',
172
+ `gist ${match.id} content does not exactly match the expected nonce`,
173
+ );
174
+ }
175
+
176
+ // Step 5: freshness — gist's updated_at must be at/after init.
177
+ const updatedAtMs = Date.parse(detail.updated_at);
178
+ if (Number.isNaN(updatedAtMs)) {
179
+ throw new PassportsignError(
180
+ 'internal_error',
181
+ `gist ${match.id} updated_at is unparseable: ${detail.updated_at}`,
182
+ );
183
+ }
184
+ if (updatedAtMs < opts.not_before.getTime()) {
185
+ throw new PassportsignError(
186
+ 'gist_predates_init',
187
+ `gist ${match.id} updated_at (${detail.updated_at}) predates init (${opts.not_before.toISOString()})`,
188
+ );
189
+ }
190
+
191
+ return {
192
+ url: detail.html_url,
193
+ content_sha256: sha256Hex(content),
194
+ updated_at: detail.updated_at,
195
+ };
196
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Public API of @passportsign/core.
3
+ *
4
+ * Day 1-2: canonical JCS + in-toto statement + bundle format.
5
+ * Day 3-4: §4 error vocabulary + nonce + GitHub gist check +
6
+ * SQLite cache + bind orchestrator (no Rekor yet).
7
+ */
8
+
9
+ export {
10
+ canonicalize,
11
+ canonicalSha256Hex,
12
+ } from './canonical.js';
13
+
14
+ export {
15
+ IN_TOTO_STATEMENT_TYPE,
16
+ PASSPORTSIGN_PREDICATE_TYPE,
17
+ buildStatement,
18
+ type BuildStatementInput,
19
+ type DisclosureLevel,
20
+ type PassportsignPredicate,
21
+ type PassportsignStatement,
22
+ } from './statement.js';
23
+
24
+ export {
25
+ BUNDLE_FORMAT_VERSION,
26
+ BundleValidationError,
27
+ readBundle,
28
+ validateBundle,
29
+ writeBundle,
30
+ type PassportsignBundle,
31
+ type RekorBundleFields,
32
+ } from './bundle.js';
33
+
34
+ export {
35
+ ERROR_CODES,
36
+ PassportsignError,
37
+ type ErrorCode,
38
+ } from './errors.js';
39
+
40
+ export {
41
+ NONCE_BYTES,
42
+ NONCE_BASE32_LENGTH,
43
+ base32Encode,
44
+ generateNonce,
45
+ } from './nonce.js';
46
+
47
+ export {
48
+ checkGistControl,
49
+ type CheckGistOptions,
50
+ type GistEvidence,
51
+ } from './github.js';
52
+
53
+ // SQLite cache is intentionally not re-exported from the main entry —
54
+ // `node:sqlite` doesn't bundle cleanly (esbuild strips the `node:` prefix
55
+ // and there's no public `sqlite` npm package by that name). Consumers
56
+ // who need it should import from `@passportsign/core/storage/sqlite`
57
+ // directly. The v0 CLI doesn't use the cache; rebuild is v1 work.
58
+
59
+ export {
60
+ prepareBinding,
61
+ type PrepareBindingDeps,
62
+ type PrepareBindingInit,
63
+ type PrepareBindingInput,
64
+ type PreparedBinding,
65
+ } from './bind.js';
66
+
67
+ export {
68
+ DSSE_VERSION,
69
+ IN_TOTO_PAYLOAD_TYPE,
70
+ pae,
71
+ signEnvelope,
72
+ type DsseEnvelope,
73
+ type DsseSignature,
74
+ type SignEnvelopeResult,
75
+ } from './dsse.js';
76
+
77
+ export {
78
+ DEFAULT_REKOR_BASE_URL,
79
+ PublicSigstoreRekorClient,
80
+ buildIntotoEntryBody,
81
+ type InclusionProof,
82
+ type PublicSigstoreRekorClientOptions,
83
+ type RekorClient,
84
+ type RekorEntryResponse,
85
+ } from './log/rekor.js';
86
+
87
+ export {
88
+ submitBinding,
89
+ type SubmitBindingDeps,
90
+ type SubmitBindingResult,
91
+ } from './submit.js';
92
+
93
+ export {
94
+ hashLeaf,
95
+ hashPair,
96
+ verifyConsistency,
97
+ verifyInclusion,
98
+ } from './merkle.js';
99
+
100
+ export {
101
+ packSdkPayload,
102
+ unpackSdkPayload,
103
+ type PackedSdkPayload,
104
+ type SdkPayload,
105
+ } from './sdk-payload.js';
106
+
107
+ export {
108
+ renderBadgeMarkdown,
109
+ renderBadgeSvg,
110
+ type BadgeInput,
111
+ } from './badge.js';
112
+
113
+ export {
114
+ verifyBundle,
115
+ type BundleVerifyResult,
116
+ type CheckResult,
117
+ type SdkVerifier,
118
+ type SdkVerifyInput,
119
+ type SdkVerifyResult,
120
+ type VerifyBundleDeps,
121
+ } from './verifier.js';