@tinycloud/node-sdk 3.9.0-beta.3 → 3.9.0-beta.5

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.
@@ -1,10 +1,52 @@
1
1
  // src/credentials/interpreter.ts
2
2
  import {
3
- CredentialError,
3
+ CredentialError as CredentialError2,
4
4
  encodeBase64Url,
5
5
  holderBindingSigningBytes,
6
6
  validateCredentialHolderBinding
7
7
  } from "@tinycloud/sdk-core";
8
+
9
+ // src/credentials/held-binding.ts
10
+ import { canonicalDigest, CredentialError } from "@tinycloud/sdk-core";
11
+
12
+ // src/credentials/pins.ts
13
+ import { ACCOUNT_HOLDER_BINDING_DOMAIN } from "@tinycloud/sdk-core";
14
+ var EMAIL_PROOF_PINS = Object.freeze({
15
+ witnessOrigin: "https://witness.credentials.org",
16
+ issuerDid: "did:web:issuer.credentials.org",
17
+ issuerKid: "did:web:issuer.credentials.org#controller",
18
+ issuerX: "eEJI4xEobto4HtQ7Pg9R1vBOwcpfGRTlXDG5QfqLnGQ",
19
+ descriptorDigest: "1tg-qphmKBVtNwzVg9xyz-xxqt_xtMXAsQyXw46m8S0"
20
+ });
21
+ var HELD_ACCOUNT_BINDING_PINS = Object.freeze({
22
+ type: ACCOUNT_HOLDER_BINDING_DOMAIN,
23
+ protocol: "tinycloud.credentials/acquisition/v1",
24
+ profile: "tinycloud.email-held/v1",
25
+ profileVersion: 1,
26
+ descriptorDigest: "dc3_DuvzipXwYkTfjKqTaK1c3VS0YSPnaBsYSQriC0w",
27
+ issuer: EMAIL_PROOF_PINS.issuerDid,
28
+ issuerKid: EMAIL_PROOF_PINS.issuerKid,
29
+ audience: "tinycloud://credentials",
30
+ openerOrigin: "https://localhost",
31
+ completionOrigin: "https://localhost",
32
+ completionContext: "sdk-acquisition",
33
+ credentialLifetimeSeconds: 31536e3
34
+ });
35
+
36
+ // src/credentials/held-binding.ts
37
+ var REQUEST_TTL_MS = 6e5;
38
+ var substituted = () => new CredentialError("REQUEST_SUBSTITUTED", "Holder binding was substituted");
39
+ async function assertHeldBinding(binding, expected) {
40
+ const { descriptor } = expected;
41
+ if (descriptor.holderBinding.alg !== "EIP191" || binding.type !== descriptor.holderBinding.domain || binding.requestId !== expected.requestId || binding.descriptorDigest !== expected.descriptorDigest || binding.requirementDigest !== expected.requirementDigest || binding.holderDid !== expected.holderDid || binding.issuer !== descriptor.issuer.did || binding.issuerKid !== descriptor.issuer.kid || binding.profile !== descriptor.profile || binding.openerOrigin !== expected.openerOrigin || binding.completionOrigin !== expected.openerOrigin || binding.audience !== HELD_ACCOUNT_BINDING_PINS.audience || binding.completionContext !== HELD_ACCOUNT_BINDING_PINS.completionContext || // The static fields, against the one frozen object the request body and the account signer also use (A2.4).
42
+ binding.type !== HELD_ACCOUNT_BINDING_PINS.type || binding.protocol !== HELD_ACCOUNT_BINDING_PINS.protocol || binding.profile !== HELD_ACCOUNT_BINDING_PINS.profile || binding.profileVersion !== HELD_ACCOUNT_BINDING_PINS.profileVersion || binding.descriptorDigest !== HELD_ACCOUNT_BINDING_PINS.descriptorDigest || binding.issuer !== HELD_ACCOUNT_BINDING_PINS.issuer || binding.issuerKid !== HELD_ACCOUNT_BINDING_PINS.issuerKid || binding.openerOrigin !== HELD_ACCOUNT_BINDING_PINS.openerOrigin || binding.completionOrigin !== HELD_ACCOUNT_BINDING_PINS.completionOrigin || binding.credentialLifetimeSeconds !== HELD_ACCOUNT_BINDING_PINS.credentialLifetimeSeconds) throw substituted();
43
+ if (expected.challengeNonce === void 0 || binding.challengeNonce !== expected.challengeNonce) throw substituted();
44
+ if (binding.normalizedClaimsDigest !== await canonicalDigest(expected.requirement.claims)) throw substituted();
45
+ const expires = Date.parse(binding.expiresAt);
46
+ if (expected.serverExpiresAt === void 0 || expires !== Date.parse(expected.serverExpiresAt) || Date.parse(binding.issuedAt) !== expires - REQUEST_TTL_MS) throw substituted();
47
+ }
48
+
49
+ // src/credentials/interpreter.ts
8
50
  var delay = (ms, signal) => new Promise((resolve, reject) => {
9
51
  const timer = setTimeout(resolve, ms);
10
52
  signal?.addEventListener("abort", () => {
@@ -17,18 +59,36 @@ function mailboxSubject(requirement) {
17
59
  return typeof email === "string" && email.length > 0 ? Object.freeze({ kind: "email", value: email }) : void 0;
18
60
  }
19
61
  async function interpretCredentialFlow(input) {
62
+ const held = input.descriptor.subjectRelationship === "account_is_subject";
63
+ if (held && input.allowAccountSubject !== true) throw new CredentialError2("UNSUPPORTED_PROFILE", "The held credential profile requires an explicit opt-in");
20
64
  const steps = new Map(input.descriptor.steps.map((step) => [step.type, step]));
21
65
  const completed = /* @__PURE__ */ new Set();
22
66
  let lastTransition = "";
67
+ const checkBinding = async (value) => {
68
+ const binding = validateCredentialHolderBinding(value);
69
+ if (binding.requestId !== input.requestId || binding.descriptorDigest !== input.descriptorDigest || binding.requirementDigest !== input.requirementDigest || binding.holderDid !== input.holderDid || binding.issuer !== input.descriptor.issuer.did || binding.issuerKid !== input.descriptor.issuer.kid || binding.profile !== input.descriptor.profile || binding.openerOrigin !== input.openerOrigin || binding.completionOrigin !== input.openerOrigin || binding.audience !== "tinycloud://credentials") throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was substituted");
70
+ const alg = input.descriptor.holderBinding.alg;
71
+ if (binding.type !== input.descriptor.holderBinding.domain || alg === "EIP191" && !input.transport.challengeNonce) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was substituted");
72
+ if (alg === "EIP191") await assertHeldBinding(binding, { descriptor: input.descriptor, requirement: input.requirement, requestId: input.requestId, descriptorDigest: input.descriptorDigest, requirementDigest: input.requirementDigest, holderDid: input.holderDid, openerOrigin: input.openerOrigin, challengeNonce: input.transport.challengeNonce(input.requestId), serverExpiresAt: input.expiresAt });
73
+ return binding;
74
+ };
75
+ const ensureHeldBinding = async () => {
76
+ if (!held || input.transport.signedBinding?.(input.requestId) !== void 0) return;
77
+ await checkBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
78
+ };
23
79
  for (; ; ) {
24
80
  input.signal?.throwIfAborted();
25
81
  const state = await input.transport.state(input.requestId, input.verifier, input.signal);
26
- if (state.requestId !== input.requestId) throw new CredentialError("REQUEST_SUBSTITUTED", "Credential request identity changed");
27
- if (state.state === "expired") throw new CredentialError("REQUEST_EXPIRED", "Credential request expired", { state: state.state, correlationId: state.correlationId });
28
- if (state.state === "canceled") throw new CredentialError("CANCELED", "Credential request was canceled", { state: state.state, correlationId: state.correlationId });
29
- if (state.state === "issuer_unready") throw new CredentialError("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: state.retryAfterMs, state: state.state, correlationId: state.correlationId });
30
- if (state.state === "complete") return;
82
+ if (state.requestId !== input.requestId) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential request identity changed");
83
+ if (state.state === "expired") throw new CredentialError2("REQUEST_EXPIRED", "Credential request expired", { state: state.state, correlationId: state.correlationId });
84
+ if (state.state === "canceled") throw new CredentialError2("CANCELED", "Credential request was canceled", { state: state.state, correlationId: state.correlationId });
85
+ if (state.state === "issuer_unready") throw new CredentialError2("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: state.retryAfterMs, state: state.state, correlationId: state.correlationId });
86
+ if (state.state === "complete") {
87
+ await ensureHeldBinding();
88
+ return;
89
+ }
31
90
  if (state.state === "ready_to_issue" || state.state === "issued") {
91
+ await ensureHeldBinding();
32
92
  if (state.state === "ready_to_issue" && !completed.has("issue")) {
33
93
  await input.transport.issue(input.requestId, input.verifier, input.signal);
34
94
  completed.add("issue");
@@ -41,7 +101,7 @@ async function interpretCredentialFlow(input) {
41
101
  continue;
42
102
  }
43
103
  const declared = steps.get(next.type);
44
- if (next.id !== next.type || !declared || declared.type !== next.type || declared.version !== next.version) throw new CredentialError("REQUEST_SUBSTITUTED", "Server selected an undeclared credential step");
104
+ if (next.id !== next.type || !declared || declared.type !== next.type || declared.version !== next.version) throw new CredentialError2("REQUEST_SUBSTITUTED", "Server selected an undeclared credential step");
45
105
  if (completed.has(next.id) || state.transitionId === lastTransition) {
46
106
  await (input.onWait?.() ?? delay(state.retryAfterMs ?? 50, input.signal));
47
107
  continue;
@@ -49,21 +109,22 @@ async function interpretCredentialFlow(input) {
49
109
  lastTransition = state.transitionId;
50
110
  if (next.type === "holder_signature") {
51
111
  input.onProgress?.({ state: "signing", stepId: next.id, correlationId: state.correlationId });
52
- const binding = validateCredentialHolderBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
53
- if (binding.requestId !== input.requestId || binding.descriptorDigest !== input.descriptorDigest || binding.requirementDigest !== input.requirementDigest || binding.holderDid !== input.holderDid || binding.issuer !== input.descriptor.issuer.did || binding.issuerKid !== input.descriptor.issuer.kid || binding.profile !== input.descriptor.profile || binding.openerOrigin !== input.openerOrigin || binding.completionOrigin !== input.openerOrigin || binding.audience !== "tinycloud://credentials") throw new CredentialError("REQUEST_SUBSTITUTED", "Holder binding was substituted");
112
+ const binding = await checkBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
113
+ const alg = input.descriptor.holderBinding.alg;
114
+ if (alg === "EIP191" !== (input.signing.holderKey?.alg === "EIP191")) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was substituted");
54
115
  const bytes = holderBindingSigningBytes(binding);
55
116
  let signature = await input.signing.autoSign?.(binding, bytes, input.signal);
56
117
  if (signature === void 0) {
57
- if (!input.signing.requestApproval) throw new CredentialError("SIGNATURE_REJECTED", "OpenKey approval is required");
118
+ if (!input.signing.requestApproval) throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey approval is required");
58
119
  try {
59
120
  signature = await input.signing.requestApproval(binding, bytes, input.signal);
60
121
  } catch (cause) {
61
- if (cause instanceof CredentialError) throw cause;
62
- throw new CredentialError("SIGNATURE_REJECTED", "OpenKey approval was rejected", { cause });
122
+ if (cause instanceof CredentialError2) throw cause;
123
+ throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey approval was rejected", { cause });
63
124
  }
64
125
  }
65
- if (!(signature instanceof Uint8Array) || signature.length === 0) throw new CredentialError("SIGNATURE_REJECTED", "OpenKey signature is invalid");
66
- await input.transport.submitHolderSignature(input.requestId, input.verifier, encodeBase64Url(signature), input.signal, input.signing.holderKey);
126
+ if (!(signature instanceof Uint8Array) || signature.length === 0) throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey signature is invalid");
127
+ await input.transport.submitHolderSignature(input.requestId, input.verifier, alg === "EIP191" ? `0x${Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("")}` : encodeBase64Url(signature), input.signal, input.signing.holderKey);
67
128
  } else {
68
129
  input.onProgress?.({ state: next.type === "collect_input" ? "collecting" : "proving", stepId: next.id, correlationId: state.correlationId });
69
130
  const handler = input.handlers?.[next.type];
@@ -97,9 +158,9 @@ async function interpretCredentialFlow(input) {
97
158
  await input.transport.submitStep(input.requestId, input.verifier, next.id, proof, input.signal);
98
159
  break;
99
160
  } catch (cause) {
100
- if (!(cause instanceof CredentialError) || cause.code !== "PROOF_REJECTED") throw cause;
161
+ if (!(cause instanceof CredentialError2) || cause.code !== "PROOF_REJECTED") throw cause;
101
162
  const attemptsRemaining = input.descriptor.lifecycle.maxProofAttempts - attempt;
102
- if (next.type !== "mailbox_otp" || attemptsRemaining <= 0) throw new CredentialError("VERIFICATION_FAILED", "The proof was not accepted", { state: attemptsRemaining <= 0 ? "proof_attempts_exhausted" : "proof_rejected", cause });
163
+ if (next.type !== "mailbox_otp" || attemptsRemaining <= 0) throw new CredentialError2("VERIFICATION_FAILED", "The proof was not accepted", { state: attemptsRemaining <= 0 ? "proof_attempts_exhausted" : "proof_rejected", cause });
103
164
  feedback = { kind: "rejected", attemptsRemaining };
104
165
  }
105
166
  }
@@ -111,8 +172,8 @@ async function interpretCredentialFlow(input) {
111
172
 
112
173
  // src/credentials/transport.ts
113
174
  import {
114
- CredentialError as CredentialError2,
115
- canonicalDigest,
175
+ CredentialError as CredentialError3,
176
+ canonicalDigest as canonicalDigest2,
116
177
  credentialEndpointPath,
117
178
  decodeBase64Url,
118
179
  encodeBase64Url as encodeBase64Url2,
@@ -121,25 +182,25 @@ import {
121
182
  validateCredentialHolderBinding as validateCredentialHolderBinding2
122
183
  } from "@tinycloud/sdk-core";
123
184
  function object(value, label) {
124
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CredentialError2("VERIFICATION_FAILED", `${label} is invalid`);
185
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CredentialError3("VERIFICATION_FAILED", `${label} is invalid`);
125
186
  return value;
126
187
  }
127
188
  function opaque(value, label) {
128
- if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) throw new CredentialError2("REQUEST_SUBSTITUTED", `${label} is invalid`);
189
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) throw new CredentialError3("REQUEST_SUBSTITUTED", `${label} is invalid`);
129
190
  return value;
130
191
  }
131
192
  function timestamp(value, label) {
132
- if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new CredentialError2("VERIFICATION_FAILED", `${label} is invalid`);
193
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new CredentialError3("VERIFICATION_FAILED", `${label} is invalid`);
133
194
  return new Date(value).toISOString();
134
195
  }
135
196
  function jwtPayload(credential) {
136
197
  const part = credential.split("~", 1)[0]?.split(".")[1];
137
- if (!part) throw new CredentialError2("VERIFICATION_FAILED", "Issued SD-JWT is invalid");
198
+ if (!part) throw new CredentialError3("VERIFICATION_FAILED", "Issued SD-JWT is invalid");
138
199
  try {
139
200
  return object(JSON.parse(new TextDecoder().decode(decodeBase64Url(part))), "SD-JWT payload");
140
201
  } catch (cause) {
141
- if (cause instanceof CredentialError2) throw cause;
142
- throw new CredentialError2("VERIFICATION_FAILED", "Issued SD-JWT is invalid", { cause });
202
+ if (cause instanceof CredentialError3) throw cause;
203
+ throw new CredentialError3("VERIFICATION_FAILED", "Issued SD-JWT is invalid", { cause });
143
204
  }
144
205
  }
145
206
  var SERVER_CODES = /* @__PURE__ */ new Set(["REQUEST_EXPIRED", "ISSUER_UNREADY", "UNSUPPORTED_PROFILE", "UNSUPPORTED_VERSION", "SIGNATURE_REJECTED"]);
@@ -148,12 +209,13 @@ function serverError(value, response, honorServerRecoverable) {
148
209
  const body = value;
149
210
  const keys = Object.keys(body).sort();
150
211
  if (keys.join(",") !== "code,correlationId,recoverable,state,type" || body.type !== "tinycloud.credentials/error/v1" || typeof body.code !== "string" || typeof body.recoverable !== "boolean" || typeof body.state !== "string" || typeof body.correlationId !== "string") return void 0;
151
- if (body.code === "PROOF_REJECTED") return body.recoverable ? new CredentialError2("PROOF_REJECTED", "The proof was not accepted", { state: body.state, correlationId: body.correlationId }) : new CredentialError2("VERIFICATION_FAILED", "The proof attempt limit was reached", { state: "proof_attempts_exhausted", correlationId: body.correlationId });
152
- if (body.code === "RATE_LIMITED") return new CredentialError2("ISSUER_UNREADY", "Too many verification codes were requested", { state: "rate_limited", correlationId: body.correlationId, retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0, recoverable: honorServerRecoverable ? body.recoverable : void 0 });
153
- if (body.code === "CHALLENGE_EXPIRED") return new CredentialError2("REQUEST_EXPIRED", "The credential challenge expired", { state: body.state, correlationId: body.correlationId, recoverable: honorServerRecoverable ? body.recoverable : void 0 });
212
+ if (body.code === "PROOF_REJECTED") return body.recoverable ? new CredentialError3("PROOF_REJECTED", "The proof was not accepted", { state: body.state, correlationId: body.correlationId }) : new CredentialError3("VERIFICATION_FAILED", "The proof attempt limit was reached", { state: "proof_attempts_exhausted", correlationId: body.correlationId });
213
+ if (body.code === "RATE_LIMITED") return new CredentialError3("ISSUER_UNREADY", "Too many verification codes were requested", { state: "rate_limited", correlationId: body.correlationId, retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0, recoverable: honorServerRecoverable ? body.recoverable : void 0 });
214
+ if (body.code === "CHALLENGE_EXPIRED") return new CredentialError3("REQUEST_EXPIRED", "The credential challenge expired", { state: body.state, correlationId: body.correlationId, recoverable: honorServerRecoverable ? body.recoverable : void 0 });
215
+ if (body.code === "DESCRIPTOR_MISMATCH") body.code = "UNSUPPORTED_PROFILE";
154
216
  if (!SERVER_CODES.has(body.code)) return void 0;
155
217
  const code = body.code;
156
- return new CredentialError2(code, `OpenCredentials rejected the request: ${code}`, {
218
+ return new CredentialError3(code, `OpenCredentials rejected the request: ${code}`, {
157
219
  state: body.state,
158
220
  correlationId: body.correlationId,
159
221
  retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0,
@@ -161,12 +223,17 @@ function serverError(value, response, honorServerRecoverable) {
161
223
  });
162
224
  }
163
225
  var OpenCredentialsHttpTransport = class {
226
+ /**
227
+ * `allowAccountSubject` is passed only by the email-proof functions, after the per-call held opt-in was accepted. Without it an account-bound
228
+ * (held) descriptor is refused here, before any request (TC-2254 A2.5).
229
+ */
164
230
  constructor(descriptor, fetchFn = globalThis.fetch.bind(globalThis), options = {}) {
165
231
  this.descriptor = descriptor;
166
232
  this.fetchFn = fetchFn;
167
233
  this.options = options;
168
234
  this.nonces = /* @__PURE__ */ new Map();
169
235
  this.bindings = /* @__PURE__ */ new Map();
236
+ if (descriptor.subjectRelationship === "account_is_subject" && options.allowAccountSubject !== true) throw new CredentialError3("UNSUPPORTED_PROFILE", "The held credential profile requires an explicit opt-in");
170
237
  }
171
238
  url(endpoint, id) {
172
239
  return new URL(credentialEndpointPath(endpoint, id), this.descriptor.issuer.origin).href;
@@ -177,8 +244,8 @@ var OpenCredentialsHttpTransport = class {
177
244
  const init = { method: input.method ?? "GET", credentials: "omit", cache: "no-store", redirect: "error", referrerPolicy: "no-referrer", signal: input.signal, headers: { accept: "application/json", ...input.body === void 0 ? {} : { "content-type": "application/json" }, ...input.verifier === void 0 ? {} : { authorization: `Bearer ${input.verifier}` } }, ...input.body === void 0 ? {} : { body: JSON.stringify(input.body) } };
178
245
  response = await this.fetchFn(this.url(endpoint, input.id), init);
179
246
  } catch (cause) {
180
- if (cause instanceof DOMException && cause.name === "AbortError") throw new CredentialError2("CANCELED", "Credential acquisition was canceled", { cause });
181
- throw new CredentialError2("OFFLINE", "OpenCredentials is unavailable", { cause });
247
+ if (cause instanceof DOMException && cause.name === "AbortError") throw new CredentialError3("CANCELED", "Credential acquisition was canceled", { cause });
248
+ throw new CredentialError3("OFFLINE", "OpenCredentials is unavailable", { cause });
182
249
  }
183
250
  if (!response.ok) {
184
251
  let parsed;
@@ -189,95 +256,112 @@ var OpenCredentialsHttpTransport = class {
189
256
  }
190
257
  const typed = serverError(parsed, response, this.options.honorServerRecoverable === true);
191
258
  if (typed) throw typed;
192
- if (response.status === 410) throw new CredentialError2("REQUEST_EXPIRED", "Credential request expired");
193
- if (response.status === 409) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential request was replayed or substituted");
194
- if (response.status === 503) throw new CredentialError2("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0 });
195
- throw new CredentialError2("VERIFICATION_FAILED", "OpenCredentials rejected the request");
259
+ if (response.status === 410) throw new CredentialError3("REQUEST_EXPIRED", "Credential request expired");
260
+ if (response.status === 409) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential request was replayed or substituted");
261
+ if (response.status === 503) throw new CredentialError3("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0 });
262
+ throw new CredentialError3("VERIFICATION_FAILED", "OpenCredentials rejected the request");
196
263
  }
197
264
  if (response.status === 204) return void 0;
198
265
  try {
199
266
  return await response.json();
200
267
  } catch (cause) {
201
- throw new CredentialError2("VERIFICATION_FAILED", "OpenCredentials response is invalid", { cause });
268
+ throw new CredentialError3("VERIFICATION_FAILED", "OpenCredentials response is invalid", { cause });
202
269
  }
203
270
  }
204
271
  async create(input) {
205
- const body = object(await this.request("request", { method: "POST", body: { protocol: input.descriptor.protocol, profile: input.descriptor.profile, profileVersion: input.descriptor.profileVersion, descriptorDigest: input.descriptorDigest, requirementDigest: input.requirementDigest, holderDid: input.holderDid, inputs: input.inputs ?? input.requirement.claims, audience: "tinycloud://credentials", openerOrigin: input.openerOrigin, completionOrigin: input.openerOrigin, completionContext: "sdk-acquisition", completionVerifierChallenge: input.completionVerifierChallenge }, signal: input.signal }), "create response");
206
- if (body.type !== "tinycloud.credentials/acquisition-request/v1" || body.protocol !== input.descriptor.protocol || body.next !== "challenge" || body.endpoint !== "challenge") throw new CredentialError2("VERIFICATION_FAILED", "create response is invalid");
272
+ const body = object(await this.request("request", { method: "POST", body: { protocol: input.descriptor.protocol, profile: input.descriptor.profile, profileVersion: input.descriptor.profileVersion, descriptorDigest: input.descriptorDigest, requirementDigest: input.requirementDigest, holderDid: input.holderDid, inputs: input.inputs ?? input.requirement.claims, audience: HELD_ACCOUNT_BINDING_PINS.audience, openerOrigin: input.openerOrigin, completionOrigin: input.openerOrigin, completionContext: HELD_ACCOUNT_BINDING_PINS.completionContext, completionVerifierChallenge: input.completionVerifierChallenge }, signal: input.signal }), "create response");
273
+ if (body.type !== "tinycloud.credentials/acquisition-request/v1" || body.protocol !== input.descriptor.protocol || body.next !== "challenge" || body.endpoint !== "challenge") throw new CredentialError3("VERIFICATION_FAILED", "create response is invalid");
207
274
  const requestId = opaque(body.requestId, "requestId");
208
275
  return { requestId, locator: requestId, expiresAt: timestamp(body.expiresAt, "expiresAt"), correlationId: requestId };
209
276
  }
277
+ /** The nonce stored from R3 or R2 for this request. Held acquisition compares the signed binding with it. */
278
+ challengeNonce(id) {
279
+ return this.nonces.get(id);
280
+ }
281
+ /** The binding fetched and validated for this request, as the account signed it. */
282
+ signedBinding(id) {
283
+ return this.bindings.get(id);
284
+ }
210
285
  proofStep() {
211
286
  return this.descriptor.steps.some((step) => step.type === "mailbox_otp") ? "mailbox_otp" : "collect_input";
212
287
  }
213
288
  async state(id, verifier, signal) {
214
289
  const raw = object(await this.request("state", { id, verifier, signal }), "state response");
215
- if (raw.type !== "tinycloud.credentials/acquisition-state/v1" || raw.requestId !== id || raw.profile !== this.descriptor.profile || raw.profileVersion !== 1) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential state response is substituted");
290
+ if (raw.type !== "tinycloud.credentials/acquisition-state/v1" || raw.requestId !== id || raw.profile !== this.descriptor.profile || raw.profileVersion !== 1) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential state response is substituted");
216
291
  const base = { type: "OpenCredentialsAcquisitionState", version: 1, requestId: id, transitionId: `${String(raw.state)}:${String(raw.resultPreviouslyRead ?? false)}`, correlationId: id };
217
292
  if (raw.state === "challenge_required") {
218
293
  const type = this.proofStep();
219
294
  return { ...base, state: "pending", nextStep: { id: type, type, version: 1, constraints: { challengeRequired: true } } };
220
295
  }
296
+ if (this.descriptor.subjectRelationship === "account_is_subject" && (raw.state === "proof_required" || raw.state === "holder_binding_required" || raw.state === "holder_signature_required" || raw.state === "ready_to_issue" || raw.state === "issued")) {
297
+ const nonce = raw.challengeNonce;
298
+ const prior = this.nonces.get(id);
299
+ if (typeof nonce !== "string" || nonce === "" || prior !== void 0 && prior !== nonce) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge nonce is missing or changed");
300
+ this.nonces.set(id, opaque(nonce, "challengeNonce"));
301
+ }
221
302
  if (raw.state === "proof_required") {
222
303
  const type = this.proofStep();
223
- this.nonces.set(id, opaque(raw.challengeNonce, "challengeNonce"));
304
+ if (this.descriptor.subjectRelationship !== "account_is_subject") this.nonces.set(id, opaque(raw.challengeNonce, "challengeNonce"));
224
305
  return { ...base, state: "pending", nextStep: { id: type, type, version: 1, constraints: {} } };
225
306
  }
226
307
  if (raw.state === "holder_binding_required" || raw.state === "holder_signature_required") return { ...base, state: "pending", nextStep: { id: "holder_signature", type: "holder_signature", version: 1, constraints: {} } };
227
308
  if (raw.state === "ready_to_issue") return { ...base, state: "ready_to_issue" };
228
309
  if (raw.state === "issued") return { ...base, state: "complete" };
229
- throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential state is unsupported");
310
+ throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential state is unsupported");
230
311
  }
231
312
  async beginStep(id, verifier, stepId, signal) {
232
313
  const step = this.proofStep();
233
- if (stepId !== step) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge step is substituted");
314
+ if (stepId !== step) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge step is substituted");
234
315
  const challenge = object(await this.request("challenge", { id, verifier, method: "POST", body: { step, stepVersion: 1 }, signal }), "challenge response");
235
- if (challenge.type !== "tinycloud.credentials/challenge/v1" || challenge.step !== step || challenge.stepVersion !== 1) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge is substituted");
316
+ if (challenge.type !== "tinycloud.credentials/challenge/v1" || challenge.step !== step || challenge.stepVersion !== 1) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge is substituted");
236
317
  this.nonces.set(id, opaque(challenge.challengeNonce, "challengeNonce"));
237
318
  }
238
319
  async submitStep(id, verifier, stepId, proof, signal) {
239
320
  const step = this.proofStep();
240
- if (stepId !== step) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential proof step is substituted");
321
+ if (stepId !== step) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential proof step is substituted");
241
322
  const nonce = this.nonces.get(id);
242
- if (!nonce) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge is missing");
323
+ if (!nonce) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge is missing");
243
324
  await this.request("proof", { id, verifier, method: "POST", body: { step, stepVersion: 1, challengeNonce: nonce, proof }, signal });
244
325
  }
245
326
  async holderBinding(id, verifier, signal) {
246
327
  const raw = object(await this.request("holder_binding", { id, verifier, signal }), "holder binding response");
247
- if (raw.type !== "tinycloud.credentials/holder-binding-request/v1" || raw.signingDomain !== "tinycloud.credentials/holder-binding/v1" || raw.alg !== "EdDSA" || typeof raw.canonical !== "string") throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding response is invalid");
328
+ if (raw.type !== "tinycloud.credentials/holder-binding-request/v1" || raw.signingDomain !== this.descriptor.holderBinding.domain || raw.alg !== this.descriptor.holderBinding.alg || typeof raw.canonical !== "string") throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding response is invalid");
248
329
  const binding = validateCredentialHolderBinding2(raw.binding);
249
- if (encodeBase64Url2(holderBindingCanonicalBytes(binding)) !== raw.canonical) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding canonical bytes were substituted");
330
+ if (binding.type !== raw.signingDomain) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding type does not match its signing domain");
331
+ if (encodeBase64Url2(holderBindingCanonicalBytes(binding)) !== raw.canonical) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding canonical bytes were substituted");
250
332
  this.bindings.set(id, binding);
251
333
  return binding;
252
334
  }
253
335
  async submitHolderSignature(id, verifier, signature, signal, key) {
254
336
  const binding = this.bindings.get(id);
255
- if (!binding) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was not retrieved");
337
+ if (!binding) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding was not retrieved");
338
+ if (this.descriptor.holderBinding.alg === "EIP191" && !key) throw new CredentialError3("REQUEST_SUBSTITUTED", "An account holder must name its key and algorithm");
339
+ const alg = key?.alg ?? "EdDSA";
256
340
  const kid = key?.kid ?? `${binding.holderDid}#${binding.holderDid.replace("did:key:", "")}`;
257
- await this.request("holder_signature", { id, verifier, method: "POST", body: { alg: key?.alg ?? "EdDSA", kid, signature }, signal });
341
+ await this.request("holder_signature", { id, verifier, method: "POST", body: { alg, kid, signature }, signal });
258
342
  }
259
343
  async issue(id, verifier, signal) {
260
344
  await this.request("issue", { id, verifier, method: "POST", signal });
261
345
  }
262
346
  async result(id, verifier, signal) {
263
347
  const raw = object(await this.request("result", { id, verifier, signal }), "result response");
264
- if (raw.type !== "tinycloud.credentials/result/v1" || raw.protocol !== this.descriptor.protocol || raw.profile !== this.descriptor.profile || raw.profileVersion !== 1 || raw.format !== "vc+sd-jwt" || raw.descriptorDigest !== await canonicalDigest(this.descriptor) || raw.issuer !== this.descriptor.issuer.did || raw.issuerKid !== this.descriptor.issuer.kid || typeof raw.credential !== "string" || await sha256Base64Url(raw.credential) !== raw.credentialDigest) throw new CredentialError2("VERIFICATION_FAILED", "Issued result metadata is invalid");
348
+ if (raw.type !== "tinycloud.credentials/result/v1" || raw.protocol !== this.descriptor.protocol || raw.profile !== this.descriptor.profile || raw.profileVersion !== 1 || raw.format !== "vc+sd-jwt" || raw.descriptorDigest !== await canonicalDigest2(this.descriptor) || raw.issuer !== this.descriptor.issuer.did || raw.issuerKid !== this.descriptor.issuer.kid || typeof raw.credential !== "string" || await sha256Base64Url(raw.credential) !== raw.credentialDigest) throw new CredentialError3("VERIFICATION_FAILED", "Issued result metadata is invalid");
265
349
  const holder = object(raw.holderBinding, "result holder binding");
266
- if (raw.subject !== holder.did || holder.signingDomain !== "tinycloud.credentials/holder-binding/v1") throw new CredentialError2("HOLDER_MISMATCH", "Issued holder binding is invalid");
350
+ if (raw.subject !== holder.did || holder.signingDomain !== this.descriptor.holderBinding.domain) throw new CredentialError3("HOLDER_MISMATCH", "Issued holder binding is invalid");
267
351
  const claimsRaw = object(raw.claims, "result claims");
268
352
  const claims = {};
269
353
  for (const [name, value] of Object.entries(claimsRaw)) {
270
- if (typeof value !== "string") throw new CredentialError2("VERIFICATION_FAILED", "Issued claims are invalid");
354
+ if (typeof value !== "string") throw new CredentialError3("VERIFICATION_FAILED", "Issued claims are invalid");
271
355
  claims[name] = value;
272
356
  }
273
357
  const validity = object(raw.validity, "result validity");
274
358
  const payload = jwtPayload(raw.credential);
275
- if (typeof payload.jti !== "string") throw new CredentialError2("VERIFICATION_FAILED", "Credential id is invalid");
359
+ if (typeof payload.jti !== "string") throw new CredentialError3("VERIFICATION_FAILED", "Credential id is invalid");
276
360
  const issuedAt = timestamp(validity.issuedAt, "issuedAt");
277
361
  const expiresAt = timestamp(validity.expiresAt, "expiresAt");
278
362
  const status = object(raw.status, "result status");
279
- if (status.type !== "none" || status.freshnessSeconds !== this.descriptor.status.freshnessSeconds) throw new CredentialError2("VERIFICATION_FAILED", "Credential status is invalid");
280
- return { type: "OpenCredentialsIssuedCredential", version: 1, protocol: this.descriptor.protocol, profile: { id: this.descriptor.profile, version: 1 }, credentialType: { id: this.descriptor.format.vct, version: 1 }, schema: this.descriptor.format.vct, format: "vc+sd-jwt", issuerDid: raw.issuer, issuerKid: raw.issuerKid, subjectDid: raw.subject, holderDid: raw.subject, claims, claimsDigest: await canonicalDigest(claims), descriptorDigest: raw.descriptorDigest, credentialId: payload.jti, issuedAt, notBefore: issuedAt, expiresAt, status: { method: "none", freshnessSeconds: status.freshnessSeconds }, credential: raw.credential };
363
+ if (status.type !== "none" || status.freshnessSeconds !== this.descriptor.status.freshnessSeconds) throw new CredentialError3("VERIFICATION_FAILED", "Credential status is invalid");
364
+ return { type: "OpenCredentialsIssuedCredential", version: 1, protocol: this.descriptor.protocol, profile: { id: this.descriptor.profile, version: 1 }, credentialType: { id: this.descriptor.format.vct, version: 1 }, schema: this.descriptor.format.vct, format: "vc+sd-jwt", issuerDid: raw.issuer, issuerKid: raw.issuerKid, subjectDid: raw.subject, holderDid: raw.subject, claims, claimsDigest: await canonicalDigest2(claims), descriptorDigest: raw.descriptorDigest, credentialId: payload.jti, issuedAt, notBefore: issuedAt, expiresAt, status: { method: "none", freshnessSeconds: status.freshnessSeconds }, credential: raw.credential };
281
365
  }
282
366
  async issuerMetadata(signal) {
283
367
  return await this.request("issuer_metadata", { signal });
@@ -289,24 +373,19 @@ var OpenCredentialsHttpTransport = class {
289
373
 
290
374
  // src/credentials/email-proof.ts
291
375
  import {
292
- canonicalDigest as canonicalDigest2,
376
+ canonicalDigest as canonicalDigest3,
293
377
  createEmailCredentialRequirement,
294
378
  credentialError,
295
379
  credentialRequirementDigest,
296
- CredentialError as CredentialError3,
380
+ CredentialError as CredentialError4,
297
381
  descriptorSatisfiesRequirement,
298
382
  encodeBase64Url as encodeBase64Url3,
299
383
  sha256Base64Url as sha256Base64Url2,
300
384
  validateCredentialFlowDescriptor,
385
+ validateCredentialRequirement,
301
386
  verifyIssuedCredential
302
387
  } from "@tinycloud/sdk-core";
303
- var EMAIL_PROOF_PINS = Object.freeze({
304
- witnessOrigin: "https://witness.credentials.org",
305
- issuerDid: "did:web:issuer.credentials.org",
306
- issuerKid: "did:web:issuer.credentials.org#controller",
307
- issuerX: "eEJI4xEobto4HtQ7Pg9R1vBOwcpfGRTlXDG5QfqLnGQ",
308
- descriptorDigest: "1tg-qphmKBVtNwzVg9xyz-xxqt_xtMXAsQyXw46m8S0"
309
- });
388
+ import { parseIssuerSet, parsePkhDid, toChecksumAddress, verifyHeldEmailCredential } from "@tinycloud/sdk-core/held-credentials";
310
389
  var EMAIL_PROOF_CREDENTIAL_TYPE = { id: "opencredentials.email/v1", version: 1 };
311
390
  var EMAIL_PROOF_PENDING_MAX_MS = 15 * 6e4;
312
391
  var DISCOVERY_URL = `${EMAIL_PROOF_PINS.witnessOrigin}/.well-known/opencredentials`;
@@ -322,10 +401,16 @@ var EMAIL_PROOF_ISSUER_KEYS = [
322
401
  var DEFAULT_EMAIL_PROOF_PROFILE = "tinycloud.email-proof/v1";
323
402
  var PROFILE_PINS = {
324
403
  "tinycloud.email-proof/v1": { descriptorDigest: EMAIL_PROOF_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: true },
325
- // Held profile: disabled until the held wire path ships. Requests refuse with PROFILE_UNAVAILABLE before any network call.
326
- "tinycloud.email-held/v1": { descriptorDigest: "dc3_DuvzipXwYkTfjKqTaK1c3VS0YSPnaBsYSQriC0w", requiredStep: "mailbox_otp", enabled: false }
404
+ // Held profile: disabled by default. A call enables it only with the per-call opt-in `allowHeldProfile: true` (see `profilePin`).
405
+ // Without it, requests refuse with PROFILE_UNAVAILABLE before any network call.
406
+ "tinycloud.email-held/v1": { descriptorDigest: HELD_ACCOUNT_BINDING_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: false }
327
407
  };
328
- var OPENER_ORIGIN = "https://localhost";
408
+ var OPENER_ORIGIN = HELD_ACCOUNT_BINDING_PINS.openerOrigin;
409
+ var HELD_PROFILE = "tinycloud.email-held/v1";
410
+ var HELD_KID_FRAGMENT = "blockchainAccountId";
411
+ var HELD_MAX_AGE_SECONDS = 31536e3;
412
+ var HELD_REQUEST_TTL_MS = 6e5;
413
+ var CLOCK_SKEW_BOUND_MS = 3e5;
329
414
  var STEP_TIMEOUT_MS = 6e4;
330
415
  var HOLDER_DID_PREFIX = "did:key:";
331
416
  var HOLDER_DID_PATTERN = /^did:key:z6Mk[^#]+$/;
@@ -345,6 +430,7 @@ var RECOVERABILITY = {
345
430
  CREDENTIAL_INVALID: false,
346
431
  SESSION_EXPIRED: false,
347
432
  SESSION_INVALID: false,
433
+ CLIENT_CLOCK_SKEW: false,
348
434
  CANCELED: false,
349
435
  FAILED: false
350
436
  };
@@ -361,9 +447,11 @@ var MESSAGES = {
361
447
  CREDENTIAL_INVALID: "Issued credential failed verification.",
362
448
  SESSION_EXPIRED: "Email verification request expired. Request a new code.",
363
449
  SESSION_INVALID: "Email verification request is no longer valid. Request a new code.",
450
+ CLIENT_CLOCK_SKEW: "Your device clock is more than 5 minutes behind. Correct it and try again.",
364
451
  CANCELED: "Email verification was canceled.",
365
452
  FAILED: "Email verification failed."
366
453
  };
454
+ var LOCAL_PROFILE_UNAVAILABLE = "This SDK build doesn't enable the email verification profile.";
367
455
  function rateLimitMessage(retryAfterMs) {
368
456
  const head = "Too many codes were requested for this address.";
369
457
  if (retryAfterMs === void 0 || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return `${head} Try again in about an hour.`;
@@ -371,8 +459,9 @@ function rateLimitMessage(retryAfterMs) {
371
459
  return `${head} Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`;
372
460
  }
373
461
  var EmailProofError = class extends Error {
374
- constructor(step, code, retryAfterMs, recoverable = RECOVERABILITY[code]) {
375
- super(code === "RATE_LIMITED" ? rateLimitMessage(retryAfterMs) : MESSAGES[code]);
462
+ /** `message` overrides the code's text for one refusal whose cause the shared text would misdescribe. It never carries request data. */
463
+ constructor(step, code, retryAfterMs, recoverable = RECOVERABILITY[code], message) {
464
+ super(message ?? (code === "RATE_LIMITED" ? rateLimitMessage(retryAfterMs) : MESSAGES[code]));
376
465
  this.name = "EmailProofError";
377
466
  this.step = step;
378
467
  this.code = code;
@@ -399,7 +488,8 @@ function restoreEmailProofPending(fields) {
399
488
  requirementDigest: fields.requirementDigest,
400
489
  openerOrigin: fields.openerOrigin,
401
490
  createdAt: fields.createdAt,
402
- expiresAt: fields.expiresAt
491
+ expiresAt: fields.expiresAt,
492
+ ...fields.serverExpiresAt === void 0 ? {} : { serverExpiresAt: fields.serverExpiresAt }
403
493
  };
404
494
  const pending = { ...shown };
405
495
  hide(pending, "verifier", fields.verifier);
@@ -439,6 +529,9 @@ function createMemoryEmailProofPendingStore() {
439
529
  }
440
530
  };
441
531
  }
532
+ function isTicketEmailProofResult(result) {
533
+ return result.envelope.profile.id !== HELD_PROFILE;
534
+ }
442
535
  var CodeRequested = class extends Error {
443
536
  };
444
537
  var pauseForCode = async () => {
@@ -446,17 +539,19 @@ var pauseForCode = async () => {
446
539
  };
447
540
  async function requestEmailProofCode(input) {
448
541
  const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
449
- profilePin(profile, "request");
450
- const address = normalizeAddress(input.address);
451
- assertHolder(input.holder, "request");
542
+ profilePin(profile, "request", input.allowHeldProfile);
543
+ const held = profile === HELD_PROFILE;
544
+ if (held) assertHolder(input.holder, "request", profile);
545
+ const address = normalizeAddress(input.address, profile);
546
+ if (!held) assertHolder(input.holder, "request", profile);
452
547
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
453
548
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
454
549
  const requirement = emailRequirement(address, profile);
455
550
  const requirementDigest = await credentialRequirementDigest(requirement);
456
- const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request");
457
- const descriptorDigest = await canonicalDigest2(descriptor);
551
+ const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request", input.allowHeldProfile);
552
+ const descriptorDigest = await canonicalDigest3(descriptor);
458
553
  const verifier = randomVerifier();
459
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
554
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
460
555
  const createdAtMs = now().getTime();
461
556
  let created;
462
557
  try {
@@ -475,6 +570,8 @@ async function requestEmailProofCode(input) {
475
570
  }
476
571
  const serverExpiry = Date.parse(created.expiresAt);
477
572
  if (!Number.isFinite(serverExpiry)) throw new EmailProofError("request", "FAILED");
573
+ const serverNowMs = serverExpiry - HELD_REQUEST_TTL_MS;
574
+ if (held && serverNowMs - now().getTime() > CLOCK_SKEW_BOUND_MS) throw new EmailProofError("request", "CLIENT_CLOCK_SKEW");
478
575
  const pending = restoreEmailProofPending({
479
576
  type: "TinyCloudEmailProofPending",
480
577
  version: 1,
@@ -487,11 +584,14 @@ async function requestEmailProofCode(input) {
487
584
  requirementDigest,
488
585
  openerOrigin: OPENER_ORIGIN,
489
586
  createdAt: new Date(createdAtMs).toISOString(),
490
- expiresAt: new Date(Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString()
587
+ // Held: the deadline is measured on the client clock (a fast client must not lose window), the witness enforces its own 600 s.
588
+ expiresAt: new Date(held ? createdAtMs + Math.min(serverExpiry - serverNowMs, EMAIL_PROOF_PENDING_MAX_MS) : Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString(),
589
+ ...held ? { serverExpiresAt: created.expiresAt } : {}
491
590
  });
492
591
  try {
493
592
  await interpretCredentialFlow({
494
593
  descriptor,
594
+ ...held ? { allowAccountSubject: true } : {},
495
595
  requirement,
496
596
  requestId: created.requestId,
497
597
  verifier,
@@ -499,8 +599,9 @@ async function requestEmailProofCode(input) {
499
599
  descriptorDigest,
500
600
  requirementDigest,
501
601
  openerOrigin: OPENER_ORIGIN,
602
+ ...held ? { expiresAt: created.expiresAt } : {},
502
603
  transport,
503
- signing: holderSigning(input.holder),
604
+ signing: holderSigning(input.holder, held),
504
605
  handlers: { mailbox_otp: pauseForCode },
505
606
  signal
506
607
  });
@@ -517,22 +618,26 @@ async function completeWithIssuerKeys(input, issuerKeys) {
517
618
  const { pending } = input;
518
619
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
519
620
  if (!isPending(pending)) throw new EmailProofError("session", "SESSION_INVALID");
621
+ profilePin(pending.profile, "session", input.allowHeldProfile);
622
+ const held = pending.profile === HELD_PROFILE;
623
+ if (held && !Number.isFinite(Date.parse(pending.serverExpiresAt ?? ""))) throw new EmailProofError("session", "SESSION_INVALID");
520
624
  if (pendingDeadlineMs(pending) <= now().getTime()) throw new EmailProofError("session", "SESSION_EXPIRED");
521
625
  if (!/^\d{8}$/.test(input.code)) throw new EmailProofError("code", "CODE_INVALID");
522
- assertHolder(input.holder, "code");
626
+ assertHolder(input.holder, "code", pending.profile);
523
627
  if (input.holder.did !== pending.holderDid) throw new EmailProofError("session", "SESSION_INVALID");
524
628
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
525
629
  const requirement = emailRequirement(pending.address, pending.profile);
526
630
  if (await credentialRequirementDigest(requirement) !== pending.requirementDigest) throw new EmailProofError("session", "SESSION_INVALID");
527
- const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session");
528
- const descriptorDigest = await canonicalDigest2(descriptor);
631
+ const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session", input.allowHeldProfile);
632
+ const descriptorDigest = await canonicalDigest3(descriptor);
529
633
  if (descriptorDigest !== pending.descriptorDigest) throw new EmailProofError("session", "SESSION_INVALID");
530
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
634
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
531
635
  try {
532
636
  const state = await transport.state(pending.requestId, pending.verifier, signal);
533
637
  if (state.nextStep?.constraints.challengeRequired === true) throw new EmailProofError("session", "SESSION_INVALID");
534
638
  await interpretCredentialFlow({
535
639
  descriptor,
640
+ ...held ? { allowAccountSubject: true } : {},
536
641
  requirement,
537
642
  requestId: pending.requestId,
538
643
  verifier: pending.verifier,
@@ -540,8 +645,9 @@ async function completeWithIssuerKeys(input, issuerKeys) {
540
645
  descriptorDigest,
541
646
  requirementDigest: pending.requirementDigest,
542
647
  openerOrigin: pending.openerOrigin,
648
+ ...held ? { expiresAt: pending.serverExpiresAt } : {},
543
649
  transport,
544
- signing: holderSigning(input.holder),
650
+ signing: holderSigning(input.holder, held),
545
651
  handlers: { mailbox_otp: async () => ({ otp: input.code }) },
546
652
  signal
547
653
  });
@@ -550,6 +656,10 @@ async function completeWithIssuerKeys(input, issuerKeys) {
550
656
  }
551
657
  try {
552
658
  const envelope = await transport.result(pending.requestId, pending.verifier, signal);
659
+ if (held) {
660
+ const verified2 = await verifyHeldResult({ envelope, pending, descriptor, descriptorDigest, transport, issuerKeys, now });
661
+ return { envelope, verified: verified2, credentialId: verified2.credentialId, issuedAt: verified2.issuedAt.toISOString(), expiresAt: verified2.expiresAt.toISOString(), descriptor };
662
+ }
553
663
  const verified = await verifyIssuedCredential({
554
664
  envelope,
555
665
  descriptor,
@@ -570,12 +680,17 @@ async function acquireEmailProof(input) {
570
680
  return acquireWithIssuerKeys(input, EMAIL_PROOF_ISSUER_KEYS);
571
681
  }
572
682
  async function acquireWithIssuerKeys(input, issuerKeys) {
573
- const address = normalizeAddress(input.address);
683
+ const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
684
+ profilePin(profile, "request", input.allowHeldProfile);
685
+ const address = normalizeAddress(input.address, profile);
574
686
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
575
687
  const store = input.pendingStore;
576
- const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
577
688
  const live = store ? await store.findLive(address, now()) : void 0;
578
689
  let pending = live?.profile === profile ? live : void 0;
690
+ if (pending && profile === HELD_PROFILE && !Number.isFinite(Date.parse(pending.serverExpiresAt ?? ""))) {
691
+ await store?.delete(pending.requestId).catch(() => void 0);
692
+ throw new EmailProofError("session", "SESSION_INVALID");
693
+ }
579
694
  if (!pending) {
580
695
  pending = await requestEmailProofCode({ ...input, address });
581
696
  await store?.put(pending);
@@ -599,30 +714,54 @@ async function acquireWithIssuerKeys(input, issuerKeys) {
599
714
  throw error;
600
715
  }
601
716
  }
602
- function normalizeAddress(address) {
717
+ function normalizeAddress(address, profile) {
718
+ if (profile === HELD_PROFILE) return normalizeHeldAddress(address);
603
719
  const value = address.trim();
604
720
  if (value.length > 254 || !EMAIL_PATTERN.test(value)) throw new EmailProofError("request", "ADDRESS_INVALID");
605
721
  return value;
606
722
  }
723
+ var HELD_LOCAL_ATOM = /^[a-z0-9!#$%&'*+\-/=?^_`{|}~]+(\.[a-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
724
+ var HELD_DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
725
+ function normalizeHeldAddress(address) {
726
+ const invalid = () => new EmailProofError("request", "ADDRESS_INVALID");
727
+ const trimmed = address.trim();
728
+ if (/[^\x00-\x7f]/.test(trimmed)) throw invalid();
729
+ const value = trimmed.toLowerCase();
730
+ const parts = value.split("@");
731
+ if (value.length < 3 || value.length > 254 || parts.length !== 2 || /[^\x21-\x7e]/.test(value)) throw invalid();
732
+ const [local, domain] = parts;
733
+ if (local.length > 64 || !HELD_LOCAL_ATOM.test(local) || local.startsWith("+")) throw invalid();
734
+ if (domain.length > 253 || !domain.includes(".") || !domain.split(".").every((label) => label.length <= 63 && HELD_DOMAIN_LABEL.test(label))) throw invalid();
735
+ return value;
736
+ }
607
737
  function holderKid(did) {
608
738
  return `${did}#${did.slice(HOLDER_DID_PREFIX.length)}`;
609
739
  }
610
- function assertHolder(holder, step) {
740
+ function assertHeldHolder(holder, step) {
741
+ const parsed = parsePkhDid(holder.did);
742
+ const canonical = parsed !== null && parsed.chainId === "1" ? `did:pkh:eip155:1:${toChecksumAddress(parsed.address)}` : void 0;
743
+ if (canonical === void 0 || holder.did !== canonical || holder.alg !== "EIP191" || holder.kid !== `${canonical}#${HELD_KID_FRAGMENT}`) throw new EmailProofError(step, "HOLDER_INVALID");
744
+ }
745
+ function assertHolder(holder, step, profile) {
746
+ if (profile === HELD_PROFILE) return assertHeldHolder(holder, step);
611
747
  if (!HOLDER_DID_PATTERN.test(holder.did) || holder.alg !== "EdDSA" || holder.kid !== holderKid(holder.did)) throw new EmailProofError(step, "HOLDER_INVALID");
612
748
  }
613
- function profilePin(profile, step) {
749
+ function profilePin(profile, step, allowHeld) {
614
750
  const entry = Object.prototype.hasOwnProperty.call(PROFILE_PINS, profile) ? PROFILE_PINS[profile] : void 0;
615
- if (!entry || !entry.enabled) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
751
+ if (!entry || !entry.enabled && !(allowHeld === true && profile === HELD_PROFILE)) {
752
+ throw new EmailProofError(step, "PROFILE_UNAVAILABLE", void 0, void 0, LOCAL_PROFILE_UNAVAILABLE);
753
+ }
616
754
  return entry;
617
755
  }
618
756
  function emailRequirement(address, profile) {
757
+ if (profile === HELD_PROFILE) return validateCredentialRequirement({ type: "TinyCloudCredentialRequirement", version: 1, profile: { id: profile, version: 1 }, credentialType: EMAIL_PROOF_CREDENTIAL_TYPE, claims: { email: address }, maxAgeSeconds: HELD_MAX_AGE_SECONDS });
619
758
  return createEmailCredentialRequirement({ email: address, profile: { id: profile, version: 1 }, credentialType: EMAIL_PROOF_CREDENTIAL_TYPE });
620
759
  }
621
760
  function emailProofRequirement(address) {
622
- return emailRequirement(normalizeAddress(address), DEFAULT_EMAIL_PROOF_PROFILE);
761
+ return emailRequirement(normalizeAddress(address, DEFAULT_EMAIL_PROOF_PROFILE), DEFAULT_EMAIL_PROOF_PROFILE);
623
762
  }
624
- async function witnessDescriptor(profile, requirement, options, step) {
625
- const pin = profilePin(profile, step);
763
+ async function witnessDescriptor(profile, requirement, options, step, allowHeld) {
764
+ const pin = profilePin(profile, step, allowHeld);
626
765
  let descriptor;
627
766
  try {
628
767
  descriptor = await discoverCatalogDescriptor(requirement, DISCOVERY_URL, { fetch: options.fetch, signal: options.signal });
@@ -632,7 +771,7 @@ async function witnessDescriptor(profile, requirement, options, step) {
632
771
  if (descriptor.issuer.origin !== EMAIL_PROOF_PINS.witnessOrigin || descriptor.issuer.did !== EMAIL_PROOF_PINS.issuerDid || !descriptor.steps.some((candidate) => candidate.type === pin.requiredStep)) {
633
772
  throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
634
773
  }
635
- if (await canonicalDigest2(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
774
+ if (await canonicalDigest3(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
636
775
  return descriptor;
637
776
  }
638
777
  async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}) {
@@ -640,30 +779,30 @@ async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}
640
779
  try {
641
780
  url = new URL(discoveryUrl);
642
781
  } catch {
643
- throw new CredentialError3("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
782
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
644
783
  }
645
- if (url.protocol !== "https:" || !["/.well-known/opencredentials", "/v1/credential-types"].includes(url.pathname) || url.search || url.hash) throw new CredentialError3("UNSUPPORTED_PROFILE", "Discovery URL is not an allowed catalog endpoint");
784
+ if (url.protocol !== "https:" || !["/.well-known/opencredentials", "/v1/credential-types"].includes(url.pathname) || url.search || url.hash) throw new CredentialError4("UNSUPPORTED_PROFILE", "Discovery URL is not an allowed catalog endpoint");
646
785
  const init = { credentials: "omit", cache: "no-store", redirect: "error", referrerPolicy: "no-referrer", signal: options.signal };
647
786
  let response;
648
787
  try {
649
788
  response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(url, init);
650
789
  } catch (cause) {
651
- throw new CredentialError3("OFFLINE", "Credential discovery is unavailable", { cause });
790
+ throw new CredentialError4("OFFLINE", "Credential discovery is unavailable", { cause });
652
791
  }
653
- if (!response.ok) throw new CredentialError3(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
792
+ if (!response.ok) throw new CredentialError4(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
654
793
  const catalog = await response.json();
655
- if (catalog.type !== "tinycloud.credentials/catalog/v1" || catalog.protocol !== "tinycloud.credentials/acquisition/v1" || catalog.catalogVersion !== 1 || !Array.isArray(catalog.profiles)) throw new CredentialError3("DESCRIPTOR_INVALID", "Credential catalog is invalid");
794
+ if (catalog.type !== "tinycloud.credentials/catalog/v1" || catalog.protocol !== "tinycloud.credentials/acquisition/v1" || catalog.catalogVersion !== 1 || !Array.isArray(catalog.profiles)) throw new CredentialError4("DESCRIPTOR_INVALID", "Credential catalog is invalid");
656
795
  for (const entryValue of catalog.profiles) {
657
796
  if (typeof entryValue !== "object" || entryValue === null || Array.isArray(entryValue)) continue;
658
797
  const entry = entryValue;
659
798
  if (entry.supported !== true || entry.enabled !== true || entry.readiness !== "ready" && entry.readiness !== "degraded") continue;
660
799
  try {
661
800
  const candidate = validateCredentialFlowDescriptor(entry.descriptor);
662
- if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest2(candidate)) return candidate;
801
+ if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest3(candidate)) return candidate;
663
802
  } catch {
664
803
  }
665
804
  }
666
- throw new CredentialError3("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
805
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
667
806
  }
668
807
  function discoveryFailure(error, signal, step) {
669
808
  if (signal.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
@@ -678,8 +817,8 @@ async function verifyStoredEmailProof(input) {
678
817
  async function verifyStoredWithIssuerKeys(input, issuerKeys) {
679
818
  const { record, requirement, holderDid } = input;
680
819
  const descriptor = record.descriptor;
681
- if (record.descriptorDigest !== EMAIL_PROOF_PINS.descriptorDigest || await canonicalDigest2(descriptor) !== EMAIL_PROOF_PINS.descriptorDigest) {
682
- throw new CredentialError3("VERIFICATION_FAILED", "stored descriptor does not match the pinned descriptor digest");
820
+ if (record.descriptorDigest !== EMAIL_PROOF_PINS.descriptorDigest || await canonicalDigest3(descriptor) !== EMAIL_PROOF_PINS.descriptorDigest) {
821
+ throw new CredentialError4("VERIFICATION_FAILED", "stored descriptor does not match the pinned descriptor digest");
683
822
  }
684
823
  const envelope = {
685
824
  type: "OpenCredentialsIssuedCredential",
@@ -714,14 +853,58 @@ async function verifyStoredWithIssuerKeys(input, issuerKeys) {
714
853
  checkStatus: async (status) => status.method === "none" && status.freshnessSeconds === descriptor.status.freshnessSeconds,
715
854
  signal: input.signal
716
855
  });
717
- if (record.holderDid !== holderDid || record.ownerDid !== holderDid) throw new CredentialError3("HOLDER_MISMATCH", "stored record belongs to another holder");
718
- if (record.credentialDigest !== verified.credentialDigest) throw new CredentialError3("VERIFICATION_FAILED", "stored credential digest does not match the verified credential");
719
- if (record.requirementDigest !== await credentialRequirementDigest(requirement)) throw new CredentialError3("VERIFICATION_FAILED", "stored requirement digest does not match the requirement");
720
- if (record.recordId !== await canonicalDigest2({ credentialDigest: verified.credentialDigest, holderDid: record.ownerDid })) throw new CredentialError3("VERIFICATION_FAILED", "stored record id does not match its credential");
856
+ if (record.holderDid !== holderDid || record.ownerDid !== holderDid) throw new CredentialError4("HOLDER_MISMATCH", "stored record belongs to another holder");
857
+ if (record.credentialDigest !== verified.credentialDigest) throw new CredentialError4("VERIFICATION_FAILED", "stored credential digest does not match the verified credential");
858
+ if (record.requirementDigest !== await credentialRequirementDigest(requirement)) throw new CredentialError4("VERIFICATION_FAILED", "stored requirement digest does not match the requirement");
859
+ if (record.recordId !== await canonicalDigest3({ credentialDigest: verified.credentialDigest, holderDid: record.ownerDid })) throw new CredentialError4("VERIFICATION_FAILED", "stored record id does not match its credential");
860
+ return verified;
861
+ }
862
+ async function verifyHeldResult(input) {
863
+ const { envelope, pending, descriptor } = input;
864
+ const refuse = (message) => new CredentialError4("VERIFICATION_FAILED", message);
865
+ const iatMs = Date.parse(envelope.issuedAt);
866
+ const clientMs = input.now().getTime();
867
+ if (!Number.isFinite(iatMs) || iatMs - clientMs > CLOCK_SKEW_BOUND_MS) throw refuse("client clock is more than 5 minutes behind the issuer");
868
+ const key = input.issuerKeys.find((candidate) => candidate.kid === EMAIL_PROOF_PINS.issuerKid);
869
+ if (!key) throw refuse("no pinned issuer key");
870
+ const issuerSet = parseIssuerSet(
871
+ { format: "tinycloud.credentials-issuers/v1", version: 1, issuers: [{ issuerDid: EMAIL_PROOF_PINS.issuerDid, kid: EMAIL_PROOF_PINS.issuerKid, alg: "EdDSA", publicKey: key.jwk.x, vcts: [EMAIL_PROOF_CREDENTIAL_TYPE.id], profiles: [HELD_PROFILE], notBefore: "2020-01-01T00:00:00Z", retiredAt: null, notAfter: null }] },
872
+ { didMethods: { issuer: ["web"] } }
873
+ );
874
+ const verified = verifyHeldEmailCredential(envelope.credential, {
875
+ now: new Date(Math.max(clientMs, iatMs)),
876
+ owner: pending.holderDid,
877
+ didMethods: { issuer: ["web"] },
878
+ acceptedIssuers: [{ issuerDid: EMAIL_PROOF_PINS.issuerDid, kid: EMAIL_PROOF_PINS.issuerKid }],
879
+ issuerSet
880
+ });
881
+ const signed = input.transport.signedBinding(pending.requestId);
882
+ const claims = Object.keys(envelope.claims).sort().join();
883
+ const toSecond = (ms) => Math.floor(ms / 1e3);
884
+ if (
885
+ // (i) the holder, as the one EIP-55 string
886
+ envelope.subjectDid !== pending.holderDid || envelope.holderDid !== pending.holderDid || // (ii) exactly {email, emailDomain}, equal to the credential's, and the requested mailbox
887
+ claims !== "email,emailDomain" || envelope.claims.email !== verified.claims.email || envelope.claims.emailDomain !== verified.claims.emailDomain || envelope.claims.email !== pending.address || // (iii) validity to the second
888
+ toSecond(Date.parse(envelope.issuedAt)) !== toSecond(verified.issuedAt.getTime()) || toSecond(Date.parse(envelope.expiresAt)) !== toSecond(verified.expiresAt.getTime()) || // (v) the pins
889
+ envelope.descriptorDigest !== input.descriptorDigest || envelope.profile.id !== HELD_PROFILE || envelope.profile.version !== 1 || envelope.issuerDid !== EMAIL_PROOF_PINS.issuerDid || envelope.issuerKid !== EMAIL_PROOF_PINS.issuerKid || envelope.format !== "vc+sd-jwt" || envelope.status.method !== "none" || envelope.status.freshnessSeconds !== descriptor.status.freshnessSeconds
890
+ ) throw refuse("issued credential does not match the request");
891
+ if (signed?.type !== descriptor.holderBinding.domain || !("credentialLifetimeSeconds" in signed)) throw refuse("signed binding is missing");
892
+ if (verified.credentialId !== signed.jti || (verified.expiresAt.getTime() - verified.issuedAt.getTime()) / 1e3 !== signed.credentialLifetimeSeconds) throw refuse("credential does not match the binding the account signed");
721
893
  return verified;
722
894
  }
723
- function holderSigning(holder) {
724
- return { autoSign: async (_binding, bytes) => holder.sign(bytes), holderKey: { alg: "EdDSA", kid: holder.kid } };
895
+ function holderSigning(holder, held) {
896
+ return {
897
+ autoSign: async (_binding, bytes) => {
898
+ try {
899
+ return await holder.sign(bytes);
900
+ } catch (error) {
901
+ const aborted = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
902
+ if (!held || error instanceof EmailProofError || aborted) throw error;
903
+ throw new EmailProofError("credential", "SIGNATURE_REJECTED");
904
+ }
905
+ },
906
+ holderKey: { alg: holder.alg, kid: holder.kid }
907
+ };
725
908
  }
726
909
  function randomVerifier() {
727
910
  return encodeBase64Url3(crypto.getRandomValues(new Uint8Array(32)));
@@ -743,6 +926,7 @@ function mapError(value, step, signal) {
743
926
  if (signal?.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
744
927
  return new EmailProofError(step, "NETWORK");
745
928
  case "UNSUPPORTED_PROFILE":
929
+ case "UNSUPPORTED_VERSION":
746
930
  case "DESCRIPTOR_INVALID":
747
931
  return new EmailProofError(step, "PROFILE_UNAVAILABLE");
748
932
  case "PROOF_REJECTED":
@@ -770,7 +954,9 @@ export {
770
954
  DEFAULT_EMAIL_PROOF_PROFILE,
771
955
  EMAIL_PROOF_CREDENTIAL_TYPE,
772
956
  EMAIL_PROOF_PENDING_MAX_MS,
957
+ EMAIL_PROOF_PINS,
773
958
  EmailProofError,
959
+ HELD_ACCOUNT_BINDING_PINS,
774
960
  OpenCredentialsHttpTransport,
775
961
  acquireEmailProof,
776
962
  completeEmailProof,
@@ -778,6 +964,7 @@ export {
778
964
  createMemoryEmailProofPendingStore,
779
965
  emailProofRequirement,
780
966
  interpretCredentialFlow,
967
+ isTicketEmailProofResult,
781
968
  mailboxSubject,
782
969
  requestEmailProofCode,
783
970
  verifyStoredEmailProof