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