@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.
@@ -7,25 +7,69 @@ import { inspectShare, ShareReceiveError as ShareReceiveError2 } from "@tinyclou
7
7
 
8
8
  // src/credentials/email-proof.ts
9
9
  import {
10
- canonicalDigest as canonicalDigest2,
10
+ canonicalDigest as canonicalDigest3,
11
11
  createEmailCredentialRequirement,
12
12
  credentialError,
13
13
  credentialRequirementDigest,
14
- CredentialError as CredentialError3,
14
+ CredentialError as CredentialError4,
15
15
  descriptorSatisfiesRequirement,
16
16
  encodeBase64Url as encodeBase64Url3,
17
17
  sha256Base64Url as sha256Base64Url2,
18
18
  validateCredentialFlowDescriptor,
19
+ validateCredentialRequirement,
19
20
  verifyIssuedCredential
20
21
  } from "@tinycloud/sdk-core";
22
+ import { parseIssuerSet, parsePkhDid, toChecksumAddress, verifyHeldEmailCredential } from "@tinycloud/sdk-core/held-credentials";
21
23
 
22
24
  // src/credentials/interpreter.ts
23
25
  import {
24
- CredentialError,
26
+ CredentialError as CredentialError2,
25
27
  encodeBase64Url,
26
28
  holderBindingSigningBytes,
27
29
  validateCredentialHolderBinding
28
30
  } from "@tinycloud/sdk-core";
