@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.
@@ -0,0 +1,64 @@
1
+ import type { Hex32 } from "@coordination-network/user-app-link"
2
+ import type { Store } from "@coordination-network/wallet"
3
+ import type { ConsentScope } from "./types"
4
+
5
+ /**
6
+ * `issuerNetworkId` is asserted by the caller and cannot be verified here. An established mapping
7
+ * still cannot be taken over while the credential under it is valid: `credential.issue` answers
8
+ * `alreadyHeld` for such a key rather than issuing against it again.
9
+ */
10
+ const ISSUER_INDEX_PREFIX = "user-app-core:issuer-index:"
11
+
12
+ /**
13
+ * One entry per issuer the wallet holds a credential from. It carries what the credential body
14
+ * cannot give back — the plaintext programme and the issuer — so a listing can name both.
15
+ */
16
+ export interface IssuerIndexEntry {
17
+ credentialId: string
18
+ /** The Baby Jubjub key that signed the credential, as decimal field elements. */
19
+ issuerPublicKey: [string, string]
20
+ /** Where the issuer published its own approved-issuer set at issuance. Nothing reads it yet:
21
+ * a presentation reads the verifier's, which is why a hostile verifier can still probe for the
22
+ * issuer by serving a subset. Recorded so that check can be anchored here instead. */
23
+ issuersUrl: string
24
+ programme: string
25
+ country: string
26
+ issuedAt: number
27
+ }
28
+
29
+ /**
30
+ * One key per issuer *and* approval scope, so a member running two programmes can issue for both.
31
+ * The two scope parts are percent-encoded, which escapes the separator, so the issuer id is always
32
+ * the first of exactly three segments.
33
+ */
34
+ const keyFor = (issuerNetworkId: Hex32, scope: ConsentScope): string =>
35
+ `${ISSUER_INDEX_PREFIX}${issuerNetworkId.toLowerCase()}:${encodeURIComponent(scope.programme)}:${encodeURIComponent(scope.country)}`
36
+
37
+ export async function readIssuerEntry(
38
+ store: Store,
39
+ issuerNetworkId: Hex32,
40
+ scope: ConsentScope
41
+ ): Promise<IssuerIndexEntry | null> {
42
+ const raw = await store.get(keyFor(issuerNetworkId, scope))
43
+ return raw === null ? null : (JSON.parse(raw) as IssuerIndexEntry)
44
+ }
45
+
46
+ /** Every entry the wallet holds, for the joins that start from a credential rather than an issuer. */
47
+ export async function listIssuerEntries(store: Store): Promise<(IssuerIndexEntry & { issuerNetworkId: Hex32 })[]> {
48
+ const entries: (IssuerIndexEntry & { issuerNetworkId: Hex32 })[] = []
49
+ for (const key of await store.list()) {
50
+ if (!key.startsWith(ISSUER_INDEX_PREFIX)) continue
51
+ const raw = await store.get(key)
52
+ if (raw === null) continue
53
+ entries.push({
54
+ ...(JSON.parse(raw) as IssuerIndexEntry),
55
+ issuerNetworkId: key.slice(ISSUER_INDEX_PREFIX.length).split(":")[0] as Hex32
56
+ })
57
+ }
58
+ return entries
59
+ }
60
+
61
+ /** The entry carries its own scope, so it is what the key is built from. */
62
+ export async function writeIssuerEntry(store: Store, issuerNetworkId: Hex32, entry: IssuerIndexEntry): Promise<void> {
63
+ await store.set(keyFor(issuerNetworkId, entry), JSON.stringify(entry))
64
+ }
@@ -0,0 +1,68 @@
1
+ import { z } from "zod"
2
+ import { LinkError, type LinkErrorCode } from "@coordination-network/user-app-link"
3
+ import type { HttpClient } from "./types"
4
+
5
+ /**
6
+ * The member-server responses the wallet reads for itself: the two issuance routes it now drives
7
+ * alone, and the verify route it posts a proof to. `@coordination-network/network-member-app-core`
8
+ * keeps its own schemas for the routes the member app still calls; the two packages do not depend on
9
+ * each other, and both are fixed by `apps/issuer-service`.
10
+ */
11
+
12
+ /**
13
+ * `POST /v1/challenge` writes its nonce with `@noble/hashes`' `bytesToHex`, which never adds the
14
+ * `0x` prefix every other hex field in this protocol carries - accept it either way and normalize
15
+ * to the `0x`-prefixed, lowercase form the binding builders require.
16
+ */
17
+ const challengeNonceSchema = z
18
+ .string()
19
+ .regex(/^(0x)?[0-9a-fA-F]{64}$/i, "must be 32-byte hex")
20
+ .transform((value) => `0x${value.replace(/^0x/i, "")}`.toLowerCase() as `0x${string}`)
21
+
22
+ export const challengeResponseSchema = z.object({ nonce: challengeNonceSchema, expiresAt: z.number() }).strict()
23
+
24
+ export const issueCredentialResponseSchema = z.object({ id: z.string().min(1), credential: z.unknown() }).strict()
25
+
26
+ /**
27
+ * Not `.strict()`: `POST /v1/verify` also answers with its own `ok`/`code`, which the wallet
28
+ * deliberately does not relay — the member app reads the outcome from its own server instead.
29
+ */
30
+ export const verifyResponseSchema = z.object({ verificationId: z.string().min(1) })
31
+
32
+ /** Not `.strict()`: an error body is read for `code`/`error`, whichever route sent it. */
33
+ export const errorBodySchema = z.object({ error: z.string().optional(), code: z.string().optional() })
34
+
35
+ /** A member answering with an error of its own, in its own vocabulary. */
36
+ export interface MemberRefusal {
37
+ code?: string
38
+ message: string
39
+ }
40
+
41
+ /**
42
+ * One POST to a member's own server. An answer the route is specified to give is a value; any
43
+ * other answer carrying an error is handed back as the member's own words, for the caller to say
44
+ * what they amount to; anything else means the server is not usefully there, under whichever
45
+ * unreachable code names the side being talked to.
46
+ */
47
+ export async function postToMember<T>(
48
+ http: HttpClient,
49
+ url: string,
50
+ body: unknown,
51
+ expected: { status: number; schema: z.ZodType<T>; unreachable: LinkErrorCode }
52
+ ): Promise<{ value: T; refusal?: never } | { value?: never; refusal: MemberRefusal }> {
53
+ let response
54
+ try {
55
+ response = await http.postJson(url, body)
56
+ } catch (error) {
57
+ throw new LinkError(expected.unreachable, error instanceof Error ? error.message : `POST ${url} failed`)
58
+ }
59
+
60
+ const parsed = expected.schema.safeParse(response.body)
61
+ if (response.status === expected.status && parsed.success) return { value: parsed.data }
62
+
63
+ const failure = errorBodySchema.safeParse(response.body)
64
+ if (failure.success && failure.data.error !== undefined) {
65
+ return { refusal: { code: failure.data.code, message: failure.data.error } }
66
+ }
67
+ throw new LinkError(expected.unreachable, `${url} returned an unusable response`)
68
+ }
package/src/now.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { UserAppPorts } from "./types"
2
+
3
+ /** `ports.now` is optional (a test harness injects a deterministic clock); real ports omit it. */
4
+ export function nowFn(ports: Pick<UserAppPorts, "now">): number {
5
+ return (ports.now ?? Date.now)()
6
+ }
@@ -0,0 +1,38 @@
1
+ import { countryCode, parseZkCredential, programmeHash } from "@coordination-network/credential"
2
+ import type { CredentialInfo } from "@coordination-network/wallet"
3
+
4
+ export interface CredentialSelectionRequest {
5
+ programme: string
6
+ country: string
7
+ }
8
+
9
+ /**
10
+ * `presentation.create` carries no credential id, so this narrows by what the credential body
11
+ * says: the requested programme and country. Status is deliberately not a filter — each candidate
12
+ * is then synced against its own issuer's snapshot, and a caller that finds none valid still
13
+ * needs to know whether it held a revoked one.
14
+ */
15
+ export function matchingCredentials<T extends CredentialInfo>(
16
+ credentials: T[],
17
+ request: CredentialSelectionRequest
18
+ ): T[] {
19
+ const wantedProgramme = programmeHash(request.programme)
20
+ const wantedCountry = countryCode(request.country)
21
+
22
+ return credentials.filter((info) => {
23
+ const { body } = parseZkCredential(info.credential)
24
+ return body.programmeHash === wantedProgramme && body.countryCode === wantedCountry
25
+ })
26
+ }
27
+
28
+ /** Of the credentials still usable, the one that stays usable longest, tie-broken by id for determinism. */
29
+ export function latestExpiring<T extends CredentialInfo>(credentials: T[]): T | null {
30
+ if (credentials.length === 0) return null
31
+
32
+ return credentials.reduce((best, candidate) => {
33
+ const bestExpiry = parseZkCredential(best.credential).body.expiresAt
34
+ const candidateExpiry = parseZkCredential(candidate.credential).body.expiresAt
35
+ if (candidateExpiry !== bestExpiry) return candidateExpiry > bestExpiry ? candidate : best
36
+ return candidate.id < best.id ? candidate : best
37
+ })
38
+ }
package/src/service.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { deriveKeys, generateDeviceSecret, type Hex } from "@coordination-network/identity"
2
+ import { parseZkCredential } from "@coordination-network/credential"
3
+ import { LinkError, type DecodedLinkRequest, type LinkHandlers } from "@coordination-network/user-app-link"
4
+ import { buildConsentSummary } from "./consent"
5
+ import { readDeviceSecret, writeDeviceSecret } from "./device-secret"
6
+ import { credentialIssueHandler } from "./handlers/credential-issue"
7
+ import { presentationCreateHandler } from "./handlers/presentation-create"
8
+ import { registrationSignHandler } from "./handlers/registration-sign"
9
+ import { assembleIdentity } from "./identity"
10
+ import { listIssuerEntries } from "./issuer-index"
11
+ import { nowFn } from "./now"
12
+ import { buildWallet, neverLoadChainState } from "./wallet-factory"
13
+ import type {
14
+ HeldCredential,
15
+ HeldCredentialStatus,
16
+ RevocationAuthority,
17
+ PublicIdentity,
18
+ StealthScanKeys,
19
+ UserAppPorts,
20
+ UserAppService
21
+ } from "./types"
22
+
23
+ /**
24
+ * Still not `Wallet`'s own status: that also carries `issuer-not-approved`, which `syncRevocations`
25
+ * stamps against whichever single member last asked for a presentation, and reporting one member's
26
+ * verdict on another member's credential would be worse than reporting nothing.
27
+ *
28
+ * Revocation is no longer in that category. It used to be - one registry per issuer left a
29
+ * mixed-issuer listing with no single authority to sync against - but the whole network now revokes
30
+ * into one registry, so there is exactly one thing to ask and asking it names no issuer.
31
+ */
32
+ function windowStatus(notBefore: number, expiresAt: number, nowSeconds: number): HeldCredentialStatus {
33
+ if (nowSeconds < notBefore) return "not-yet-valid"
34
+ if (nowSeconds > expiresAt) return "expired"
35
+ return "valid"
36
+ }
37
+
38
+ /**
39
+ * The window first, since it is signed into the credential and needs no one to confirm: a
40
+ * credential already outside its dates is reported as such without disclosing anything to a node.
41
+ * Only an otherwise usable credential is worth asking the registry about.
42
+ */
43
+ async function statusOf(
44
+ notBefore: number,
45
+ expiresAt: number,
46
+ nowSeconds: number,
47
+ revocationCommitment: bigint,
48
+ authority: RevocationAuthority | undefined
49
+ ): Promise<HeldCredentialStatus> {
50
+ const window = windowStatus(notBefore, expiresAt, nowSeconds)
51
+ if (window !== "valid") return window
52
+ if (authority === undefined) return "unchecked"
53
+
54
+ // A listing has to render whatever the network is doing, so an unreachable authority is an
55
+ // answer here - "not checked" - rather than a failure that blanks the screen.
56
+ let revoked: boolean | null
57
+ try {
58
+ revoked = await authority.isRevoked(revocationCommitment)
59
+ } catch {
60
+ revoked = null
61
+ }
62
+ if (revoked === null) return "unchecked"
63
+ return revoked ? "revoked" : "valid"
64
+ }
65
+
66
+ /** Inverse of `packages/credential`'s `countryCode`: three ASCII bytes packed into one integer. */
67
+ function decodeCountryCode(code: number): string {
68
+ return String.fromCharCode((code >> 16) & 0xff, (code >> 8) & 0xff, code & 0xff)
69
+ }
70
+
71
+ export function createUserAppService(ports: UserAppPorts): UserAppService {
72
+ // `undefined` means "not yet read from the store"; `null` means "confirmed not onboarded".
73
+ // Distinguishing the two means onboarding status is read from the store at most once.
74
+ let deviceSecretCache: Hex | null | undefined
75
+
76
+ async function resolveDeviceSecret(): Promise<Hex | null> {
77
+ if (deviceSecretCache === undefined) {
78
+ deviceSecretCache = await readDeviceSecret(ports.store)
79
+ }
80
+ return deviceSecretCache
81
+ }
82
+
83
+ /** The gate every handler shares: onboarding is an explicit app action, never implicit. */
84
+ async function requireOnboarded(): Promise<Hex> {
85
+ const secret = await resolveDeviceSecret()
86
+ if (secret === null) throw new LinkError("NOT_ONBOARDED", "no identity has been created yet")
87
+ return secret
88
+ }
89
+
90
+ // One slot deep: a handler body reads and writes the same store a concurrent one might be
91
+ // mid-sync against, so two deep links run their handler bodies one at a time, never interleaved.
92
+ let queue: Promise<unknown> = Promise.resolve()
93
+ function serialize<T>(run: () => Promise<T>): Promise<T> {
94
+ const result = queue.then(run)
95
+ queue = result.then(
96
+ () => undefined,
97
+ () => undefined
98
+ )
99
+ return result
100
+ }
101
+
102
+ const handlers: LinkHandlers = {
103
+ "registration.sign": (params) =>
104
+ serialize(async () => registrationSignHandler(ports, await requireOnboarded(), params)),
105
+
106
+ "credential.issue": (params) =>
107
+ serialize(async () => credentialIssueHandler(ports, await requireOnboarded(), params)),
108
+
109
+ "presentation.create": (params) =>
110
+ serialize(async () => {
111
+ await requireOnboarded()
112
+ return presentationCreateHandler(ports, params)
113
+ })
114
+ }
115
+
116
+ return {
117
+ handlers,
118
+
119
+ async consent(request: DecodedLinkRequest): Promise<boolean> {
120
+ return ports.consent(buildConsentSummary(request))
121
+ },
122
+
123
+ async isOnboarded(): Promise<boolean> {
124
+ return (await resolveDeviceSecret()) !== null
125
+ },
126
+
127
+ createIdentity(): Promise<PublicIdentity> {
128
+ return serialize(async () => {
129
+ if ((await resolveDeviceSecret()) !== null) {
130
+ throw new Error("createIdentity: an identity already exists — erase() before creating another")
131
+ }
132
+ const secret = generateDeviceSecret()
133
+ const identity = await assembleIdentity(secret, ports.deviceKey)
134
+ await writeDeviceSecret(ports.store, secret)
135
+ deviceSecretCache = secret
136
+ return identity
137
+ })
138
+ },
139
+
140
+ async readIdentity(): Promise<PublicIdentity> {
141
+ const secret = await resolveDeviceSecret()
142
+ if (secret === null) throw new Error("readIdentity: no identity has been created yet")
143
+ return assembleIdentity(secret, ports.deviceKey)
144
+ },
145
+
146
+ async readStealthScanKeys(): Promise<StealthScanKeys> {
147
+ const secret = await resolveDeviceSecret()
148
+ if (secret === null) throw new Error("readStealthScanKeys: no identity has been created yet")
149
+ const keys = deriveKeys(secret)
150
+ return { spendingPublicKey: keys.spending.publicKey, viewingPrivateKey: keys.viewing.privateKey }
151
+ },
152
+
153
+ /**
154
+ * Joined by credential id with the issuer index, which is the only place the issuer's
155
+ * name and the plaintext programme survive — the credential body carries a hash.
156
+ */
157
+ async listCredentials(): Promise<HeldCredential[]> {
158
+ const wallet = await buildWallet(ports, neverLoadChainState)
159
+ const held = await wallet.listCredentials()
160
+ const entryByCredentialId = new Map(
161
+ (await listIssuerEntries(ports.store)).map((entry) => [entry.credentialId, entry])
162
+ )
163
+ const nowSeconds = Math.floor(nowFn(ports) / 1000)
164
+ const named = held.flatMap((info) => {
165
+ const entry = entryByCredentialId.get(info.id)
166
+ // A credential with no entry predates the index and cannot be named or proved;
167
+ // the demo erases and reinstalls rather than migrating.
168
+ if (entry === undefined) return []
169
+ return [{ info, entry, body: parseZkCredential(info.credential).body }]
170
+ })
171
+ // One registry read per usable credential, all in flight together: a listing of two
172
+ // credentials should not cost two round trips in sequence.
173
+ return Promise.all(
174
+ named.map(async ({ info, entry, body }) => ({
175
+ id: info.id,
176
+ status: await statusOf(
177
+ body.notBefore,
178
+ body.expiresAt,
179
+ nowSeconds,
180
+ body.revocationCommitment,
181
+ ports.revocations
182
+ ),
183
+ issuerNetworkId: entry.issuerNetworkId,
184
+ programme: entry.programme,
185
+ issuedAt: entry.issuedAt,
186
+ programmeHash: body.programmeHash.toString(),
187
+ country: decodeCountryCode(body.countryCode),
188
+ notBefore: body.notBefore,
189
+ expiresAt: body.expiresAt
190
+ }))
191
+ )
192
+ },
193
+
194
+ /**
195
+ * Clears the entire store, not only this package's own keys — a host app shares it, and an
196
+ * "erase everything" that left its configuration behind would be a half-erase. A caller
197
+ * must therefore drop whatever it keeps in there too.
198
+ */
199
+ erase(): Promise<void> {
200
+ return serialize(async () => {
201
+ for (const key of await ports.store.list()) await ports.store.delete(key)
202
+ deviceSecretCache = null
203
+ })
204
+ }
205
+ }
206
+ }
package/src/steps.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The steps `credential.issue` reports, in the order it reports them. Named here rather than
3
+ * spelled out twice, so the screen that groups the six into the three a person approved cannot
4
+ * drift from the handler reporting them.
5
+ */
6
+ export const CREDENTIAL_ISSUE_STEPS = {
7
+ held: "Checking what you hold",
8
+ lists: "Checking the lists",
9
+ challenge: "Getting a challenge",
10
+ binding: "Binding to this phone",
11
+ signing: "Asking the issuer to sign",
12
+ storing: "Saving to your wallet"
13
+ } as const
package/src/types.ts ADDED
@@ -0,0 +1,175 @@
1
+ import type { Hex } from "@coordination-network/identity"
2
+ import type { DeviceSigner } from "@coordination-network/credential"
3
+ import type { ProofGenerator, Store } from "@coordination-network/wallet"
4
+ import type {
5
+ Caller,
6
+ DecodedLinkRequest,
7
+ Hex32,
8
+ Hex65,
9
+ Hex66,
10
+ LinkHandlers,
11
+ LinkOp,
12
+ SecurityLevel
13
+ } from "@coordination-network/user-app-link"
14
+
15
+ /**
16
+ * The holder P-256 device key. Extends `DeviceSigner` (rather than replacing it) so a `DeviceKey`
17
+ * is accepted as-is anywhere a `DeviceSigner` is expected — `Wallet`, `buildZkPresentationInputs`,
18
+ * `deriveDeviceKeyCommitment`.
19
+ */
20
+ export interface DeviceKey extends DeviceSigner {
21
+ /** X coordinate of hwPriv · peerPublicKey, 32 raw bytes. Feeds `deriveEddsaKeypair`. */
22
+ ecdh(peerPublicKey: Uint8Array): Promise<Uint8Array>
23
+ getSecurityLevel(): Promise<SecurityLevel>
24
+ }
25
+
26
+ /** A minimal HTTP JSON port: no DOM or RN fetch types needed, and a test fake is three lines. */
27
+ export interface HttpJsonResponse {
28
+ status: number
29
+ body: unknown
30
+ }
31
+
32
+ export interface HttpClient {
33
+ getJson(url: string): Promise<HttpJsonResponse>
34
+ postJson(url: string, body: unknown): Promise<HttpJsonResponse>
35
+ }
36
+
37
+ /** One human-readable line of a consent screen, e.g. `{ label: "Programme", value: "demo-programme" }`. */
38
+ export interface ConsentDetail {
39
+ label: string
40
+ value: string
41
+ /** Set false for a value whose full text is the point and must never hide behind a tap - the callback. */
42
+ truncate?: boolean
43
+ }
44
+
45
+ /** The programme and country a consent sentence names, on the two ops that scope one. */
46
+ export interface ConsentScope {
47
+ programme: string
48
+ country: string
49
+ }
50
+
51
+ interface ConsentSummaryBase {
52
+ caller: Caller
53
+ /** The technical rows the consent sheet folds away, in the order it lists them. */
54
+ details: ConsentDetail[]
55
+ }
56
+
57
+ /**
58
+ * What a request amounts to, in values rather than sentences: the wallet writes every line a
59
+ * person reads, so that it can name the caller mid-sentence in the caller's own colour. Keyed by
60
+ * `op` so a screen that switches on it gets `scope` typed rather than optional.
61
+ */
62
+ export type ConsentSummary =
63
+ | (ConsentSummaryBase & { op: "registration.sign" })
64
+ | (ConsentSummaryBase & { op: "credential.issue"; scope: ConsentScope })
65
+ | (ConsentSummaryBase & { op: "presentation.create"; scope: ConsentScope })
66
+
67
+ /** The single consent gate: shown once per deep link, before dispatch. */
68
+ export type UserConsentPrompt = (summary: ConsentSummary) => Promise<boolean>
69
+
70
+ interface PresentationReviewBase {
71
+ verifier: string
72
+ programme: string
73
+ country: string
74
+ issuerNetworkId: Hex32
75
+ }
76
+
77
+ /**
78
+ * What a verifier would learn. `proof` is what the built proof reveals, which is no longer the
79
+ * issuer: a presentation proves membership of the whole approved set and carries one network-wide
80
+ * revocation root, so nothing in it singles an issuer out. `issuerNetworkId` is still here because
81
+ * the person is shown which of their own credentials answered. `revoked` is the refusal itself:
82
+ * telling a verifier that the person holds a revoked credential says something about them that no
83
+ * proof would have, so it is theirs to allow.
84
+ */
85
+ export type PresentationReview =
86
+ (PresentationReviewBase & { kind: "proof" }) | (PresentationReviewBase & { kind: "revoked" })
87
+
88
+ /**
89
+ * The second gate, on `presentation.create` only: nothing has left the phone yet, so the person
90
+ * can be shown what would. `false` refuses with `CONSENT_DENIED`.
91
+ */
92
+ export type PresentationReviewPrompt = (summary: PresentationReview) => Promise<boolean>
93
+
94
+ /**
95
+ * Whether a held credential has been revoked, from the one registry the whole network writes to.
96
+ * `null` is "nobody to ask": no stack is configured, or it could not be reached. The caller then
97
+ * says it has not checked, rather than calling a credential valid on the strength of its dates.
98
+ */
99
+ export interface RevocationAuthority {
100
+ isRevoked(commitment: bigint): Promise<boolean | null>
101
+ }
102
+
103
+ export interface UserAppPorts {
104
+ store: Store
105
+ deviceKey: DeviceKey
106
+ proofGenerator: ProofGenerator
107
+ http: HttpClient
108
+ consent: UserConsentPrompt
109
+ review: PresentationReviewPrompt
110
+ /** Absent leaves every held credential's revocation unchecked; see {@link RevocationAuthority}. */
111
+ revocations?: RevocationAuthority
112
+ /** Named steps inside a long handler, for a screen that shows what the wallet is doing. */
113
+ progress: (op: LinkOp, step: string) => void
114
+ now?: () => number
115
+ }
116
+
117
+ /** What the app's own Identity screen shows; assembled from the device secret, never from a link. */
118
+ export interface PublicIdentity {
119
+ networkId: Hex32
120
+ metaStealthAddress: Hex66
121
+ accountPublicKey: Hex65
122
+ securityLevel: SecurityLevel
123
+ }
124
+
125
+ /**
126
+ * What a held credential's state can honestly be called here. The validity window is signed into
127
+ * the credential body, so it is answerable offline. Revocation takes an authority, and there is now
128
+ * exactly one for the whole network — asking it names no issuer, which is what makes this listing
129
+ * able to say "revoked" at all. Issuer approval stays out: an approved-issuer set is still each
130
+ * member's own, so this screen has no one to ask and reports nothing.
131
+ *
132
+ * `unchecked` is a credential inside its window whose revocation could not be established. It is
133
+ * deliberately not folded into `valid`: the difference between "the network says this is live" and
134
+ * "nobody could be asked" is the whole point of showing a status.
135
+ */
136
+ export type HeldCredentialStatus = "valid" | "revoked" | "not-yet-valid" | "expired" | "unchecked"
137
+
138
+ export interface HeldCredential {
139
+ id: string
140
+ /** See {@link HeldCredentialStatus}: the window and revocation, never issuer approval. */
141
+ status: HeldCredentialStatus
142
+ /** The three fields the credential body cannot give back, kept in the issuer index at issuance. */
143
+ issuerNetworkId: Hex32
144
+ programme: string
145
+ issuedAt: number
146
+ /** A one-way hash of {@link programme}, as signed into the credential. */
147
+ programmeHash: string
148
+ /** Unlike `programmeHash`, exactly invertible: `countryCode` just packs three ASCII bytes. */
149
+ country: string
150
+ notBefore: number
151
+ expiresAt: number
152
+ }
153
+
154
+ /**
155
+ * The two keys an app needs to scan ERC-5564 announcements for itself, where there is no indexer.
156
+ * `viewingPrivateKey` is a private key: it detects incoming payments but cannot spend, and it is
157
+ * deliberately the narrowest thing that does the job — far less than the device secret every other
158
+ * key derives from.
159
+ */
160
+ export interface StealthScanKeys {
161
+ spendingPublicKey: Hex
162
+ viewingPrivateKey: Hex
163
+ }
164
+
165
+ export interface UserAppService {
166
+ readonly handlers: LinkHandlers
167
+ consent(request: DecodedLinkRequest): Promise<boolean>
168
+ isOnboarded(): Promise<boolean>
169
+ createIdentity(): Promise<PublicIdentity>
170
+ readIdentity(): Promise<PublicIdentity>
171
+ listCredentials(): Promise<HeldCredential[]>
172
+ /** See {@link StealthScanKeys}. Throws if no identity has been created. */
173
+ readStealthScanKeys(): Promise<StealthScanKeys>
174
+ erase(): Promise<void>
175
+ }
@@ -0,0 +1,42 @@
1
+ import { deriveEddsaKeypair, eddsaPeerPointBytes } from "@coordination-network/identity"
2
+ import { ChainStateLoader, Wallet } from "@coordination-network/wallet"
3
+ import { hexToBytes } from "./hex"
4
+ import { nowFn } from "./now"
5
+ import type { DeviceKey, UserAppPorts } from "./types"
6
+
7
+ /**
8
+ * The Baby Jubjub identity seed, rooted in the hardware ECDH rather than the device secret.
9
+ * `deriveEddsaKeypair` returns `seed` as a `0x`-prefixed hex string — handing that string to
10
+ * Semaphore derives a different identity (`DERIVATION.md` §12.4); `Wallet.identitySeed` must be
11
+ * the 32 raw bytes decoded from it.
12
+ */
13
+ export async function resolveIdentitySeed(deviceKey: DeviceKey): Promise<Uint8Array> {
14
+ const sharedSecret = await deviceKey.ecdh(eddsaPeerPointBytes())
15
+ return hexToBytes(deriveEddsaKeypair(sharedSecret).seed)
16
+ }
17
+
18
+ /**
19
+ * `listCredentials` never syncs — there is no single authority to sync a mixed-issuer listing
20
+ * against — so this sentinel documents that, and would surface loudly if a future bug called it.
21
+ */
22
+ export const neverLoadChainState: ChainStateLoader = async () => {
23
+ throw new Error("chain state must not be requested outside a per-request authority")
24
+ }
25
+
26
+ /**
27
+ * A fresh `Wallet` per request, scoped to one member's authority. The private `Wallet` gets a
28
+ * constant-`true` consent prompt: the person has already been asked once, at the link boundary,
29
+ * and prompting again here would show a second screen for one `presentation.create`.
30
+ */
31
+ export async function buildWallet(ports: UserAppPorts, loadChainState: ChainStateLoader): Promise<Wallet> {
32
+ const identitySeed = await resolveIdentitySeed(ports.deviceKey)
33
+ return new Wallet({
34
+ identitySeed,
35
+ deviceSigner: ports.deviceKey,
36
+ consent: async () => true,
37
+ proofGenerator: ports.proofGenerator,
38
+ store: ports.store,
39
+ now: () => nowFn(ports),
40
+ loadChainState
41
+ })
42
+ }