@coordination-network/user-app-core 0.0.1

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/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@coordination-network/user-app-core",
3
+ "version": "0.0.1",
4
+ "description": "The three user-app-link operations a user app serves, over injected device, storage and proving ports.",
5
+ "license": "MIT",
6
+ "repository": "https://github.com/zk-kit/coordination-network",
7
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/user-app-core",
8
+ "bugs": "https://github.com/zk-kit/coordination-network/issues",
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "main": "dist/index.cjs",
12
+ "module": "dist/index.js",
13
+ "types": "types/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./types/index.d.ts",
17
+ "require": "./dist/index.cjs",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist/",
23
+ "types/",
24
+ "src/",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "rimraf dist types && rollup -c rollup.config.mjs"
33
+ },
34
+ "dependencies": {
35
+ "@coordination-network/credential": "^0.0.1",
36
+ "@coordination-network/identity": "^0.0.1",
37
+ "@coordination-network/issuer-set": "^0.0.1",
38
+ "@coordination-network/revocation-registry": "^0.0.1",
39
+ "@coordination-network/user-app-link": "^0.0.1",
40
+ "@coordination-network/user-client": "^0.0.1",
41
+ "@coordination-network/wallet": "^0.0.1",
42
+ "@semaphore-protocol/identity": "4.14.3",
43
+ "zod": "^4.3.11"
44
+ },
45
+ "devDependencies": {
46
+ "@noble/curves": "^1.9.7",
47
+ "@rollup/plugin-typescript": "^12.3.0",
48
+ "rimraf": "^6.1.3",
49
+ "rollup": "^4.62.2",
50
+ "rollup-plugin-cleanup": "^3.2.1",
51
+ "rollup-plugin-dts": "^6.4.1",
52
+ "tslib": "^2.8.0",
53
+ "typescript": "^6.0.3"
54
+ }
55
+ }
@@ -0,0 +1,61 @@
1
+ import { ApprovedIssuerSet } from "@coordination-network/issuer-set"
2
+ import { RevocationRegistry } from "@coordination-network/revocation-registry"
3
+ import type { ChainStateLoader, WalletChainState } from "@coordination-network/wallet"
4
+ import type { HttpClient } from "./types"
5
+
6
+ interface IssuersResponseBody {
7
+ leaves: string[]
8
+ root: string
9
+ }
10
+
11
+ interface RevocationSnapshotResponseBody {
12
+ root: string
13
+ snapshot: string
14
+ }
15
+
16
+ /** The half of a chain state a member's approved-issuer set supplies. */
17
+ export type IssuerSetState = Pick<WalletChainState, "approvedIssuers" | "approvedIssuerRoot">
18
+
19
+ /** The half its revocation snapshot supplies. */
20
+ export type RevocationsState = Pick<WalletChainState, "revocations" | "revocationRoot">
21
+
22
+ /**
23
+ * `GET /v1/issuers` carries a `root` the server read from chain independently of the `leaves` it
24
+ * also served, so `Wallet`'s comparison between the tree rebuilt from `leaves` and
25
+ * `approvedIssuerRoot` catches a server that served a mismatched pair.
26
+ */
27
+ export async function readIssuerSet(http: HttpClient, issuersUrl: string): Promise<IssuerSetState> {
28
+ const response = await http.getJson(issuersUrl)
29
+ if (response.status !== 200) {
30
+ throw new Error(`GET ${issuersUrl} returned ${response.status}`)
31
+ }
32
+ const { leaves, root } = response.body as IssuersResponseBody
33
+
34
+ return {
35
+ approvedIssuers: ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf))),
36
+ approvedIssuerRoot: BigInt(root)
37
+ }
38
+ }
39
+
40
+ /** The snapshot's own `root` gives the revocation side the same cross-check. */
41
+ export async function readRevocations(http: HttpClient, revocationsUrl: string): Promise<RevocationsState> {
42
+ const response = await http.getJson(revocationsUrl)
43
+ if (response.status !== 200) {
44
+ throw new Error(`GET ${revocationsUrl} returned ${response.status}`)
45
+ }
46
+ const { root, snapshot } = response.body as RevocationSnapshotResponseBody
47
+
48
+ return { revocations: RevocationRegistry.import(snapshot), revocationRoot: BigInt(root) }
49
+ }
50
+
51
+ /**
52
+ * Both lists are named per request by `issuersUrl`/`revocationsUrl`, so this is built fresh per
53
+ * request and never held onto. A handler with several credentials to check reads the two halves
54
+ * itself instead, so one read of each serves them all (see `presentation-create.ts`).
55
+ */
56
+ export function buildChainStateLoader(http: HttpClient, issuersUrl: string, revocationsUrl: string): ChainStateLoader {
57
+ return async () => ({
58
+ ...(await readIssuerSet(http, issuersUrl)),
59
+ ...(await readRevocations(http, revocationsUrl))
60
+ })
61
+ }
package/src/consent.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { DecodedLinkRequest, LinkOp, Params } from "@coordination-network/user-app-link"
2
+ import type { ConsentSummary } from "./types"
3
+
4
+ /**
5
+ * `createRequestHandler` calls this after confirming `request.op` is a known op and validating
6
+ * `params` against its schema, so the casts below are re-attaching types the caller already
7
+ * proved, not asserting past them — an exhaustive switch covers every case with no defensive
8
+ * default branch needed. Every value here ends up behind the sheet's "Technical details" toggle;
9
+ * the sentence above it is the app's, not this package's.
10
+ */
11
+ export function buildConsentSummary(request: DecodedLinkRequest): ConsentSummary {
12
+ const caller = request.caller
13
+ // The callback is the only signal against a spoofed caller, so it is never shortened.
14
+ const callback = { label: "Callback", value: request.callback, truncate: false }
15
+
16
+ switch (request.op as LinkOp) {
17
+ case "registration.sign": {
18
+ const p = request.params as Params<"registration.sign">
19
+ return {
20
+ op: "registration.sign",
21
+ caller,
22
+ details: [
23
+ callback,
24
+ // The signature is bound to this contract, so it names what approving commits to.
25
+ { label: "Registry contract", value: p.verifyingContract },
26
+ { label: "Chain ID", value: String(p.chainId) }
27
+ ]
28
+ }
29
+ }
30
+
31
+ case "credential.issue": {
32
+ const p = request.params as Params<"credential.issue">
33
+ return {
34
+ op: "credential.issue",
35
+ caller,
36
+ scope: { programme: p.approvalScope.programme, country: p.approvalScope.country },
37
+ details: [
38
+ { label: "Issuer", value: p.issuerNetworkId },
39
+ // This app posts the binding signatures there itself, so this row is the one
40
+ // that says where they go. The caller picks the URL, and the schema holds all
41
+ // four issuance URLs to that one origin, so this row speaks for the rest.
42
+ { label: "Issuer server", value: p.credentialsUrl },
43
+ { label: "Programme", value: p.approvalScope.programme },
44
+ { label: "Country", value: p.approvalScope.country },
45
+ callback
46
+ ]
47
+ }
48
+ }
49
+
50
+ case "presentation.create": {
51
+ const p = request.params as Params<"presentation.create">
52
+ return {
53
+ op: "presentation.create",
54
+ caller,
55
+ scope: { programme: p.programme, country: p.country },
56
+ details: [
57
+ { label: "Verifier", value: p.audience },
58
+ // The proof is posted by this app, not handed back to the caller, so this row
59
+ // is the one that says where it actually goes. Unlike the callback, this URL
60
+ // is not scheme-restricted, and a caller picks it.
61
+ { label: "Proof is sent to", value: p.responseUrl },
62
+ callback,
63
+ { label: "Programme", value: p.programme },
64
+ { label: "Country", value: p.country }
65
+ ]
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,16 @@
1
+ import type { Hex } from "@coordination-network/identity"
2
+ import type { Store } from "@coordination-network/wallet"
3
+
4
+ /**
5
+ * This package holds exactly one identity, so unlike `Wallet`'s `key:`-prefixed store (built for
6
+ * several imported keys) the device secret lives under one fixed key.
7
+ */
8
+ export const DEVICE_SECRET_KEY = "user-app-core:device-secret"
9
+
10
+ export async function readDeviceSecret(store: Store): Promise<Hex | null> {
11
+ return (await store.get(DEVICE_SECRET_KEY)) as Hex | null
12
+ }
13
+
14
+ export async function writeDeviceSecret(store: Store, secret: Hex): Promise<void> {
15
+ await store.set(DEVICE_SECRET_KEY, secret)
16
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { WalletError, type WalletErrorCode } from "@coordination-network/wallet"
2
+ import { LinkError, type LinkErrorCode } from "@coordination-network/user-app-link"
3
+
4
+ /**
5
+ * `WalletError` and `LinkError` speak different vocabularies for the same underlying failures.
6
+ * Split along one line: a malformed or structurally-invalid credential is `INVALID_REQUEST`;
7
+ * a well-formed credential that is simply not usable right now (not yet valid, expired, its
8
+ * issuer retired) is `NO_CREDENTIAL`, while a revoked one says so outright. A chain-state failure
9
+ * means whichever server published those lists is unreachable or inconsistent, not a fault in
10
+ * this app, hence an unreachable code rather than `INTERNAL`.
11
+ */
12
+ const WALLET_ERROR_TO_LINK_ERROR_CODE: Record<WalletErrorCode, LinkErrorCode> = {
13
+ NOT_FOUND: "NO_CREDENTIAL",
14
+ ALREADY_STORED: "INVALID_REQUEST",
15
+ CREDENTIAL_INVALID: "INVALID_REQUEST",
16
+ CONSENT_DENIED: "CONSENT_DENIED",
17
+ REVOKED: "CREDENTIAL_REVOKED",
18
+ CHAIN_STATE_UNAVAILABLE: "VERIFIER_UNREACHABLE",
19
+ CHAIN_STATE_INVALID: "VERIFIER_UNREACHABLE",
20
+ NOT_YET_VALID: "NO_CREDENTIAL",
21
+ EXPIRED: "NO_CREDENTIAL",
22
+ ISSUER_NOT_APPROVED: "NO_CREDENTIAL",
23
+ PRESENTATION_FAILED: "PROOF_FAILED"
24
+ }
25
+
26
+ /**
27
+ * Preserves the original message on the thrown `LinkError`. `chainStateCode` names the side whose
28
+ * lists were being read: during issuance that is the issuer, not a verifier.
29
+ *
30
+ * Takes `unknown` rather than `WalletError` because the wallet's sync methods are not total over
31
+ * `WalletError`: they parse each stored credential and write each record back, so a corrupt record
32
+ * or a failing device store throws something else entirely. Indexing the table with that error's
33
+ * absent `code` would have produced a `LinkError` with no code at all.
34
+ */
35
+ export function mapWalletError(error: unknown, chainStateCode?: LinkErrorCode): LinkError {
36
+ if (!(error instanceof WalletError)) {
37
+ return new LinkError("INTERNAL", error instanceof Error ? error.message : String(error))
38
+ }
39
+ const isChainState = error.code === "CHAIN_STATE_UNAVAILABLE" || error.code === "CHAIN_STATE_INVALID"
40
+ const code = isChainState && chainStateCode ? chainStateCode : WALLET_ERROR_TO_LINK_ERROR_CODE[error.code]
41
+ return new LinkError(code, error.message)
42
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ buildDeviceBindingMessage,
3
+ deriveEddsaKeypair,
4
+ deriveKeys,
5
+ eddsaPeerPointBytes,
6
+ signAccountBinding,
7
+ type Hex
8
+ } from "@coordination-network/identity"
9
+ import {
10
+ deriveRevocationCommitment,
11
+ generateRevocationSecret,
12
+ jwkToPublicKeyBytes,
13
+ parseZkCredential,
14
+ signCommitmentOwnership
15
+ } from "@coordination-network/credential"
16
+ import type { ChainStateLoader, Wallet, WalletChainState } from "@coordination-network/wallet"
17
+ import { LinkError, type CredentialIssueParams, type CredentialIssueResult } from "@coordination-network/user-app-link"
18
+ import { buildChainStateLoader } from "../chain-state"
19
+ import { mapWalletError } from "../errors"
20
+ import { bigintToBytes32, bytesToHex, hexToBytes } from "../hex"
21
+ import { challengeResponseSchema, issueCredentialResponseSchema, postToMember, type MemberRefusal } from "../member-api"
22
+ import { readIssuerEntry, writeIssuerEntry } from "../issuer-index"
23
+ import { nowFn } from "../now"
24
+ import { CREDENTIAL_ISSUE_STEPS } from "../steps"
25
+ import { buildWallet } from "../wallet-factory"
26
+ import type { UserAppPorts } from "../types"
27
+
28
+ type Refusal = Extract<CredentialIssueResult, { status: "refused" }>
29
+
30
+ /** The issuer answered, but named no reason of its own. */
31
+ const UNNAMED_REFUSAL = "ISSUANCE_REFUSED"
32
+
33
+ /** A Baby Jubjub point as the decimal-string pair the wire protocol carries. */
34
+ const pointToStrings = (point: readonly [bigint, bigint]): [string, string] => [String(point[0]), String(point[1])]
35
+
36
+ /** Relayed in the issuer's own words, under a stand-in code when it named none of its own. */
37
+ const refused = ({ code, message }: MemberRefusal): Refusal => ({
38
+ status: "refused",
39
+ code: code ?? UNNAMED_REFUSAL,
40
+ message
41
+ })
42
+
43
+ /**
44
+ * Whether an indexed credential still stops this member issuing another for the same programme.
45
+ * Only one that is valid right now does: a revoked or expired credential, or an entry whose
46
+ * credential is gone, would otherwise leave the person with no way back but erasing everything.
47
+ */
48
+ async function isStillValid(wallet: Wallet, credentialId: string, load: ChainStateLoader): Promise<boolean> {
49
+ if ((await wallet.getCredential(credentialId)) === null) return false
50
+ await wallet.syncCredential(credentialId, load)
51
+ return (await wallet.getCredential(credentialId))?.status === "valid"
52
+ }
53
+
54
+ /**
55
+ * The whole issuance, behind the one consent the link boundary already took: hold check, nonce,
56
+ * binding, the issuer's signature, the two authority checks, storage. Assembly over existing
57
+ * functions, no new cryptography.
58
+ *
59
+ * The revocation secret is generated and opened inside this one call, so it never leaves the
60
+ * function and nothing has to persist it between two deep links.
61
+ */
62
+ export async function credentialIssueHandler(
63
+ ports: UserAppPorts,
64
+ deviceSecret: Hex,
65
+ params: CredentialIssueParams
66
+ ): Promise<CredentialIssueResult> {
67
+ const step = (name: string) => ports.progress("credential.issue", name)
68
+
69
+ step(CREDENTIAL_ISSUE_STEPS.held)
70
+ const held = await readIssuerEntry(ports.store, params.issuerNetworkId, params.approvalScope)
71
+
72
+ // Read before the issuer signs: the revocation secret below never leaves this call, so a
73
+ // failure after the 201 would orphan the credential at the issuer with nothing able to open it.
74
+ step(CREDENTIAL_ISSUE_STEPS.lists)
75
+ let chainState: WalletChainState
76
+ try {
77
+ chainState = await buildChainStateLoader(ports.http, params.issuersUrl, params.revocationsUrl)()
78
+ } catch (error) {
79
+ throw new LinkError(
80
+ "ISSUER_UNREACHABLE",
81
+ error instanceof Error
82
+ ? error.message
83
+ : "the issuer's approved-issuer set or revocation snapshot is unreadable"
84
+ )
85
+ }
86
+
87
+ const loadChainState: ChainStateLoader = async () => chainState
88
+ const wallet = await buildWallet(ports, loadChainState)
89
+ if (held !== null && (await isStillValid(wallet, held.credentialId, loadChainState))) {
90
+ return { status: "alreadyHeld", credentialId: held.credentialId }
91
+ }
92
+
93
+ const keys = deriveKeys(deviceSecret)
94
+
95
+ step(CREDENTIAL_ISSUE_STEPS.challenge)
96
+ const challenge = await postToMember(
97
+ ports.http,
98
+ params.challengeUrl,
99
+ { subjectNetworkId: keys.networkId },
100
+ { status: 200, schema: challengeResponseSchema, unreachable: "ISSUER_UNREACHABLE" }
101
+ )
102
+ if (challenge.refusal) return refused(challenge.refusal)
103
+ const nonce = challenge.value.nonce
104
+
105
+ step(CREDENTIAL_ISSUE_STEPS.binding)
106
+ const confirmationKey = await ports.deviceKey.getPublicKeyJwk()
107
+ const eddsa = deriveEddsaKeypair(await ports.deviceKey.ecdh(eddsaPeerPointBytes()))
108
+ const revocationSecret = generateRevocationSecret()
109
+ const ownership = signCommitmentOwnership(revocationSecret, hexToBytes(nonce))
110
+
111
+ const deviceBindingMessage = buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(eddsa.commitment))
112
+ const devicePopSignature = bytesToHex(await ports.deviceKey.sign(deviceBindingMessage))
113
+ const { signature: accountPopSignature } = signAccountBinding(
114
+ keys.account.privateKey,
115
+ nonce,
116
+ jwkToPublicKeyBytes(confirmationKey)
117
+ )
118
+
119
+ step(CREDENTIAL_ISSUE_STEPS.signing)
120
+ const issued = await postToMember(
121
+ ports.http,
122
+ params.credentialsUrl,
123
+ {
124
+ subjectNetworkId: keys.networkId,
125
+ approvalScope: params.approvalScope,
126
+ confirmationKey,
127
+ accountPublicKey: keys.account.uncompressedPublicKey,
128
+ devicePopSignature,
129
+ accountPopSignature,
130
+ identityPublicKey: pointToStrings(eddsa.publicKey),
131
+ revocation: {
132
+ commitment: deriveRevocationCommitment(revocationSecret).toString(),
133
+ publicKey: pointToStrings(ownership.publicKey),
134
+ ownershipSignature: {
135
+ R8: pointToStrings(ownership.signature.R8),
136
+ S: String(ownership.signature.S)
137
+ }
138
+ }
139
+ },
140
+ { status: 201, schema: issueCredentialResponseSchema, unreachable: "ISSUER_UNREACHABLE" }
141
+ )
142
+ if (issued.refusal) return refused(issued.refusal)
143
+
144
+ let issuerPublicKey: [string, string]
145
+ try {
146
+ const parsed = parseZkCredential(issued.value.credential)
147
+ issuerPublicKey = [parsed.issuerPublicKey[0].toString(), parsed.issuerPublicKey[1].toString()]
148
+ } catch (error) {
149
+ throw new LinkError("ISSUER_UNREACHABLE", error instanceof Error ? error.message : "malformed credential")
150
+ }
151
+
152
+ // Built above for the hold check; the lists are read before the signature, so this is the
153
+ // only step left that can fail.
154
+ step(CREDENTIAL_ISSUE_STEPS.storing)
155
+ let credentialId: string
156
+ try {
157
+ credentialId = await wallet.addCredential({
158
+ id: issued.value.id,
159
+ credential: issued.value.credential,
160
+ revocationSecret
161
+ })
162
+ } catch (error) {
163
+ throw mapWalletError(error, "ISSUER_UNREACHABLE")
164
+ }
165
+
166
+ await writeIssuerEntry(ports.store, params.issuerNetworkId, {
167
+ credentialId,
168
+ issuerPublicKey,
169
+ issuersUrl: params.issuersUrl,
170
+ programme: params.approvalScope.programme,
171
+ country: params.approvalScope.country,
172
+ issuedAt: Math.floor(nowFn(ports) / 1000)
173
+ })
174
+
175
+ return { status: "stored", credentialId }
176
+ }
@@ -0,0 +1,168 @@
1
+ import { WalletError, type CredentialInfo, type WalletChainState } from "@coordination-network/wallet"
2
+ import {
3
+ LinkError,
4
+ type Hex32,
5
+ type PresentationCreateParams,
6
+ type PresentationCreateResult
7
+ } from "@coordination-network/user-app-link"
8
+ import { readIssuerSet, readRevocations, type IssuerSetState, type RevocationsState } from "../chain-state"
9
+ import { mapWalletError } from "../errors"
10
+ import { bytesToHex, hexToBytes } from "../hex"
11
+ import { listIssuerEntries, type IssuerIndexEntry } from "../issuer-index"
12
+ import { postToMember, verifyResponseSchema } from "../member-api"
13
+ import { latestExpiring, matchingCredentials } from "../selection"
14
+ import { buildWallet, neverLoadChainState } from "../wallet-factory"
15
+ import type { UserAppPorts } from "../types"
16
+
17
+ /** A held credential together with the issuer entry naming its plaintext programme and issuer. */
18
+ interface Candidate extends CredentialInfo {
19
+ issuerNetworkId: Hex32
20
+ entry: IssuerIndexEntry
21
+ }
22
+
23
+ /** A candidate checked against the network state, carrying the state that check read. */
24
+ interface SyncedCandidate extends Candidate {
25
+ chainState: WalletChainState
26
+ }
27
+
28
+ export async function presentationCreateHandler(
29
+ ports: UserAppPorts,
30
+ params: PresentationCreateParams
31
+ ): Promise<PresentationCreateResult> {
32
+ const step = (name: string) => ports.progress("presentation.create", name)
33
+
34
+ // The state is read below, once the request is known to have candidates, so the wallet is built
35
+ // without an authority and handed one per call.
36
+ const wallet = await buildWallet(ports, neverLoadChainState)
37
+
38
+ /**
39
+ * The same for every candidate, so the request reads it once however many credentials it has
40
+ * to check — and a verifier that cannot serve it fails the whole request, since no candidate
41
+ * can be judged without it.
42
+ */
43
+ async function verifierIssuerSet(): Promise<IssuerSetState> {
44
+ try {
45
+ return await readIssuerSet(ports.http, params.issuersUrl)
46
+ } catch (error) {
47
+ throw new LinkError(
48
+ "VERIFIER_UNREACHABLE",
49
+ error instanceof Error ? error.message : `GET ${params.issuersUrl} failed`
50
+ )
51
+ }
52
+ }
53
+
54
+ /** Same reasoning as the issuer set: the verifier is the party that must accept this root. */
55
+ async function verifierRevocations(): Promise<RevocationsState> {
56
+ try {
57
+ return await readRevocations(ports.http, params.revocationsUrl)
58
+ } catch (error) {
59
+ throw new LinkError(
60
+ "VERIFIER_UNREACHABLE",
61
+ error instanceof Error ? error.message : `GET ${params.revocationsUrl} failed`
62
+ )
63
+ }
64
+ }
65
+
66
+ async function readCandidates(): Promise<Candidate[]> {
67
+ const infoById = new Map((await wallet.listCredentials()).map((info) => [info.id, info]))
68
+ const held = (await listIssuerEntries(ports.store)).flatMap(({ issuerNetworkId, ...entry }) => {
69
+ const info = infoById.get(entry.credentialId)
70
+ // An entry whose credential is gone names a snapshot for nothing; a credential with
71
+ // no entry has no snapshot to be checked against. Neither can be proved.
72
+ return info === undefined ? [] : [{ ...info, issuerNetworkId, entry }]
73
+ })
74
+ return matchingCredentials(held, params)
75
+ }
76
+
77
+ step("Checking your credential")
78
+ const candidates = await readCandidates()
79
+
80
+ // One read for the whole request: every member serves the same network tree, and reading it
81
+ // per issuer was itself what told whoever served it which issuer a credential came from. The
82
+ // state is kept so the proof is built from the same read, not a second one that could disagree.
83
+ let chainState: WalletChainState | undefined
84
+ if (candidates.length > 0) {
85
+ const state = { ...(await verifierIssuerSet()), ...(await verifierRevocations()) }
86
+ chainState = state
87
+ for (const candidate of candidates) {
88
+ try {
89
+ await wallet.syncCredential(candidate.id, async () => state)
90
+ } catch (error) {
91
+ // A served root that does not match the tree served beside it is the verifier's
92
+ // fault, not this wallet's; without mapping it the caller is told INTERNAL.
93
+ throw mapWalletError(error, "VERIFIER_UNREACHABLE")
94
+ }
95
+ }
96
+ }
97
+
98
+ const synced = (await readCandidates()).flatMap<SyncedCandidate>((candidate) =>
99
+ chainState === undefined ? [] : [{ ...candidate, chainState }]
100
+ )
101
+ const selected = latestExpiring(synced.filter((candidate) => candidate.status === "valid"))
102
+ if (selected === null) {
103
+ const revoked = latestExpiring(synced.filter((candidate) => candidate.status === "revoked"))
104
+ if (revoked !== null) {
105
+ // The refusal itself says the person holds a revoked credential, which a proof never
106
+ // would, so it goes through the same gate the proof does.
107
+ const told = await ports.review({
108
+ kind: "revoked",
109
+ verifier: params.audience,
110
+ programme: params.programme,
111
+ country: params.country,
112
+ issuerNetworkId: revoked.issuerNetworkId
113
+ })
114
+ if (!told) throw new LinkError("CONSENT_DENIED", "the refusal was not shared")
115
+ throw new LinkError("CREDENTIAL_REVOKED", `the ${params.programme} credential you hold has been revoked`)
116
+ }
117
+ throw new LinkError(
118
+ "NO_CREDENTIAL",
119
+ `no held credential matches programme "${params.programme}"/"${params.country}"`
120
+ )
121
+ }
122
+
123
+ step("Proving")
124
+ let proof
125
+ try {
126
+ proof = await wallet.present(
127
+ selected.id,
128
+ {
129
+ challenge: hexToBytes(params.challenge),
130
+ audience: params.audience,
131
+ currentTime: params.currentTime,
132
+ programme: params.programme,
133
+ country: params.country
134
+ },
135
+ async () => selected.chainState
136
+ )
137
+ } catch (error) {
138
+ if (error instanceof WalletError) throw mapWalletError(error)
139
+ // A raw prover failure, not a WalletError - keep it distinguishable from NO_CREDENTIAL above.
140
+ throw new LinkError("PROOF_FAILED", error instanceof Error ? error.message : "proof generation failed")
141
+ }
142
+
143
+ const approved = await ports.review({
144
+ kind: "proof",
145
+ verifier: params.audience,
146
+ programme: params.programme,
147
+ country: params.country,
148
+ issuerNetworkId: selected.issuerNetworkId
149
+ })
150
+ if (!approved) throw new LinkError("CONSENT_DENIED", "the proof was built but not shared")
151
+
152
+ step("Sharing")
153
+ // Only the id comes back: the verifier's own `ok` would be the verifier reporting on itself,
154
+ // and its member app can read the recorded outcome from its own server under this id.
155
+ const posted = await postToMember(
156
+ ports.http,
157
+ params.responseUrl,
158
+ { requestId: params.requestId, proof: bytesToHex(proof.proof), publicInputs: proof.publicInputs },
159
+ { status: 200, schema: verifyResponseSchema, unreachable: "VERIFIER_UNREACHABLE" }
160
+ )
161
+ // An issuer's error body is the issuer refusing this person, and the wallet relays it. A
162
+ // verifier has nothing to refuse — it is answering a request the wallet made — so an error
163
+ // body from it is just an answer the wallet cannot use.
164
+ if (posted.refusal) {
165
+ throw new LinkError("VERIFIER_UNREACHABLE", `${params.responseUrl} returned an unusable response`)
166
+ }
167
+ return { verificationId: posted.value.verificationId }
168
+ }
@@ -0,0 +1,19 @@
1
+ import { deriveKeys, type Hex } from "@coordination-network/identity"
2
+ import type { RegistrationSignParams, RegistrationSignResult } from "@coordination-network/user-app-link"
3
+ import { buildRegistrationRequest } from "@coordination-network/user-client"
4
+ import type { UserAppPorts } from "../types"
5
+
6
+ export async function registrationSignHandler(
7
+ ports: UserAppPorts,
8
+ deviceSecret: Hex,
9
+ params: RegistrationSignParams
10
+ ): Promise<RegistrationSignResult> {
11
+ const keys = deriveKeys(deviceSecret)
12
+ return {
13
+ request: buildRegistrationRequest(keys, {
14
+ chainId: params.chainId,
15
+ verifyingContract: params.verifyingContract
16
+ }),
17
+ securityLevel: await ports.deviceKey.getSecurityLevel()
18
+ }
19
+ }
package/src/hex.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { bytesToHex, hexToBytes } from "@coordination-network/identity"
2
+
3
+ export { bytesToHex, hexToBytes }
4
+
5
+ /** Encodes a field element as the 32-byte big-endian value the binding and commitment builders expect. */
6
+ export function bigintToBytes32(value: bigint): Uint8Array {
7
+ return hexToBytes(`0x${value.toString(16).padStart(64, "0")}`)
8
+ }
@@ -0,0 +1,15 @@
1
+ import { deriveKeys, type Hex } from "@coordination-network/identity"
2
+ import { metaStealthAddressFromKeys } from "@coordination-network/user-client"
3
+ import type { DeviceKey, PublicIdentity } from "./types"
4
+
5
+ /** Assembles the four values the Identity screen shows — `Wallet` exposes none of them directly. */
6
+ export async function assembleIdentity(deviceSecret: Hex, deviceKey: DeviceKey): Promise<PublicIdentity> {
7
+ const keys = deriveKeys(deviceSecret)
8
+
9
+ return {
10
+ networkId: keys.networkId,
11
+ metaStealthAddress: metaStealthAddressFromKeys(keys),
12
+ accountPublicKey: keys.account.uncompressedPublicKey,
13
+ securityLevel: await deviceKey.getSecurityLevel()
14
+ }
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@coordination-network/user-app-core` — the three `user-app-link` operations a user app serves,
3
+ * over injected device, storage and proving ports. See `README.md`.
4
+ */
5
+
6
+ export { createUserAppService } from "./service"
7
+ export { mapWalletError } from "./errors"
8
+ export { latestExpiring, matchingCredentials, type CredentialSelectionRequest } from "./selection"
9
+ export { buildConsentSummary } from "./consent"
10
+ export { DEVICE_SECRET_KEY } from "./device-secret"
11
+ export { CREDENTIAL_ISSUE_STEPS } from "./steps"
12
+ export { listIssuerEntries, readIssuerEntry, type IssuerIndexEntry } from "./issuer-index"
13
+ export type {
14
+ ConsentDetail,
15
+ ConsentScope,
16
+ ConsentSummary,
17
+ DeviceKey,
18
+ HeldCredential,
19
+ HeldCredentialStatus,
20
+ HttpClient,
21
+ HttpJsonResponse,
22
+ PresentationReview,
23
+ PresentationReviewPrompt,
24
+ PublicIdentity,
25
+ RevocationAuthority,
26
+ StealthScanKeys,
27
+ UserAppPorts,
28
+ UserAppService,
29
+ UserConsentPrompt
30
+ } from "./types"