31
+
32
+ // src/credentials/held-binding.ts
33
+ import { canonicalDigest, CredentialError } from "@tinycloud/sdk-core";
34
+
35
+ // src/credentials/pins.ts
36
+ import { ACCOUNT_HOLDER_BINDING_DOMAIN } from "@tinycloud/sdk-core";
37
+ var EMAIL_PROOF_PINS = Object.freeze({
38
+ witnessOrigin: "https://witness.credentials.org",
39
+ issuerDid: "did:web:issuer.credentials.org",
40
+ issuerKid: "did:web:issuer.credentials.org#controller",
41
+ issuerX: "eEJI4xEobto4HtQ7Pg9R1vBOwcpfGRTlXDG5QfqLnGQ",
42
+ descriptorDigest: "1tg-qphmKBVtNwzVg9xyz-xxqt_xtMXAsQyXw46m8S0"
43
+ });
44
+ var HELD_ACCOUNT_BINDING_PINS = Object.freeze({
45
+ type: ACCOUNT_HOLDER_BINDING_DOMAIN,
46
+ protocol: "tinycloud.credentials/acquisition/v1",
47
+ profile: "tinycloud.email-held/v1",
48
+ profileVersion: 1,
49
+ descriptorDigest: "dc3_DuvzipXwYkTfjKqTaK1c3VS0YSPnaBsYSQriC0w",
50
+ issuer: EMAIL_PROOF_PINS.issuerDid,
51
+ issuerKid: EMAIL_PROOF_PINS.issuerKid,
52
+ audience: "tinycloud://credentials",
53
+ openerOrigin: "https://localhost",
54
+ completionOrigin: "https://localhost",
55
+ completionContext: "sdk-acquisition",
56
+ credentialLifetimeSeconds: 31536e3
57
+ });
58
+
59
+ // src/credentials/held-binding.ts
60
+ var REQUEST_TTL_MS = 6e5;
61
+ var substituted = () => new CredentialError("REQUEST_SUBSTITUTED", "Holder binding was substituted");
62
+ async function assertHeldBinding(binding, expected) {
63
+ const { descriptor } = expected;
64
+ 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).
65
+ 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();
66
+ if (expected.challengeNonce === void 0 || binding.challengeNonce !== expected.challengeNonce) throw substituted();
67
+ if (binding.normalizedClaimsDigest !== await canonicalDigest(expected.requirement.claims)) throw substituted();
68
+ const expires = Date.parse(binding.expiresAt);
69
+ if (expected.serverExpiresAt === void 0 || expires !== Date.parse(expected.serverExpiresAt) || Date.parse(binding.issuedAt) !== expires - REQUEST_TTL_MS) throw substituted();
70
+ }
71
+
72
+ // src/credentials/interpreter.ts
29
73
  var delay = (ms, signal) => new Promise((resolve, reject) => {
30
74
  const timer = setTimeout(resolve, ms);
31
75
  signal?.addEventListener("abort", () => {
@@ -34,18 +78,36 @@ var delay = (ms, signal) => new Promise((resolve, reject) => {
34
78
  }, { once: true });
35
79
  });
36
80
  async function interpretCredentialFlow(input) {
81
+ const held = input.descriptor.subjectRelationship === "account_is_subject";
82
+ if (held && input.allowAccountSubject !== true) throw new CredentialError2("UNSUPPORTED_PROFILE", "The held credential profile requires an explicit opt-in");
37
83
  const steps = new Map(input.descriptor.steps.map((step) => [step.type, step]));
38
84
  const completed = /* @__PURE__ */ new Set();
39
85
  let lastTransition = "";
86
+ const checkBinding = async (value) => {
87
+ const binding = validateCredentialHolderBinding(value);
88
+ 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");
89
+ const alg = input.descriptor.holderBinding.alg;
90
+ if (binding.type !== input.descriptor.holderBinding.domain || alg === "EIP191" && !input.transport.challengeNonce) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was substituted");
91
+ 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 });
92
+ return binding;
93
+ };
94
+ const ensureHeldBinding = async () => {
95
+ if (!held || input.transport.signedBinding?.(input.requestId) !== void 0) return;
96
+ await checkBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
97
+ };
40
98
  for (; ; ) {
41
99
  input.signal?.throwIfAborted();
42
100
  const state = await input.transport.state(input.requestId, input.verifier, input.signal);
43
- if (state.requestId !== input.requestId) throw new CredentialError("REQUEST_SUBSTITUTED", "Credential request identity changed");
44
- if (state.state === "expired") throw new CredentialError("REQUEST_EXPIRED", "Credential request expired", { state: state.state, correlationId: state.correlationId });
45
- if (state.state === "canceled") throw new CredentialError("CANCELED", "Credential request was canceled", { state: state.state, correlationId: state.correlationId });
46
- if (state.state === "issuer_unready") throw new CredentialError("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: state.retryAfterMs, state: state.state, correlationId: state.correlationId });
47
- if (state.state === "complete") return;
101
+ if (state.requestId !== input.requestId) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential request identity changed");
102
+ if (state.state === "expired") throw new CredentialError2("REQUEST_EXPIRED", "Credential request expired", { state: state.state, correlationId: state.correlationId });
103
+ if (state.state === "canceled") throw new CredentialError2("CANCELED", "Credential request was canceled", { state: state.state, correlationId: state.correlationId });
104
+ if (state.state === "issuer_unready") throw new CredentialError2("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: state.retryAfterMs, state: state.state, correlationId: state.correlationId });
105
+ if (state.state === "complete") {
106
+ await ensureHeldBinding();
107
+ return;
108
+ }
48
109
  if (state.state === "ready_to_issue" || state.state === "issued") {
110
+ await ensureHeldBinding();
49
111
  if (state.state === "ready_to_issue" && !completed.has("issue")) {
50
112
  await input.transport.issue(input.requestId, input.verifier, input.signal);
51
113
  completed.add("issue");
@@ -58,7 +120,7 @@ async function interpretCredentialFlow(input) {
58
120
  continue;
59
121
  }
60
122
  const declared = steps.get(next.type);
61
- 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");
123
+ 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");
62
124
  if (completed.has(next.id) || state.transitionId === lastTransition) {
63
125
  await (input.onWait?.() ?? delay(state.retryAfterMs ?? 50, input.signal));
64
126
  continue;
@@ -66,21 +128,22 @@ async function interpretCredentialFlow(input) {
66
128
  lastTransition = state.transitionId;
67
129
  if (next.type === "holder_signature") {
68
130
  input.onProgress?.({ state: "signing", stepId: next.id, correlationId: state.correlationId });
69
- const binding = validateCredentialHolderBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
70
- 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");
131
+ const binding = await checkBinding(await input.transport.holderBinding(input.requestId, input.verifier, input.signal));
132
+ const alg = input.descriptor.holderBinding.alg;
133
+ if (alg === "EIP191" !== (input.signing.holderKey?.alg === "EIP191")) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was substituted");
71
134
  const bytes = holderBindingSigningBytes(binding);
72
135
  let signature = await input.signing.autoSign?.(binding, bytes, input.signal);
73
136
  if (signature === void 0) {
74
- if (!input.signing.requestApproval) throw new CredentialError("SIGNATURE_REJECTED", "OpenKey approval is required");
137
+ if (!input.signing.requestApproval) throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey approval is required");
75
138
  try {
76
139
  signature = await input.signing.requestApproval(binding, bytes, input.signal);
77
140
  } catch (cause) {
78
- if (cause instanceof CredentialError) throw cause;
79
- throw new CredentialError("SIGNATURE_REJECTED", "OpenKey approval was rejected", { cause });
141
+ if (cause instanceof CredentialError2) throw cause;
142
+ throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey approval was rejected", { cause });
80
143
  }
81
144
  }
82
- if (!(signature instanceof Uint8Array) || signature.length === 0) throw new CredentialError("SIGNATURE_REJECTED", "OpenKey signature is invalid");
83
- await input.transport.submitHolderSignature(input.requestId, input.verifier, encodeBase64Url(signature), input.signal, input.signing.holderKey);
145
+ if (!(signature instanceof Uint8Array) || signature.length === 0) throw new CredentialError2("SIGNATURE_REJECTED", "OpenKey signature is invalid");
146
+ 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);
84
147
  } else {
85
148
  input.onProgress?.({ state: next.type === "collect_input" ? "collecting" : "proving", stepId: next.id, correlationId: state.correlationId });
86
149
  const handler = input.handlers?.[next.type];
@@ -114,9 +177,9 @@ async function interpretCredentialFlow(input) {
114
177
  await input.transport.submitStep(input.requestId, input.verifier, next.id, proof, input.signal);
115
178
  break;
116
179
  } catch (cause) {
117
- if (!(cause instanceof CredentialError) || cause.code !== "PROOF_REJECTED") throw cause;
180
+ if (!(cause instanceof CredentialError2) || cause.code !== "PROOF_REJECTED") throw cause;
118
181
  const attemptsRemaining = input.descriptor.lifecycle.maxProofAttempts - attempt;
119
- 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 });
182
+ 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 });
120
183
  feedback = { kind: "rejected", attemptsRemaining };
121
184
  }
122
185
  }
@@ -128,8 +191,8 @@ async function interpretCredentialFlow(input) {
128
191
 
129
192
  // src/credentials/transport.ts
130
193
  import {
131
- CredentialError as CredentialError2,
132
- canonicalDigest,
194
+ CredentialError as CredentialError3,
195
+ canonicalDigest as canonicalDigest2,
133
196
  credentialEndpointPath,
134
197
  decodeBase64Url,
135
198
  encodeBase64Url as encodeBase64Url2,
@@ -138,25 +201,25 @@ import {
138
201
  validateCredentialHolderBinding as validateCredentialHolderBinding2
139
202
  } from "@tinycloud/sdk-core";
140
203
  function object(value, label) {
141
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CredentialError2("VERIFICATION_FAILED", `${label} is invalid`);
204
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CredentialError3("VERIFICATION_FAILED", `${label} is invalid`);
142
205
  return value;
143
206
  }
144
207
  function opaque(value, label) {
145
- if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) throw new CredentialError2("REQUEST_SUBSTITUTED", `${label} is invalid`);
208
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) throw new CredentialError3("REQUEST_SUBSTITUTED", `${label} is invalid`);
146
209
  return value;
147
210
  }
148
211
  function timestamp(value, label) {
149
- if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new CredentialError2("VERIFICATION_FAILED", `${label} is invalid`);
212
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new CredentialError3("VERIFICATION_FAILED", `${label} is invalid`);
150
213
  return new Date(value).toISOString();
151
214
  }
152
215
  function jwtPayload(credential) {
153
216
  const part = credential.split("~", 1)[0]?.split(".")[1];
154
- if (!part) throw new CredentialError2("VERIFICATION_FAILED", "Issued SD-JWT is invalid");
217
+ if (!part) throw new CredentialError3("VERIFICATION_FAILED", "Issued SD-JWT is invalid");
155
218
  try {
156
219
  return object(JSON.parse(new TextDecoder().decode(decodeBase64Url(part))), "SD-JWT payload");
157
220
  } catch (cause) {
158
- if (cause instanceof CredentialError2) throw cause;
159
- throw new CredentialError2("VERIFICATION_FAILED", "Issued SD-JWT is invalid", { cause });
221
+ if (cause instanceof CredentialError3) throw cause;
222
+ throw new CredentialError3("VERIFICATION_FAILED", "Issued SD-JWT is invalid", { cause });
160
223
  }
161
224
  }
162
225
  var SERVER_CODES = /* @__PURE__ */ new Set(["REQUEST_EXPIRED", "ISSUER_UNREADY", "UNSUPPORTED_PROFILE", "UNSUPPORTED_VERSION", "SIGNATURE_REJECTED"]);
@@ -165,12 +228,13 @@ function serverError(value, response, honorServerRecoverable) {
165
228
  const body = value;
166
229
  const keys = Object.keys(body).sort();
167
230
  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;
168
- 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 });
169
- 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 });
170
- 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 });
231
+ 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 });
232
+ 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 });
233
+ 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 });
234
+ if (body.code === "DESCRIPTOR_MISMATCH") body.code = "UNSUPPORTED_PROFILE";
171
235
  if (!SERVER_CODES.has(body.code)) return void 0;
