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