import { WalletError, ApiClient } from '@cef-ai/wallet-api-client'; /** Spec §3.1. Public contract for the identity layer. */ interface Addresses { cere: string; solana: string; evm: string; } type Chain = 'cere' | 'solana' | 'evm'; /** * The S0 account this session belongs to, as the server reports it on every * session response (register-finish, login-finish, session refresh). * * An account is the durable identity; a credential (passkey) is one way into * it. `email`/`displayName` are nullable because accounts created before S0 have * neither — the migration does not invent them, and the Settings nudge asks for * an email without ever gating on one. */ interface AccountInfo { accountId: string; email: string | null; displayName: string | null; } /** * Bearer-only session resume capability (Task 2/3). Kept OFF the core * `Identity` interface deliberately: it is an * optional, refresh-flow-only concern, and forcing every `Identity` * implementation (including thin test doubles used across the SPA's test * suite) to provide it would widen the interface for no benefit to callers * that never boot the embed route. Concrete `IdentityImpl` implements it; * SPA wiring feature-detects via `supportsSessionResume()`. */ interface SessionResume { /** * Adopt a session from `apiClient.session.refresh()` WITHOUT running a * passkey ceremony and WITHOUT deriving signing keys — the "bearer-active" * state. See `IdentityImpl.resumeSession` for the full contract. */ resumeSession(session: { token: string; addresses: Addresses; credentialId: string; /** * The account fields off the same `SessionResponse` the token came from, so * a bearer-resumed tab knows its account without a ceremony. Optional so a * caller that spreads a whole `SessionResponse` gets them for free while an * older, narrower call site still compiles; when `accountId` is absent the * previously-known account is left alone rather than blanked. */ accountId?: string; email?: string | null; displayName?: string | null; }): void; /** * `true` once real signing keys exist (register/login/adoptSession); `false` * for a bearer-active session with a token but no keys. */ hasKeys(): boolean; } /** * Narrow an `Identity` to one that supports bearer-only session resume. * `IdentityImpl` returns true; thin test doubles return false and are simply * skipped by boot-time resume wiring (e.g. the embed route falls back to * "stay logged out" rather than crashing on a missing method). */ declare function supportsSessionResume(identity: Identity): identity is Identity & SessionResume; /** * SPA-injected keystore encoder. Lives OUTSIDE the published identity package * so the heavy keystore libraries (`@polkadot/keyring`, `ethers`) stay in the * SPA bundle only. Given raw key bytes it returns a password-encrypted keystore * JSON string. Called synchronously inside `IdentityImpl`'s `sessionKeys` seam * (see `WalletExport`); it MUST be pure and retain nothing. * * - `chain: 'cere'` → keyBytes is the 32-byte ed25519 seed → Polkadot ed25519 * keystore JSON (SS58 prefix 54). * - `chain: 'evm'` → keyBytes is the 32-byte secp256k1 key → Ethereum V3 keystore JSON. */ type KeystoreEncryptor = (chain: 'cere' | 'evm', keyBytes: Uint8Array, passphrase: string, address: string) => Promise; /** * Wallet-export capability. Kept OFF the core `Identity` interface for the same * reason as `SessionResume`: it is an optional, * standalone-SPA-only concern. Concrete `IdentityImpl` implements it; UI wiring * feature-detects via `supportsWalletExport()`. * * SECURITY: `exportKeystore` returns only ciphertext — raw seeds never leave the * vault seam. `revealSolanaSecret` is the sole raw-key path (Phantom cannot * import an encrypted file); it is deliberately a separate, explicitly-named * method so the raw path cannot be taken by accident. */ interface WalletExport { /** Password-encrypted keystore JSON string for the given chain. */ exportKeystore(chain: 'cere' | 'evm', passphrase: string): Promise; /** base58-encoded 64-byte Solana secret key (raw — Phantom import). */ revealSolanaSecret(): Promise; } /** * Narrow an `Identity` to one that supports wallet export. `IdentityImpl` * returns true; thin test doubles return false and hide the export UI. */ declare function supportsWalletExport(identity: Identity): identity is Identity & WalletExport; interface Identity { readonly isAuthenticated: boolean; readonly addresses: Addresses | null; readonly credentialId: string | null; /** * The account behind the current session, or `null` when signed out. Set by * register / login / `resumeSession` from the server's session response — a * pure read, never a ceremony trigger. * * Optional on the interface (not on `IdentityImpl`, which always defines it) * so the SPA's thin `Identity` test doubles are not all forced to carry an * account they do not model. */ readonly account?: AccountInfo | null; /** * Replace the account fields for the current session (see * `IdentityImpl.setAccount`). The only caller is a successful * `PATCH /v1/accounts/me`, which returns the whole updated account and is * the one way the account changes WITHOUT a session response to carry it. * * Optional for the same reason `account` is: the SPA's thin `Identity` test * doubles do not model an account, and a caller must therefore treat this * as a best-effort refresh (`identity.setAccount?.(...)`), never as the * thing that makes the patch durable — the server already did that. */ setAccount?(account: AccountInfo): void; /** * Create a NEW account and enrol this device's passkey as its ORIGIN * credential — the one whose PRF output IS the account root (described * under "`is_origin`" in `ARCHITECTURE.md`, which lives in the wallet * repo, not in this package). Registration writes no root * envelope: adding a further device is recover-then-enrol, a later * sub-project, not a second `register()`. * * `emailToken` comes from `accounts.emailVerify` — S0 proves the mailbox * BEFORE the ceremony, so there is no half-registered account to clean up * and a conflicting address is disclosed only to someone who controls it. * It is REQUIRED: there is no registration path that does not begin with a * verified mailbox. `IdentityImpl` still rejects an empty one by name * (`email-unverified`) as defence in depth for JS callers the type cannot * reach. * * `displayName` is the account's display name, and becomes the WebAuthn * `user.displayName` hint. * * `email` is the address the caller just proved control of, and becomes the * WebAuthn `user.name` hint — the line an OS passkey manager shows as the * account. It is optional only because JS callers exist that cannot supply * it; omitting it leaves the entry reading `scp-wallet`, which identifies * nothing (CEF-AI/wallet#72). Pass the address that `emailToken` attests, * never a separately-typed one. * * Neither field is transmitted: the WebAuthn user entity is consumed by the * authenticator and never appears in `clientDataJSON` or the attestation, so * `email` here creates no second, unverified copy server-side. It is * persisted in the resident credential and so may sync to the platform's * passkey store (iCloud Keychain and equivalents) — which is where a passkey * manager has to read it from to show it at all. */ register(opts: { emailToken: string; displayName?: string; email?: string; label?: string; }): Promise; /** * `opts.credentialId`, when supplied, pins the WebAuthn `allowCredentials` * to that credential (no discoverable-credential picker) instead of the * vault-snapshot credential this identity would otherwise fall back to. * Used by the step-up ceremony (Task 5 fix), where the vault is closed — * so there is no vault-snapshot credential to pin to — but the caller * (the SDK) already knows which credential the active session belongs to * and must not let the ceremony silently authenticate a different one. * Omitted for a normal login()/step-up-of-a-fresh-session, unchanged from * prior behavior. */ login(opts?: { credentialId?: string; }): Promise; logout(): void; /** Returns a valid JWT; runs the passkey ceremony if needed. */ getJwt(): Promise; /** * Release any browser-owned handles this identity holds (cross-tab channel, * timers, broadcast listeners). After `dispose()` the identity is unusable. * * Idempotent: callable multiple times safely. */ dispose(): void; } /** * Signers/* receive this function via a constructor-time closure handoff * from a concrete Identity instance. It is exported as a type-only alias so * dependents can annotate the closures they receive, but it is intentionally * absent from the `Identity` interface — there is no public `sign()` method. */ type InternalSign = (chain: Chain, payload: Uint8Array) => Promise; /** Spec §3.2 locked decision #14. Permanent for v1; rotate via "-v2" suffix. */ declare const PRF_INPUT_LABEL = "cere-wallet-prf-v1"; /** * SHA-256("cere-wallet-prf-v1") — 32 bytes. Fed to WebAuthn `prf.eval.first`. * * Hard-coded as a byte literal so this module is browser-safe with NO * `node:crypto` dependency — it is evaluated at module-load time, and relying on * `createHash` there forced every browser consumer to wire a `node:crypto` * polyfill (`vite-plugin-node-polyfills`). The literal is verified to equal * `sha256(PRF_INPUT_LABEL)` in `constants.test.ts`, so the constant cannot drift * silently. Rotating the label (`-v2`) means recomputing these bytes. */ declare const PRF_INPUT_SEED: Uint8Array; /** Spec §3.2. Used as HKDF-SHA256 info to derive the secp256k1 32-byte secret. */ declare const EVM_HKDF_INFO = "cere-wallet-evm-secp256k1-v1"; /** * Runtime support detection for WebAuthn + PRF. * * Spec §3.7. Real PRF capability is only knowable from a completed ceremony * (via `clientExtensionResults.prf.enabled` or `prf.results.first`). This * pre-check returns the best signal we can get without spending a credential. */ /** * The key PRF occupies in a `PublicKeyCredential.getClientCapabilities()` map. * * Singular `extension:`, matching the rest of the family the map exposes * (`extension:appid`, `extension:credBlob`, `extension:hmacCreateSecret`, …). * There is no `extensions:` prefix in the map and there never was — a plural * spelling here is not a legacy form, it is a key no browser has ever * produced, and reading it makes the whole pre-check `undefined === false`, * i.e. permanently optimistic. That is exactly the bug #80 fixed. * * Exported so the pin in `tests-e2e/prf-capability-key.spec.ts` (in the wallet * repo -- not part of this package) can assert THIS value against a capability * map taken from a real browser, rather than against a literal a typo would * travel with. */ declare const PRF_CAPABILITY_KEY = "extension:prf"; interface PrfSupportResult { webAuthn: boolean; /** * Whether a platform authenticator (Touch ID / Windows Hello) is enrolled. * INFORMATIONAL ONLY — it is NOT a precondition for using the wallet, which * also works with roaming security keys and phone passkeys (via hybrid). */ platformAuthenticator: boolean; /** * Best-effort PRF signal. `true` unless we can prove otherwise: modern * browsers expose `PublicKeyCredential.getClientCapabilities()`, which gives * a definitive `extension:prf` answer. When that API is absent (older * browsers) we stay optimistic and let the ceremony be the final arbiter * (it throws `prf-unsupported` if the authenticator returns no PRF output). */ prfPotentiallySupported: boolean; } declare function detectPrfSupport(): Promise; /** * Inspect a `PublicKeyCredential.getClientExtensionResults()` payload to * determine whether PRF actually worked in the ceremony. Spec §3.7 calls this * "final answer; the pre-check above is best-effort only". */ declare function isPrfSupportedResult(extensions: { prf?: { enabled?: boolean; results?: { first?: Uint8Array; }; }; }): boolean; /** Cere's SS58 network prefix. Spec §3.2. */ declare const CERE_SS58_PREFIX = 54; /** * SS58 address for the Cere network. * * SS58 spec (Substrate): for prefixes 0–63 the format is: * [prefix byte] || [32-byte pubkey] || [2-byte checksum] * where checksum = blake2b-512("SS58PRE" || prefix || pubkey)[0..2]. * The whole thing is base58-encoded. We use prefix 54 (Cere) which fits in * the 1-byte encoding (< 64). * * @param edPubkey 32-byte Ed25519 public key. */ declare function deriveCereAddress(edPubkey: Uint8Array): string; /** * Solana address: base58(Ed25519 pubkey). No prefix, no checksum. * * @param edPubkey 32-byte Ed25519 public key. */ declare function deriveSolanaAddress(edPubkey: Uint8Array): string; /** * Recover the raw 32-byte Ed25519 public key from a Solana address. * * The Solana address is `base58(edPubkey)` with no prefix or checksum * ({@link deriveSolanaAddress}), so a base58 decode yields the pubkey directly. * The public key is not secret — it IS the address — so this introduces no key * exposure beyond what the address already reveals; the session keys never * leave `SessionVault`. The same Ed25519 key backs the Cere SS58 address. * * @param solanaAddress base58 Solana address. * @returns the 32-byte Ed25519 public key. */ declare function decodeEd25519Pubkey(solanaAddress: string): Uint8Array; /** * EVM address: '0x' + last 20 bytes of keccak256(secp256k1 uncompressed pubkey X||Y). * * Accepts either 64-byte (raw X||Y) or 65-byte (0x04||X||Y) input. The 0x04 * prefix byte is stripped if present. * * @param secpPubkey secp256k1 public key. */ declare function deriveEvmAddress(secpPubkey: Uint8Array): string; interface DerivedKeys { /** Ed25519 32-byte secret key seed; used for Cere + Solana signing. */ edSeed: Uint8Array; /** secp256k1 32-byte secret key; used for EVM signing. */ secpKey: Uint8Array; /** 32-byte Ed25519 public key derived from edSeed. */ edPubkey: Uint8Array; /** 65-byte uncompressed secp256k1 public key (0x04 || X || Y) derived from secpKey. */ secpPubkeyUncompressed: Uint8Array; } /** * Derive the v2 session keys from a WebAuthn PRF output. Spec §3.2. * * edSeed = prfOutput * secpKey = HKDF-SHA256(prfOutput, salt="", info="cere-wallet-evm-secp256k1-v1", L=32) * * @param prfOutput 32-byte PRF output from `prf.results.first`. */ declare function derivedKeys(prfOutput: Uint8Array): DerivedKeys; /** * HKDF info for the root-wrapping key. Deliberately distinct from the signing * derivation: `derive.ts` uses the PRF output AS the ed25519 seed, so wrapping * under an unlabelled copy of it would encrypt the root under the key it * protects. Rotating this label invalidates every stored envelope, so it does * not change without a migration. */ declare const ROOT_WRAP_HKDF_INFO = "cere-wallet-root-wrap-v1"; /** * The `wrapVersion` this client writes into every envelope it creates * (`RootEnvelopeWire.wrapVersion`). Bump this, and add an entry to * `ROOT_WRAP_LABELS_BY_VERSION`, when a new label is introduced — never * repoint an existing version string at a different label. */ declare const ROOT_WRAP_VERSION = "v1"; /** * Thrown by `deriveWrapKeyForVersion` for a `wrapVersion` outside the * compiled-in set. * * A `WalletError` subclass, not a bare `Error`: the SPA's `walletErrorMessage` * only recognises `WalletError`, so anything else reaches the user as raw * technical text, and a caller with only a message string to match on cannot * tell this apart from the other envelope failures. Subclassing keeps BOTH * discriminators — `instanceof UnrecognisedWrapVersionError` and * `err.code === 'root-wrap-version-unknown'` — and `wrapVersion` still names * the offending value programmatically. */ declare class UnrecognisedWrapVersionError extends WalletError { readonly wrapVersion: string; constructor(wrapVersion: string); } /** * Thrown when an envelope is not wrapped by the credential's PRF output at all * — `wrapSource: 'password'`, say, which belongs to the password-recovery path * and cannot be opened with a credential key. * * Its own condition rather than a decrypt failure: attacking a password * envelope with a PRF-derived key is fail-safe (it will not open, so no wrong * root is ever derived) but it would be diagnosed as "this envelope is * damaged" when the truth is "this is the wrong kind of envelope" — a * difference between "your data is corrupt" and "use your recovery password". */ declare class UnsupportedWrapSourceError extends WalletError { readonly wrapSource: string; readonly credentialId: string; constructor(wrapSource: string, credentialId: string); } /** * Thrown when an envelope's ciphertext will not open under the wrap key derived * from a credential's PRF output — a wrong credential, or a corrupt/tampered * envelope. Deliberately its own type so the three envelope failures stay three * diagnoses: * * - `root-envelope-missing` — the server holds no envelope for this * credential (it was never enrolled, or the row is gone); * - `UnrecognisedWrapVersionError` / `root-wrap-version-unknown` — an envelope * exists but names a label this build does not know (a client too old for a * rotated label); * - `UnsupportedWrapSourceError` / `root-wrap-source-unsupported` — the * envelope is not wrapped by a credential PRF at all; * - `RootUnwrapFailedError` / `root-unwrap-failed` — the envelope is the right * kind and a known label, and still will not open. * * Collapsing them would leave a caller unable to tell "this device was never * added to the account" from "upgrade this client" from "use your recovery * password" from "this envelope is damaged" — four different next actions. The underlying failure is preserved * on `cause`; nothing is swallowed. */ declare class RootUnwrapFailedError extends WalletError { readonly credentialId: string; constructor(credentialId: string, options?: { cause?: unknown; }); } /** * Derives the root-wrap key for a given `wrapVersion`, resolving it through * the compiled-in `ROOT_WRAP_LABELS_BY_VERSION` map. The `wrapVersion` string * itself is NEVER passed into the HKDF as `info` -- only the label it maps * to is. This is deliberate defense in depth: a leaked or server-supplied * `wrapVersion` on its own is not enough to move the derived key, even * though the label is not secret and even though an attacker would still * need the credential's PRF output to do anything with it. Deciding this * once, here, means no future call site has to re-derive the same judgment. * * Throws `UnrecognisedWrapVersionError` -- distinguishable from a decryption * failure -- for any version outside the known set, so "unknown version", * "wrong credential", and "corrupt ciphertext" stay three separate, * diagnosable conditions instead of collapsing into one generic failure. */ declare function deriveWrapKeyForVersion(prfOutput: Uint8Array, wrapVersion: string): Uint8Array; declare function deriveWrapKey(prfOutput: Uint8Array): Uint8Array; interface RootWrapper { wrap(root: Uint8Array, key: Uint8Array): { ciphertext: Uint8Array; nonce: Uint8Array; }; } interface RootUnwrapper { unwrap(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array; } /** * XSalsa20-Poly1305 (NaCl secretbox shape), matching the envelope format * wallet-api stores and the format PR #94's recovery envelope uses — one * primitive for device envelopes and recovery envelopes alike. * * Exposed behind the `RootWrapper`/`RootUnwrapper` interfaces so a consumer * can substitute its own wrapper implementation; this one is the provided * default, not a mandated one. */ declare function createSecretboxWrapper(): RootWrapper & RootUnwrapper; /** * `hints` is a top-level field on the WebAuthn creation/request options * (WebAuthn L3), but our TS DOM lib doesn't ship it yet. Declare-merge the * precise W3C union so the ceremony can request authenticator hints without * casts. Drop this augmentation once the workspace lib.dom.d.ts ships `hints`. */ declare global { interface PublicKeyCredentialCreationOptions { hints?: ('client-device' | 'hybrid' | 'security-key')[]; } interface PublicKeyCredentialRequestOptions { hints?: ('client-device' | 'hybrid' | 'security-key')[]; } } /** * Adapter that runs WebAuthn ceremonies. Production implementations call * `navigator.credentials.create/get`; the test implementation in * `SoftAuthenticator` produces deterministic results without browser APIs. * * Spec §3.7. The `prfOutput` field is the result of the WebAuthn PRF extension * — present when the authenticator supports PRF, absent otherwise. */ interface CeremonyAdapter { register(opts: RegisterOptions): Promise; login(opts: LoginOptions): Promise; } interface RegisterOptions { rpId: string; challenge: Uint8Array; userHandle: Uint8Array; /** 32-byte PRF input (typically `PRF_INPUT_SEED`). */ prfInput: Uint8Array; /** * Legacy single label. Used as a fallback for both `user.displayName` and * `user.name` when `name`/`email` are not supplied. Prefer `name` + `email`. */ label?: string; /** Friendly account name → WebAuthn `user.displayName` (e.g. "Alex Müller"). */ name?: string; /** * Account identifier → WebAuthn `user.name` (e.g. "alex@example.com"). This is * the field most OS/browser passkey managers surface to distinguish credentials. */ email?: string; } interface RegisterResult { credentialId: string; clientDataJSON: string; attestationObject: string; transports: string[]; /** Present iff PRF extension worked. */ prfOutput?: Uint8Array; } interface LoginOptions { rpId: string; challenge: Uint8Array; /** Optional: if absent, the authenticator picks a credential. Present from us. */ credentialId?: string; prfInput: Uint8Array; } interface LoginResult { credentialId: string; clientDataJSON: string; authenticatorData: string; signature: string; prfOutput?: Uint8Array; } interface WebAuthnCeremonyAdapterOptions { /** * Override for the WebAuthn credentials manager. Defaults to * `globalThis.navigator?.credentials`. Tests inject a mock. */ credentials?: CredentialsContainer; } /** * Production WebAuthn ceremony adapter. Calls `navigator.credentials.create/get` * with the PRF extension. Spec §3.7. * * - `register`: requires a platform authenticator, user verification, Ed25519 * (`alg: -8`), and the PRF extension with the wallet's input seed. * - `login`: allows a specific credentialId if known; otherwise lets the * authenticator pick (resident key). * * Returned ArrayBuffers are base64url-encoded for API transport. */ declare class WebAuthnCeremonyAdapter implements CeremonyAdapter { private readonly credentials?; constructor(opts?: WebAuthnCeremonyAdapterOptions); register(opts: RegisterOptions): Promise; login(opts: LoginOptions): Promise; } /** * Base64url (RFC 4648 §5) helpers used by the WebAuthn ceremony adapter. * Trailing `=` padding is stripped on encode; both padded and unpadded inputs * are accepted on decode. */ /** Encode a Uint8Array or ArrayBuffer as a base64url string (no padding). */ declare function bytesToB64u(input: Uint8Array | ArrayBuffer): string; /** Decode a base64url string into a Uint8Array. Padding is optional. */ declare function b64uToBytes(s: string): Uint8Array; /** * Somewhere a `SoftAuthenticator`'s credentials can outlive the JS context * that created them. Two strings in, two strings out — the authenticator owns * the encoding, so a store cannot corrupt a credential by mis-serialising it. * * Exists because a real platform authenticator is shared by every window on * the origin, and the in-memory Map is not: a credential created in a * registration popup was invisible to the iframe that then had to re-derive * from it, and to the step-up popup opened later. That gap silently disabled * e2e coverage of the whole registration path (CEF-AI/wallet#91). */ interface SoftCredentialStore { read(): string | null; write(value: string): void; } /** * `localStorage`-backed store, for the e2e SPA build only. * * Per-test isolation comes from Playwright, which gives every test its own * BrowserContext and therefore its own `localStorage` — while a popup opened * BY the page shares that context, which is exactly the sharing this exists * for. Nothing here needs to clear between tests; if a spec ever drives two * contexts and wants them to share, that is a different mechanism. * * Returns a store that silently no-ops when `localStorage` is unreachable * (a sandboxed frame, a browser with site data blocked): the authenticator * then behaves exactly as it did before this existed — per-context memory — * rather than failing the ceremony outright. */ declare function browserCredentialStore(key?: string): SoftCredentialStore; interface SoftAuthenticatorOptions { /** * If provided, derive the per-registration `edSeed`, `prfSecret`, and * `credentialId` deterministically from this seed (mixed with a monotonic * counter so multiple register() calls on the same instance still produce * distinct credentials). Used by Playwright e2e specs that pin server-side * address stubs against the SoftAuthenticator's output. Production code * never sets this — the `useSoft` branch in `createIdentity.ts` is itself * forbidden in production builds. * * Default: undefined (each register() draws fresh CSPRNG bytes, matching * the pre-cycle-8.1 behavior used by unit tests in `ceremony.test.ts`). */ seed?: string; /** * When the caller invokes login() without a credentialId, fall back to the * most-recently-registered credential in memory. Default: false (login() * throws when credentialId is missing, matching pre-cycle-8 behavior). * * Required for e2e flows that simulate the user signing back in after the * vault has been cleared (post-logout re-login): the wallet has discarded * the credentialId locally, but the soft authenticator's in-memory creds * map still holds the registration, and the spec test needs login() to * succeed anyway. Production code must never enable this — real WebAuthn's * no-allowCredentials path shows an OS credential picker; the fallback * here is a deterministic test convenience, not a real picker, so leaving * it on by default would silently let a single registered credential * impersonate any login. */ fallbackToLastRegistered?: boolean; /** * Where credentials live. Omitted (the default) they live in this instance's * memory, which is what every unit test wants and what this class always * did. The e2e SPA build passes `browserCredentialStore()` so that a * credential registered in a popup is visible to the iframe and to later * popups — as a real platform authenticator's would be. * * The registration counter is stored alongside the credentials, so a second * window registering against the same seed produces the NEXT credential * rather than colliding with the first. */ store?: SoftCredentialStore; } /** * Deterministic in-memory WebAuthn authenticator for tests. NOT for production. * * - Stores credentials in a private Map keyed by credentialId. * - `prfOutput` is computed as HKDF-SHA256(prfSecret, salt="", info=prfInput, L=32), * so for a given credential and prfInput the result is stable across login calls. * - `signature` is a valid Ed25519 signature over `authenticatorData || sha256(clientDataJSON)`. * * The output shapes match the real WebAuthn response well enough that the * `Identity` integration tests (Task 6) can drive register → login → derive * → cross-check without a real browser. */ declare class SoftAuthenticator implements CeremonyAdapter { private readonly creds; private readonly seedBytes; private readonly fallbackToLastRegistered; private readonly store; /** Monotonic counter mixed into the seed-derived material so successive * register() calls on a seeded instance produce distinct credentials. */ private regCounter; constructor(opts?: SoftAuthenticatorOptions); /** * Pull the shared store into this instance's Map before any read of it. * * Re-read every time rather than once at construction: the other window may * have registered since. Merges rather than replaces, so a credential this * instance created is never lost to a stale write from elsewhere — the * store is last-writer-wins per key, and these keys are content-addressed * (a credentialId is derived from the material it names), so a merge cannot * produce a credential whose id disagrees with its bytes. */ private hydrate; /** Publish this instance's credentials + counter back to the shared store. */ private persist; register(opts: RegisterOptions): Promise; login(opts: LoginOptions): Promise; /** Public-key bytes for a credential. Used by integration tests for cross-checks. */ getEd25519PublicKey(credentialId: string): Uint8Array; private computePrf; } interface VaultSnapshot { addresses: Addresses; credentialId: string; } interface SessionVault { isOpen(): boolean; /** Public-safe view: never includes raw keys. */ snapshot(): VaultSnapshot | null; /** Open the vault with derived keys + the addresses they yielded + the credentialId. */ set(args: { keys: DerivedKeys; addresses: Addresses; credentialId: string; }): void; /** Drop the keys and address state. Safe to call when already closed. */ clear(): void; /** * Sign a payload with the chain-appropriate key. Spec §3.6 invariant: * this is the only way the rest of the package can reach the keys. * * - 'cere' / 'solana': Ed25519 signature over the raw payload (64 bytes). * - 'evm': secp256k1 ECDSA over the raw payload (65 bytes: r || s || v). * The caller (an EVM signer) is responsible for framing (e.g. EIP-191) * and hashing (keccak256) before passing the digest as the payload. */ sign(chain: Chain, payload: Uint8Array): Promise; } /** * Closure-encapsulated session vault. The keys live in the closure variables * `state.edSeed` / `state.secpKey` and are not reachable from outside. * * Spec §3.4. Lifetime: from `set()` until `clear()` or tab unload. */ declare function createSessionVault(): SessionVault; interface IdentityImplOptions { apiClient: ApiClient; ceremony: CeremonyAdapter; /** * SPA-injected keystore encoder (see `KeystoreEncryptor`). Optional at the * type level so lightweight test doubles need not supply one; * `exportKeystore` throws if it is absent. NOTE: in the running wallet SPA * this is always supplied — `createIdentity` defaults it to * `spaKeystoreEncryptor` — and the single `IdentityImpl` instance is shared * by both the `/settings` and `/embed/wallet` routes (one `WalletProvider` * wraps both), so this option is not how the standalone/embed boundary is * enforced. See `exportKeystore`'s doc comment for where that boundary * actually lives. */ keystoreEncryptor?: KeystoreEncryptor; /** * Opens a non-origin credential's root envelope (see `login()`). Optional at * the type level so lightweight test doubles need not supply one — but a * non-origin login without it fails rather than falling back to the * credential's own PRF output, because that fallback IS the second-wallet * bug. `createIdentity` defaults it to `createSecretboxWrapper()`, mirroring * `keystoreEncryptor`. */ rootUnwrapper?: RootUnwrapper; } declare class IdentityImpl implements Identity, SessionResume, WalletExport { private readonly opts; private readonly vault; private cachedJwt; /** Server-returned credential ID (used as the public identity.credentialId). */ private serverCredentialId; /** * Raw derived keys for the CURRENT session, captured at every `vault.set()` * call site (register / login / adoptSession). The vault deliberately hides * the seeds behind its closure (Spec §3.6 invariant 1); this field is the * single, explicit, same-origin-only seam that lets `exportSession()` hand a * live session to a sibling wallet-origin surface (the persistent iframe). * Cleared on logout / cross-tab logout alongside the vault. Never serialised * to storage and never sent to a host page. */ private sessionKeys; /** * Absolute expiry (epoch ms) for `exportSession()`. Tracks the cached JWT * expiry after register/login; set from the adopted session in adoptSession. */ private sessionExpMs; /** * Addresses for a bearer-active session (set by `resumeSession`, which has * no vault entry to read addresses from). Register/login/adoptSession keep * this nil and rely on the vault snapshot instead; `syncPublicState()` * falls back to this field only when the vault is closed. */ private resumedAddresses; /** * The S0 account behind the current session, as the server reported it on the * last session response (register-finish / login-finish / refresh). Non- * observable like `serverCredentialId`; pushed into `_account` by * `syncPublicState()`. Cleared on logout / cross-tab logout. */ private currentAccount; /** * De-dupes concurrent bearer-session refreshes. Refresh-token rotation + * reuse-detection means two parallel `session.refresh()` calls would replay a * rotated token and revoke the whole family, so `getJwt()`'s bearer fallback * shares a single in-flight refresh. Cleared once it settles. */ private bearerRefreshInFlight; private readonly _isAuthenticated; private readonly _addresses; private readonly _credentialId; private readonly _account; /** Cross-tab logout coordination via BroadcastChannel('scp-wallet-v2'). */ private readonly crossTabSync; /** Handle to detach the cross-tab logout subscriber on dispose(). */ private readonly crossTabSyncUnsubscribe; /** Idempotency flag for dispose(). */ private disposed; constructor(opts: IdentityImplOptions); get isAuthenticated(): boolean; get addresses(): Addresses | null; /** * Returns the server-returned credentialId from the last register/login ceremony. * The vault internally tracks the authenticator-generated credential ID for login use. */ get credentialId(): string | null; /** * The account behind the current session, or `null` when signed out. Comes * straight off the server's session response — reading it never runs a * ceremony, which is what lets `wallet:session:status` report it. */ get account(): AccountInfo | null; /** * Discriminates the two authenticated states: `true` when the session has * derived signing keys (register/login/adoptSession); `false` for a * bearer-active session adopted via `resumeSession` (JWT only). Token- * authorized ops (e.g. delegation mint) only require `isAuthenticated`; * anything that needs a raw signature must check `hasKeys()` first and * step up (re-run login) when it's false. */ hasKeys(): boolean; /** * Read the (non-observable) vault snapshot + cached server credential ID and * push the values into the three public observable boxes. Call after any * mutation that could change the public derived state: register, login, * logout, or constructor (hydration). */ private syncPublicState; /** * Create a NEW account and enrol this device's passkey as its ORIGIN * credential: the PRF output of a credential registered here IS the account * root (`derive.ts` uses it verbatim as the ed25519 seed), which is exactly * what `is_origin` records server-side. Registration therefore writes NO root * envelope — there is no other root to wrap. Adding a SECOND device is * recover-then-enrol (a later sub-project), never a second `register()`. */ register(opts: { emailToken: string; displayName?: string; email?: string; label?: string; }): Promise; login(opts?: { credentialId?: string; }): Promise; /** * Resolve the account root for a NON-origin credential by unwrapping that * credential's root envelope. `prfOutput` here is a wrapping key, never a * seed. * * The ways this fails stay distinct, diagnosable conditions — that is what * `wrapSource` and `wrapVersion` on the wire are for, and collapsing them * would leave a caller unable to tell "this device was never enrolled" from * "upgrade this client" from "use your recovery password" from "this envelope * is damaged". Every one of them is a `WalletError` subclass, so a caller can * switch on `.code` and the SPA can render real copy: * * 1. no envelope for the credential — a 404, or the `root-envelope-missing` * URN — → `WalletError('root-envelope-missing')`, with the underlying * failure kept on `cause`. Any OTHER failure of that fetch (a 500, a * 401, a network error) is not this condition and propagates as * whatever `ApiClient` already made of it; * 2. an envelope that is not credential-PRF-wrapped → * `UnsupportedWrapSourceError` (`root-wrap-source-unsupported`); * 3. a `wrapVersion` outside this build's compiled-in set → * `UnrecognisedWrapVersionError` (`root-wrap-version-unknown`), * propagated untouched from `deriveWrapKeyForVersion` (which is also the * only place a server-supplied version string is turned into * key-derivation input); * 4. ciphertext that will not open under the derived key → * `RootUnwrapFailedError` (`root-unwrap-failed`), carrying the * primitive's error as `cause`. * * Each `catch` wraps exactly one call for exactly that reason; there is no * blanket try around the whole method. */ private unwrapRootForCredential; /** * Adopt a session from a token-refresh response (`apiClient.session.refresh()`) * WITHOUT running a passkey ceremony and WITHOUT deriving keys — the * "bearer-active" state (Task 2). Sets `cachedJwt` + its expiry + addresses; * leaves `sessionKeys` (and the vault) untouched, so `hasKeys()` stays * false unless a prior login already populated them. Callers needing a raw * signature must step up via `login()` first; token-authorized ops (e.g. * delegation mint) only need `isAuthenticated`, which this satisfies. */ resumeSession(session: { token: string; addresses: Addresses; credentialId: string; accountId?: string; email?: string | null; displayName?: string | null; }): void; /** * Replace the account fields for the CURRENT session, without a ceremony and * without touching keys, the vault, or the JWT. * * The one caller is `PATCH /v1/accounts/me`, which returns the full updated * account. Everything else that sets `currentAccount` does so from a session * response (register-finish / login-finish / refresh), and a patch produces * none — so without this, an account that has just bound an email keeps * reporting `email: null` from `identity.account` until the next session * response, and every reader of that field (the Settings nudge, the * `wallet:session:status` payload) is working from a value the client itself * knows to be stale. * * Deliberately narrow: it writes `currentAccount` and re-syncs public state, * nothing else. It is NOT a login — a call with no live session is ignored * rather than resurrecting an account onto a signed-out identity, which is * what a logout racing an in-flight patch would otherwise do. */ setAccount(account: AccountInfo): void; logout(): void; getJwt(): Promise; /** * Silently re-mint the bearer session's JWT from the wallet-origin refresh * cookie, de-duped across concurrent callers. Refresh-token rotation means two * parallel refreshes would replay a rotated token → reuse-detection revokes * the family; sharing one in-flight promise avoids that. Resolves to the fresh * JWT; rejects (propagating the `session.refresh()` error) if the cookie is * missing/expired/revoked. */ private refreshBearerSession; /** * Read the cached JWT verbatim — no expiry check, no refresh. A cache * INSPECTOR, for tests and diagnostics. * * NOT the token source for `ApiClient.getAuthToken`. It was, under the name * `getCachedJwtForApiClient`, and that is what caused the outage this rename * exists to prevent: the JWT TTL (~20m) is far shorter than a tab's life, so * every authRequired call from an idle tab shipped a long-dead token and * wallet-api answered `token-expired` — permanently, because nothing on that * path refreshed. Anything that needs a token to SEND must call `getJwt()`, * which refreshes within `JWT_REFRESH_SLACK_MS` (silently, off the refresh * cookie, for a bearer-active session). See `src/bootstrap/createIdentity.ts` * and its `createIdentity.authToken.test.ts`. */ peekCachedJwt(): string | null; /** * Returns the session vault. Used by popup-side `wallet:sign` handlers * which need direct access to `vault.sign(chain, payload)`. NOT for host- * side use — keys never reach the host. Spec §3.6 invariant 1. */ getVault(): SessionVault; /** * Export the current account's key for `chain` as a password-encrypted * keystore JSON string. The raw seed is read from the `sessionKeys` seam, * handed to the injected `KeystoreEncryptor`, and only the returned ciphertext * escapes — Spec §3.6 containment holds. * * This method has no notion of "standalone" vs "embed" surface: one * `WalletProvider` wraps both the `/settings` and `/embed/wallet` routes, * `createIdentity` always defaults the `keystoreEncryptor`, and the same * `IdentityImpl` instance backs both — so this call is exactly as reachable * from the embed popup as from Settings. The standalone-only boundary is * enforced above this layer: `ExportAccount` is only mounted by the * `/settings` route, and it additionally asserts `!isEmbedSurface()` at * runtime (`src/wallet/export/isEmbedSurface.ts`) as a belt-and-suspenders * check. Do not rely on this method to gate the surface. */ exportKeystore(chain: 'cere' | 'evm', passphrase: string): Promise; /** * Reveal the Solana secret key as base58 (64 bytes: seed || pubkey). This is * the ONE raw-key export path — Phantom cannot import an encrypted file. It is * a deliberately separate, explicitly-named method so the raw path is never * taken by accident. As with `exportKeystore`, this method itself does not * know the standalone/embed surface; that boundary is enforced by the UI * (see `exportKeystore`'s doc comment). */ revealSolanaSecret(): Promise; /** * Release the BroadcastChannel + cross-tab subscriber. Idempotent. * * Called by: * - WalletProvider's useEffect cleanup (SPA unmount / apiBaseUrl change) * - test afterEach hooks (prevent listener leakage across tests) * * After dispose(), the identity is unusable: register/login/logout/getJwt * still execute their normal logic, but cross-tab broadcasts no longer * propagate (channel is closed) and the local subscriber is detached. * The vault remains cleared (logout() runs locally). Spec §9.4 lifecycle. */ dispose(): void; private crossCheckAddresses; } /** * Cross-tab session synchronisation helper. * * Wraps a `BroadcastChannel` on a fixed channel name so all tabs of the same * origin can coordinate. Today we only use it for `{ type: 'logout' }` — when * one tab logs out, every other tab clears its in-memory vault + JWT so its * MobX-reactive UI immediately reflects the logged-out state. * * The channel name must match `BROADCAST_CHANNEL_NAME` in * `packages/embed-sdk/src/protocol.ts` ('scp-wallet-v2'). Defined here as a * local constant rather than imported to avoid a circular dep between * `@cef-ai/wallet-identity` and `@cef-ai/wallet`. The name is part of the * wire protocol — if you change one, change the other (and bump the major). * * Spec §9.4 (cross-tab session sharing). */ type CrossTabMessage = { type: 'logout'; }; declare class CrossTabSync { private channel; private readonly listeners; constructor(); /** Broadcast a message to all other tabs (and not to this tab — that's the * BroadcastChannel spec). The originating tab is expected to have already * applied the state change locally before broadcasting. */ broadcast(msg: CrossTabMessage): void; /** Subscribe to messages from other tabs. Returns an unsubscribe function. */ subscribe(fn: (msg: CrossTabMessage) => void): () => void; /** * Close the underlying channel and drop all listeners. Idempotent. * * Sets `this.channel = null` so subsequent `broadcast()` calls become a * no-op (the `?.postMessage` short-circuits) and we don't accidentally * postMessage on a closed BroadcastChannel (which throws InvalidStateError * in some implementations). */ close(): void; } export { type AccountInfo, 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 KeystoreEncryptor, type LoginOptions, type LoginResult, PRF_CAPABILITY_KEY, PRF_INPUT_LABEL, PRF_INPUT_SEED, type PrfSupportResult, ROOT_WRAP_HKDF_INFO, ROOT_WRAP_VERSION, type RegisterOptions, type RegisterResult, RootUnwrapFailedError, type RootUnwrapper, type RootWrapper, type SessionResume, type SessionVault, SoftAuthenticator, type SoftAuthenticatorOptions, type SoftCredentialStore, UnrecognisedWrapVersionError, UnsupportedWrapSourceError, type VaultSnapshot, type WalletExport, WebAuthnCeremonyAdapter, type WebAuthnCeremonyAdapterOptions, b64uToBytes, browserCredentialStore, bytesToB64u, createSecretboxWrapper, createSessionVault, decodeEd25519Pubkey, deriveCereAddress, deriveEvmAddress, deriveSolanaAddress, deriveWrapKey, deriveWrapKeyForVersion, derivedKeys, detectPrfSupport, isPrfSupportedResult, supportsSessionResume, supportsWalletExport };