172
236
  const code = body.code;
173
- return new CredentialError2(code, `OpenCredentials rejected the request: ${code}`, {
237
+ return new CredentialError3(code, `OpenCredentials rejected the request: ${code}`, {
174
238
  state: body.state,
175
239
  correlationId: body.correlationId,
176
240
  retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0,
@@ -178,12 +242,17 @@ function serverError(value, response, honorServerRecoverable) {
178
242
  });
179
243
  }
180
244
  var OpenCredentialsHttpTransport = class {
245
+ /**
246
+ * `allowAccountSubject` is passed only by the email-proof functions, after the per-call held opt-in was accepted. Without it an account-bound
247
+ * (held) descriptor is refused here, before any request (TC-2254 A2.5).
248
+ */
181
249
  constructor(descriptor, fetchFn = globalThis.fetch.bind(globalThis), options = {}) {
182
250
  this.descriptor = descriptor;
183
251
  this.fetchFn = fetchFn;
184
252
  this.options = options;
185
253
  this.nonces = /* @__PURE__ */ new Map();
186
254
  this.bindings = /* @__PURE__ */ new Map();
255
+ if (descriptor.subjectRelationship === "account_is_subject" && options.allowAccountSubject !== true) throw new CredentialError3("UNSUPPORTED_PROFILE", "The held credential profile requires an explicit opt-in");
187
256
  }
188
257
  url(endpoint, id) {
189
258
  return new URL(credentialEndpointPath(endpoint, id), this.descriptor.issuer.origin).href;
@@ -194,8 +263,8 @@ var OpenCredentialsHttpTransport = class {
194
263
  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) } };
195
264
  response = await this.fetchFn(this.url(endpoint, input.id), init);
196
265
  } catch (cause) {
197
- if (cause instanceof DOMException && cause.name === "AbortError") throw new CredentialError2("CANCELED", "Credential acquisition was canceled", { cause });
198
- throw new CredentialError2("OFFLINE", "OpenCredentials is unavailable", { cause });
266
+ if (cause instanceof DOMException && cause.name === "AbortError") throw new CredentialError3("CANCELED", "Credential acquisition was canceled", { cause });
267
+ throw new CredentialError3("OFFLINE", "OpenCredentials is unavailable", { cause });
199
268
  }
200
269
  if (!response.ok) {
201
270
  let parsed;
@@ -206,95 +275,112 @@ var OpenCredentialsHttpTransport = class {
206
275
  }
207
276
  const typed = serverError(parsed, response, this.options.honorServerRecoverable === true);
208
277
  if (typed) throw typed;
209
- if (response.status === 410) throw new CredentialError2("REQUEST_EXPIRED", "Credential request expired");
210
- if (response.status === 409) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential request was replayed or substituted");
211
- if (response.status === 503) throw new CredentialError2("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0 });
212
- throw new CredentialError2("VERIFICATION_FAILED", "OpenCredentials rejected the request");
278
+ if (response.status === 410) throw new CredentialError3("REQUEST_EXPIRED", "Credential request expired");
279
+ if (response.status === 409) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential request was replayed or substituted");
280
+ if (response.status === 503) throw new CredentialError3("ISSUER_UNREADY", "Credential issuer is not ready", { retryAfterMs: Number(response.headers.get("retry-after")) * 1e3 || void 0 });
281
+ throw new CredentialError3("VERIFICATION_FAILED", "OpenCredentials rejected the request");
213
282
  }
214
283
  if (response.status === 204) return void 0;
215
284
  try {
216
285
  return await response.json();
217
286
  } catch (cause) {
218
- throw new CredentialError2("VERIFICATION_FAILED", "OpenCredentials response is invalid", { cause });
287
+ throw new CredentialError3("VERIFICATION_FAILED", "OpenCredentials response is invalid", { cause });
219
288
  }
220
289
  }
221
290
  async create(input) {
222
- 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");
223
- 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");
291
+ 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");
292
+ 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");
224
293
  const requestId = opaque(body.requestId, "requestId");
225
294
  return { requestId, locator: requestId, expiresAt: timestamp(body.expiresAt, "expiresAt"), correlationId: requestId };
226
295
  }
296
+ /** The nonce stored from R3 or R2 for this request. Held acquisition compares the signed binding with it. */
297
+ challengeNonce(id) {
298
+ return this.nonces.get(id);
299
+ }
300
+ /** The binding fetched and validated for this request, as the account signed it. */
301
+ signedBinding(id) {
302
+ return this.bindings.get(id);
303
+ }
227
304
  proofStep() {
228
305
  return this.descriptor.steps.some((step) => step.type === "mailbox_otp") ? "mailbox_otp" : "collect_input";
229
306
  }
230
307
  async state(id, verifier, signal) {
231
308
  const raw = object(await this.request("state", { id, verifier, signal }), "state response");
232
- 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");
309
+ 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");
233
310
  const base = { type: "OpenCredentialsAcquisitionState", version: 1, requestId: id, transitionId: `${String(raw.state)}:${String(raw.resultPreviouslyRead ?? false)}`, correlationId: id };
234
311
  if (raw.state === "challenge_required") {
235
312
  const type = this.proofStep();
236
313
  return { ...base, state: "pending", nextStep: { id: type, type, version: 1, constraints: { challengeRequired: true } } };
237
314
  }
315
+ 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")) {
316
+ const nonce = raw.challengeNonce;
317
+ const prior = this.nonces.get(id);
318
+ if (typeof nonce !== "string" || nonce === "" || prior !== void 0 && prior !== nonce) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge nonce is missing or changed");
319
+ this.nonces.set(id, opaque(nonce, "challengeNonce"));
320
+ }
238
321
  if (raw.state === "proof_required") {
239
322
  const type = this.proofStep();
240
- this.nonces.set(id, opaque(raw.challengeNonce, "challengeNonce"));
323
+ if (this.descriptor.subjectRelationship !== "account_is_subject") this.nonces.set(id, opaque(raw.challengeNonce, "challengeNonce"));
241
324
  return { ...base, state: "pending", nextStep: { id: type, type, version: 1, constraints: {} } };
242
325
  }
243
326
  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: {} } };
