@tinycloud/node-sdk 3.9.0-beta.3 → 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.
- 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 +300 -115
- 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 +288 -105
- package/dist/credentials.js.map +1 -1
- package/dist/{email-proof-D0r_WShV.d.cts → email-proof-BaOqVlB-.d.cts} +20 -3
- package/dist/{email-proof-D0r_WShV.d.ts → email-proof-BaOqVlB-.d.ts} +20 -3
- package/dist/share-receive.cjs +288 -113
- 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 +272 -96
- 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,6 +452,7 @@ 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
|
};
|
|
@@ -397,7 +484,8 @@ function restoreEmailProofPending(fields) {
|
|
|
397
484
|
requirementDigest: fields.requirementDigest,
|
|
398
485
|
openerOrigin: fields.openerOrigin,
|
|
399
486
|
createdAt: fields.createdAt,
|
|
400
|
-
expiresAt: fields.expiresAt
|
|
487
|
+
expiresAt: fields.expiresAt,
|
|
488
|
+
...fields.serverExpiresAt === void 0 ? {} : { serverExpiresAt: fields.serverExpiresAt }
|
|
401
489
|
};
|
|
402
490
|
const pending = { ...shown };
|
|
403
491
|
hide(pending, "verifier", fields.verifier);
|
|
@@ -411,6 +499,9 @@ function hide(target, key, value) {
|
|
|
411
499
|
function pendingDeadlineMs(pending) {
|
|
412
500
|
return Math.min(Date.parse(pending.expiresAt), Date.parse(pending.createdAt) + EMAIL_PROOF_PENDING_MAX_MS);
|
|
413
501
|
}
|
|
502
|
+
function isTicketEmailProofResult(result) {
|
|
503
|
+
return result.envelope.profile.id !== HELD_PROFILE;
|
|
504
|
+
}
|
|
414
505
|
var CodeRequested = class extends Error {
|
|
415
506
|
};
|
|
416
507
|
var pauseForCode = async () => {
|
|
@@ -418,17 +509,19 @@ var pauseForCode = async () => {
|
|
|
418
509
|
};
|
|
419
510
|
async function requestEmailProofCode(input) {
|
|
420
511
|
const profile = input.profile ?? DEFAULT_EMAIL_PROOF_PROFILE;
|
|
421
|
-
profilePin(profile, "request");
|
|
422
|
-
const
|
|
423
|
-
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);
|
|
424
517
|
const now = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
425
518
|
const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
|
|
426
519
|
const requirement = emailRequirement(address, profile);
|
|
427
520
|
const requirementDigest = await credentialRequirementDigest(requirement);
|
|
428
|
-
const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request");
|
|
429
|
-
const descriptorDigest = await
|
|
521
|
+
const descriptor = await witnessDescriptor(profile, requirement, { fetch: input.fetch, signal }, "request", input.allowHeldProfile);
|
|
522
|
+
const descriptorDigest = await canonicalDigest3(descriptor);
|
|
430
523
|
const verifier = randomVerifier();
|
|
431
|
-
const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
|
|
524
|
+
const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
|
|
432
525
|
const createdAtMs = now().getTime();
|
|
433
526
|
let created;
|
|
434
527
|
try {
|
|
@@ -447,6 +540,8 @@ async function requestEmailProofCode(input) {
|
|
|
447
540
|
}
|
|
448
541
|
const serverExpiry = Date.parse(created.expiresAt);
|
|
449
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");
|
|
450
545
|
const pending = restoreEmailProofPending({
|
|
451
546
|
type: "TinyCloudEmailProofPending",
|
|
452
547
|
version: 1,
|
|
@@ -459,11 +554,14 @@ async function requestEmailProofCode(input) {
|
|
|
459
554
|
requirementDigest,
|
|
460
555
|
openerOrigin: OPENER_ORIGIN,
|
|
461
556
|
createdAt: new Date(createdAtMs).toISOString(),
|
|
462
|
-
|
|
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 } : {}
|
|
463
560
|
});
|
|
464
561
|
try {
|
|
465
562
|
await interpretCredentialFlow({
|
|
466
563
|
descriptor,
|
|
564
|
+
...held ? { allowAccountSubject: true } : {},
|
|
467
565
|
requirement,
|
|
468
566
|
requestId: created.requestId,
|
|
469
567
|
verifier,
|
|
@@ -471,8 +569,9 @@ async function requestEmailProofCode(input) {
|
|
|
471
569
|
descriptorDigest,
|
|
472
570
|
requirementDigest,
|
|
473
571
|
openerOrigin: OPENER_ORIGIN,
|
|
572
|
+
...held ? { expiresAt: created.expiresAt } : {},
|
|
474
573
|
transport,
|
|
475
|
-
signing: holderSigning(input.holder),
|
|
574
|
+
signing: holderSigning(input.holder, held),
|
|
476
575
|
handlers: { mailbox_otp: pauseForCode },
|
|
477
576
|
signal
|
|
478
577
|
});
|
|
@@ -489,22 +588,26 @@ async function completeWithIssuerKeys(input, issuerKeys) {
|
|
|
489
588
|
const { pending } = input;
|
|
490
589
|
const now = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
491
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");
|
|
492
594
|
if (pendingDeadlineMs(pending) <= now().getTime()) throw new EmailProofError("session", "SESSION_EXPIRED");
|
|
493
595
|
if (!/^\d{8}$/.test(input.code)) throw new EmailProofError("code", "CODE_INVALID");
|
|
494
|
-
assertHolder(input.holder, "code");
|
|
596
|
+
assertHolder(input.holder, "code", pending.profile);
|
|
495
597
|
if (input.holder.did !== pending.holderDid) throw new EmailProofError("session", "SESSION_INVALID");
|
|
496
598
|
const signal = input.signal ?? AbortSignal.timeout(STEP_TIMEOUT_MS);
|
|
497
599
|
const requirement = emailRequirement(pending.address, pending.profile);
|
|
498
600
|
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
|
|
601
|
+
const descriptor = await witnessDescriptor(pending.profile, requirement, { fetch: input.fetch, signal }, "session", input.allowHeldProfile);
|
|
602
|
+
const descriptorDigest = await canonicalDigest3(descriptor);
|
|
501
603
|
if (descriptorDigest !== pending.descriptorDigest) throw new EmailProofError("session", "SESSION_INVALID");
|
|
502
|
-
const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true });
|
|
604
|
+
const transport = new OpenCredentialsHttpTransport(descriptor, input.fetch, { honorServerRecoverable: true, ...held ? { allowAccountSubject: true } : {} });
|
|
503
605
|
try {
|
|
504
606
|
const state = await transport.state(pending.requestId, pending.verifier, signal);
|
|
505
607
|
if (state.nextStep?.constraints.challengeRequired === true) throw new EmailProofError("session", "SESSION_INVALID");
|
|
506
608
|
await interpretCredentialFlow({
|
|
507
609
|
descriptor,
|
|
610
|
+
...held ? { allowAccountSubject: true } : {},
|
|
508
611
|
requirement,
|
|
509
612
|
requestId: pending.requestId,
|
|
510
613
|
verifier: pending.verifier,
|
|
@@ -512,8 +615,9 @@ async function completeWithIssuerKeys(input, issuerKeys) {
|
|
|
512
615
|
descriptorDigest,
|
|
513
616
|
requirementDigest: pending.requirementDigest,
|
|
514
617
|
openerOrigin: pending.openerOrigin,
|
|
618
|
+
...held ? { expiresAt: pending.serverExpiresAt } : {},
|
|
515
619
|
transport,
|
|
516
|
-
signing: holderSigning(input.holder),
|
|
620
|
+
signing: holderSigning(input.holder, held),
|
|
517
621
|
handlers: { mailbox_otp: async () => ({ otp: input.code }) },
|
|
518
622
|
signal
|
|
519
623
|
});
|
|
@@ -522,6 +626,10 @@ async function completeWithIssuerKeys(input, issuerKeys) {
|
|
|
522
626
|
}
|
|
523
627
|
try {
|
|
524
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
|
+
}
|
|
525
633
|
const verified = await verifyIssuedCredential({
|
|
526
634
|
envelope,
|
|
527
635
|
descriptor,
|
|
@@ -538,27 +646,49 @@ async function completeWithIssuerKeys(input, issuerKeys) {
|
|
|
538
646
|
throw mapError(error, "credential", signal);
|
|
539
647
|
}
|
|
540
648
|
}
|
|
541
|
-
function normalizeAddress(address) {
|
|
649
|
+
function normalizeAddress(address, profile) {
|
|
650
|
+
if (profile === HELD_PROFILE) return normalizeHeldAddress(address);
|
|
542
651
|
const value = address.trim();
|
|
543
652
|
if (value.length > 254 || !EMAIL_PATTERN.test(value)) throw new EmailProofError("request", "ADDRESS_INVALID");
|
|
544
653
|
return value;
|
|
545
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
|
+
}
|
|
546
669
|
function holderKid(did) {
|
|
547
670
|
return `${did}#${did.slice(HOLDER_DID_PREFIX.length)}`;
|
|
548
671
|
}
|
|
549
|
-
function
|
|
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);
|
|
550
679
|
if (!HOLDER_DID_PATTERN.test(holder.did) || holder.alg !== "EdDSA" || holder.kid !== holderKid(holder.did)) throw new EmailProofError(step, "HOLDER_INVALID");
|
|
551
680
|
}
|
|
552
|
-
function profilePin(profile, step) {
|
|
681
|
+
function profilePin(profile, step, allowHeld) {
|
|
553
682
|
const entry = Object.prototype.hasOwnProperty.call(PROFILE_PINS, profile) ? PROFILE_PINS[profile] : void 0;
|
|
554
|
-
if (!entry || !entry.enabled) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
|
|
683
|
+
if (!entry || !entry.enabled && !(allowHeld === true && profile === HELD_PROFILE)) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
|
|
555
684
|
return entry;
|
|
556
685
|
}
|
|
557
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 });
|
|
558
688
|
return createEmailCredentialRequirement({ email: address, profile: { id: profile, version: 1 }, credentialType: EMAIL_PROOF_CREDENTIAL_TYPE });
|
|
559
689
|
}
|
|
560
|
-
async function witnessDescriptor(profile, requirement, options, step) {
|
|
561
|
-
const pin = profilePin(profile, step);
|
|
690
|
+
async function witnessDescriptor(profile, requirement, options, step, allowHeld) {
|
|
691
|
+
const pin = profilePin(profile, step, allowHeld);
|
|
562
692
|
let descriptor;
|
|
563
693
|
try {
|
|
564
694
|
descriptor = await discoverCatalogDescriptor(requirement, DISCOVERY_URL, { fetch: options.fetch, signal: options.signal });
|
|
@@ -568,7 +698,7 @@ async function witnessDescriptor(profile, requirement, options, step) {
|
|
|
568
698
|
if (descriptor.issuer.origin !== EMAIL_PROOF_PINS.witnessOrigin || descriptor.issuer.did !== EMAIL_PROOF_PINS.issuerDid || !descriptor.steps.some((candidate) => candidate.type === pin.requiredStep)) {
|
|
569
699
|
throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
|
|
570
700
|
}
|
|
571
|
-
if (await
|
|
701
|
+
if (await canonicalDigest3(descriptor) !== pin.descriptorDigest) throw new EmailProofError(step, "PROFILE_UNAVAILABLE");
|
|
572
702
|
return descriptor;
|
|
573
703
|
}
|
|
574
704
|
async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}) {
|
|
@@ -576,30 +706,30 @@ async function discoverCatalogDescriptor(requirement, discoveryUrl, options = {}
|
|
|
576
706
|
try {
|
|
577
707
|
url = new URL(discoveryUrl);
|
|
578
708
|
} catch {
|
|
579
|
-
throw new
|
|
709
|
+
throw new CredentialError4("UNSUPPORTED_PROFILE", "Discovery URL is invalid");
|
|
580
710
|
}
|
|
581
|
-
if (url.protocol !== "https:" || !["/.well-known/opencredentials", "/v1/credential-types"].includes(url.pathname) || url.search || url.hash) throw new
|
|
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");
|
|
582
712
|
const init = { credentials: "omit", cache: "no-store", redirect: "error", referrerPolicy: "no-referrer", signal: options.signal };
|
|
583
713
|
let response;
|
|
584
714
|
try {
|
|
585
715
|
response = await (options.fetch ?? globalThis.fetch.bind(globalThis))(url, init);
|
|
586
716
|
} catch (cause) {
|
|
587
|
-
throw new
|
|
717
|
+
throw new CredentialError4("OFFLINE", "Credential discovery is unavailable", { cause });
|
|
588
718
|
}
|
|
589
|
-
if (!response.ok) throw new
|
|
719
|
+
if (!response.ok) throw new CredentialError4(response.status === 503 ? "ISSUER_UNREADY" : "OFFLINE", "Credential discovery is unavailable");
|
|
590
720
|
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
|
|
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");
|
|
592
722
|
for (const entryValue of catalog.profiles) {
|
|
593
723
|
if (typeof entryValue !== "object" || entryValue === null || Array.isArray(entryValue)) continue;
|
|
594
724
|
const entry = entryValue;
|
|
595
725
|
if (entry.supported !== true || entry.enabled !== true || entry.readiness !== "ready" && entry.readiness !== "degraded") continue;
|
|
596
726
|
try {
|
|
597
727
|
const candidate = validateCredentialFlowDescriptor(entry.descriptor);
|
|
598
|
-
if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await
|
|
728
|
+
if (descriptorSatisfiesRequirement(candidate, requirement) && entry.descriptorDigest === await canonicalDigest3(candidate)) return candidate;
|
|
599
729
|
} catch {
|
|
600
730
|
}
|
|
601
731
|
}
|
|
602
|
-
throw new
|
|
732
|
+
throw new CredentialError4("UNSUPPORTED_PROFILE", "No available credential profile satisfies the requirement");
|
|
603
733
|
}
|
|
604
734
|
function discoveryFailure(error, signal, step) {
|
|
605
735
|
if (signal.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
|
|
@@ -608,8 +738,52 @@ function discoveryFailure(error, signal, step) {
|
|
|
608
738
|
function pinnedIssuerMetadata(keys) {
|
|
609
739
|
return { type: "OpenCredentialsIssuerMetadata", version: 1, origin: EMAIL_PROOF_PINS.witnessOrigin, issuerDid: EMAIL_PROOF_PINS.issuerDid, keys, cache: { maxAgeSeconds: 3600, etag: "pinned" } };
|
|
610
740
|
}
|
|
611
|
-
function
|
|
612
|
-
|
|
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
|
+
};
|
|
613
787
|
}
|
|
614
788
|
function randomVerifier() {
|
|
615
789
|
return encodeBase64Url3(crypto.getRandomValues(new Uint8Array(32)));
|
|
@@ -631,6 +805,7 @@ function mapError(value, step, signal) {
|
|
|
631
805
|
if (signal?.aborted && signal.reason?.name !== "TimeoutError") return new EmailProofError(step, "CANCELED");
|
|
632
806
|
return new EmailProofError(step, "NETWORK");
|
|
633
807
|
case "UNSUPPORTED_PROFILE":
|
|
808
|
+
case "UNSUPPORTED_VERSION":
|
|
634
809
|
case "DESCRIPTOR_INVALID":
|
|
635
810
|
return new EmailProofError(step, "PROFILE_UNAVAILABLE");
|
|
636
811
|
case "PROOF_REJECTED":
|
|
@@ -660,6 +835,7 @@ import { admitPolicyCredentialV4, credentialMatchesRequirement, verifyOwnerNodeB
|
|
|
660
835
|
import { ShareReceiveError, ShareRecipientClient } from "@tinycloud/share-sdk";
|
|
661
836
|
async function openVerifiedAgentShare(pending, result, requirement, options) {
|
|
662
837
|
const { envelope } = pending;
|
|
838
|
+
if (!isTicketEmailProofResult(result)) throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
|
|
663
839
|
if (!credentialMatchesRequirement(requirement, result.verified, options.holder.did, new Date(options.now?.() ?? Date.now()))) {
|
|
664
840
|
throw new ShareReceiveError("authorization-denied", "email credential does not match this share");
|
|
665
841
|
}
|