@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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/index.cjs +754 -0
- package/dist/index.js +744 -0
- package/package.json +55 -0
- package/src/chain-state.ts +61 -0
- package/src/consent.ts +69 -0
- package/src/device-secret.ts +16 -0
- package/src/errors.ts +42 -0
- package/src/handlers/credential-issue.ts +176 -0
- package/src/handlers/presentation-create.ts +168 -0
- package/src/handlers/registration-sign.ts +19 -0
- package/src/hex.ts +8 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +30 -0
- package/src/issuer-index.ts +64 -0
- package/src/member-api.ts +68 -0
- package/src/now.ts +6 -0
- package/src/selection.ts +38 -0
- package/src/service.ts +206 -0
- package/src/steps.ts +13 -0
- package/src/types.ts +175 -0
- package/src/wallet-factory.ts +42 -0
- package/types/index.d.ts +238 -0
|
@@ -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
package/src/selection.ts
ADDED
|
@@ -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
|
+
}
|