244
327
  if (raw.state === "ready_to_issue") return { ...base, state: "ready_to_issue" };
245
328
  if (raw.state === "issued") return { ...base, state: "complete" };
246
- throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential state is unsupported");
329
+ throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential state is unsupported");
247
330
  }
248
331
  async beginStep(id, verifier, stepId, signal) {
249
332
  const step = this.proofStep();
250
- if (stepId !== step) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge step is substituted");
333
+ if (stepId !== step) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge step is substituted");
251
334
  const challenge = object(await this.request("challenge", { id, verifier, method: "POST", body: { step, stepVersion: 1 }, signal }), "challenge response");
252
- if (challenge.type !== "tinycloud.credentials/challenge/v1" || challenge.step !== step || challenge.stepVersion !== 1) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge is substituted");
335
+ if (challenge.type !== "tinycloud.credentials/challenge/v1" || challenge.step !== step || challenge.stepVersion !== 1) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge is substituted");
253
336
  this.nonces.set(id, opaque(challenge.challengeNonce, "challengeNonce"));
254
337
  }
255
338
  async submitStep(id, verifier, stepId, proof, signal) {
256
339
  const step = this.proofStep();
257
- if (stepId !== step) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential proof step is substituted");
340
+ if (stepId !== step) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential proof step is substituted");
258
341
  const nonce = this.nonces.get(id);
259
- if (!nonce) throw new CredentialError2("REQUEST_SUBSTITUTED", "Credential challenge is missing");
342
+ if (!nonce) throw new CredentialError3("REQUEST_SUBSTITUTED", "Credential challenge is missing");
260
343
  await this.request("proof", { id, verifier, method: "POST", body: { step, stepVersion: 1, challengeNonce: nonce, proof }, signal });
261
344
  }
262
345
  async holderBinding(id, verifier, signal) {
263
346
  const raw = object(await this.request("holder_binding", { id, verifier, signal }), "holder binding response");
264
- 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");
347
+ 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");
265
348
  const binding = validateCredentialHolderBinding2(raw.binding);
266
- if (encodeBase64Url2(holderBindingCanonicalBytes(binding)) !== raw.canonical) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding canonical bytes were substituted");
349
+ if (binding.type !== raw.signingDomain) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding type does not match its signing domain");
350
+ if (encodeBase64Url2(holderBindingCanonicalBytes(binding)) !== raw.canonical) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding canonical bytes were substituted");
267
351
  this.bindings.set(id, binding);
268
352
  return binding;
269
353
  }
270
354
  async submitHolderSignature(id, verifier, signature, signal, key) {
271
355
  const binding = this.bindings.get(id);
272
- if (!binding) throw new CredentialError2("REQUEST_SUBSTITUTED", "Holder binding was not retrieved");
356
+ if (!binding) throw new CredentialError3("REQUEST_SUBSTITUTED", "Holder binding was not retrieved");
357
+ if (this.descriptor.holderBinding.alg === "EIP191" && !key) throw new CredentialError3("REQUEST_SUBSTITUTED", "An account holder must name its key and algorithm");
358
+ const alg = key?.alg ?? "EdDSA";
273
359
  const kid = key?.kid ?? `${binding.holderDid}#${binding.holderDid.replace("did:key:", "")}`;
274
- await this.request("holder_signature", { id, verifier, method: "POST", body: { alg: key?.alg ?? "EdDSA", kid, signature }, signal });
360
+ await this.request("holder_signature", { id, verifier, method: "POST", body: { alg, kid, signature }, signal });
275
361
  }
276
362
  async issue(id, verifier, signal) {
277
363
  await this.request("issue", { id, verifier, method: "POST", signal });
278
364
  }
279
365
  async result(id, verifier, signal) {
280
366
  const raw = object(await this.request("result", { id, verifier, signal }), "result response");
281
- 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");
367
+ 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");
282
368
  const holder = object(raw.holderBinding, "result holder binding");
283
- if (raw.subject !== holder.did || holder.signingDomain !== "tinycloud.credentials/holder-binding/v1") throw new CredentialError2("HOLDER_MISMATCH", "Issued holder binding is invalid");
369
+ if (raw.subject !== holder.did || holder.signingDomain !== this.descriptor.holderBinding.domain) throw new CredentialError3("HOLDER_MISMATCH", "Issued holder binding is invalid");
284
370
  const claimsRaw = object(raw.claims, "result claims");
285
371
  const claims = {};
286
372
  for (const [name, value] of Object.entries(claimsRaw)) {
287
- if (typeof value !== "string") throw new CredentialError2("VERIFICATION_FAILED", "Issued claims are invalid");
373
+ if (typeof value !== "string") throw new CredentialError3("VERIFICATION_FAILED", "Issued claims are invalid");
288
374
  claims[name] = value;
289
375
  }
290
376
  const validity = object(raw.validity, "result validity");
291
377
  const payload = jwtPayload(raw.credential);
292
- if (typeof payload.jti !== "string") throw new CredentialError2("VERIFICATION_FAILED", "Credential id is invalid");
378
+ if (typeof payload.jti !== "string") throw new CredentialError3("VERIFICATION_FAILED", "Credential id is invalid");
293
379
  const issuedAt = timestamp(validity.issuedAt, "issuedAt");
294
380
  const expiresAt = timestamp(validity.expiresAt, "expiresAt");
295
381
  const status = object(raw.status, "result status");
296
- if (status.type !== "none" || status.freshnessSeconds !== this.descriptor.status.freshnessSeconds) throw new CredentialError2("VERIFICATION_FAILED", "Credential status is invalid");
297
- 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 };
382
+ if (status.type !== "none" || status.freshnessSeconds !== this.descriptor.status.freshnessSeconds) throw new CredentialError3("VERIFICATION_FAILED", "Credential status is invalid");
383
+ 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 };
298
384
  }
