@cef-ai/wallet-identity 1.0.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.
@@ -0,0 +1,443 @@
1
+ import { ApiClient } from '@cef-ai/wallet-api-client';
2
+
3
+ /** Spec §3.1. Public contract for the identity layer. */
4
+ interface Addresses {
5
+ cere: string;
6
+ solana: string;
7
+ evm: string;
8
+ }
9
+ type Chain = 'cere' | 'solana' | 'evm';
10
+ interface Identity {
11
+ readonly isAuthenticated: boolean;
12
+ readonly addresses: Addresses | null;
13
+ readonly credentialId: string | null;
14
+ register(opts?: {
15
+ label?: string;
16
+ }): Promise<void>;
17
+ login(): Promise<void>;
18
+ logout(): void;
19
+ /** Returns a valid JWT; runs the passkey ceremony if needed. */
20
+ getJwt(): Promise<string>;
21
+ /**
22
+ * Release any browser-owned handles this identity holds (cross-tab channel,
23
+ * timers, broadcast listeners). After `dispose()` the identity is unusable.
24
+ *
25
+ * Idempotent: callable multiple times safely.
26
+ */
27
+ dispose(): void;
28
+ }
29
+ /**
30
+ * Signers/* receive this function via a constructor-time closure handoff
31
+ * from a concrete Identity instance. It is exported as a type-only alias so
32
+ * dependents can annotate the closures they receive, but it is intentionally
33
+ * absent from the `Identity` interface — there is no public `sign()` method.
34
+ */
35
+ type InternalSign = (chain: Chain, payload: Uint8Array) => Promise<Uint8Array>;
36
+
37
+ /** Spec §3.2 locked decision #14. Permanent for v1; rotate via "-v2" suffix. */
38
+ declare const PRF_INPUT_LABEL = "cere-wallet-prf-v1";
39
+ /**
40
+ * SHA-256("cere-wallet-prf-v1") — 32 bytes. Fed to WebAuthn `prf.eval.first`.
41
+ *
42
+ * Implementation note: `createHash` runs at module evaluation time. In the test
43
+ * environment (Vitest on Node) this works natively via `node:crypto`. In the
44
+ * browser this depends on a `node:crypto` polyfill — `vite-plugin-node-polyfills`
45
+ * is wired in by Cycle 1 Task 8. A follow-up cycle should replace this expression
46
+ * with a hard-coded `Uint8Array` literal to eliminate the polyfill dependency.
47
+ */
48
+ declare const PRF_INPUT_SEED: Uint8Array;
49
+ /** Spec §3.2. Used as HKDF-SHA256 info to derive the secp256k1 32-byte secret. */
50
+ declare const EVM_HKDF_INFO = "cere-wallet-evm-secp256k1-v1";
51
+
52
+ /**
53
+ * Runtime support detection for WebAuthn + PRF.
54
+ *
55
+ * Spec §3.7. Real PRF capability is only knowable from a completed ceremony
56
+ * (via `clientExtensionResults.prf.enabled` or `prf.results.first`). This
57
+ * pre-check returns the best signal we can get without spending a credential.
58
+ */
59
+ interface PrfSupportResult {
60
+ webAuthn: boolean;
61
+ /**
62
+ * Whether a platform authenticator (Touch ID / Windows Hello) is enrolled.
63
+ * INFORMATIONAL ONLY — it is NOT a precondition for using the wallet, which
64
+ * also works with roaming security keys and phone passkeys (via hybrid).
65
+ */
66
+ platformAuthenticator: boolean;
67
+ /**
68
+ * Best-effort PRF signal. `true` unless we can prove otherwise: modern
69
+ * browsers expose `PublicKeyCredential.getClientCapabilities()`, which gives
70
+ * a definitive `extensions:prf` answer. When that API is absent (older
71
+ * browsers) we stay optimistic and let the ceremony be the final arbiter
72
+ * (it throws `prf-unsupported` if the authenticator returns no PRF output).
73
+ */
74
+ prfPotentiallySupported: boolean;
75
+ }
76
+ declare function detectPrfSupport(): Promise<PrfSupportResult>;
77
+ /**
78
+ * Inspect a `PublicKeyCredential.getClientExtensionResults()` payload to
79
+ * determine whether PRF actually worked in the ceremony. Spec §3.7 calls this
80
+ * "final answer; the pre-check above is best-effort only".
81
+ */
82
+ declare function isPrfSupportedResult(extensions: {
83
+ prf?: {
84
+ enabled?: boolean;
85
+ results?: {
86
+ first?: Uint8Array;
87
+ };
88
+ };
89
+ }): boolean;
90
+
91
+ /** Cere's SS58 network prefix. Spec §3.2. */
92
+ declare const CERE_SS58_PREFIX = 54;
93
+ /**
94
+ * SS58 address for the Cere network.
95
+ *
96
+ * SS58 spec (Substrate): for prefixes 0–63 the format is:
97
+ * [prefix byte] || [32-byte pubkey] || [2-byte checksum]
98
+ * where checksum = blake2b-512("SS58PRE" || prefix || pubkey)[0..2].
99
+ * The whole thing is base58-encoded. We use prefix 54 (Cere) which fits in
100
+ * the 1-byte encoding (< 64).
101
+ *
102
+ * @param edPubkey 32-byte Ed25519 public key.
103
+ */
104
+ declare function deriveCereAddress(edPubkey: Uint8Array): string;
105
+ /**
106
+ * Solana address: base58(Ed25519 pubkey). No prefix, no checksum.
107
+ *
108
+ * @param edPubkey 32-byte Ed25519 public key.
109
+ */
110
+ declare function deriveSolanaAddress(edPubkey: Uint8Array): string;
111
+ /**
112
+ * Recover the raw 32-byte Ed25519 public key from a Solana address.
113
+ *
114
+ * The Solana address is `base58(edPubkey)` with no prefix or checksum
115
+ * ({@link deriveSolanaAddress}), so a base58 decode yields the pubkey directly.
116
+ * The public key is not secret — it IS the address — so this introduces no key
117
+ * exposure beyond what the address already reveals; the session keys never
118
+ * leave `SessionVault`. The same Ed25519 key backs the Cere SS58 address.
119
+ *
120
+ * @param solanaAddress base58 Solana address.
121
+ * @returns the 32-byte Ed25519 public key.
122
+ */
123
+ declare function decodeEd25519Pubkey(solanaAddress: string): Uint8Array;
124
+ /**
125
+ * EVM address: '0x' + last 20 bytes of keccak256(secp256k1 uncompressed pubkey X||Y).
126
+ *
127
+ * Accepts either 64-byte (raw X||Y) or 65-byte (0x04||X||Y) input. The 0x04
128
+ * prefix byte is stripped if present.
129
+ *
130
+ * @param secpPubkey secp256k1 public key.
131
+ */
132
+ declare function deriveEvmAddress(secpPubkey: Uint8Array): string;
133
+
134
+ interface DerivedKeys {
135
+ /** Ed25519 32-byte secret key seed; used for Cere + Solana signing. */
136
+ edSeed: Uint8Array;
137
+ /** secp256k1 32-byte secret key; used for EVM signing. */
138
+ secpKey: Uint8Array;
139
+ /** 32-byte Ed25519 public key derived from edSeed. */
140
+ edPubkey: Uint8Array;
141
+ /** 65-byte uncompressed secp256k1 public key (0x04 || X || Y) derived from secpKey. */
142
+ secpPubkeyUncompressed: Uint8Array;
143
+ }
144
+ /**
145
+ * Derive the v2 session keys from a WebAuthn PRF output. Spec §3.2.
146
+ *
147
+ * edSeed = prfOutput
148
+ * secpKey = HKDF-SHA256(prfOutput, salt="", info="cere-wallet-evm-secp256k1-v1", L=32)
149
+ *
150
+ * @param prfOutput 32-byte PRF output from `prf.results.first`.
151
+ */
152
+ declare function derivedKeys(prfOutput: Uint8Array): DerivedKeys;
153
+
154
+ /**
155
+ * `hints` is a top-level field on the WebAuthn creation/request options
156
+ * (WebAuthn L3), but our TS DOM lib doesn't ship it yet. Declare-merge the
157
+ * precise W3C union so the ceremony can request authenticator hints without
158
+ * casts. Drop this augmentation once the workspace lib.dom.d.ts ships `hints`.
159
+ */
160
+ declare global {
161
+ interface PublicKeyCredentialCreationOptions {
162
+ hints?: ('client-device' | 'hybrid' | 'security-key')[];
163
+ }
164
+ interface PublicKeyCredentialRequestOptions {
165
+ hints?: ('client-device' | 'hybrid' | 'security-key')[];
166
+ }
167
+ }
168
+ /**
169
+ * Adapter that runs WebAuthn ceremonies. Production implementations call
170
+ * `navigator.credentials.create/get`; the test implementation in
171
+ * `SoftAuthenticator` produces deterministic results without browser APIs.
172
+ *
173
+ * Spec §3.7. The `prfOutput` field is the result of the WebAuthn PRF extension
174
+ * — present when the authenticator supports PRF, absent otherwise.
175
+ */
176
+ interface CeremonyAdapter {
177
+ register(opts: RegisterOptions): Promise<RegisterResult>;
178
+ login(opts: LoginOptions): Promise<LoginResult>;
179
+ }
180
+ interface RegisterOptions {
181
+ rpId: string;
182
+ challenge: Uint8Array;
183
+ userHandle: Uint8Array;
184
+ /** 32-byte PRF input (typically `PRF_INPUT_SEED`). */
185
+ prfInput: Uint8Array;
186
+ /** Human-readable label for the credential. Currently a hint, not stored. */
187
+ label?: string;
188
+ }
189
+ interface RegisterResult {
190
+ credentialId: string;
191
+ clientDataJSON: string;
192
+ attestationObject: string;
193
+ transports: string[];
194
+ /** Present iff PRF extension worked. */
195
+ prfOutput?: Uint8Array;
196
+ }
197
+ interface LoginOptions {
198
+ rpId: string;
199
+ challenge: Uint8Array;
200
+ /** Optional: if absent, the authenticator picks a credential. Present from us. */
201
+ credentialId?: string;
202
+ prfInput: Uint8Array;
203
+ }
204
+ interface LoginResult {
205
+ credentialId: string;
206
+ clientDataJSON: string;
207
+ authenticatorData: string;
208
+ signature: string;
209
+ prfOutput?: Uint8Array;
210
+ }
211
+ interface WebAuthnCeremonyAdapterOptions {
212
+ /**
213
+ * Override for the WebAuthn credentials manager. Defaults to
214
+ * `globalThis.navigator?.credentials`. Tests inject a mock.
215
+ */
216
+ credentials?: CredentialsContainer;
217
+ }
218
+ /**
219
+ * Production WebAuthn ceremony adapter. Calls `navigator.credentials.create/get`
220
+ * with the PRF extension. Spec §3.7.
221
+ *
222
+ * - `register`: requires a platform authenticator, user verification, Ed25519
223
+ * (`alg: -8`), and the PRF extension with the wallet's input seed.
224
+ * - `login`: allows a specific credentialId if known; otherwise lets the
225
+ * authenticator pick (resident key).
226
+ *
227
+ * Returned ArrayBuffers are base64url-encoded for API transport.
228
+ */
229
+ declare class WebAuthnCeremonyAdapter implements CeremonyAdapter {
230
+ private readonly credentials?;
231
+ constructor(opts?: WebAuthnCeremonyAdapterOptions);
232
+ register(opts: RegisterOptions): Promise<RegisterResult>;
233
+ login(opts: LoginOptions): Promise<LoginResult>;
234
+ }
235
+
236
+ /**
237
+ * Base64url (RFC 4648 §5) helpers used by the WebAuthn ceremony adapter.
238
+ * Trailing `=` padding is stripped on encode; both padded and unpadded inputs
239
+ * are accepted on decode.
240
+ */
241
+ /** Encode a Uint8Array or ArrayBuffer as a base64url string (no padding). */
242
+ declare function bytesToB64u(input: Uint8Array | ArrayBuffer): string;
243
+ /** Decode a base64url string into a Uint8Array. Padding is optional. */
244
+ declare function b64uToBytes(s: string): Uint8Array;
245
+
246
+ interface SoftAuthenticatorOptions {
247
+ /**
248
+ * If provided, derive the per-registration `edSeed`, `prfSecret`, and
249
+ * `credentialId` deterministically from this seed (mixed with a monotonic
250
+ * counter so multiple register() calls on the same instance still produce
251
+ * distinct credentials). Used by Playwright e2e specs that pin server-side
252
+ * address stubs against the SoftAuthenticator's output. Production code
253
+ * never sets this — the `useSoft` branch in `createIdentity.ts` is itself
254
+ * forbidden in production builds.
255
+ *
256
+ * Default: undefined (each register() draws fresh CSPRNG bytes, matching
257
+ * the pre-cycle-8.1 behavior used by unit tests in `ceremony.test.ts`).
258
+ */
259
+ seed?: string;
260
+ /**
261
+ * When the caller invokes login() without a credentialId, fall back to the
262
+ * most-recently-registered credential in memory. Default: false (login()
263
+ * throws when credentialId is missing, matching pre-cycle-8 behavior).
264
+ *
265
+ * Required for e2e flows that simulate the user signing back in after the
266
+ * vault has been cleared (post-logout re-login): the wallet has discarded
267
+ * the credentialId locally, but the soft authenticator's in-memory creds
268
+ * map still holds the registration, and the spec test needs login() to
269
+ * succeed anyway. Production code must never enable this — real WebAuthn's
270
+ * no-allowCredentials path shows an OS credential picker; the fallback
271
+ * here is a deterministic test convenience, not a real picker, so leaving
272
+ * it on by default would silently let a single registered credential
273
+ * impersonate any login.
274
+ */
275
+ fallbackToLastRegistered?: boolean;
276
+ }
277
+ /**
278
+ * Deterministic in-memory WebAuthn authenticator for tests. NOT for production.
279
+ *
280
+ * - Stores credentials in a private Map keyed by credentialId.
281
+ * - `prfOutput` is computed as HKDF-SHA256(prfSecret, salt="", info=prfInput, L=32),
282
+ * so for a given credential and prfInput the result is stable across login calls.
283
+ * - `signature` is a valid Ed25519 signature over `authenticatorData || sha256(clientDataJSON)`.
284
+ *
285
+ * The output shapes match the real WebAuthn response well enough that the
286
+ * `Identity` integration tests (Task 6) can drive register → login → derive
287
+ * → cross-check without a real browser.
288
+ */
289
+ declare class SoftAuthenticator implements CeremonyAdapter {
290
+ private readonly creds;
291
+ private readonly seedBytes;
292
+ private readonly fallbackToLastRegistered;
293
+ /** Monotonic counter mixed into the seed-derived material so successive
294
+ * register() calls on a seeded instance produce distinct credentials. */
295
+ private regCounter;
296
+ constructor(opts?: SoftAuthenticatorOptions);
297
+ register(opts: RegisterOptions): Promise<RegisterResult>;
298
+ login(opts: LoginOptions): Promise<LoginResult>;
299
+ /** Public-key bytes for a credential. Used by integration tests for cross-checks. */
300
+ getEd25519PublicKey(credentialId: string): Uint8Array;
301
+ private computePrf;
302
+ }
303
+
304
+ interface VaultSnapshot {
305
+ addresses: Addresses;
306
+ credentialId: string;
307
+ }
308
+ interface SessionVault {
309
+ isOpen(): boolean;
310
+ /** Public-safe view: never includes raw keys. */
311
+ snapshot(): VaultSnapshot | null;
312
+ /** Open the vault with derived keys + the addresses they yielded + the credentialId. */
313
+ set(args: {
314
+ keys: DerivedKeys;
315
+ addresses: Addresses;
316
+ credentialId: string;
317
+ }): void;
318
+ /** Drop the keys and address state. Safe to call when already closed. */
319
+ clear(): void;
320
+ /**
321
+ * Sign a payload with the chain-appropriate key. Spec §3.6 invariant:
322
+ * this is the only way the rest of the package can reach the keys.
323
+ *
324
+ * - 'cere' / 'solana': Ed25519 signature over the raw payload (64 bytes).
325
+ * - 'evm': secp256k1 ECDSA over the raw payload (65 bytes: r || s || v).
326
+ * The caller (an EVM signer) is responsible for framing (e.g. EIP-191)
327
+ * and hashing (keccak256) before passing the digest as the payload.
328
+ */
329
+ sign(chain: Chain, payload: Uint8Array): Promise<Uint8Array>;
330
+ }
331
+ /**
332
+ * Closure-encapsulated session vault. The keys live in the closure variables
333
+ * `state.edSeed` / `state.secpKey` and are not reachable from outside.
334
+ *
335
+ * Spec §3.4. Lifetime: from `set()` until `clear()` or tab unload.
336
+ */
337
+ declare function createSessionVault(): SessionVault;
338
+
339
+ interface IdentityImplOptions {
340
+ apiClient: ApiClient;
341
+ ceremony: CeremonyAdapter;
342
+ }
343
+ declare class IdentityImpl implements Identity {
344
+ private readonly opts;
345
+ private readonly vault;
346
+ private cachedJwt;
347
+ /** Server-returned credential ID (used as the public identity.credentialId). */
348
+ private serverCredentialId;
349
+ private readonly _isAuthenticated;
350
+ private readonly _addresses;
351
+ private readonly _credentialId;
352
+ /** Cross-tab logout coordination via BroadcastChannel('scp-wallet-v2'). */
353
+ private readonly crossTabSync;
354
+ /** Handle to detach the cross-tab logout subscriber on dispose(). */
355
+ private readonly crossTabSyncUnsubscribe;
356
+ /** Idempotency flag for dispose(). */
357
+ private disposed;
358
+ constructor(opts: IdentityImplOptions);
359
+ get isAuthenticated(): boolean;
360
+ get addresses(): Addresses | null;
361
+ /**
362
+ * Returns the server-returned credentialId from the last register/login ceremony.
363
+ * The vault internally tracks the authenticator-generated credential ID for login use.
364
+ */
365
+ get credentialId(): string | null;
366
+ /**
367
+ * Read the (non-observable) vault snapshot + cached server credential ID and
368
+ * push the values into the three public observable boxes. Call after any
369
+ * mutation that could change the public derived state: register, login,
370
+ * logout, or constructor (hydration).
371
+ */
372
+ private syncPublicState;
373
+ register(opts?: {
374
+ label?: string;
375
+ }): Promise<void>;
376
+ login(): Promise<void>;
377
+ logout(): void;
378
+ getJwt(): Promise<string>;
379
+ /** Helper used by callers (e.g. ApiClient.getAuthToken). */
380
+ getCachedJwtForApiClient(): string | null;
381
+ /**
382
+ * Returns the session vault. Used by popup-side `wallet:sign` handlers
383
+ * which need direct access to `vault.sign(chain, payload)`. NOT for host-
384
+ * side use — keys never reach the host. Spec §3.6 invariant 1.
385
+ */
386
+ getVault(): SessionVault;
387
+ /**
388
+ * Release the BroadcastChannel + cross-tab subscriber. Idempotent.
389
+ *
390
+ * Called by:
391
+ * - WalletProvider's useEffect cleanup (SPA unmount / apiBaseUrl change)
392
+ * - test afterEach hooks (prevent listener leakage across tests)
393
+ *
394
+ * After dispose(), the identity is unusable: register/login/logout/getJwt
395
+ * still execute their normal logic, but cross-tab broadcasts no longer
396
+ * propagate (channel is closed) and the local subscriber is detached.
397
+ * The vault remains cleared (logout() runs locally). Spec §9.4 lifecycle.
398
+ */
399
+ dispose(): void;
400
+ private crossCheckAddresses;
401
+ }
402
+
403
+ /**
404
+ * Cross-tab session synchronisation helper.
405
+ *
406
+ * Wraps a `BroadcastChannel` on a fixed channel name so all tabs of the same
407
+ * origin can coordinate. Today we only use it for `{ type: 'logout' }` — when
408
+ * one tab logs out, every other tab clears its in-memory vault + JWT so its
409
+ * MobX-reactive UI immediately reflects the logged-out state.
410
+ *
411
+ * The channel name must match `BROADCAST_CHANNEL_NAME` in
412
+ * `packages/embed-sdk/src/protocol.ts` ('scp-wallet-v2'). Defined here as a
413
+ * local constant rather than imported to avoid a circular dep between
414
+ * `@cef-ai/wallet-identity` and `@cef-ai/wallet`. The name is part of the
415
+ * wire protocol — if you change one, change the other (and bump the major).
416
+ *
417
+ * Spec §9.4 (cross-tab session sharing).
418
+ */
419
+ type CrossTabMessage = {
420
+ type: 'logout';
421
+ };
422
+ declare class CrossTabSync {
423
+ private channel;
424
+ private readonly listeners;
425
+ constructor();
426
+ /** Broadcast a message to all other tabs (and not to this tab — that's the
427
+ * BroadcastChannel spec). The originating tab is expected to have already
428
+ * applied the state change locally before broadcasting. */
429
+ broadcast(msg: CrossTabMessage): void;
430
+ /** Subscribe to messages from other tabs. Returns an unsubscribe function. */
431
+ subscribe(fn: (msg: CrossTabMessage) => void): () => void;
432
+ /**
433
+ * Close the underlying channel and drop all listeners. Idempotent.
434
+ *
435
+ * Sets `this.channel = null` so subsequent `broadcast()` calls become a
436
+ * no-op (the `?.postMessage` short-circuits) and we don't accidentally
437
+ * postMessage on a closed BroadcastChannel (which throws InvalidStateError
438
+ * in some implementations).
439
+ */
440
+ close(): void;
441
+ }
442
+
443
+ export { type Addresses, CERE_SS58_PREFIX, type CeremonyAdapter, type Chain, type CrossTabMessage, CrossTabSync, type DerivedKeys, EVM_HKDF_INFO, type Identity, IdentityImpl, type IdentityImplOptions, type InternalSign, type LoginOptions, type LoginResult, PRF_INPUT_LABEL, PRF_INPUT_SEED, type PrfSupportResult, type RegisterOptions, type RegisterResult, type SessionVault, SoftAuthenticator, type VaultSnapshot, WebAuthnCeremonyAdapter, type WebAuthnCeremonyAdapterOptions, b64uToBytes, bytesToB64u, createSessionVault, decodeEd25519Pubkey, deriveCereAddress, deriveEvmAddress, deriveSolanaAddress, derivedKeys, detectPrfSupport, isPrfSupportedResult };