@coordination-network/user-app-core 0.0.2 → 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 CHANGED
@@ -17,7 +17,7 @@ import { createRequestHandler } from "@coordination-network/user-app-link"
17
17
 
18
18
  const service = createUserAppService({
19
19
  store, // Store from @coordination-network/wallet, e.g. expo-secure-store-backed
20
- deviceKey, // DeviceKey: a DeviceSigner plus ecdh() and getSecurityLevel()
20
+ deviceKey, // DeviceKey: signerFor(prompt), ecdh(peer, prompt), getPublicKeyJwk(), getSecurityLevel()
21
21
  proofGenerator, // ProofGenerator from @coordination-network/wallet
22
22
  http: { getJson, postJson },
23
23
  consent: async (summary) => showConsentScreen(summary),
@@ -35,8 +35,9 @@ const responseUrl = await handler.handle(incomingUrl)
35
35
 
36
36
  `service.consent` is the single gate `createRequestHandler` calls before dispatch; `Wallet`'s own
37
37
  `ConsentPrompt` is wired to a constant `true` internally, so one deep link never shows two consent
38
- screens. `presentation.create` asks once more through `review`, but only after the proof exists,
39
- so the question is about what the proof reveals rather than about starting the work. Handler
38
+ screens. `presentation.create` asks once more through `review`, before it proves anything or asks
39
+ the device key to sign, so a refusal costs no key use and the question is about what the proof
40
+ reveals. Handler
40
41
  bodies run one at a time — a second deep link arriving mid-sync waits for the first to finish
41
42
  rather than interleaving with it.
42
43
 
@@ -47,14 +48,32 @@ and reads the one inside it.
47
48
  ## Ports
48
49
 
49
50
  - `store` / `proofGenerator` — `@coordination-network/wallet`'s own interfaces, taken verbatim.
50
- - `deviceKey: DeviceKey` — extends `@coordination-network/credential`'s `DeviceSigner`
51
- (`getPublicKeyJwk`, `sign`, `signDigest`) with `ecdh(peerPublicKey)` (the X coordinate of
52
- `hwPriv · peerPublicKey`, 32 raw bytes — feeds `deriveEddsaKeypair`) and `getSecurityLevel()`.
53
- Extending rather than replacing `DeviceSigner` means a `DeviceKey` is accepted as-is by `Wallet`,
54
- `buildZkPresentationInputs` and `deriveDeviceKeyCommitment`. `SecurityLevel` is reported, never
55
- enforced — fail-closed policy belongs in `apps/identity-testbed`, not here.
51
+ - `deviceKey: DeviceKey` — the holder's two P-256 hardware keys: `getPublicKeyJwk()` for the
52
+ signing key, `signerFor(prompt)` for a `DeviceSigner` whose every signature raises `prompt`,
53
+ `ecdh(peerPublicKey, prompt)` (the X coordinate of `agreementPriv · peerPublicKey`, 32 raw
54
+ bytes — feeds `deriveEddsaKeypair`) and `getSecurityLevel()`, which reports the lower of the two
55
+ keys' levels, and `getAttestationChain()`, the base64 DER key attestation chain of the signing
56
+ key, leaf first, or `[]` when it has none. The `signerFor` signer is accepted as-is by `Wallet` and
57
+ `buildZkPresentationInputs`. A cancelled prompt, an invalidated key or a failed authentication
58
+ throws `DeviceKeyError`; a cancel reaches the caller as `CONSENT_DENIED`, the other two as
59
+ `INTERNAL`. The identity seed is unlocked by one ECDH prompt the first time a process needs it,
60
+ then cached until `erase()`, or until `removeUnboundCredentials()` removes something. Listing and
61
+ `registration.sign` never touch a key. Issuance posts the chain as `deviceAttestation` (omitted
62
+ when empty), and `createIdentity` refuses a level below `minimumDeviceSecurityLevel`.
63
+ - `minimumDeviceSecurityLevel?: SecurityLevel` — the lowest level `createIdentity` accepts. Absent
64
+ means no floor; `UNKNOWN` ranks with `SOFTWARE`. The issuer enforces its own minimum on
65
+ the attested level.
56
66
  - `http: HttpClient` — `getJson`/`postJson`, each returning `{ status, body }`. Deliberately
57
67
  structural: no DOM or Node fetch types leak into either app, and a test fake is three lines.
68
+ - `directory: MemberDirectory` — `issuerKeyLeavesOf(networkId)`, the leaves of a member's live issuer
69
+ keys from `ApprovedIssuerSet.issuerKeysOf`. `credential.issue` requires the signing key to be among
70
+ them; a rejection means the chain could not be read, an unknown member (removed, or missing from
71
+ the wallet's own member list) answers `[]`. An implementation must not reveal to the chain node
72
+ which member is asked about.
73
+ - `roots: RootAuthority` — `currentRoots()`, the chain's current approved-issuer-set root and
74
+ leaf count, and revocation root. `credential.issue` and `presentation.create` check the lists a
75
+ server serves against these; a rejection means the chain could not be read (`CHAIN_UNREACHABLE`). An
76
+ implementation must read the chain, never a server.
58
77
  - `consent: UserConsentPrompt` — `(summary: ConsentSummary) => Promise<boolean>`. The summary is
59
78
  values, never prose: the caller, the rows a screen folds behind its technical toggle, and — on
60
79
  the two ops that scope one — the programme and country. The app writes the sentence, so it can
@@ -63,26 +82,25 @@ and reads the one inside it.
63
82
  been selected yet for `presentation.create`, so its summary names the verifier, programme and
64
83
  country rather than a credential — that is the material fact anyway.
65
84
  - `review: PresentationReviewPrompt` — `(summary: PresentationReview) => Promise<boolean>`, asked
66
- after `presentation.create` has built its proof and before it is posted. The summary names the
67
- verifier, programme, country and the credential's issuer, which the proof itself reveals. `false`
68
- refuses with `CONSENT_DENIED`.
85
+ after `presentation.create` has chosen a credential and before it proves or posts. The summary names the
86
+ verifier, programme, country and the credential's issuer, so the person sees which credential
87
+ answered; the proof itself no longer reveals the issuer. `false` refuses with `CONSENT_DENIED`.
69
88
  - `progress: (op, step) => void` — named steps inside a long handler, for a screen that shows what
70
89
  the wallet is doing. `credential.issue` reports six; `presentation.create` reports three.
71
90
  - `now?: () => number` — defaults to `Date.now`; inject it for deterministic tests.
72
91
 
73
- ## Two honesty notes
92
+ ## Honesty note
74
93
 
75
- **Revocation is answered by the issuer, never by the asker.** A member publishes a revocation
76
- registry covering only its own credentials, so a verifier's snapshot cannot say whether another
77
- member's credential still stands. `presentation.create` therefore takes no `revocationsUrl`: each
78
- candidate credential is checked against the snapshot its own issuer published, read from this
79
- package's issuer index. A candidate found revoked refuses with `CREDENTIAL_REVOKED`, before any
80
- proof is built and without posting anything.
81
-
82
- **The approved-issuer cross-check is real on both sides.** `GET /v1/issuers` returns both the
83
- `leaves` and a `root` the server read from chain independently of them, and `Wallet` compares that
84
- `approvedIssuerRoot` against the tree rebuilt from `leaves` before trusting either — the same
85
- property `GET /v1/revocations/snapshot`'s own `root` already had.
94
+ **Served lists are checked against the chain, not against the server.** The leaves from
95
+ `GET /v1/issuers` and the snapshot from `GET /v1/revocations/snapshot` are bulk data: the `root`
96
+ each also serves is never read. Both handlers read the chain's current roots through `roots`. The
97
+ lists must hash to them, and the issuer set must have the chain's leaf count: an internal node can
98
+ stand in for the leaves under it and keep the root, so the count is what fixes every leaf. A server
99
+ therefore cannot choose which root a holder proves against, nor learn which side of a cut an issuer
100
+ is on. A mismatch is `VERIFIER_UNREACHABLE` on `presentation.create` and
101
+ `ISSUER_UNREACHABLE` on `credential.issue`; roots that cannot be read are `CHAIN_UNREACHABLE`. There
102
+ is no retry and no root cache, so a wallet offline from the chain cannot present or issue. When a
103
+ list and the chain disagree, the person runs the request again.
86
104
 
87
105
  ## Store keys
88
106
 
@@ -95,14 +113,20 @@ and `vc:`-prefixed entries in that store; this package owns two prefixes of its
95
113
  - `user-app-core:issuer-index:<issuerNetworkId>` — what a credential body cannot give back: the
96
114
  issuer's `networkId` (the body carries its Baby Jubjub public key instead), the plaintext
97
115
  programme (the body carries a hash), the country, when this wallet stored it, and where that
98
- issuer publishes its approved-issuer set and revocation snapshot. Written by `credential.issue`;
99
- read by `presentation.create` to reach the right snapshot, by `listCredentials` to name the
100
- issuer, and by `credential.issue` itself to answer `alreadyHeld`.
116
+ issuer publishes its approved-issuer set (`issuersUrl`). Written by `credential.issue`;
117
+ read by `presentation.create` to match each credential to its issuer, by `listCredentials` to
118
+ name the issuer, and by `credential.issue` itself to answer `alreadyHeld`.
101
119
 
102
120
  The revocation secret is no longer stored between deep links: `credential.issue` generates it,
103
121
  opens the credential with it and hands it to `Wallet` inside one call, so it never leaves that
104
122
  function.
105
123
 
124
+ `removeUnboundCredentials()` compares each held credential's `deviceKeyCommitment` with the current
125
+ signing key and deletes the ones bound to another key, with their revocation secrets and issuer
126
+ index entries, then resolves to how many it removed. It reads only the public key, so it raises no
127
+ prompt, and keeps the device secret and every other key, so the person stays registered. Run it at
128
+ every start: a run cut short leaves the rest still unbound, and the next one removes them.
129
+
106
130
  `erase()` deletes every key in `store`, not only this package's own three prefixes and `Wallet`'s —
107
131
  a host app shares the same store for its own state (the user app keeps its stack descriptor there),
108
132
  so a full wipe reaches that too. A caller that wants to survive `erase()` keeps its own state
@@ -112,7 +136,7 @@ elsewhere.
112
136
 
113
137
  `presentation.create`'s params carry no credential id. Held credentials are filtered to those whose
114
138
  body matches the requested programme and country — no status filter — and each survivor is then
115
- synced against its own issuer's snapshot. The one expiring latest among those still `valid` wins,
139
+ synced against the verifier's lists, which must hash to the chain's current roots. The one expiring latest among those still `valid` wins,
116
140
  ties broken by id for determinism. None valid but one revoked throws `CREDENTIAL_REVOKED`; none at
117
141
  all throws `NO_CREDENTIAL`. Both stay distinguishable from a `PROOF_FAILED` thrown once proving has
118
142
  actually started.
@@ -124,7 +148,7 @@ yarn test:library user-app-core --watchman=false
124
148
  ```
125
149
 
126
150
  Real collaborators are used wherever they are pure: a real `Wallet` over a real `MemoryStore`, a
127
- software P-256 `DeviceKey` (`LocalP256Signer` plus a plain ECDH), real `ApprovedIssuerSet` and
151
+ software P-256 `DeviceKey` (`LocalP256Signer` plus a plain ECDH that records its prompts), real `ApprovedIssuerSet` and
128
152
  `RevocationRegistry`, and credentials issued with the real `issueZkCredential`. Only the proof
129
153
  generator and the HTTP port are faked.
130
154