@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.
- package/dist/credential-pending-store.cjs +45 -9
- package/dist/credential-pending-store.cjs.map +1 -1
- package/dist/credential-pending-store.d.cts +2 -1
- package/dist/credential-pending-store.d.ts +2 -1
- package/dist/credential-pending-store.js +54 -17
- package/dist/credential-pending-store.js.map +1 -1
- package/dist/credentials-core.cjs +140 -56
- package/dist/credentials-core.cjs.map +1 -1
- package/dist/credentials-core.d.cts +21 -1
- package/dist/credentials-core.d.ts +21 -1
- package/dist/credentials-core.js +138 -54
- package/dist/credentials-core.js.map +1 -1
- package/dist/credentials.cjs +306 -117
- package/dist/credentials.cjs.map +1 -1
- package/dist/credentials.d.cts +33 -1
- package/dist/credentials.d.ts +33 -1
- package/dist/credentials.js +294 -107
- package/dist/credentials.js.map +1 -1
- package/dist/{email-proof-D0r_WShV.d.cts → email-proof-B4DRizIL.d.cts} +23 -5
- package/dist/{email-proof-D0r_WShV.d.ts → email-proof-B4DRizIL.d.ts} +23 -5
- package/dist/share-receive.cjs +294 -115
- package/dist/share-receive.cjs.map +1 -1
- package/dist/share-receive.d.cts +2 -1
- package/dist/share-receive.d.ts +2 -1
- package/dist/share-receive.js +278 -98
- package/dist/share-receive.js.map +1 -1
- package/package.json +2 -2
package/dist/share-receive.js
CHANGED
|
@@ -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
|
|
10
|
+
canonicalDigest as canonicalDigest3,
|
|
11
11
|
createEmailCredentialRequirement,
|
|
12
12
|
credentialError,
|
|
13
13
|
credentialRequirementDigest,
|
|
14
|
-
CredentialError as
|
|
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
|
|
44
|
-
if (state.state === "expired") throw new
|
|
45
|
-
if (state.state === "canceled") throw new
|
|
46
|
-
if (state.state === "issuer_unready") throw new
|
|
47
|
-
if (state.state === "complete")
|
|
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
|
|
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 =
|
|
70
|
-
|
|
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
|
|
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
|
|
79
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
159
|
-
throw new
|
|
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
|
|
169
|
-
if (body.code === "RATE_LIMITED") return new
|
|
170
|
-
if (body.code === "CHALLENGE_EXPIRED") return new
|
|
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
|
|
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
|
|
198
|
-
throw new
|
|
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
|
|
210
|
-
if (response.status === 409) throw new
|
|
211
|
-
if (response.status === 503) throw new
|
|
212
|
-
throw new
|
|
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
|
|
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:
|
|
223
|
-
if (body.type !== "tinycloud.credentials/acquisition-request/v1" || body.protocol !== input.descriptor.protocol || body.next !== "challenge" || body.endpoint !== "challenge") throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 !==
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 !==
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
331
|
-
|
|
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 =
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
721
|
+
throw new CredentialError4("OFFLINE", "Credential discovery is unavailable", { cause });
|
|
588
722
|
}
|
|
589
|
-
if (!response.ok) throw new
|
|
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
|
|
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
|
|
732
|
+
if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest3(candidate)) return candidate;
|
|
599
733
|
} catch {
|
|
600
734
|
}
|
|
601
735
|
}
|
|
602
|
-
throw new
|
|
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
|
|
612
|
-
|
|
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
|
}
|