@coordination-network/user-app-core 0.0.1 → 0.1.0
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/README.md +53 -29
- package/dist/index.cjs +286 -91
- package/dist/index.js +285 -94
- package/package.json +9 -9
- package/src/chain-state.ts +28 -32
- package/src/errors.ts +24 -0
- package/src/handlers/credential-issue.ts +104 -29
- package/src/handlers/presentation-create.ts +41 -40
- package/src/index.ts +6 -1
- package/src/issuer-index.ts +22 -6
- package/src/member-api.ts +3 -1
- package/src/security-level.ts +8 -0
- package/src/selection.ts +2 -2
- package/src/service.ts +50 -6
- package/src/types.ts +61 -8
- package/src/wallet-factory.ts +62 -6
- package/types/index.d.ts +72 -15
package/dist/index.cjs
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
var identity = require('@coordination-network/identity');
|
|
4
4
|
var credential = require('@coordination-network/credential');
|
|
5
|
+
var wallet = require('@coordination-network/wallet');
|
|
5
6
|
var userAppLink = require('@coordination-network/user-app-link');
|
|
7
|
+
var identity$1 = require('@semaphore-protocol/identity');
|
|
6
8
|
var issuerSet = require('@coordination-network/issuer-set');
|
|
7
9
|
var revocationRegistry = require('@coordination-network/revocation-registry');
|
|
8
|
-
var wallet = require('@coordination-network/wallet');
|
|
9
10
|
var zod = require('zod');
|
|
10
11
|
var userClient = require('@coordination-network/user-client');
|
|
11
12
|
|
|
@@ -85,43 +86,59 @@ async function writeDeviceSecret(store, secret) {
|
|
|
85
86
|
await store.set(DEVICE_SECRET_KEY, secret);
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
* `GET /v1/issuers` carries a `root` the server read from chain independently of the `leaves` it
|
|
90
|
-
* also served, so `Wallet`'s comparison between the tree rebuilt from `leaves` and
|
|
91
|
-
* `approvedIssuerRoot` catches a server that served a mismatched pair.
|
|
92
|
-
*/
|
|
93
89
|
async function readIssuerSet(http, issuersUrl) {
|
|
94
90
|
const response = await http.getJson(issuersUrl);
|
|
95
91
|
if (response.status !== 200) {
|
|
96
92
|
throw new Error(`GET ${issuersUrl} returned ${response.status}`);
|
|
97
93
|
}
|
|
98
|
-
const { leaves
|
|
99
|
-
return
|
|
100
|
-
approvedIssuers: issuerSet.ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf))),
|
|
101
|
-
approvedIssuerRoot: BigInt(root)
|
|
102
|
-
};
|
|
94
|
+
const { leaves } = response.body;
|
|
95
|
+
return issuerSet.ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf)));
|
|
103
96
|
}
|
|
104
|
-
/** The snapshot's own `root` gives the revocation side the same cross-check. */
|
|
105
97
|
async function readRevocations(http, revocationsUrl) {
|
|
106
98
|
const response = await http.getJson(revocationsUrl);
|
|
107
99
|
if (response.status !== 200) {
|
|
108
100
|
throw new Error(`GET ${revocationsUrl} returned ${response.status}`);
|
|
109
101
|
}
|
|
110
|
-
const {
|
|
111
|
-
return
|
|
102
|
+
const { snapshot } = response.body;
|
|
103
|
+
return revocationRegistry.RevocationRegistry.import(snapshot);
|
|
112
104
|
}
|
|
113
105
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
106
|
+
* Bulk data only: the `root` each endpoint also serves is not read, because the server choosing
|
|
107
|
+
* both halves of the comparison makes it pass whatever it serves. The caller pairs these trees
|
|
108
|
+
* with roots read from chain, and `listsMatchChain` rejects a pair that disagrees.
|
|
117
109
|
*/
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
110
|
+
async function readLists(http, issuersUrl, revocationsUrl) {
|
|
111
|
+
const [approvedIssuers, revocations] = await Promise.all([
|
|
112
|
+
readIssuerSet(http, issuersUrl),
|
|
113
|
+
readRevocations(http, revocationsUrl)
|
|
114
|
+
]);
|
|
115
|
+
return { approvedIssuers, revocations };
|
|
116
|
+
}
|
|
117
|
+
/** Both roots, and the issuer set's leaf count, which with its root fixes every leaf. */
|
|
118
|
+
function listsMatchChain(lists, chain) {
|
|
119
|
+
return (lists.approvedIssuers.root === chain.approvedIssuerRoot &&
|
|
120
|
+
lists.approvedIssuers.leafCount === chain.approvedIssuerLeafCount &&
|
|
121
|
+
lists.revocations.root === chain.revocationRoot);
|
|
123
122
|
}
|
|
124
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A key use the person or the platform ended: cancelled at the prompt, the key invalidated, or the
|
|
126
|
+
* authentication itself failed (lockout, no foreground activity, another prompt already showing).
|
|
127
|
+
*/
|
|
128
|
+
class DeviceKeyError extends Error {
|
|
129
|
+
constructor(code, message = code) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = "DeviceKeyError";
|
|
132
|
+
this.code = code;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A cancel is the person's refusal; anything else the key reports is this app's fault, not a request problem. */
|
|
136
|
+
function mapDeviceKeyError(error) {
|
|
137
|
+
if (error instanceof DeviceKeyError && error.code === "USER_CANCELED") {
|
|
138
|
+
return new userAppLink.LinkError("CONSENT_DENIED", "the person cancelled the device prompt");
|
|
139
|
+
}
|
|
140
|
+
return new userAppLink.LinkError("INTERNAL", error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
125
142
|
/**
|
|
126
143
|
* `WalletError` and `LinkError` speak different vocabularies for the same underlying failures.
|
|
127
144
|
* Split along one line: a malformed or structurally-invalid credential is `INVALID_REQUEST`;
|
|
@@ -153,6 +170,9 @@ const WALLET_ERROR_TO_LINK_ERROR_CODE = {
|
|
|
153
170
|
* absent `code` would have produced a `LinkError` with no code at all.
|
|
154
171
|
*/
|
|
155
172
|
function mapWalletError(error, chainStateCode) {
|
|
173
|
+
// The wallet wraps whatever its signer or seed thunk threw; the key's own failures keep their meaning.
|
|
174
|
+
if (error instanceof wallet.WalletError && error.cause instanceof DeviceKeyError)
|
|
175
|
+
return mapDeviceKeyError(error.cause);
|
|
156
176
|
if (!(error instanceof wallet.WalletError)) {
|
|
157
177
|
return new userAppLink.LinkError("INTERNAL", error instanceof Error ? error.message : String(error));
|
|
158
178
|
}
|
|
@@ -181,7 +201,9 @@ const challengeNonceSchema = zod.z
|
|
|
181
201
|
.string()
|
|
182
202
|
.regex(/^(0x)?[0-9a-fA-F]{64}$/i, "must be 32-byte hex")
|
|
183
203
|
.transform((value) => `0x${value.replace(/^0x/i, "")}`.toLowerCase());
|
|
184
|
-
const challengeResponseSchema = zod.z
|
|
204
|
+
const challengeResponseSchema = zod.z
|
|
205
|
+
.object({ challengeId: zod.z.uuid(), nonce: challengeNonceSchema, expiresAt: zod.z.number() })
|
|
206
|
+
.strict();
|
|
185
207
|
const issueCredentialResponseSchema = zod.z.object({ id: zod.z.string().min(1), credential: zod.z.unknown() }).strict();
|
|
186
208
|
/**
|
|
187
209
|
* Not `.strict()`: `POST /v1/verify` also answers with its own `ok`/`code`, which the wallet
|
|
@@ -215,9 +237,10 @@ async function postToMember(http, url, body, expected) {
|
|
|
215
237
|
}
|
|
216
238
|
|
|
217
239
|
/**
|
|
218
|
-
* `issuerNetworkId` is asserted by the caller
|
|
219
|
-
*
|
|
220
|
-
* `
|
|
240
|
+
* `issuerNetworkId` is asserted by the caller, so `credential.issue` checks it against the chain:
|
|
241
|
+
* an entry is written, or honoured as `alreadyHeld`, only for a key in the member's
|
|
242
|
+
* `issuerKeysOf` list. Listings and presentation read entries as written, including any from before
|
|
243
|
+
* this check.
|
|
221
244
|
*/
|
|
222
245
|
const ISSUER_INDEX_PREFIX = "user-app-core:issuer-index:";
|
|
223
246
|
/**
|
|
@@ -250,6 +273,25 @@ async function listIssuerEntries(store) {
|
|
|
250
273
|
async function writeIssuerEntry(store, issuerNetworkId, entry) {
|
|
251
274
|
await store.set(keyFor(issuerNetworkId, entry), JSON.stringify(entry));
|
|
252
275
|
}
|
|
276
|
+
/** Removes the entries naming any of `credentialIds`; every other entry stays. */
|
|
277
|
+
async function deleteIssuerEntries(store, credentialIds) {
|
|
278
|
+
for (const key of await store.list()) {
|
|
279
|
+
if (!key.startsWith(ISSUER_INDEX_PREFIX))
|
|
280
|
+
continue;
|
|
281
|
+
const raw = await store.get(key);
|
|
282
|
+
if (raw === null)
|
|
283
|
+
continue;
|
|
284
|
+
let credentialId;
|
|
285
|
+
try {
|
|
286
|
+
credentialId = JSON.parse(raw).credentialId;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (credentialIds.has(credentialId))
|
|
292
|
+
await store.delete(key);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
253
295
|
|
|
254
296
|
/** `ports.now` is optional (a test harness injects a deterministic clock); real ports omit it. */
|
|
255
297
|
function nowFn(ports) {
|
|
@@ -270,6 +312,11 @@ const CREDENTIAL_ISSUE_STEPS = {
|
|
|
270
312
|
storing: "Saving to your wallet"
|
|
271
313
|
};
|
|
272
314
|
|
|
315
|
+
/** Its own text, so the person never sees two identical dialogs back to back. */
|
|
316
|
+
const ECDH_PROMPT = {
|
|
317
|
+
title: "Unlock your credentials",
|
|
318
|
+
subtitle: "Needed once each time the app starts"
|
|
319
|
+
};
|
|
273
320
|
/**
|
|
274
321
|
* The Baby Jubjub identity seed, rooted in the hardware ECDH rather than the device secret.
|
|
275
322
|
* `deriveEddsaKeypair` returns `seed` as a `0x`-prefixed hex string — handing that string to
|
|
@@ -277,9 +324,29 @@ const CREDENTIAL_ISSUE_STEPS = {
|
|
|
277
324
|
* the 32 raw bytes decoded from it.
|
|
278
325
|
*/
|
|
279
326
|
async function resolveIdentitySeed(deviceKey) {
|
|
280
|
-
const sharedSecret = await deviceKey.ecdh(identity.eddsaPeerPointBytes());
|
|
327
|
+
const sharedSecret = await deviceKey.ecdh(identity.eddsaPeerPointBytes(), ECDH_PROMPT);
|
|
281
328
|
return identity.hexToBytes(identity.deriveEddsaKeypair(sharedSecret).seed);
|
|
282
329
|
}
|
|
330
|
+
function createIdentitySeedCache(deviceKey) {
|
|
331
|
+
let pending = null;
|
|
332
|
+
return {
|
|
333
|
+
resolve() {
|
|
334
|
+
if (pending === null) {
|
|
335
|
+
const attempt = resolveIdentitySeed(deviceKey);
|
|
336
|
+
pending = attempt;
|
|
337
|
+
// A cancelled prompt must not be cached: the next operation asks again.
|
|
338
|
+
attempt.catch(() => {
|
|
339
|
+
if (pending === attempt)
|
|
340
|
+
pending = null;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return pending;
|
|
344
|
+
},
|
|
345
|
+
clear() {
|
|
346
|
+
pending = null;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
283
350
|
/**
|
|
284
351
|
* `listCredentials` never syncs — there is no single authority to sync a mixed-issuer listing
|
|
285
352
|
* against — so this sentinel documents that, and would surface loudly if a future bug called it.
|
|
@@ -287,16 +354,31 @@ async function resolveIdentitySeed(deviceKey) {
|
|
|
287
354
|
const neverLoadChainState = async () => {
|
|
288
355
|
throw new Error("chain state must not be requested outside a per-request authority");
|
|
289
356
|
};
|
|
357
|
+
/** A listing wallet's identity seed: like `neverLoadChainState`, loud if a future bug reaches for it. */
|
|
358
|
+
const neverSeed = async () => {
|
|
359
|
+
throw new Error("a listing must not ask the device key for the identity seed");
|
|
360
|
+
};
|
|
361
|
+
const neverSign = async () => {
|
|
362
|
+
throw new Error("a listing must not ask the device key to sign");
|
|
363
|
+
};
|
|
364
|
+
/** A listing wallet's signer: it reads the public key, which raises no prompt, and never signs. */
|
|
365
|
+
const listingSigner = (deviceKey) => ({
|
|
366
|
+
getPublicKeyJwk: () => deviceKey.getPublicKeyJwk(),
|
|
367
|
+
sign: neverSign,
|
|
368
|
+
signDigest: neverSign
|
|
369
|
+
});
|
|
290
370
|
/**
|
|
291
371
|
* A fresh `Wallet` per request, scoped to one member's authority. The private `Wallet` gets a
|
|
292
372
|
* constant-`true` consent prompt: the person has already been asked once, at the link boundary,
|
|
293
373
|
* and prompting again here would show a second screen for one `presentation.create`.
|
|
374
|
+
*
|
|
375
|
+
* `prompt` is what a signature raises. `null` builds a wallet for listing and syncing, which must
|
|
376
|
+
* never touch a key: its seed and signer throw.
|
|
294
377
|
*/
|
|
295
|
-
|
|
296
|
-
const identitySeed = await resolveIdentitySeed(ports.deviceKey);
|
|
378
|
+
function buildWallet(ports, seeds, loadChainState, prompt) {
|
|
297
379
|
return new wallet.Wallet({
|
|
298
|
-
identitySeed,
|
|
299
|
-
deviceSigner: ports.deviceKey,
|
|
380
|
+
identitySeed: prompt === null ? neverSeed : () => seeds.resolve(),
|
|
381
|
+
deviceSigner: prompt === null ? listingSigner(ports.deviceKey) : ports.deviceKey.signerFor(prompt),
|
|
300
382
|
consent: async () => true,
|
|
301
383
|
proofGenerator: ports.proofGenerator,
|
|
302
384
|
store: ports.store,
|
|
@@ -315,6 +397,16 @@ const refused = ({ code, message }) => ({
|
|
|
315
397
|
code: code ?? UNNAMED_REFUSAL,
|
|
316
398
|
message
|
|
317
399
|
});
|
|
400
|
+
const isMemberKey = (memberKeyLeaves, key) => memberKeyLeaves.has(issuerSet.issuerKeyLeaf([BigInt(key[0]), BigInt(key[1])]));
|
|
401
|
+
/** A cancel or an invalidated key reaches the person as the link error that says so, not as an internal failure. */
|
|
402
|
+
async function keyUse(use) {
|
|
403
|
+
try {
|
|
404
|
+
return await use;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
throw error instanceof DeviceKeyError ? mapDeviceKeyError(error) : error;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
318
410
|
/**
|
|
319
411
|
* Whether an indexed credential still stops this member issuing another for the same programme.
|
|
320
412
|
* Only one that is valid right now does: a revoked or expired credential, or an entry whose
|
|
@@ -334,25 +426,56 @@ async function isStillValid(wallet, credentialId, load) {
|
|
|
334
426
|
* The revocation secret is generated and opened inside this one call, so it never leaves the
|
|
335
427
|
* function and nothing has to persist it between two deep links.
|
|
336
428
|
*/
|
|
337
|
-
async function credentialIssueHandler(ports, deviceSecret, params) {
|
|
429
|
+
async function credentialIssueHandler(ports, seeds, deviceSecret, params) {
|
|
338
430
|
const step = (name) => ports.progress("credential.issue", name);
|
|
339
431
|
step(CREDENTIAL_ISSUE_STEPS.held);
|
|
340
432
|
const held = await readIssuerEntry(ports.store, params.issuerNetworkId, params.approvalScope);
|
|
341
433
|
// Read before the issuer signs: the revocation secret below never leaves this call, so a
|
|
342
434
|
// failure after the 201 would orphan the credential at the issuer with nothing able to open it.
|
|
435
|
+
// So is the chain read, and an empty key list refuses before any challenge. Whether the signing
|
|
436
|
+
// key is among the keys can only be known after the signature; that mismatch is refused below.
|
|
343
437
|
step(CREDENTIAL_ISSUE_STEPS.lists);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
438
|
+
const [lists, leafRead, rootsRead] = await Promise.allSettled([
|
|
439
|
+
readLists(ports.http, params.issuersUrl, params.revocationsUrl),
|
|
440
|
+
ports.directory.issuerKeyLeavesOf(params.issuerNetworkId),
|
|
441
|
+
ports.roots.currentRoots()
|
|
442
|
+
]);
|
|
443
|
+
if (lists.status === "rejected") {
|
|
444
|
+
throw new userAppLink.LinkError("ISSUER_UNREACHABLE", lists.reason instanceof Error
|
|
445
|
+
? lists.reason.message
|
|
351
446
|
: "the issuer's approved-issuer set or revocation snapshot is unreadable");
|
|
352
447
|
}
|
|
448
|
+
if (leafRead.status === "rejected") {
|
|
449
|
+
// Fixed text: a viem message carries the RPC URL, which can hold a provider key.
|
|
450
|
+
throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the approved issuer keys could not be read from chain");
|
|
451
|
+
}
|
|
452
|
+
if (rootsRead.status === "rejected") {
|
|
453
|
+
throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain");
|
|
454
|
+
}
|
|
455
|
+
// The wallet's own check only runs once the issuer has signed, which would orphan the credential.
|
|
456
|
+
if (!listsMatchChain(lists.value, rootsRead.value)) {
|
|
457
|
+
throw new userAppLink.LinkError("ISSUER_UNREACHABLE", "the issuer's lists do not match the chain");
|
|
458
|
+
}
|
|
459
|
+
const chainState = {
|
|
460
|
+
...lists.value,
|
|
461
|
+
approvedIssuerRoot: rootsRead.value.approvedIssuerRoot,
|
|
462
|
+
revocationRoot: rootsRead.value.revocationRoot
|
|
463
|
+
};
|
|
464
|
+
const memberKeyLeaves = new Set(leafRead.value);
|
|
465
|
+
if (memberKeyLeaves.size === 0) {
|
|
466
|
+
throw new userAppLink.LinkError("ISSUER_NOT_APPROVED", `no approved issuer key is known for ${params.issuerNetworkId}`);
|
|
467
|
+
}
|
|
353
468
|
const loadChainState = async () => chainState;
|
|
354
|
-
|
|
355
|
-
|
|
469
|
+
// The hold check below reads and syncs only, so it asks nothing of the keys; the seed and the
|
|
470
|
+
// signature come after the challenge.
|
|
471
|
+
const prompt = {
|
|
472
|
+
title: "Bind a credential to this phone",
|
|
473
|
+
subtitle: `${params.approvalScope.programme} (${params.approvalScope.country})`
|
|
474
|
+
};
|
|
475
|
+
const wallet = buildWallet(ports, seeds, loadChainState, prompt);
|
|
476
|
+
if (held !== null &&
|
|
477
|
+
isMemberKey(memberKeyLeaves, held.issuerPublicKey) &&
|
|
478
|
+
(await isStillValid(wallet, held.credentialId, loadChainState))) {
|
|
356
479
|
return { status: "alreadyHeld", credentialId: held.credentialId };
|
|
357
480
|
}
|
|
358
481
|
const keys = identity.deriveKeys(deviceSecret);
|
|
@@ -362,22 +485,30 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
|
|
|
362
485
|
return refused(challenge.refusal);
|
|
363
486
|
const nonce = challenge.value.nonce;
|
|
364
487
|
step(CREDENTIAL_ISSUE_STEPS.binding);
|
|
488
|
+
const seed = await keyUse(seeds.resolve());
|
|
489
|
+
const identity$2 = new identity$1.Identity(seed);
|
|
365
490
|
const confirmationKey = await ports.deviceKey.getPublicKeyJwk();
|
|
366
|
-
const eddsa = identity.deriveEddsaKeypair(await ports.deviceKey.ecdh(identity.eddsaPeerPointBytes()));
|
|
367
491
|
const revocationSecret = credential.generateRevocationSecret();
|
|
368
492
|
const ownership = credential.signCommitmentOwnership(revocationSecret, identity.hexToBytes(nonce));
|
|
369
|
-
const
|
|
370
|
-
const
|
|
493
|
+
const holderOwnership = credential.signHolderKeyOwnership(seed, identity.hexToBytes(nonce));
|
|
494
|
+
const deviceBindingMessage = identity.buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(identity$2.commitment), credential.issuanceRequestCommitment(params.issuerNetworkId, params.approvalScope));
|
|
495
|
+
const devicePopSignature = identity.bytesToHex(await keyUse(ports.deviceKey.signerFor(prompt).sign(deviceBindingMessage)));
|
|
371
496
|
const { signature: accountPopSignature } = identity.signAccountBinding(keys.account.privateKey, nonce, credential.jwkToPublicKeyBytes(confirmationKey));
|
|
372
497
|
step(CREDENTIAL_ISSUE_STEPS.signing);
|
|
498
|
+
const deviceAttestation = await ports.deviceKey.getAttestationChain();
|
|
373
499
|
const issued = await postToMember(ports.http, params.credentialsUrl, {
|
|
500
|
+
challengeId: challenge.value.challengeId,
|
|
374
501
|
subjectNetworkId: keys.networkId,
|
|
375
502
|
approvalScope: params.approvalScope,
|
|
376
503
|
confirmationKey,
|
|
377
504
|
accountPublicKey: keys.account.uncompressedPublicKey,
|
|
378
505
|
devicePopSignature,
|
|
379
506
|
accountPopSignature,
|
|
380
|
-
identityPublicKey: pointToStrings(
|
|
507
|
+
identityPublicKey: pointToStrings(identity$2.publicKey),
|
|
508
|
+
identityOwnershipSignature: {
|
|
509
|
+
R8: pointToStrings(holderOwnership.signature.R8),
|
|
510
|
+
S: String(holderOwnership.signature.S)
|
|
511
|
+
},
|
|
381
512
|
revocation: {
|
|
382
513
|
commitment: credential.deriveRevocationCommitment(revocationSecret).toString(),
|
|
383
514
|
publicKey: pointToStrings(ownership.publicKey),
|
|
@@ -385,18 +516,30 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
|
|
|
385
516
|
R8: pointToStrings(ownership.signature.R8),
|
|
386
517
|
S: String(ownership.signature.S)
|
|
387
518
|
}
|
|
388
|
-
}
|
|
519
|
+
},
|
|
520
|
+
...(deviceAttestation.length > 0 && { deviceAttestation })
|
|
389
521
|
}, { status: 201, schema: issueCredentialResponseSchema, unreachable: "ISSUER_UNREACHABLE" });
|
|
390
522
|
if (issued.refusal)
|
|
391
523
|
return refused(issued.refusal);
|
|
392
|
-
let
|
|
524
|
+
let parsed;
|
|
393
525
|
try {
|
|
394
|
-
|
|
395
|
-
issuerPublicKey = [parsed.issuerPublicKey[0].toString(), parsed.issuerPublicKey[1].toString()];
|
|
526
|
+
parsed = credential.parseZkCredential(issued.value.credential);
|
|
396
527
|
}
|
|
397
528
|
catch (error) {
|
|
398
529
|
throw new userAppLink.LinkError("ISSUER_UNREACHABLE", error instanceof Error ? error.message : "malformed credential");
|
|
399
530
|
}
|
|
531
|
+
// The issuer's own binding check enforces this; a dishonest issuer is not trusted to.
|
|
532
|
+
if (parsed.body.programmeHash !== credential.programmeHash(params.approvalScope.programme) ||
|
|
533
|
+
parsed.body.countryCode !== credential.countryCode(params.approvalScope.country)) {
|
|
534
|
+
throw new userAppLink.LinkError("ISSUER_UNREACHABLE", "credential is not for the programme and country consented to");
|
|
535
|
+
}
|
|
536
|
+
const issuerPublicKey = [
|
|
537
|
+
parsed.issuerPublicKey[0].toString(),
|
|
538
|
+
parsed.issuerPublicKey[1].toString()
|
|
539
|
+
];
|
|
540
|
+
if (!isMemberKey(memberKeyLeaves, issuerPublicKey)) {
|
|
541
|
+
throw new userAppLink.LinkError("ISSUER_NOT_APPROVED", `the credential is not signed by a key of ${params.issuerNetworkId}`);
|
|
542
|
+
}
|
|
400
543
|
// Built above for the hold check; the lists are read before the signature, so this is the
|
|
401
544
|
// only step left that can fail.
|
|
402
545
|
step(CREDENTIAL_ISSUE_STEPS.storing);
|
|
@@ -425,8 +568,8 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
|
|
|
425
568
|
/**
|
|
426
569
|
* `presentation.create` carries no credential id, so this narrows by what the credential body
|
|
427
570
|
* says: the requested programme and country. Status is deliberately not a filter — each candidate
|
|
428
|
-
* is then synced against
|
|
429
|
-
* needs to know whether it held a revoked one.
|
|
571
|
+
* is then synced against the verifier's lists, checked against the chain's roots, and a caller
|
|
572
|
+
* that finds none valid still needs to know whether it held a revoked one.
|
|
430
573
|
*/
|
|
431
574
|
function matchingCredentials(credentials, request) {
|
|
432
575
|
const wantedProgramme = credential.programmeHash(request.programme);
|
|
@@ -449,32 +592,36 @@ function latestExpiring(credentials) {
|
|
|
449
592
|
});
|
|
450
593
|
}
|
|
451
594
|
|
|
452
|
-
async function presentationCreateHandler(ports, params) {
|
|
595
|
+
async function presentationCreateHandler(ports, seeds, params) {
|
|
453
596
|
const step = (name) => ports.progress("presentation.create", name);
|
|
454
597
|
// The state is read below, once the request is known to have candidates, so the wallet is built
|
|
455
|
-
// without an authority and handed one per call.
|
|
456
|
-
const wallet$1 =
|
|
598
|
+
// without an authority and handed one per call. This one only lists and syncs: it holds no key.
|
|
599
|
+
const wallet$1 = buildWallet(ports, seeds, neverLoadChainState, null);
|
|
457
600
|
/**
|
|
458
601
|
* The same for every candidate, so the request reads it once however many credentials it has
|
|
459
|
-
* to check — and a verifier that cannot serve
|
|
460
|
-
* can be judged without
|
|
602
|
+
* to check — and a verifier that cannot serve the lists, or a chain that cannot be read, fails
|
|
603
|
+
* the whole request, since no candidate can be judged without them.
|
|
461
604
|
*/
|
|
462
|
-
async function
|
|
463
|
-
|
|
464
|
-
|
|
605
|
+
async function networkState() {
|
|
606
|
+
const [lists, roots] = await Promise.allSettled([
|
|
607
|
+
readLists(ports.http, params.issuersUrl, params.revocationsUrl),
|
|
608
|
+
ports.roots.currentRoots()
|
|
609
|
+
]);
|
|
610
|
+
if (lists.status === "rejected") {
|
|
611
|
+
throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", lists.reason instanceof Error ? lists.reason.message : "the verifier's lists are unreadable");
|
|
465
612
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
/** Same reasoning as the issuer set: the verifier is the party that must accept this root. */
|
|
471
|
-
async function verifierRevocations() {
|
|
472
|
-
try {
|
|
473
|
-
return await readRevocations(ports.http, params.revocationsUrl);
|
|
613
|
+
if (roots.status === "rejected") {
|
|
614
|
+
// Fixed text: a viem message carries the RPC URL, which can hold a provider key.
|
|
615
|
+
throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain");
|
|
474
616
|
}
|
|
475
|
-
|
|
476
|
-
throw new userAppLink.LinkError("VERIFIER_UNREACHABLE",
|
|
617
|
+
if (!listsMatchChain(lists.value, roots.value)) {
|
|
618
|
+
throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", "the verifier's lists do not match the chain");
|
|
477
619
|
}
|
|
620
|
+
return {
|
|
621
|
+
...lists.value,
|
|
622
|
+
approvedIssuerRoot: roots.value.approvedIssuerRoot,
|
|
623
|
+
revocationRoot: roots.value.revocationRoot
|
|
624
|
+
};
|
|
478
625
|
}
|
|
479
626
|
async function readCandidates() {
|
|
480
627
|
const infoById = new Map((await wallet$1.listCredentials()).map((info) => [info.id, info]));
|
|
@@ -493,17 +640,10 @@ async function presentationCreateHandler(ports, params) {
|
|
|
493
640
|
// state is kept so the proof is built from the same read, not a second one that could disagree.
|
|
494
641
|
let chainState;
|
|
495
642
|
if (candidates.length > 0) {
|
|
496
|
-
const state =
|
|
643
|
+
const state = await networkState();
|
|
497
644
|
chainState = state;
|
|
498
645
|
for (const candidate of candidates) {
|
|
499
|
-
|
|
500
|
-
await wallet$1.syncCredential(candidate.id, async () => state);
|
|
501
|
-
}
|
|
502
|
-
catch (error) {
|
|
503
|
-
// A served root that does not match the tree served beside it is the verifier's
|
|
504
|
-
// fault, not this wallet's; without mapping it the caller is told INTERNAL.
|
|
505
|
-
throw mapWalletError(error, "VERIFIER_UNREACHABLE");
|
|
506
|
-
}
|
|
646
|
+
await wallet$1.syncCredential(candidate.id, async () => state);
|
|
507
647
|
}
|
|
508
648
|
}
|
|
509
649
|
const synced = (await readCandidates()).flatMap((candidate) => chainState === undefined ? [] : [{ ...candidate, chainState }]);
|
|
@@ -526,10 +666,23 @@ async function presentationCreateHandler(ports, params) {
|
|
|
526
666
|
}
|
|
527
667
|
throw new userAppLink.LinkError("NO_CREDENTIAL", `no held credential matches programme "${params.programme}"/"${params.country}"`);
|
|
528
668
|
}
|
|
669
|
+
const approved = await ports.review({
|
|
670
|
+
kind: "proof",
|
|
671
|
+
verifier: params.audience,
|
|
672
|
+
programme: params.programme,
|
|
673
|
+
country: params.country,
|
|
674
|
+
issuerNetworkId: selected.issuerNetworkId
|
|
675
|
+
});
|
|
676
|
+
if (!approved)
|
|
677
|
+
throw new userAppLink.LinkError("CONSENT_DENIED", "the proof was not shared");
|
|
529
678
|
step("Proving");
|
|
679
|
+
const presenter = buildWallet(ports, seeds, neverLoadChainState, {
|
|
680
|
+
title: "Share a proof",
|
|
681
|
+
subtitle: `Proves your ${params.programme} credential (${params.country})`
|
|
682
|
+
});
|
|
530
683
|
let proof;
|
|
531
684
|
try {
|
|
532
|
-
proof = await
|
|
685
|
+
proof = await presenter.present(selected.id, {
|
|
533
686
|
challenge: identity.hexToBytes(params.challenge),
|
|
534
687
|
audience: params.audience,
|
|
535
688
|
currentTime: params.currentTime,
|
|
@@ -543,15 +696,6 @@ async function presentationCreateHandler(ports, params) {
|
|
|
543
696
|
// A raw prover failure, not a WalletError - keep it distinguishable from NO_CREDENTIAL above.
|
|
544
697
|
throw new userAppLink.LinkError("PROOF_FAILED", error instanceof Error ? error.message : "proof generation failed");
|
|
545
698
|
}
|
|
546
|
-
const approved = await ports.review({
|
|
547
|
-
kind: "proof",
|
|
548
|
-
verifier: params.audience,
|
|
549
|
-
programme: params.programme,
|
|
550
|
-
country: params.country,
|
|
551
|
-
issuerNetworkId: selected.issuerNetworkId
|
|
552
|
-
});
|
|
553
|
-
if (!approved)
|
|
554
|
-
throw new userAppLink.LinkError("CONSENT_DENIED", "the proof was built but not shared");
|
|
555
699
|
step("Sharing");
|
|
556
700
|
// Only the id comes back: the verifier's own `ok` would be the verifier reporting on itself,
|
|
557
701
|
// and its member app can read the recorded outcome from its own server under this id.
|
|
@@ -587,6 +731,12 @@ async function assembleIdentity(deviceSecret, deviceKey) {
|
|
|
587
731
|
};
|
|
588
732
|
}
|
|
589
733
|
|
|
734
|
+
// UNKNOWN ranks with SOFTWARE: a keystore that cannot say is trusted no more than one that admits it.
|
|
735
|
+
const RANK = { STRONGBOX: 2, TRUSTED_ENVIRONMENT: 1, SOFTWARE: 0, UNKNOWN: 0 };
|
|
736
|
+
function meetsSecurityLevel(level, minimum) {
|
|
737
|
+
return RANK[level] >= RANK[minimum];
|
|
738
|
+
}
|
|
739
|
+
|
|
590
740
|
/**
|
|
591
741
|
* Still not `Wallet`'s own status: that also carries `issuer-not-approved`, which `syncRevocations`
|
|
592
742
|
* stamps against whichever single member last asked for a presentation, and reporting one member's
|
|
@@ -648,6 +798,7 @@ function createUserAppService(ports) {
|
|
|
648
798
|
throw new userAppLink.LinkError("NOT_ONBOARDED", "no identity has been created yet");
|
|
649
799
|
return secret;
|
|
650
800
|
}
|
|
801
|
+
const seeds = createIdentitySeedCache(ports.deviceKey);
|
|
651
802
|
// One slot deep: a handler body reads and writes the same store a concurrent one might be
|
|
652
803
|
// mid-sync against, so two deep links run their handler bodies one at a time, never interleaved.
|
|
653
804
|
let queue = Promise.resolve();
|
|
@@ -658,10 +809,10 @@ function createUserAppService(ports) {
|
|
|
658
809
|
}
|
|
659
810
|
const handlers = {
|
|
660
811
|
"registration.sign": (params) => serialize(async () => registrationSignHandler(ports, await requireOnboarded(), params)),
|
|
661
|
-
"credential.issue": (params) => serialize(async () => credentialIssueHandler(ports, await requireOnboarded(), params)),
|
|
812
|
+
"credential.issue": (params) => serialize(async () => credentialIssueHandler(ports, seeds, await requireOnboarded(), params)),
|
|
662
813
|
"presentation.create": (params) => serialize(async () => {
|
|
663
814
|
await requireOnboarded();
|
|
664
|
-
return presentationCreateHandler(ports, params);
|
|
815
|
+
return presentationCreateHandler(ports, seeds, params);
|
|
665
816
|
})
|
|
666
817
|
};
|
|
667
818
|
return {
|
|
@@ -677,6 +828,13 @@ function createUserAppService(ports) {
|
|
|
677
828
|
if ((await resolveDeviceSecret()) !== null) {
|
|
678
829
|
throw new Error("createIdentity: an identity already exists — erase() before creating another");
|
|
679
830
|
}
|
|
831
|
+
const minimum = ports.minimumDeviceSecurityLevel;
|
|
832
|
+
if (minimum !== undefined) {
|
|
833
|
+
const level = await ports.deviceKey.getSecurityLevel();
|
|
834
|
+
if (!meetsSecurityLevel(level, minimum)) {
|
|
835
|
+
throw new Error(`This phone's key protection is ${level}; creating an identity needs a hardware-backed key (${minimum} or better).`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
680
838
|
const secret = identity.generateDeviceSecret();
|
|
681
839
|
const identity$1 = await assembleIdentity(secret, ports.deviceKey);
|
|
682
840
|
await writeDeviceSecret(ports.store, secret);
|
|
@@ -702,7 +860,7 @@ function createUserAppService(ports) {
|
|
|
702
860
|
* name and the plaintext programme survive — the credential body carries a hash.
|
|
703
861
|
*/
|
|
704
862
|
async listCredentials() {
|
|
705
|
-
const wallet =
|
|
863
|
+
const wallet = buildWallet(ports, seeds, neverLoadChainState, null);
|
|
706
864
|
const held = await wallet.listCredentials();
|
|
707
865
|
const entryByCredentialId = new Map((await listIssuerEntries(ports.store)).map((entry) => [entry.credentialId, entry]));
|
|
708
866
|
const nowSeconds = Math.floor(nowFn(ports) / 1000);
|
|
@@ -728,6 +886,37 @@ function createUserAppService(ports) {
|
|
|
728
886
|
expiresAt: body.expiresAt
|
|
729
887
|
})));
|
|
730
888
|
},
|
|
889
|
+
removeUnboundCredentials() {
|
|
890
|
+
return serialize(async () => {
|
|
891
|
+
const commitment = credential.deriveDeviceKeyCommitment(await ports.deviceKey.getPublicKeyJwk());
|
|
892
|
+
const wallet$1 = buildWallet(ports, seeds, neverLoadChainState, null);
|
|
893
|
+
const unbound = [];
|
|
894
|
+
for (const key of await ports.store.list()) {
|
|
895
|
+
if (!key.startsWith(wallet.CREDENTIAL_PREFIX))
|
|
896
|
+
continue;
|
|
897
|
+
const id = key.slice(wallet.CREDENTIAL_PREFIX.length);
|
|
898
|
+
try {
|
|
899
|
+
const info = await wallet$1.getCredential(id);
|
|
900
|
+
if (info !== null &&
|
|
901
|
+
credential.parseZkCredential(info.credential).body.deviceKeyCommitment !== commitment) {
|
|
902
|
+
unbound.push(id);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
// Unreadable, so not proven unbound: left in place.
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (unbound.length === 0)
|
|
910
|
+
return 0;
|
|
911
|
+
// Index entries first: a run killed before the credentials go leaves them still
|
|
912
|
+
// unbound, so the next run removes them.
|
|
913
|
+
await deleteIssuerEntries(ports.store, new Set(unbound));
|
|
914
|
+
for (const id of unbound)
|
|
915
|
+
await wallet$1.deleteCredential(id);
|
|
916
|
+
seeds.clear();
|
|
917
|
+
return unbound.length;
|
|
918
|
+
});
|
|
919
|
+
},
|
|
731
920
|
/**
|
|
732
921
|
* Clears the entire store, not only this package's own keys — a host app shares it, and an
|
|
733
922
|
* "erase everything" that left its configuration behind would be a half-erase. A caller
|
|
@@ -738,13 +927,19 @@ function createUserAppService(ports) {
|
|
|
738
927
|
for (const key of await ports.store.list())
|
|
739
928
|
await ports.store.delete(key);
|
|
740
929
|
deviceSecretCache = null;
|
|
930
|
+
seeds.clear();
|
|
741
931
|
});
|
|
742
932
|
}
|
|
743
933
|
};
|
|
744
934
|
}
|
|
745
935
|
|
|
936
|
+
Object.defineProperty(exports, "DEVICE_ATTESTATION_CHALLENGE", {
|
|
937
|
+
enumerable: true,
|
|
938
|
+
get: function () { return credential.DEVICE_ATTESTATION_CHALLENGE; }
|
|
939
|
+
});
|
|
746
940
|
exports.CREDENTIAL_ISSUE_STEPS = CREDENTIAL_ISSUE_STEPS;
|
|
747
941
|
exports.DEVICE_SECRET_KEY = DEVICE_SECRET_KEY;
|
|
942
|
+
exports.DeviceKeyError = DeviceKeyError;
|
|
748
943
|
exports.buildConsentSummary = buildConsentSummary;
|
|
749
944
|
exports.createUserAppService = createUserAppService;
|
|
750
945
|
exports.latestExpiring = latestExpiring;
|