@tinycloud/node-sdk 3.8.1-beta.2 → 3.9.0-beta.4

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`;
@@ -326,9 +405,17 @@ var EMAIL_PROOF_ISSUER_KEYS = [
326
405
  ];
327
406
  var DEFAULT_EMAIL_PROOF_PROFILE = "tinycloud.email-proof/v1";
328
407
  var PROFILE_PINS = {
329
- "tinycloud.email-proof/v1": { descriptorDigest: EMAIL_PROOF_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: true }
408
+ "tinycloud.email-proof/v1": { descriptorDigest: EMAIL_PROOF_PINS.descriptorDigest, requiredStep: "mailbox_otp", enabled: true },
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 }
330
412
  };
331
- 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;
332
419
  var STEP_TIMEOUT_MS = 6e4;
333
420
  var HOLDER_DID_PREFIX = "did:key:";
334
421
  var HOLDER_DID_PATTERN = /^did:key:z6Mk[^#]+$/;
@@ -348,6 +435,7 @@ var RECOVERABILITY = {
348
435
  CREDENTIAL_INVALID: false,
349
436
  SESSION_EXPIRED: false,
350
437
  SESSION_INVALID: false,
438
+ CLIENT_CLOCK_SKEW: false,
351
439
  CANCELED: false,
352
440
  FAILED: false
353
441
  };
@@ -364,6 +452,7 @@ var MESSAGES = {
364
452
  CREDENTIAL_INVALID: "Issued credential failed verification.",
365
453
  SESSION_EXPIRED: "Email verification request expired. Request a new code.",
366
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.",
367
456
  CANCELED: "Email verification was canceled.",
368
457
  FAILED: "Email verification failed."
369
458
  };
@@ -395,7 +484,8 @@ function restoreEmailProofPending(fields) {
395
484
  requirementDigest: fields.requirementDigest,
396
485
  openerOrigin: fields.openerOrigin,
397
486
  createdAt: fields.createdAt,
398
- expiresAt: fields.expiresAt
487
+ expiresAt: fields.expiresAt,
488
+ ...fields.serverExpiresAt === void 0 ? {} : { serverExpiresAt: fields.serverExpiresAt }
399
489
  };
400
490
  const pending = { ...shown };
401
491
  hide(pending, "verifier", fields.verifier);
@@ -409,6 +499,9 @@ function hide(target, key, value) {
409
499
  function pendingDeadlineMs(pending) {
410
500
  return Math.min(Date.parse(pending.expiresAt), Date.parse(pending.createdAt) + EMAIL_PROOF_PENDING_MAX_MS);
411
501
  }
502
+ function isTicketEmailProofResult(result) {
503
+ return result.envelope.profile.id !== HELD_PROFILE;
504
+ }
412
505
  var CodeRequested = class extends Error {
413
506
  };
414
507
  var pauseForCode = async () => {
@@ -416,17 +509,19 @@ var pauseForCode = async () => {
416
509
  };
417
510
  async function requestEmailProofCode(input) {
418
511
  const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
419
- profilePin(profile, "request");
420
- const address = normalizeAddress(input.address);
421
- assertHolder(input.holder, "request");
512
+ profilePin(profile, "request", input.allowHeldProfile);
513
+ const held = profile === HELD_PROFILE;
514
+ if (held) assertHolder(input.holder, "request", profile);
515
+ const address = normalizeAddress(input.address, profile);
516
+ if (!held) assertHolder(input.holder, "request", profile);
422
517
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
423
518
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
424
519
  const requirement = emailRequirement(address, profile);
425
520
  const requirementDigest = await credentialRequirementDigest(requirement);
426
- const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request");
427
- const descriptorDigest = await canonicalDigest2(descriptor);
521
+ const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request", input.allowHeldProfile);
522
+ const descriptorDigest = await canonicalDigest3(descriptor);
428
523
  const verifier = randomVerifier();
429
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
524
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
430
525
  const createdAtMs = now().getTime();
431
526
  let created;
432
527
  try {
@@ -445,6 +540,8 @@ async function requestEmailProofCode(input) {
445
540
  }
446
541
  const serverExpiry = Date.parse(created.expiresAt);
447
542
  if (!Number.isFinite(serverExpiry)) throw new EmailProofError("request", "FAILED");
543
+ const serverNowMs = serverExpiry - HELD_REQUEST_TTL_MS;
544
+ if (held && serverNowMs - now().getTime() > CLOCK_SKEW_BOUND_MS) throw new EmailProofError("request", "CLIENT_CLOCK_SKEW");
448
545
  const pending = restoreEmailProofPending({
449
546
  type: "TinyCloudEmailProofPending",
450
547
  version: 1,
@@ -457,11 +554,14 @@ async function requestEmailProofCode(input) {
457
554
  requirementDigest,
458
555
  openerOrigin: OPENER_ORIGIN,
459
556
  createdAt: new Date(createdAtMs).toISOString(),
460
- expiresAt: new Date(Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString()
557
+ // Held: the deadline is measured on the client clock (a fast client must not lose window), the witness enforces its own 600 s.
558
+ expiresAt: new Date(held ? createdAtMs + Math.min(serverExpiry - serverNowMs, EMAIL_PROOF_PENDING_MAX_MS) : Math.min(serverExpiry, createdAtMs + EMAIL_PROOF_PENDING_MAX_MS)).toISOString(),
559
+ ...held ? { serverExpiresAt: created.expiresAt } : {}
461
560
  });
462
561
  try {
463
562
  await interpretCredentialFlow({
464
563
  descriptor,
564
+ ...held ? { allowAccountSubject: true } : {},
465
565
  requirement,
466
566
  requestId: created.requestId,
467
567
  verifier,
@@ -469,8 +569,9 @@ async function requestEmailProofCode(input) {
469
569
  descriptorDigest,
470
570
  requirementDigest,
471
571
  openerOrigin: OPENER_ORIGIN,
572
+ ...held ? { expiresAt: created.expiresAt } : {},
472
573
  transport,
473
- signing: holderSigning(input.holder),
574
+ signing: holderSigning(input.holder, held),
474
575
  handlers: { mailbox_otp: pauseForCode },
475
576
  signal
476
577
  });
@@ -487,22 +588,26 @@ async function completeWithIssuerKeys(input, issuerKeys) {
487
588
  const { pending } = input;
488
589
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
489
590
  if (!isPending(pending)) throw new EmailProofError("session", "SESSION_INVALID");
591
+ profilePin(pending.profile, "session", input.allowHeldProfile);
592
+ const held = pending.profile === HELD_PROFILE;
593
+ if (held && !Number.isFinite(Date.parse(pending.serverExpiresAt ?? ""))) throw new EmailProofError("session", "SESSION_INVALID");
490
594
  if (pendingDeadlineMs(pending) <= now().getTime()) throw new EmailProofError("session", "SESSION_EXPIRED");
491
595
  if (!/^\d{8}$/.test(input.code)) throw new EmailProofError("code", "CODE_INVALID");
492
- assertHolder(input.holder, "code");
596
+ assertHolder(input.holder, "code", pending.profile);
493
597
  if (input.holder.did !== pending.holderDid) throw new EmailProofError("session", "SESSION_INVALID");
494
598
  const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
495
599
  const requirement = emailRequirement(pending.address, pending.profile);
496
600
  if (await credentialRequirementDigest(requirement) !== pending.requirementDigest) throw new EmailProofError("session", "SESSION_INVALID");
497
- const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session");
498
- const descriptorDigest = await canonicalDigest2(descriptor);
601
+ const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session", input.allowHeldProfile);
602
+ const descriptorDigest = await canonicalDigest3(descriptor);
499
603
  if (descriptorDigest !== pending.descriptorDigest) throw new EmailProofError("session", "SESSION_INVALID");
500
- const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
604
+ const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
501
605
  try {
502
606
  const state = await transport.state(pending.requestId, pending.verifier, signal);
503
607
  if (state.nextStep?.constraints.challengeRequired === true) throw new EmailProofError("session", "SESSION_INVALID");
504
608
  await interpretCredentialFlow({
505
609
  descriptor,
610
+ ...held ? { allowAccountSubject: true } : {},
506
611
  requirement,
507
612
  requestId: pending.requestId,
508
613
  verifier: pending.verifier,
@@ -510,8 +615,9 @@ async function completeWithIssuerKeys(input, issuerKeys) {
510
615
  descriptorDigest,
511
616
  requirementDigest: pending.requirementDigest,
512
617
  openerOrigin: pending.openerOrigin,
618
+ ...held ? { expiresAt: pending.serverExpiresAt } : {},
513
619
  transport,
514
- signing: holderSigning(input.holder),
620
+ signing: holderSigning(input.holder, held),
515
621
  handlers: { mailbox_otp: async () => ({ otp: input.code }) },
516
622
  signal
517
623
  });
@@ -520,6 +626,10 @@ async function completeWithIssuerKeys(input, issuerKeys) {
520
626
  }
521
627
  try {
522
628
  const envelope = await transport.result(pending.requestId, pending.verifier, signal);
629
+ if (held) {
630
+ const verified2 = await verifyHeldResult({ envelope, pending, descriptor, descriptorDigest, transport, issuerKeys, now });
631
+ return { envelope, verified: verified2, credentialId: verified2.credentialId, issuedAt: verified2.issuedAt.toISOString(), expiresAt: verified2.expiresAt.toISOString(), descriptor };
632
+ }
523
633
  const verified = await verifyIssuedCredential({
524
634
  envelope,
525
635
  descriptor,
@@ -536,27 +646,49 @@ async function completeWithIssuerKeys(input, issuerKeys) {
536
646
  throw mapError(error, "credential", signal);
537
647
  }
538
648
  }
539
- function normalizeAddress(address) {
649
+ function normalizeAddress(address, profile) {
650
+ if (profile === HELD_PROFILE) return normalizeHeldAddress(address);
540
651
  const value = address.trim();
541
652
  if (value.length > 254 || !EMAIL_PATTERN.test(value)) throw new EmailProofError("request", "ADDRESS_INVALID");
542
653
  return value;
543
654
  }
655
+ var HELD_LOCAL_ATOM = /^[a-z0-9!#$%&'*+\-/=?^_`{|}~]+(\.[a-z0-9!#$%&'*+\-/=?^_`{|}~]+)*$/;
656
+ var HELD_DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
657
+ function normalizeHeldAddress(address) {
658
+ const invalid = () => new EmailProofError("request", "ADDRESS_INVALID");
659
+ const trimmed = address.trim();
660
+ if (/[^\x00-\x7f]/.test(trimmed)) throw invalid();
661
+ const value = trimmed.toLowerCase();
662
+ const parts = value.split("@");
663
+ if (value.length < 3 || value.length > 254 || parts.length !== 2 || /[^\x21-\x7e]/.test(value)) throw invalid();
664
+ const [local, domain] = parts;
665
+ if (local.length > 64 || !HELD_LOCAL_ATOM.test(local) || local.startsWith("+")) throw invalid();
666
+ if (domain.length > 253 || !domain.includes(".") || !domain.split(".").every((label) => label.length <= 63 && HELD_DOMAIN_LABEL.test(label))) throw invalid();
667
+ return value;
668
+ }
544
669
  function holderKid(did) {
545
670
  return `${did}#${did.slice(HOLDER_DID_PREFIX.length)}`;
546
671
  }
547
- function assertHolder(holder, step) {
672
+ function assertHeldHolder(holder, step) {
673
+ const parsed = parsePkhDid(holder.did);
674
+ const canonical = parsed !== null && parsed.chainId === "1" ? `did:pkh:eip155:1:${toChecksumAddress(parsed.address)}` : void 0;
675
+ if (canonical === void 0 || holder.did !== canonical || holder.alg !== "EIP191" || holder.kid !== `${canonical}#${HELD_KID_FRAGMENT}`) throw new EmailProofError(step, "HOLDER_INVALID");
676
+ }
677
+ function assertHolder(holder, step, profile) {
678
+ if (profile === HELD_PROFILE) return assertHeldHolder(holder, step);
548
679
  if (!HOLDER_DID_PATTERN.test(holder.did) || holder.alg !== "EdDSA" || holder.kid !== holderKid(holder.did)) throw new EmailProofError(step, "HOLDER_INVALID");
549
680
  }
550
- function profilePin(profile, step) {
681
+ function profilePin(profile, step, allowHeld) {
551
682
  const entry = Object.prototype.hasOwnProperty.call(PROFILE_PINS, profile) ? PROFILE_PINS[profile] : void 0;
552
- if (!entry || !entry.enabled) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
683
+ if (!entry || !entry.enabled && !(allowHeld === true && profile === HELD_PROFILE)) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
553
684
  return entry;
554
685
  }
555
686
  function emailRequirement(address, profile) {
687
+ 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 });
556
688
  return createEmailCredentialRequirement({ email: address, profile: { id: profile, version: 1 }, credentialType: EMAIL_PROOF_CREDENTIAL_TYPE });
557
689
  }
558
- async function witnessDescriptor(profile, requirement, options, step) {
559
- const pin = profilePin(profile, step);
690
+ async function witnessDescriptor(profile, requirement, options, step, allowHeld) {
691
+ const pin = profilePin(profile, step, allowHeld);
560
692
  let descriptor;
561
693
  try {
562
694
  descriptor = await discoverCatalogDescriptor(requirement, DISCOVERY_URL, { fetch: options.fetch, signal: options.signal });
@@ -566,7 +698,7 @@ async function witnessDescriptor(profile, requirement, options, step) {
566
698
  if (descriptor.issuer.origin !== EMAIL_PROOF_PINS.witnessOrigin || descriptor.issuer.did !== EMAIL_PROOF_PINS.issuerDid || !descriptor.steps.some((candidate) => candidate.type === pin.requiredStep)) {
567
699
  throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
568
700
  }
569
- if (await canonicalDigest2(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
701
+ if (await canonicalDigest3(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
570
702
  return descriptor;
571
703
  }
572
704
  async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}) {
@@ -574,30 +706,30 @@ async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}
574
706
  try {
575
707
  url = new URL(discoveryUrl);
576
708
  } catch {
577
- throw new CredentialError3("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
709
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
578
710
  }
579
- 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");
711
+ 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");
580
712
  const init = { credentials: "omit", cache: "no-store", redirect: "error", referrerPolicy: "no-referrer", signal: options.signal };
581
713
  let response;
582
714
  try {
583
715
  response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(url, init);
584
716
  } catch (cause) {
585
- throw new CredentialError3("OFFLINE", "Credential discovery is unavailable", { cause });
717
+ throw new CredentialError4("OFFLINE", "Credential discovery is unavailable", { cause });
586
718
  }
587
- if (!response.ok) throw new CredentialError3(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
719
+ if (!response.ok) throw new CredentialError4(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
588
720
  const catalog = await response.json();
589
- 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");
721
+ 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");
590
722
  for (const entryValue of catalog.profiles) {
591
723
  if (typeof entryValue !== "object" || entryValue === null || Array.isArray(entryValue)) continue;
592
724
  const entry = entryValue;
593
725
  if (entry.supported !== true || entry.enabled !== true || entry.readiness !== "ready" && entry.readiness !== "degraded") continue;
594
726
  try {
595
727
  const candidate = validateCredentialFlowDescriptor(entry.descriptor);
596
- if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest2(candidate)) return candidate;
728
+ if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest3(candidate)) return candidate;
597
729
  } catch {
598
730
  }
599
731
  }
600
- throw new CredentialError3("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
732
+ throw new CredentialError4("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
601
733
  }
602
734
  function discoveryFailure(error, signal, step) {
603
735
  if (signal.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
@@ -606,8 +738,52 @@ function discoveryFailure(error, signal, step) {
606
738
  function pinnedIssuerMetadata(keys) {
607
739
  return { type: "OpenCredentialsIssuerMetadata", version: 1, origin: EMAIL_PROOF_PINS.witnessOrigin, issuerDid: EMAIL_PROOF_PINS.issuerDid, keys, cache: { maxAgeSeconds: 3600, etag: "pinned" } };
608
740
  }
609
- function holderSigning(holder) {
610
- return { autoSign: async (_binding, bytes) => holder.sign(bytes), holderKey: { alg: "EdDSA", kid: holder.kid } };
741
+ async function verifyHeldResult(input) {
742
+ const { envelope, pending, descriptor } = input;
743
+ const refuse = (message) => new CredentialError4("VERIFICATION_FAILED", message);
744
+ const iatMs = Date.parse(envelope.issuedAt);
745
+ const clientMs = input.now().getTime();
746
+ if (!Number.isFinite(iatMs) || iatMs - clientMs > CLOCK_SKEW_BOUND_MS) throw refuse("client clock is more than 5 minutes behind the issuer");
747
+ const key = input.issuerKeys.find((candidate) => candidate.kid === EMAIL_PROOF_PINS.issuerKid);
748
+ if (!key) throw refuse("no pinned issuer key");
749
+ const issuerSet = parseIssuerSet(
750
+ { 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 }] },
751
+ { didMethods: { issuer: ["web"] } }
752
+ );
753
+ const verified = verifyHeldEmailCredential(envelope.credential, {
754
+ now: new Date(Math.max(clientMs, iatMs)),
755
+ owner: pending.holderDid,
756
+ didMethods: { issuer: ["web"] },
757
+ acceptedIssuers: [{ issuerDid: EMAIL_PROOF_PINS.issuerDid, kid: EMAIL_PROOF_PINS.issuerKid }],
758
+ issuerSet
759
+ });
760
+ const signed = input.transport.signedBinding(pending.requestId);
761
+ const claims = Object.keys(envelope.claims).sort().join();
762
+ const toSecond = (ms) => Math.floor(ms / 1e3);
763
+ if (
764
+ // (i) the holder, as the one EIP-55 string
765
+ envelope.subjectDid !== pending.holderDid || envelope.holderDid !== pending.holderDid || // (ii) exactly {email, emailDomain}, equal to the credential's, and the requested mailbox
766
+ 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
767
+ toSecond(Date.parse(envelope.issuedAt)) !== toSecond(verified.issuedAt.getTime()) || toSecond(Date.parse(envelope.expiresAt)) !== toSecond(verified.expiresAt.getTime()) || // (v) the pins
768
+ 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
769
+ ) throw refuse("issued credential does not match the request");
770
+ if (signed?.type !== descriptor.holderBinding.domain || !("credentialLifetimeSeconds" in signed)) throw refuse("signed binding is missing");
771
+ 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");
772
+ return verified;
773
+ }
774
+ function holderSigning(holder, held) {
775
+ return {
776
+ autoSign: async (_binding, bytes) => {
777
+ try {
778
+ return await holder.sign(bytes);
779
+ } catch (error) {
780
+ const aborted = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
781
+ if (!held || error instanceof EmailProofError || aborted) throw error;
782
+ throw new EmailProofError("credential", "SIGNATURE_REJECTED");
783
+ }
784
+ },
785
+ holderKey: { alg: holder.alg, kid: holder.kid }
786
+ };
611
787
  }
612
788
  function randomVerifier() {
613
789
  return encodeBase64Url3(crypto.getRandomValues(new Uint8Array(32)));
@@ -629,6 +805,7 @@ function mapError(value, step, signal) {
629
805
  if (signal?.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
630
806
  return new EmailProofError(step, "NETWORK");
631
807
  case "UNSUPPORTED_PROFILE":
808
+ case "UNSUPPORTED_VERSION":
632
809
  case "DESCRIPTOR_INVALID":
633
810
  return new EmailProofError(step, "PROFILE_UNAVAILABLE");
634
811
  case "PROOF_REJECTED":
@@ -658,6 +835,7 @@ import { admitPolicyCredentialV4, credentialMatchesRequirement, verifyOwnerNodeB
658
835
  import { ShareReceiveError, ShareRecipientClient } from "@tinycloud/share-sdk";
659
836
  async function openVerifiedAgentShare(pending, result, requirement, options) {
660
837
  const { envelope } = pending;
838
+ if (!isTicketEmailProofResult(result)) throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
661
839
  if (!credentialMatchesRequirement(requirement, result.verified, options.holder.did, new Date(options.now?.() ?? Date.now()))) {
662
840
  throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
663
841
  }