299
385
  async issuerMetadata(signal) {
300
386
  return await this.request("issuer_metadata", { signal });
@@ -305,13 +391,6 @@ var OpenCredentialsHttpTransport = class {
305
391
  };
306
392
 
307
393
  // src/credentials/email-proof.ts
308
- var EMAIL_PROOF_PINS = Object.freeze({
309
- witnessOrigin: "https://witness.credentials.org",
310
- issuerDid: "did:web:issuer.credentials.org",
311
- issuerKid: "did:web:issuer.credentials.org#controller",
312
- issuerX: "eEJI4xEobto4HtQ7Pg9R1vBOwcpfGRTlXDG5QfqLnGQ",
313
- descriptorDigest: "1tg-qphmKBVtNwzVg9xyz-xxqt_xtMXAsQyXw46m8S0"
314
- });
315
394
  var EMAIL_PROOF_CREDENTIAL_TYPE = { id: "opencredentials.email/v1", version: 1 };
316
395
  var EMAIL_PROOF_PENDING_MAX_MS = 15 * 6e4;
317
396
  var DISCOVERY_URL = `${EMAIL_PROOF_PINS.witnessOrigin}/.well-known/opencredentials`;
@@ -327,10 +406,16 @@ var EMAIL_PROOF_ISSUER_KEYS = [
327
406
  var DEFAULT_EMAIL_PROOF_PROFILE = "tinycloud.email-proof/v1";
328
407
  var PROFILE_PINS = {
329
408
  "tinycloud.email-proof/v1": { descriptorDigest: EMAIL_PROOF_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: true },
330
- // Held profile: disabled until the held wire path ships. Requests refuse with PROFILE_UNAVAILABLE before any network call.
331
- "tinycloud.email-held/v1": { descriptorDigest: "dc3_DuvzipXwYkTfjKqTaK1c3VS0YSPnaBsYSQriC0w", requiredStep: "mailbox_otp", enabled: false }
409
+ // Held profile: disabled by default. A call enables it only with the per-call opt-in `allowHeldProfile: true` (see `profilePin`).
410
+ // Without it, requests refuse with PROFILE_UNAVAILABLE before any network call.
411
+ "tinycloud.email-held/v1": { descriptorDigest: HELD_ACCOUNT_BINDING_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: false }
332
412
  };
333
- var OPENER_ORIGIN = "https://localhost";
413
+ var OPENER_ORIGIN = HELD_ACCOUNT_BINDING_PINS.openerOrigin;
414
+ var HELD_PROFILE = "tinycloud.email-held/v1";
415
+ var HELD_KID_FRAGMENT = "blockchainAccountId";
416
+ var HELD_MAX_AGE_SECONDS = 31536e3;
417
+ var HELD_REQUEST_TTL_MS = 6e5;
418
+ var CLOCK_SKEW_BOUND_MS = 3e5;
334
419
  var STEP_TIMEOUT_MS = 6e4;
335
420
  var HOLDER_DID_PREFIX = "did:key:";
336
421
  var HOLDER_DID_PATTERN = /^did:key:z6Mk[^#]+$/;
@@ -350,6 +435,7 @@ var RECOVERABILITY = {
350
435
  CREDENTIAL_INVALID: false,
351
436
  SESSION_EXPIRED: false,
352
437
  SESSION_INVALID: false,
438
+ CLIENT_CLOCK_SKEW: false,
353
439
  CANCELED: false,
354
440
  FAILED: false
355
441
  };
@@ -366,9 +452,11 @@ var MESSAGES = {
366
452
  CREDENTIAL_INVALID: "Issued credential failed verification.",
367
453
  SESSION_EXPIRED: "Email verification request expired. Request a new code.",
368
454
  SESSION_INVALID: "Email verification request is no longer valid. Request a new code.",
455
+ CLIENT_CLOCK_SKEW: "Your device clock is more than 5 minutes behind. Correct it and try again.",
369
456
  CANCELED: "Email verification was canceled.",
370
457
  FAILED: "Email verification failed."
371
458
  };
459
+ var LOCAL_PROFILE_UNAVAILABLE = "This SDK build doesn't enable the email verification profile.";
372
460
  function rateLimitMessage(retryAfterMs) {
373
461
  const head = "Too many codes were requested for this address.";
374
462
  if (retryAfterMs === void 0 || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return `${head} Try again in about an hour.`;
@@ -376,8 +464,9 @@ function rateLimitMessage(retryAfterMs) {
376
464
  return `${head} Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`;
377
465
  }
378
466
  var EmailProofError = class extends Error {
379
- constructor(step, code, retryAfterMs, recoverable = RECOVERABILITY[code]) {
380
- super(code === "RATE_LIMITED" ? rateLimitMessage(retryAfterMs) : MESSAGES[code]);
467
+ /** `message` overrides the code's text for one refusal whose cause the shared text would misdescribe. It never carries request data. */
468
+ constructor(step, code, retryAfterMs, recoverable = RECOVERABILITY[code], message) {
469
+ super(message ?? (code === "RATE_LIMITED" ? rateLimitMessage(retryAfterMs) : MESSAGES[code]));
381
470
  this.name = "EmailProofError";
382
471
  this.step = step;
383
472
  this.code = code;
@@ -397,7 +486,8 @@ function restoreEmailProofPending(fields) {
397
486
  requirementDigest: fields.requirementDigest,
398
487
  openerOrigin: fields.openerOrigin,
399
488
  createdAt: fields.createdAt,
400
- expiresAt: fields.expiresAt
489
+ expiresAt: fields.expiresAt,
490
+ ...fields.serverExpiresAt === void 0 ? {} : { serverExpiresAt: fields.serverExpiresAt }
401
491
  };
402
492
  const pending = { ...shown };
403
493
  hide(pending, "verifier", fields.verifier);
@@ -411,6 +501,9 @@ function hide(target, key, value) {
411
501
  function pendingDeadlineMs(pending) {
412
502
  return Math.min(Date.parse(pending.expiresAt), Date.parse(pending.createdAt) + EMAIL_PROOF_PENDING_MAX_MS);
413
503
  }
504
+ function isTicketEmailProofResult(result) {
505
+ return result.envelope.profile.id !== HELD_PROFILE;
506
+ }
414
507
  var CodeRequested = class extends Error {
415
508
  };
416
509
  var pauseForCode = async () => {
@@ -418,17 +511,19 @@ var pauseForCode = async () => {
418
511
  };
419
512
  async function requestEmailProofCode(input) {
420
513
  const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
421
- profilePin(profile, "request");
422
- const address = normalizeAddress(input.address);
423
- assertHolder(input.holder, "request");
514
+ profilePin(profile, "request", input.allowHeldProfile);
515
+ const held = profile === HELD_PROFILE;
516
+ if (held) assertHolder(input.holder, "request", profile);
517
+ const address = normalizeAddress(input.address, profile);
518
+ if (!held) assertHolder(input.holder, "request", profile);
424
519
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
425
520
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
426
521
  const requirement = emailRequirement(address, profile);
427
522
  const requirementDigest = await credentialRequirementDigest(requirement);
428
- const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request");
429
- const descriptorDigest = await canonicalDigest2(descriptor);
523
+ const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request", input.allowHeldProfile);
524
+ const descriptorDigest = await canonicalDigest3(descriptor);
430
525
  const verifier = randomVerifier();
431
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
526
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
432
527
  const createdAtMs = now().getTime();
433
528
  let created;
434
529
  try {
@@ -447,6 +542,8 @@ async function requestEmailProofCode(input) {
447
542
  }
448
543
  const serverExpiry = Date.parse(created.expiresAt);
449
544
  if (!Number.isFinite(serverExpiry)) throw new EmailProofError("request", "FAILED");
545
+ const serverNowMs = serverExpiry - HELD_REQUEST_TTL_MS;
546
+ if (held && serverNowMs - now().getTime() > CLOCK_SKEW_BOUND_MS) throw new EmailProofError("request", "CLIENT_CLOCK_SKEW");
450
547
  const pending = restoreEmailProofPending({
451
548
  type: "TinyCloudEmailProofPending",
452
549
  version: 1,
@@ -459,11 +556,14 @@ async function requestEmailProofCode(input) {
459
556
  requirementDigest,
460
557
  openerOrigin: OPENER_ORIGIN,
461
558
  createdAt: new Date(createdAtMs).toISOString(),
462
- expiresAt: new Date(Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString()
559
+ // Held: the deadline is measured on the client clock (a fast client must not lose window), the witness enforces its own 600 s.
560
+ expiresAt: new Date(held ? createdAtMs + Math.min(serverExpiry - serverNowMs, EMAIL_PROOF_PENDING_MAX_MS) : Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString(),
561
+ ...held ? { serverExpiresAt: created.expiresAt } : {}
463
562
  });
464
563
  try {
465
564
  await interpretCredentialFlow({
466
565
  descriptor,
566
+ ...held ? { allowAccountSubject: true } : {},
467
567
  requirement,
468
568
  requestId: created.requestId,
469
569
  verifier,
@@ -471,8 +571,9 @@ async function requestEmailProofCode(input) {
471
571
  descriptorDigest,
472
572
  requirementDigest,
473
573
  openerOrigin: OPENER_ORIGIN,
574
+ ...held ? { expiresAt: created.expiresAt } : {},
474
575
  transport,
475
- signing: holderSigning(input.holder),
576
+ signing: holderSigning(input.holder, held),
476
577
  handlers: { mailbox_otp: pauseForCode },
477
578
  signal
478
579
  });
@@ -489,22 +590,26 @@ async function completeWithIssuerKeys(input, issuerKeys) {
489
590
  const { pending } = input;
490
591
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
491
592
  if (!isPending(pending)) throw new EmailProofError("session", "SESSION_INVALID");
593
+ profilePin(pending.profile, "session", input.allowHeldProfile);
594
+ const held = pending.profile === HELD_PROFILE;
595
+ if (held && !Number.isFinite(Date.parse(pending.serverExpiresAt ?? ""))) throw new EmailProofError("session", "SESSION_INVALID");
492
596
  if (pendingDeadlineMs(pending) <= now().getTime()) throw new EmailProofError("session", "SESSION_EXPIRED");
493
597
  if (!/^\d{8}$/.test(input.code)) throw new EmailProofError("code", "CODE_INVALID");
494
- assertHolder(input.holder, "code");
598
+ assertHolder(input.holder, "code", pending.profile);
495
599
  if (input.holder.did !== pending.holderDid) throw new EmailProofError("session", "SESSION_INVALID");
496
600
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
497
601
  const requirement = emailRequirement(pending.address, pending.profile);
498
602
  if (await credentialRequirementDigest(requirement) !== pending.requirementDigest) throw new EmailProofError("session", "SESSION_INVALID");
499
- const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session");
500
- const descriptorDigest = await canonicalDigest2(descriptor);
603
+ const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session", input.allowHeldProfile);
604
+ const descriptorDigest = await canonicalDigest3(descriptor);
501
605
  if (descriptorDigest !== pending.descriptorDigest) throw new EmailProofError("session", "SESSION_INVALID");
502
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
606
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
503
607
  try {
504
608
  const state = await transport.state(pending.requestId, pending.verifier, signal);
505
609
  if (state.nextStep?.constraints.challengeRequired === true) throw new EmailProofError("session", "SESSION_INVALID");
506
610
  await interpretCredentialFlow({
507
611
  descriptor,
612
+ ...held ? { allowAccountSubject: true } : {},
508
613
  requirement,
509
614
  requestId: pending.requestId,
510
615
  verifier: pending.verifier,
@@ -512,8 +617,9 @@ async function completeWithIssuerKeys(input, issuerKeys) {
512
617
  descriptorDigest,
513
618
  requirementDigest: pending.requirementDigest,
514
619
  openerOrigin: pending.openerOrigin,
620
+ ...held ? { expiresAt: pending.serverExpiresAt } : {},
515
621
  transport,
516
- signing: holderSigning(input.holder),
622
+ signing: holderSigning(input.holder, held),
517
623
  handlers: { mailbox_otp: async () => ({ otp: input.code }) },
518
624
  signal
519
625
  });
@@ -522,6 +628,10 @@ async function completeWithIssuerKeys(input, issuerKeys) {
522
628
  }
523
629
  try {
524
630
  const envelope = await transport.result(pending.requestId, pending.verifier, signal);
631
+ if (held) {
632
+ const verified2 = await verifyHeldResult({ envelope, pending, descriptor, descriptorDigest, transport, issuerKeys, now });
633
+ return { envelope, verified: verified2, credentialId: verified2.credentialId, issuedAt: verified2.issuedAt.toISOString(), expiresAt: verified2.expiresAt.toISOString(), descriptor };
634
+ }
525
635
  const verified = await verifyIssuedCredential({
526
636
  envelope,
527
637
  descriptor,
@@ -538,27 +648,51 @@ async function completeWithIssuerKeys(input, issuerKeys) {
538
648
  throw mapError(error, "credential", signal);
539
649
  }
540
650
  }
541
- function normalizeAddress(address) {
651
+ function normalizeAddress(address, profile) {
652
+ if (profile === HELD_PROFILE) return normalizeHeldAddress(address);
542
653
  const value = address.trim();
543
654
  if (value.length > 254 || !EMAIL_PATTERN.test(value)) throw new EmailProofError("request", "ADDRESS_INVALID");
544
655
  return value;
545
656
  }
657
+ var HELD_LOCAL_ATOM = /^[a-z0-9!#$%&'*+\-/=?^_`{|}~]+(\.[a-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
658
+ var HELD_DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
659
+ function normalizeHeldAddress(address) {
660
+ const invalid = () => new EmailProofError("request", "ADDRESS_INVALID");
661
+ const trimmed = address.trim();
662
+ if (/[^\x00-\x7f]/.test(trimmed)) throw invalid();
663
+ const value = trimmed.toLowerCase();
664
+ const parts = value.split("@");
665
+ if (value.length < 3 || value.length > 254 || parts.length !== 2 || /[^\x21-\x7e]/.test(value)) throw invalid();
666
+ const [local, domain] = parts;
667
+ if (local.length > 64 || !HELD_LOCAL_ATOM.test(local) || local.startsWith("+")) throw invalid();
668
+ if (domain.length > 253 || !domain.includes(".") || !domain.split(".").every((label) => label.length <= 63 && HELD_DOMAIN_LABEL.test(label))) throw invalid();
669
+ return value;
670
+ }
546
671
  function holderKid(did) {
547
672
  return `${did}#${did.slice(HOLDER_DID_PREFIX.length)}`;
548
673
  }
549
- function assertHolder(holder, step) {
674
+ function assertHeldHolder(holder, step) {
675
+ const parsed = parsePkhDid(holder.did);
676
+ const canonical = parsed !== null && parsed.chainId === "1" ? `did:pkh:eip155:1:${toChecksumAddress(parsed.address)}` : void 0;
677
+ if (canonical === void 0 || holder.did !== canonical || holder.alg !== "EIP191" || holder.kid !== `${canonical}#${HELD_KID_FRAGMENT}`) throw new EmailProofError(step, "HOLDER_INVALID");
678
+ }
679
+ function assertHolder(holder, step, profile) {
680
+ if (profile === HELD_PROFILE) return assertHeldHolder(holder, step);
550
681
  if (!HOLDER_DID_PATTERN.test(holder.did) || holder.alg !== "EdDSA" || holder.kid !== holderKid(holder.did)) throw new EmailProofError(step, "HOLDER_INVALID");
551
682
  }
552
- function profilePin(profile, step) {
683
+ function profilePin(profile, step, allowHeld) {
553
684
  const entry = Object.prototype.hasOwnProperty.call(PROFILE_PINS, profile) ? PROFILE_PINS[profile] : void 0;
554
- if (!entry || !entry.enabled) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
685
+ if (!entry || !entry.enabled && !(allowHeld === true && profile === HELD_PROFILE)) {
686
+ throw new EmailProofError(step, "PROFILE_UNAVAILABLE", void 0, void 0, LOCAL_PROFILE_UNAVAILABLE);
687
+ }
555
688
  return entry;
556
689
  }
557
690
  function emailRequirement(address, profile) {
691
+ 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 });
558
692
  return createEmailCredentialRequirement({ email: address, profile: { id: profile, version: 1 }, credentialType: EMAIL_PROOF_CREDENTIAL_TYPE });
559
693
  }
560
- async function witnessDescriptor(profile, requirement, options, step) {
561
- const pin = profilePin(profile, step);
694
+ async function witnessDescriptor(profile, requirement, options, step, allowHeld) {
695
+ const pin = profilePin(profile, step, allowHeld);
562
696
  let descriptor;
563
697
  try {
564
698
  descriptor = await discoverCatalogDescriptor(requirement, DISCOVERY_URL, { fetch: options.fetch, signal: options.signal });
@@ -568,7 +702,7 @@ async function witnessDescriptor(profile, requirement, options, step) {
568
702
  if (descriptor.issuer.origin !== EMAIL_PROOF_PINS.witnessOrigin || descriptor.issuer.did !== EMAIL_PROOF_PINS.issuerDid || !descriptor.steps.some((candidate) => candidate.type === pin.requiredStep)) {
569
703
  throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
570
704
  }
571
- if (await canonicalDigest2(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
705
+ if (await canonicalDigest3(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
572
706
  return descriptor;
573
707
  }
574
708
  async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}) {
@@ -576,30 +710,30 @@ async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}
576
710
  try {
577
711
  url = new URL(discoveryUrl);
578
712
  } catch {
579
- throw new CredentialError3("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
713
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
580
714
  }
581
- 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");
715
+ 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");
582
716
  const init = { credentials: "omit", cache: "no-store", redirect: "error", referrerPolicy: "no-referrer", signal: options.signal };
583
717
  let response;
584
718
  try {
585
719
  response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(url, init);
586
720
  } catch (cause) {
587
- throw new CredentialError3("OFFLINE", "Credential discovery is unavailable", { cause });
721
+ throw new CredentialError4("OFFLINE", "Credential discovery is unavailable", { cause });
588
722
  }
589
- if (!response.ok) throw new CredentialError3(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
723
+ if (!response.ok) throw new CredentialError4(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
590
724
  const catalog = await response.json();
591
- 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");
725
+ 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");
592
726
  for (const entryValue of catalog.profiles) {
593
727
  if (typeof entryValue !== "object" || entryValue === null || Array.isArray(entryValue)) continue;
594
728
  const entry = entryValue;
595
729
  if (entry.supported !== true || entry.enabled !== true || entry.readiness !== "ready" && entry.readiness !== "degraded") continue;
596
730
  try {
597
731
  const candidate = validateCredentialFlowDescriptor(entry.descriptor);
598
- if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest2(candidate)) return candidate;
732
+ if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest3(candidate)) return candidate;
599
733
  } catch {
600
734
  }
601
735
  }
602
- throw new CredentialError3("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
736
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
603
737
  }
604
738
  function discoveryFailure(error, signal, step) {
605
739
  if (signal.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
@@ -608,8 +742,52 @@ function discoveryFailure(error, signal, step) {
608
742
  function pinnedIssuerMetadata(keys) {
609
743
  return { type: "OpenCredentialsIssuerMetadata", version: 1, origin: EMAIL_PROOF_PINS.witnessOrigin, issuerDid: EMAIL_PROOF_PINS.issuerDid, keys, cache: { maxAgeSeconds: 3600, etag: "pinned" } };
610
744
  }
611
- function holderSigning(holder) {
612
- return { autoSign: async (_binding, bytes) => holder.sign(bytes), holderKey: { alg: "EdDSA", kid: holder.kid } };
745
+ async function verifyHeldResult(input) {
746
+ const { envelope, pending, descriptor } = input;
747
+ const refuse = (message) => new CredentialError4("VERIFICATION_FAILED", message);
748
+ const iatMs = Date.parse(envelope.issuedAt);
749
+ const clientMs = input.now().getTime();
750
+ if (!Number.isFinite(iatMs) || iatMs - clientMs > CLOCK_SKEW_BOUND_MS) throw refuse("client clock is more than 5 minutes behind the issuer");
751
+ const key = input.issuerKeys.find((candidate) => candidate.kid === EMAIL_PROOF_PINS.issuerKid);
752
+ if (!key) throw refuse("no pinned issuer key");
753
+ const issuerSet = parseIssuerSet(
754
+ { 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 }] },
755
+ { didMethods: { issuer: ["web"] } }
756
+ );
757
+ const verified = verifyHeldEmailCredential(envelope.credential, {
758
+ now: new Date(Math.max(clientMs, iatMs)),
759
+ owner: pending.holderDid,
760
+ didMethods: { issuer: ["web"] },
761
+ acceptedIssuers: [{ issuerDid: EMAIL_PROOF_PINS.issuerDid, kid: EMAIL_PROOF_PINS.issuerKid }],
762
+ issuerSet
763
+ });
764
+ const signed = input.transport.signedBinding(pending.requestId);
765
+ const claims = Object.keys(envelope.claims).sort().join();
766
+ const toSecond = (ms) => Math.floor(ms / 1e3);
767
+ if (
768
+ // (i) the holder, as the one EIP-55 string
769
+ envelope.subjectDid !== pending.holderDid || envelope.holderDid !== pending.holderDid || // (ii) exactly {email, emailDomain}, equal to the credential's, and the requested mailbox
770
+ 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
771
+ toSecond(Date.parse(envelope.issuedAt)) !== toSecond(verified.issuedAt.getTime()) || toSecond(Date.parse(envelope.expiresAt)) !== toSecond(verified.expiresAt.getTime()) || // (v) the pins
772
+ 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
773
+ ) throw refuse("issued credential does not match the request");
774
+ if (signed?.type !== descriptor.holderBinding.domain || !("credentialLifetimeSeconds" in signed)) throw refuse("signed binding is missing");
775
+ 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");
776
+ return verified;
777
+ }
778
+ function holderSigning(holder, held) {
779
+ return {
780
+ autoSign: async (_binding, bytes) => {
781
+ try {
782
+ return await holder.sign(bytes);
783
+ } catch (error) {
784
+ const aborted = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
785
+ if (!held || error instanceof EmailProofError || aborted) throw error;
786
+ throw new EmailProofError("credential", "SIGNATURE_REJECTED");
787
+ }
788
+ },
789
+ holderKey: { alg: holder.alg, kid: holder.kid }
790
+ };
613
791
  }
614
792
  function randomVerifier() {
615
793
  return encodeBase64Url3(crypto.getRandomValues(new Uint8Array(32)));
@@ -631,6 +809,7 @@ function mapError(value, step, signal) {
631
809
  if (signal?.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
632
810
  return new EmailProofError(step, "NETWORK");
633
811
  case "UNSUPPORTED_PROFILE":
812
+ case "UNSUPPORTED_VERSION":
634
813
  case "DESCRIPTOR_INVALID":
635
814
  return new EmailProofError(step, "PROFILE_UNAVAILABLE");
636
815
  case "PROOF_REJECTED":
@@ -660,6 +839,7 @@ import { admitPolicyCredentialV4, credentialMatchesRequirement, verifyOwnerNodeB
660
839
  import { ShareReceiveError, ShareRecipientClient } from "@tinycloud/share-sdk";
661
840
  async function openVerifiedAgentShare(pending, result, requirement, options) {
662
841
  const { envelope } = pending;
842
+ if (!isTicketEmailProofResult(result)) throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
663
843
  if (!credentialMatchesRequirement(requirement, result.verified, options.holder.did, new Date(options.now?.() ?? Date.now()))) {
664
844
  throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
665
845
  }