@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/bind.ts CHANGED
@@ -1,137 +1,128 @@
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
- }
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 { canonicalize, canonicalSha256Hex } from './canonical.js';
16
+ import { base64ToBytes, sha256Hex } from './encoding.js';
17
+ import { PassportsignError } from './errors.js';
18
+ import { checkGistControl, type GistEvidence } from './github.js';
19
+ import { buildStatement, type PassportsignStatement } from './statement.js';
20
+
21
+ export interface PrepareBindingInput {
22
+ github_username: string;
23
+ /** Base64-encoded zkPassport proof blob (the SDK callback's serialized output). */
24
+ proof_blob_b64: string;
25
+ /** From the SDK's `onResult` callback. Deterministic for this passport + scope. */
26
+ unique_identifier: string;
27
+ /** ICAO 3-letter code if disclosed, else null. Pass through as-returned by SDK. */
28
+ issuing_country: string | null;
29
+ /** Per-binding nonce that was placed in the user's gist for the control check. */
30
+ nonce: string;
31
+ /** Full scope string (e.g. "passportsign.dev:nationality-disclose:1"). */
32
+ scope: string;
33
+ /** Version string from the zkPassport SDK that produced the proof. */
34
+ zkpassport_sdk_version: string;
35
+ /** Optional GitHub token for the gist check (rate limits only — no special access). */
36
+ github_token?: string;
37
+ }
38
+
39
+ export interface PrepareBindingInit {
40
+ /** Init timestamp — gist `updated_at` must be on or after this. */
41
+ issuedAt: Date;
42
+ /** Filename to look for in the user's gists. Defaults to `passportsign.txt`. */
43
+ gistFilename?: string;
44
+ }
45
+
46
+ export interface PrepareBindingDeps {
47
+ /** Inject for tests. Defaults to {@link checkGistControl}. */
48
+ github?: typeof checkGistControl;
49
+ /** Inject a fetch (forwarded to github). */
50
+ fetch?: typeof fetch;
51
+ }
52
+
53
+ export interface PreparedBinding {
54
+ statement: PassportsignStatement;
55
+ statement_canonical: Uint8Array;
56
+ statement_sha256_hex: string;
57
+ proof_blob_b64: string;
58
+ proof_blob_sha256_hex: string;
59
+ gist: GistEvidence;
60
+ }
61
+
62
+ const DEFAULT_GIST_FILENAME = 'passportsign.txt';
63
+
64
+ /**
65
+ * Run the GitHub gist control check, then build the in-toto statement and
66
+ * compute canonical bytes + hashes for the Rekor handoff.
67
+ *
68
+ * Throws {@link PassportsignError} with the matching §4 code on any
69
+ * failure path.
70
+ */
71
+ export async function prepareBinding(
72
+ input: PrepareBindingInput,
73
+ init: PrepareBindingInit,
74
+ deps: PrepareBindingDeps = {},
75
+ ): Promise<PreparedBinding> {
76
+ const githubImpl = deps.github ?? checkGistControl;
77
+
78
+ // 1. GitHub gist control check (throws PassportsignError on any §4 path).
79
+ const gist = await githubImpl({
80
+ username: input.github_username,
81
+ expected_filename: init.gistFilename ?? DEFAULT_GIST_FILENAME,
82
+ expected_content: input.nonce,
83
+ not_before: init.issuedAt,
84
+ ...(input.github_token ? { token: input.github_token } : {}),
85
+ ...(deps.fetch ? { fetch: deps.fetch } : {}),
86
+ });
87
+
88
+ // 2. Derive proof_blob sha256 from the base64 input.
89
+ let proofBytes: Uint8Array;
90
+ try {
91
+ proofBytes = base64ToBytes(input.proof_blob_b64);
92
+ } catch (err) {
93
+ throw new PassportsignError(
94
+ 'proof_invalid',
95
+ `proof_blob_b64 is not valid base64`,
96
+ err,
97
+ );
98
+ }
99
+ if (proofBytes.length === 0) {
100
+ throw new PassportsignError('proof_invalid', 'proof_blob_b64 decoded to zero bytes');
101
+ }
102
+ const proof_blob_sha256_hex = sha256Hex(proofBytes);
103
+
104
+ // 3. Build the in-toto statement (enforces hex / non-empty invariants).
105
+ const statement = buildStatement({
106
+ github_username: input.github_username,
107
+ unique_identifier: input.unique_identifier,
108
+ issuing_country: input.issuing_country,
109
+ proof_blob_sha256: proof_blob_sha256_hex,
110
+ gist_url: gist.url,
111
+ gist_content_sha256: gist.content_sha256,
112
+ scope: input.scope,
113
+ zkpassport_sdk_version: input.zkpassport_sdk_version,
114
+ });
115
+
116
+ // 4. Canonical bytes + sha256 for the Rekor entry.
117
+ const statement_canonical = canonicalize(statement);
118
+ const statement_sha256_hex = canonicalSha256Hex(statement);
119
+
120
+ return {
121
+ statement,
122
+ statement_canonical,
123
+ statement_sha256_hex,
124
+ proof_blob_b64: input.proof_blob_b64,
125
+ proof_blob_sha256_hex,
126
+ gist,
127
+ };
128
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Node-only file I/O for `binding.passportsign.json` bundles. Kept out
3
+ * of `bundle.ts` so the validation logic stays runtime-neutral (the
4
+ * `./web` subpath exports validation but not these).
5
+ */
6
+
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+
9
+ import {
10
+ BundleValidationError,
11
+ validateBundle,
12
+ type PassportsignBundle,
13
+ } from './bundle.js';
14
+
15
+ /**
16
+ * Read and validate a `binding.passportsign.json` file. Throws on
17
+ * invalid JSON or schema violations.
18
+ */
19
+ export function readBundle(path: string): PassportsignBundle {
20
+ const raw = readFileSync(path, 'utf8');
21
+ let parsed: unknown;
22
+ try {
23
+ parsed = JSON.parse(raw);
24
+ } catch (err) {
25
+ throw new BundleValidationError(
26
+ '$',
27
+ `invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
28
+ );
29
+ }
30
+ validateBundle(parsed);
31
+ return parsed;
32
+ }
33
+
34
+ /**
35
+ * Validate and write a `binding.passportsign.json` file (pretty-printed).
36
+ */
37
+ export function writeBundle(path: string, bundle: PassportsignBundle): void {
38
+ validateBundle(bundle);
39
+ writeFileSync(path, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
40
+ }
package/src/bundle.ts CHANGED
@@ -1,127 +1,138 @@
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
- }
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 { bytesToHex } from './encoding.js';
17
+ import { type RekorEntryResponse } from './log/rekor.js';
18
+
19
+ export const BUNDLE_FORMAT_VERSION = 1 as const;
20
+
21
+ export interface RekorBundleFields {
22
+ log_entry_hash: string;
23
+ inclusion_proof: unknown;
24
+ log_root_at_submission: string;
25
+ }
26
+
27
+ export interface PassportsignBundle {
28
+ bundle_format_version: typeof BUNDLE_FORMAT_VERSION;
29
+ /** Hex-encoded canonical JCS bytes of the in-toto statement. */
30
+ statement: string;
31
+ /** Base64-encoded zkPassport proof blob. */
32
+ proof_blob: string;
33
+ rekor: RekorBundleFields;
34
+ }
35
+
36
+ const HEX_EVEN = /^(?:[0-9a-f]{2})+$/;
37
+ // Standard base64: A-Z, a-z, 0-9, +, /, with 0-2 trailing '=' for padding.
38
+ // Length must be multiple of 4.
39
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
40
+
41
+ export class BundleValidationError extends Error {
42
+ constructor(
43
+ readonly path: string,
44
+ message: string,
45
+ ) {
46
+ super(`${path}: ${message}`);
47
+ this.name = 'BundleValidationError';
48
+ }
49
+ }
50
+
51
+ function fail(path: string, message: string): never {
52
+ throw new BundleValidationError(path, message);
53
+ }
54
+
55
+ function isObject(v: unknown): v is Record<string, unknown> {
56
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
57
+ }
58
+
59
+ /**
60
+ * Type-guard validator for `PassportsignBundle`. Throws
61
+ * `BundleValidationError` with a structured path on the first issue.
62
+ */
63
+ export function validateBundle(value: unknown): asserts value is PassportsignBundle {
64
+ if (!isObject(value)) fail('$', 'bundle must be a JSON object');
65
+
66
+ if (value['bundle_format_version'] !== BUNDLE_FORMAT_VERSION) {
67
+ fail(
68
+ '$.bundle_format_version',
69
+ `expected ${BUNDLE_FORMAT_VERSION}, got ${JSON.stringify(value['bundle_format_version'])}`,
70
+ );
71
+ }
72
+
73
+ const statement = value['statement'];
74
+ if (typeof statement !== 'string') fail('$.statement', 'must be a string');
75
+ if (!HEX_EVEN.test(statement)) {
76
+ fail('$.statement', 'must be lowercase even-length hex (canonical JCS bytes)');
77
+ }
78
+
79
+ const proofBlob = value['proof_blob'];
80
+ if (typeof proofBlob !== 'string') fail('$.proof_blob', 'must be a string');
81
+ if (!BASE64.test(proofBlob)) {
82
+ fail('$.proof_blob', 'must be standard base64 (A-Z, a-z, 0-9, +, /, = padding)');
83
+ }
84
+
85
+ const rekor = value['rekor'];
86
+ if (!isObject(rekor)) fail('$.rekor', 'must be an object');
87
+
88
+ const logEntryHash = rekor['log_entry_hash'];
89
+ if (typeof logEntryHash !== 'string' || logEntryHash.length === 0) {
90
+ fail('$.rekor.log_entry_hash', 'must be a non-empty string');
91
+ }
92
+
93
+ if (!('inclusion_proof' in rekor)) {
94
+ fail('$.rekor.inclusion_proof', 'is required (shape pinned in Day 5)');
95
+ }
96
+
97
+ const logRootAtSubmission = rekor['log_root_at_submission'];
98
+ if (typeof logRootAtSubmission !== 'string' || logRootAtSubmission.length === 0) {
99
+ fail('$.rekor.log_root_at_submission', 'must be a non-empty string');
100
+ }
101
+ }
102
+
103
+ // readBundle / writeBundle live in `bundle-fs.ts` (node:fs) so this
104
+ // module stays runtime-neutral; the main index re-exports both.
105
+
106
+ /**
107
+ * What bundle assembly needs from a prepared statement — satisfied by
108
+ * both `PreparedBinding` and `PreparedRevocation`. The statement kind
109
+ * doesn't matter; Rekor sees canonical bytes either way.
110
+ */
111
+ export interface SubmittableStatement {
112
+ statement_canonical: Uint8Array;
113
+ proof_blob_b64: string;
114
+ }
115
+
116
+ /**
117
+ * Assemble (and validate) the portable bundle from a prepared
118
+ * statement and the Rekor entry it produced. Pure — used by the node
119
+ * `submitBinding` path and by runtimes that sign with
120
+ * `signEnvelopeWeb` and submit through the Rekor client themselves.
121
+ */
122
+ export function assembleBundle(
123
+ prepared: SubmittableStatement,
124
+ rekorEntry: RekorEntryResponse,
125
+ ): PassportsignBundle {
126
+ const bundle: PassportsignBundle = {
127
+ bundle_format_version: BUNDLE_FORMAT_VERSION,
128
+ statement: bytesToHex(prepared.statement_canonical),
129
+ proof_blob: prepared.proof_blob_b64,
130
+ rekor: {
131
+ log_entry_hash: rekorEntry.uuid,
132
+ inclusion_proof: rekorEntry.verification.inclusionProof,
133
+ log_root_at_submission: rekorEntry.verification.inclusionProof.rootHash,
134
+ },
135
+ };
136
+ validateBundle(bundle);
137
+ return bundle;
138
+ }
package/src/canonical.ts CHANGED
@@ -1,33 +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
- }
1
+ import canonify from '@truestamp/canonify';
2
+
3
+ import { sha256Hex, utf8ToBytes } from './encoding.js';
4
+
5
+ /**
6
+ * RFC 8785 JCS-canonical UTF-8 bytes for a JSON-serializable value.
7
+ *
8
+ * Wraps `@truestamp/canonify` (pinned at exact 1.0.3) and UTF-8 encodes the
9
+ * resulting string. The fixture-pinned drift test in
10
+ * `test/canonical.test.ts` guards against silent behavior changes in the
11
+ * underlying library JCS implementations have had subtle bugs and this
12
+ * function's output is the most security-critical artifact in the repo.
13
+ *
14
+ * Throws `TypeError` if the value cannot be canonicalized (e.g. undefined,
15
+ * cycles, non-JSON-serializable types).
16
+ */
17
+ export function canonicalize(value: unknown): Uint8Array {
18
+ const canonical = canonify(value);
19
+ if (canonical === undefined) {
20
+ throw new TypeError(
21
+ 'canonicalize: value cannot be JCS-canonicalized (undefined / cycle / non-JSON)',
22
+ );
23
+ }
24
+ return utf8ToBytes(canonical);
25
+ }
26
+
27
+ /**
28
+ * Lowercase-hex SHA-256 of `canonicalize(value)`. Used to derive the
29
+ * Rekor entry hash for the in-toto statement.
30
+ */
31
+ export function canonicalSha256Hex(value: unknown): string {
32
+ return sha256Hex(canonicalize(value));
33
+ }