From 963d9263caf342504e07e29bcb9f911895518255 Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:05:50 -0600 Subject: [PATCH 1/2] Let users delete their account and turn off enclave recovery useCavos() gains deleteAccount(), disableEnclaveRecovery() and enableEnclaveRecovery(). Each is confirmed in a sheet the vault draws and signed with the wallet's spend key over a challenge from the Cavos backend, so the embedding app cannot act for the user. Deleting also removes this browser's keys and wraps. A user who turned recovery off is not re-enrolled on sign-in, and a new device gets a clear error instead of a failed recovery. The vault refuses to sign a consent message as a Solana transaction. --- CHANGELOG.md | 17 +++++ README.md | 22 ++++++ package-lock.json | 4 +- package.json | 2 +- src/account/AccountClient.ts | 54 +++++++++++++++ src/account/consent.test.ts | 71 +++++++++++++++++++ src/account/consent.ts | 69 +++++++++++++++++++ src/index.ts | 1 + src/react/CavosProvider.tsx | 73 ++++++++++++++++++++ src/recovery/EnclaveDekPort.test.ts | 18 +++++ src/recovery/EnclaveDekPort.ts | 16 +++-- src/secret/DeviceSecret.test.ts | 59 ++++++++++++++++ src/secret/DeviceSecret.ts | 10 ++- src/secret/erase.ts | 20 ++++++ src/vault/VaultClient.ts | 10 +++ src/vault/host.test.ts | 78 ++++++++++++++++++++- src/vault/host.ts | 38 +++++++++- src/vault/modal.ts | 103 ++++++++++++++++++++++++++++ src/vault/protocol.ts | 8 +++ 19 files changed, 660 insertions(+), 13 deletions(-) create mode 100644 src/account/AccountClient.ts create mode 100644 src/account/consent.test.ts create mode 100644 src/account/consent.ts create mode 100644 src/secret/erase.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9e521a..bd1745e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.6.0 + +Needs the current Cavos backend. + +- **Account deletion.** `deleteAccount()` from `useCavos()` removes the user's + Cavos account data for the environment (registration, devices, passkey backup, + recovery) and this browser's keys, then signs them out. The wallet stays + on-chain. +- **Recovery opt-out and opt-in.** `disableEnclaveRecovery()` and + `enableEnclaveRecovery()` stop and resume Cavos holding a recovery copy of the + key. A user who opted out is never re-enrolled silently, and a new device gets + a clear error instead of a failed recovery. +- **The consent sheet.** Each of these is confirmed in a sheet the vault draws, + which signs the server's challenge with the wallet's spend key. Deleting asks + the user to type `delete`. The app cannot answer for the user. +- **The vault refuses to sign a consent message as a Solana transaction.** + ## 0.4.1 - **The code screen fits its card.** The nine code boxes share the card's width diff --git a/README.md b/README.md index 2ccb823..d8b3cd0 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,28 @@ Signing up never asks for a passkey. After connect, call `enrollPasskeyDefault() from `useCavos()` to add one; it can then restore the wallet on another device, on every chain. There is no on-chain approver and no recovery-code `add_signer`. +## Account deletion and recovery opt-out + +From `useCavos()`, with the Cavos vault and an `appId`: + +```tsx +const { deleteAccount, disableEnclaveRecovery, enableEnclaveRecovery } = useCavos(); + +await disableEnclaveRecovery(); +await enableEnclaveRecovery(); +await deleteAccount(); +``` + +Each call opens a confirmation sheet in the vault, which signs the request with +the wallet's key, so the app cannot act for the user. `disableEnclaveRecovery` +stops Cavos holding a recovery copy of the key; `enableEnclaveRecovery` allows it +again at the next sign-in. `deleteAccount` removes the user's Cavos data for the +environment and this browser's keys, then signs them out; the wallet stays +on-chain. Turning recovery on or off needs this device to hold the wallet key. +Deleting from a device without it fails with `consent_proof_required` while +Cavos still holds recovery for the account: restore the wallet on that device +first. + ## Quickstart — Starknet ```ts diff --git a/package-lock.json b/package-lock.json index 646368d..d67fee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cavos/kit", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cavos/kit", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", diff --git a/package.json b/package.json index e16bdcc..151b8a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cavos/kit", - "version": "0.5.0", + "version": "0.6.0", "description": "Embedded Stellar wallet SDK, embedded Solana wallet, and embedded Starknet wallet. Device-native self-custodial accounts for React Native and web.", "repository": { "type": "git", diff --git a/src/account/AccountClient.ts b/src/account/AccountClient.ts new file mode 100644 index 0000000..9df5d58 --- /dev/null +++ b/src/account/AccountClient.ts @@ -0,0 +1,54 @@ +import type { ConsentAction, ConsentProof } from "./consent"; + +export interface AccountClientOptions { + baseUrl: string; + appId: string; + environment?: "development" | "production"; + /** The login's token. Read fresh on each call; these tokens expire. */ + authToken: () => string | null; +} + +/** `/api/end-users/me` on the Cavos backend: the end user's own recovery switch and account deletion. */ +export class AccountClient { + constructor(private readonly opts: AccountClientOptions) {} + + async challenge(action: ConsentAction): Promise { + const { challenge } = await this.send<{ challenge: string }>("POST", "/api/end-users/me/challenge", { action }); + return challenge; + } + + async setRecovery(enabled: boolean, challenge: string, proof: ConsentProof): Promise<"on" | "off"> { + const { recovery } = await this.send<{ recovery: "on" | "off" }>("POST", "/api/end-users/me/recovery", { + enabled, + challenge, + proof, + }); + return recovery; + } + + /** `proof` is null when this device holds no key; the server then deletes only if Cavos holds no recovery for the account. */ + async deleteAccount(challenge: string, proof: ConsentProof | null): Promise { + await this.send("DELETE", "/api/end-users/me", { challenge, ...(proof ? { proof } : {}) }); + } + + private async send(method: "POST" | "DELETE", path: string, body: Record): Promise { + const token = this.opts.authToken(); + const res = await fetch(new URL(path, this.opts.baseUrl), { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + app_id: this.opts.appId, + ...(this.opts.environment ? { environment: this.opts.environment } : {}), + ...body, + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`kit/account: ${res.status} ${text}`); + } + return (await res.json()) as T; + } +} diff --git a/src/account/consent.test.ts b/src/account/consent.test.ts new file mode 100644 index 0000000..23b258f --- /dev/null +++ b/src/account/consent.test.ts @@ -0,0 +1,71 @@ +import { ed25519 } from "@noble/curves/ed25519"; +import { ec, hash } from "starknet"; +import { starknetAddressFromPublicKey } from "../chains/starknet/keys"; +import { bytesToBase64Url, utf8ToBytes } from "../crypto/encoding"; +import { generateMasterDEK, parseMasterDEK } from "../secret/dek"; +import { nativeAddress, parseAppSalt } from "../secret/derive"; +import { PublicKey } from "@solana/web3.js"; +import { consentAction, consentMessage, signConsent } from "./consent"; + +const SALT = "salt"; + +function challenge(payload: Record): string { + return `v1.${bytesToBase64Url(utf8ToBytes(JSON.stringify(payload)))}.sig`; +} + +const valid = { kind: "end_user_consent", app_id: "a", environment_id: "e", sub: "u", state: "s", issued_at: 1 }; + +describe("consent", () => { + it("frames the message with the challenge length", () => { + expect(consentMessage("abc")).toBe("Cavos Consent:\n3\nabc"); + }); + + it("reads the action of a challenge", () => { + expect(consentAction(challenge({ ...valid, action: "delete_account" }))).toBe("delete_account"); + expect(consentAction(challenge({ ...valid, action: "disable_recovery" }))).toBe("disable_recovery"); + expect(consentAction(challenge({ ...valid, action: "enable_recovery" }))).toBe("enable_recovery"); + }); + + it("rejects anything that is not a consent challenge", () => { + expect(() => consentAction("abc")).toThrow("not a consent challenge"); + expect(() => consentAction("v2.e30.sig")).toThrow("not a consent challenge"); + expect(() => consentAction("v1.!!.sig")).toThrow("not a consent challenge"); + expect(() => consentAction(challenge({ ...valid, kind: "other", action: "delete_account" }))).toThrow( + "not a consent challenge", + ); + expect(() => consentAction(challenge({ ...valid, action: "wipe" }))).toThrow("not a consent challenge"); + }); + + it.each(["solana", "stellar"] as const)("signs with the %s Ed25519 key", (chain) => { + const bytes = generateMasterDEK(); + const text = challenge({ ...valid, action: "delete_account" }); + const proof = signConsent(parseMasterDEK(bytes), chain, SALT, text); + expect(proof.chain).toBe(chain); + expect(proof.public_key).toMatch(/^[0-9a-f]{64}$/); + expect(proof.signature).toHaveLength(1); + const publicKey = Buffer.from(proof.public_key, "hex"); + expect(ed25519.verify(Buffer.from(proof.signature[0], "hex"), utf8ToBytes(consentMessage(text)), publicKey)).toBe(true); + if (chain === "solana") { + expect(new PublicKey(publicKey).toBase58()).toBe(nativeAddress(bytes, "solana", parseAppSalt(SALT))); + } + }); + + it("signs with the Starknet key", () => { + const bytes = generateMasterDEK(); + const text = challenge({ ...valid, action: "disable_recovery" }); + const proof = signConsent(parseMasterDEK(bytes), "starknet", SALT, text); + expect(proof.chain).toBe("starknet"); + expect(proof.public_key).toMatch(/^04[0-9a-f]{128}$/); + const compact = proof.signature.map((value) => BigInt(value).toString(16).padStart(64, "0")).join(""); + const digest = hash.starknetKeccak(consentMessage(text)).toString(16).padStart(64, "0"); + expect(ec.starkCurve.verify(compact, digest, proof.public_key)).toBe(true); + const x = proof.public_key.slice(2, 66); + expect(starknetAddressFromPublicKey(`0x${x}`)).toBe(nativeAddress(bytes, "starknet", parseAppSalt(SALT))); + }); + + it("zeroizes the DEK it was given", () => { + const dek = generateMasterDEK(); + signConsent(dek, "solana", SALT, challenge({ ...valid, action: "delete_account" })); + expect(dek.every((byte) => byte === 0)).toBe(true); + }); +}); diff --git a/src/account/consent.ts b/src/account/consent.ts new file mode 100644 index 0000000..044853e --- /dev/null +++ b/src/account/consent.ts @@ -0,0 +1,69 @@ +import { ed25519 } from "@noble/curves/ed25519"; +import { ec, hash } from "starknet"; +import { starkKeyFromSeed } from "../chains/starknet/keys"; +import { base64UrlToBytes, bytesToHex, bytesToUtf8, utf8ToBytes } from "../crypto/encoding"; +import { zeroize, type MasterDEK } from "../secret/dek"; +import { deriveSeed, parseAppSalt, type NativeChain } from "../secret/derive"; + +/** What a user can ask Cavos to do to their account, each one confirmed in the vault. */ +export type ConsentAction = "delete_account" | "disable_recovery" | "enable_recovery"; + +/** A signature by the wallet's spend key over the consent message. */ +export type ConsentProof = { + chain: NativeChain; + public_key: string; + signature: string[]; +}; + +export const CONSENT_PREFIX = "Cavos Consent:\n"; + +const ACTIONS: readonly ConsentAction[] = ["delete_account", "disable_recovery", "enable_recovery"]; + +/** The text the wallet signs for a server-issued challenge. */ +export function consentMessage(challenge: string): string { + return `${CONSENT_PREFIX}${challenge.length}\n${challenge}`; +} + +/** Reads which action a challenge asks for. The signature is the server's to check, so only the shape is verified here. */ +export function consentAction(challenge: string): ConsentAction { + const parts = challenge.split("."); + if (parts.length !== 3 || parts[0] !== "v1") throw new Error("kit/consent: not a consent challenge"); + let payload: { kind?: unknown; action?: unknown }; + try { + payload = JSON.parse(bytesToUtf8(base64UrlToBytes(parts[1]))); + } catch { + throw new Error("kit/consent: not a consent challenge"); + } + if (payload?.kind !== "end_user_consent" || !ACTIONS.includes(payload.action as ConsentAction)) { + throw new Error("kit/consent: not a consent challenge"); + } + return payload.action as ConsentAction; +} + +const hex = (bytes: Uint8Array) => bytesToHex(bytes).slice(2); + +/** Signs the consent message with the spend key of one chain. Zeroizes the DEK and the seed. */ +export function signConsent(dek: MasterDEK, chain: NativeChain, appSalt: string, challenge: string): ConsentProof { + const text = consentMessage(challenge); + const seed = deriveSeed(dek, chain, parseAppSalt(appSalt)); + try { + if (chain === "starknet") { + const priv = starkKeyFromSeed(seed); + const digest = hash.starknetKeccak(text).toString(16).padStart(64, "0"); + const { r, s } = ec.starkCurve.sign(digest, priv); + return { + chain, + public_key: hex(ec.starkCurve.getPublicKey(priv, false)), + signature: [`0x${r.toString(16)}`, `0x${s.toString(16)}`], + }; + } + return { + chain, + public_key: hex(ed25519.getPublicKey(seed)), + signature: [hex(ed25519.sign(utf8ToBytes(text), seed))], + }; + } finally { + zeroize(seed); + zeroize(dek); + } +} diff --git a/src/index.ts b/src/index.ts index 1441a4c..f3ddc7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,3 +154,4 @@ export { bigIntTo32Bytes, } from "./crypto/encoding"; export { VaultClient } from "./vault/VaultClient"; +export type { ConsentAction, ConsentProof } from "./account/consent"; diff --git a/src/react/CavosProvider.tsx b/src/react/CavosProvider.tsx index d473937..5659caf 100644 --- a/src/react/CavosProvider.tsx +++ b/src/react/CavosProvider.tsx @@ -33,6 +33,7 @@ import { type SocialRecoveryProvider, } from '../recovery/SocialRecoveryClient'; import { DEFAULT_SOCIAL_RECOVERY_ATTESTATION } from '../recovery/attestationDefaults'; +import { AccountClient } from '../account/AccountClient'; import { VaultClient } from '../vault/VaultClient'; import { zeroize } from '../secret/dek'; import { readLocalDek } from '../secret/localDek'; @@ -321,6 +322,23 @@ export interface CavosContextValue { * in the dashboard, so the enclave accepts tokens minted for your client. */ submitSocialRecoveryToken: (idToken: string) => void; + /** + * Delete the signed-in user's Cavos account data for this environment: wallet + * registration, devices, passkey backup and recovery. The user confirms in the + * Cavos vault, which signs the request with the wallet's key. The wallet stays + * on-chain. Signs the user out when done. Needs the vault and an `appId`. + */ + deleteAccount: () => Promise; + /** + * Stop Cavos holding a recovery copy of the key for sign-in recovery. The user + * confirms in the Cavos vault. Needs this device to hold the wallet key. + */ + disableEnclaveRecovery: () => Promise; + /** + * Allow recovery by sign-in again. The recovery copy is sealed the next time + * the user signs in. Needs this device to hold the wallet key. + */ + enableEnclaveRecovery: () => Promise; logout: () => void; } @@ -1292,6 +1310,58 @@ export function CavosProvider({ } }, [auth, config, identity, isExternalAuth]); + const consentSession = useCallback( + (what: string) => { + const cfg = configRef.current; + const vault = vaultSetting(cfg); + if (!session || !identity) throw new Error('Not logged in'); + if (!vault || !cfg.appId) throw new Error(`kit: ${what} needs the Cavos vault and an appId`); + return { + userId: identity.userId, + appSalt: cfg.appSalt, + vault: VaultClient.attach({ appId: cfg.appId, ...(typeof vault === 'object' ? vault : {}) }), + client: new AccountClient({ + baseUrl: cfg.authBackendUrl ?? 'https://cavos.xyz', + appId: cfg.appId, + ...(cfg.environment ? { environment: cfg.environment } : {}), + authToken: () => auth.getAuthToken(), + }), + }; + }, + [session, identity, auth], + ); + + const deleteAccount = useCallback(async () => { + const { userId, appSalt, vault, client } = consentSession('deleteAccount'); + const challenge = await client.challenge('delete_account'); + const proof = await vault.consent({ userId, appSalt, challenge }); + await client.deleteAccount(challenge, proof); + await vault.erase(userId, appSalt); + logout(); + }, [consentSession, logout]); + + const setEnclaveRecovery = useCallback( + async (enabled: boolean) => { + const { userId, appSalt, vault, client } = consentSession( + enabled ? 'enableEnclaveRecovery' : 'disableEnclaveRecovery', + ); + const challenge = await client.challenge(enabled ? 'enable_recovery' : 'disable_recovery'); + const proof = await vault.consent({ userId, appSalt, challenge }); + if (!proof) throw new Error('kit: this device does not hold the wallet key'); + await client.setRecovery(enabled, challenge, proof); + if (!enabled) { + setWalletStatus((status) => { + const methods = status.recovery.methods.filter((method) => method !== 'social'); + return { ...status, recovery: { protected: methods.length > 0, methods } }; + }); + } + }, + [consentSession], + ); + + const disableEnclaveRecovery = useCallback(() => setEnclaveRecovery(false), [setEnclaveRecovery]); + const enableEnclaveRecovery = useCallback(() => setEnclaveRecovery(true), [setEnclaveRecovery]); + const value: CavosContextValue = { openModal, closeModal, @@ -1324,6 +1394,9 @@ export function CavosProvider({ setupRecovery, recover, submitSocialRecoveryToken, + deleteAccount, + disableEnclaveRecovery, + enableEnclaveRecovery, logout, }; diff --git a/src/recovery/EnclaveDekPort.test.ts b/src/recovery/EnclaveDekPort.test.ts index 2a1314f..6e2fee5 100644 --- a/src/recovery/EnclaveDekPort.test.ts +++ b/src/recovery/EnclaveDekPort.test.ts @@ -23,6 +23,24 @@ describe("toEnclaveDekPort", () => { expect(enroll).toHaveBeenCalledWith({ walletAddress: "Addr1", credential, dek }); }); + it("treats a revoked recovery as an opt-out, not a failure", async () => { + const dek = generateMasterDEK(); + const enroll = jest.fn(async () => { + throw new Error('kit/social-recovery: /enroll -> 403 {"error":"recovery_revoked"}'); + }); + const port = toEnclaveDekPort({ enroll, recover: jest.fn() } as unknown as SocialRecoveryClient); + await expect(port.enrollDek({ address: "Addr1", credential, dek })).resolves.toEqual({ kind: "opted-out" }); + }); + + it("still raises other enroll failures", async () => { + const dek = generateMasterDEK(); + const enroll = jest.fn(async () => { + throw new Error("enclave down"); + }); + const port = toEnclaveDekPort({ enroll, recover: jest.fn() } as unknown as SocialRecoveryClient); + await expect(port.enrollDek({ address: "Addr1", credential, dek })).rejects.toThrow("enclave down"); + }); + it("asks the enclave about a wallet that already has a record", async () => { const dek = generateMasterDEK(); const enroll = jest.fn(async () => ({ diff --git a/src/recovery/EnclaveDekPort.ts b/src/recovery/EnclaveDekPort.ts index 0eb5896..71646f5 100644 --- a/src/recovery/EnclaveDekPort.ts +++ b/src/recovery/EnclaveDekPort.ts @@ -24,11 +24,17 @@ export function toEnclaveDekPort( } }, async enrollDek(params) { - const { result } = await client.enroll({ - walletAddress: params.address, - credential: params.credential, - dek: params.dek, - }); + let result; + try { + ({ result } = await client.enroll({ + walletAddress: params.address, + credential: params.credential, + dek: params.dek, + })); + } catch (error) { + if (error instanceof Error && error.message.includes("recovery_revoked")) return { kind: "opted-out" }; + throw error; + } if (result.result !== "confirmed") return { kind: "enrolled" }; // The wallet already had a record, and the enclave has now said whether // it seals this DEK. A mismatch must not lock the user out of a wallet diff --git a/src/secret/DeviceSecret.test.ts b/src/secret/DeviceSecret.test.ts index f7f5f46..481d880 100644 --- a/src/secret/DeviceSecret.test.ts +++ b/src/secret/DeviceSecret.test.ts @@ -296,4 +296,63 @@ describe("ensureEnclave", () => { }); expect(second.addresses).toEqual(first.addresses); }); + + it("finishes a login for a user who opted out of recovery", async () => { + const key = LocalDeviceUnwrapKey.generate(); + const recovery: EnclaveDekPort = { + lookupDek: async () => null, + enrollDek: async () => ({ kind: "opted-out" }), + wrapDekToFactor: async () => { + throw new Error("nothing to recover"); + }, + }; + const input = { + userId: "user-out", + appSalt: salt, + chains, + factor: factor(key), + recovery, + registry: new MemoryIndex(), + store: new MemoryStore(), + credential, + }; + const first = await ensureEnclave(input); + expect(first.kind).toBe("ready"); + const again = await ensureEnclave(input); + expect(again.addresses).toEqual(first.addresses); + }); + + it("says recovery is off when a new device finds it revoked", async () => { + const registry = new MemoryIndex(); + const first = await ensureEnclave({ + userId: "user-rev", + appSalt: salt, + chains, + factor: factor(LocalDeviceUnwrapKey.generate()), + recovery: new MemoryEnclave(), + registry, + store: new MemoryStore(), + credential, + }); + expect(first.kind).toBe("ready"); + const revoked: EnclaveDekPort = { + lookupDek: async () => null, + enrollDek: async () => ({ kind: "opted-out" }), + wrapDekToFactor: async () => { + throw new Error('kit/social-recovery: /recover -> 403 {"error":"recovery_revoked"}'); + }, + }; + await expect( + ensureEnclave({ + userId: "user-rev", + appSalt: salt, + chains, + factor: factor(LocalDeviceUnwrapKey.generate()), + recovery: revoked, + registry, + store: new MemoryStore(), + credential, + }), + ).rejects.toThrow("recovery by sign-in is turned off for this wallet"); + }); }); diff --git a/src/secret/DeviceSecret.ts b/src/secret/DeviceSecret.ts index 373569b..260c79c 100644 --- a/src/secret/DeviceSecret.ts +++ b/src/secret/DeviceSecret.ts @@ -39,7 +39,7 @@ export interface EnclaveDekPort { address: string; credential: SocialRecoveryCredential; dek: MasterDEK; - }): Promise<{ kind: "enrolled" } | { kind: "already-enrolled"; address: string }>; + }): Promise<{ kind: "enrolled" } | { kind: "already-enrolled"; address: string } | { kind: "opted-out" }>; wrapDekToFactor(params: { address: string; credential: SocialRecoveryCredential; @@ -88,6 +88,13 @@ function isMissingEnrollment(error: unknown): boolean { ); } +function isRecoveryRevoked(error: unknown): boolean { + return (error instanceof Error ? error.message : String(error)).includes("recovery_revoked"); +} + +const RECOVERY_OFF = + "kit/secret: recovery by sign-in is turned off for this wallet. Restore it with a passkey or from a device that already has it."; + export async function ensureEnclave(input: EnclaveEnsureInput): Promise { if (input.chains.length === 0) throw new Error("kit/secret: ensure needs a chain"); const key = `${input.userId}:${input.chains.join(",")}`; @@ -149,6 +156,7 @@ async function runEnsure(input: EnclaveEnsureInput): Promise { try { return await recoverAt(registered.address, false); } catch (error) { + if (isRecoveryRevoked(error)) throw new Error(RECOVERY_OFF); if (isMissingEnrollment(error)) { throw new Error( "kit/secret: this wallet has no sealed recovery. Open it once on the device that created it.", diff --git a/src/secret/erase.ts b/src/secret/erase.ts new file mode 100644 index 0000000..bde4613 --- /dev/null +++ b/src/secret/erase.ts @@ -0,0 +1,20 @@ +import { WebCryptoControlKey } from "../chains/stellar/WebCryptoControlKey"; +import { WebCryptoDeviceUnwrapKey } from "../chains/stellar/WebCryptoDeviceUnwrapKey"; +import { idbWrapStore } from "./idbWrapStore"; + +export interface EraseLocalAccountInput { + userId: string; + appSalt: string; + /** The same scope connect used; the vault passes the app id. */ + keyScope?: string; +} + +/** Removes this browser's copy of an account: its wrap, its device unwrap key and its stored control keys. */ +export async function eraseLocalAccount(input: EraseLocalAccountInput): Promise { + const scoped = (id: string) => (input.keyScope ? `${input.keyScope}|${id}` : id); + await idbWrapStore(input.keyScope).clear(input.userId); + await WebCryptoDeviceUnwrapKey.remove(scoped(`${input.userId}:${input.appSalt}`)); + for (const chain of ["solana", "stellar"]) { + await WebCryptoControlKey.remove(scoped(`${chain}:${input.userId}:${input.appSalt}`)); + } +} diff --git a/src/vault/VaultClient.ts b/src/vault/VaultClient.ts index 2133098..89387c4 100644 --- a/src/vault/VaultClient.ts +++ b/src/vault/VaultClient.ts @@ -135,6 +135,16 @@ export class VaultClient { return this.call("forget", { userId, appSalt }); } + /** The user confirms in the vault, which signs the challenge with the wallet's key. Null when this browser holds no key. */ + consent(params: VaultParams<"consent">): Promise> { + return this.call("consent", params); + } + + /** Remove this browser's keys and wraps for the account. */ + erase(userId: string, appSalt: string): Promise { + return this.call("erase", { userId, appSalt }); + } + /** Create a passkey, in the vault's own origin, that can restore the connected account on another device. */ enrollPasskey(params: VaultParams<"enrollPasskey">): Promise { return this.call("enrollPasskey", params); diff --git a/src/vault/host.test.ts b/src/vault/host.test.ts index 0cae815..e2976b4 100644 --- a/src/vault/host.test.ts +++ b/src/vault/host.test.ts @@ -9,6 +9,8 @@ import { unwrapDek } from "../secret/passkeyWrap"; import type { StoredPasskeyWrap } from "../registry/PasskeyWrapStore"; import { solanaSpendFromSeed } from "../signer/Ed25519SpendSigner"; import { prefixedMessageBytes } from "../signing"; +import { consentAction, consentMessage, type ConsentAction } from "../account/consent"; +import { bytesToBase64Url, utf8ToBytes } from "../crypto/encoding"; import { createVaultHandler, serveVault } from "./host"; import { memoryLedger } from "./ledger"; import type { OverLimit } from "./limits"; @@ -22,6 +24,8 @@ const BLOCKHASH = "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi"; const params = { network: "solana-devnet", appSalt: "salt", userId: "u1", recovery: "none" as const }; function setup(confirm: () => Promise = async () => true, overLimit: OverLimit = "ask") { + const consents: ConsentAction[] = []; + const consent = { answer: true, keyOnDevice: true }; const seed = parseEd25519Seed(randomBytes(32)); const spend = solanaSpendFromSeed(seed); const stellarKey = Keypair.random(); @@ -60,8 +64,13 @@ function setup(confirm: () => Promise = async () => true, overLimit: Ov wraps.push(entry); }, }), + consent: async (action) => { + consents.push(action); + return consent.answer; + }, readDek: async (input) => { dekReads.push(input); + if (!consent.keyOnDevice) throw new Error("kit/secret: this device does not hold the wallet key."); return parseMasterDEK(dek); }, resolveSolana: async (input) => { @@ -86,7 +95,7 @@ function setup(confirm: () => Promise = async () => true, overLimit: Ov channel.port1.close(); channel.port2.close(); }; - return { client, asked, networks, scopes, stellarKey, starknetSpend, close, dek, prf, dekReads, wraps, spend }; + return { consents, consent, client, asked, networks, scopes, stellarKey, starknetSpend, close, dek, prf, dekReads, wraps, spend }; } function solTransfer(from: string, lamports: number): Uint8Array { @@ -298,3 +307,70 @@ describe("vault passkeys", () => { close(); }); }); + +function challengeFor(action: string, kind = "end_user_consent"): string { + const payload = { kind, app_id: "app", environment_id: "env", sub: "u1", action, state: "s", issued_at: 1 }; + return `v1.${bytesToBase64Url(utf8ToBytes(JSON.stringify(payload)))}.sig`; +} + +describe("vault consent", () => { + it("refuses before a wallet is connected", async () => { + const { client, consents, close } = setup(); + await expect(client.consent({ userId: "u1", appSalt: "salt", challenge: challengeFor("delete_account") })).rejects.toThrow( + "connect a wallet first", + ); + expect(consents).toHaveLength(0); + close(); + }); + + it("refuses a challenge that is not a consent request", async () => { + const { client, close } = setup(); + await client.connectSolana(params); + await expect(client.consent({ userId: "u1", appSalt: "salt", challenge: challengeFor("delete_account", "other") })).rejects.toThrow( + "not a consent challenge", + ); + close(); + }); + + it("throws when the user cancels", async () => { + const { client, consent, close } = setup(); + await client.connectSolana(params); + consent.answer = false; + await expect(client.consent({ userId: "u1", appSalt: "salt", challenge: challengeFor("delete_account") })).rejects.toThrow( + "the user cancelled", + ); + close(); + }); + + it("answers null when this browser holds no key", async () => { + const { client, consent, consents, close } = setup(); + await client.connectSolana(params); + consent.keyOnDevice = false; + await expect( + client.consent({ userId: "u1", appSalt: "salt", challenge: challengeFor("disable_recovery") }), + ).resolves.toBeNull(); + expect(consents).toEqual(["disable_recovery"]); + close(); + }); + + it("signs the challenge with the spend key of the connected chain", async () => { + const { client, consents, close } = setup(); + await client.connectSolana(params); + const challenge = challengeFor("enable_recovery"); + const proof = await client.consent({ userId: "u1", appSalt: "salt", challenge }); + expect(consents).toEqual([consentAction(challenge)]); + expect(proof?.chain).toBe("solana"); + const publicKey = Buffer.from(proof!.public_key, "hex"); + const signature = Buffer.from(proof!.signature[0], "hex"); + expect(ed25519.verify(signature, utf8ToBytes(consentMessage(challenge)), publicKey)).toBe(true); + close(); + }); + + it("will not sign a consent message as a Solana transaction", async () => { + const { client, close } = setup(); + const { spend } = await client.connectSolana(params); + const message = utf8ToBytes(consentMessage(challengeFor("delete_account"))); + await expect(spend!.signTransaction(message)).rejects.toThrow("refusing to sign a consent message as a transaction"); + close(); + }); +}); diff --git a/src/vault/host.ts b/src/vault/host.ts index a728112..a3a3667 100644 --- a/src/vault/host.ts +++ b/src/vault/host.ts @@ -1,3 +1,4 @@ +import { consentAction, CONSENT_PREFIX, signConsent, type ConsentAction } from "../account/consent"; import type { ControlKey } from "../chains/stellar/WebCryptoControlKey"; import { STARKNET_NETWORKS, type StarknetNetwork } from "../chains/starknet/constants"; import { resolveNativeStarknet, type StarknetSpend } from "../chains/starknet/nativeConnect"; @@ -11,12 +12,13 @@ import { SocialRecoveryClient, type AttestationPolicy } from "../recovery/Social import { HttpWalletRegistry } from "../registry/HttpWalletRegistry"; import type { ResolveNativeInput } from "../secret/nativeAccount"; import { zeroize, type MasterDEK } from "../secret/dek"; +import { eraseLocalAccount } from "../secret/erase"; import { readLocalDek, type LocalDekInput } from "../secret/localDek"; import { addPasskey } from "../secret/PasskeyDekPort"; import { HttpPasskeyWrapStore, type PasskeyWrapStore } from "../registry/PasskeyWrapStore"; import type { Ed25519SpendSigner } from "../signer/Ed25519SpendSigner"; import type { PasskeyPrfProvider } from "../signer/PasskeyProvider"; -import { bigIntTo32Bytes } from "../crypto/encoding"; +import { bigIntTo32Bytes, utf8ToBytes } from "../crypto/encoding"; import { solanaNetworkName, starknetNetworkName, stellarNetworkName, stellarNetworkNameFromId } from "./network"; import { VersionedMessage } from "@solana/web3.js"; import { Signer, stark, type Signature } from "starknet"; @@ -45,6 +47,8 @@ import { type VaultRequest, } from "./protocol"; +const CHAINS = ["solana", "stellar", "starknet"] as const; + type NativeBase = Omit; export interface VaultHandlerDeps { @@ -54,6 +58,7 @@ export interface VaultHandlerDeps { policy: VaultPolicy; ledger: SpendLedger; confirm(lines: Line[], context: { network: string }): Promise; + consent(action: ConsentAction): Promise; passkey(user: { userId: string; userName: string }): PasskeyPrfProvider; passkeyWraps?: (auth: { environment?: "development" | "production"; authToken?: string | null }) => PasskeyWrapStore; readDek?: (input: LocalDekInput) => Promise; @@ -81,6 +86,7 @@ interface Account { export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultRequest) => Promise { const accounts = new Map(); + const consentPrefix = utf8ToBytes(CONSENT_PREFIX); const resolveSolana = deps.resolveSolana ?? resolveNativeSolana; const resolveStellar = deps.resolveStellar ?? resolveNativeStellar; const resolveStarknet = deps.resolveStarknet ?? resolveNativeStarknet; @@ -100,6 +106,10 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque const scope = deps.appId; const queues = new Map>(); + function connectedChain(userId: string, appSalt: string) { + return CHAINS.find((c) => accounts.get(`${c}:${userId}:${appSalt}`)?.address); + } + async function connect(params: ConnectParams): Promise { const handle = `${params.chain}:${params.userId}:${params.appSalt}`; const wraps = params.recovery === "passkey" ? passkeyWraps(params) : undefined; @@ -253,6 +263,9 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque const methods: { [M in VaultMethod]: (params: VaultParams) => Promise } = { connect, async signSolanaTransaction({ handle, message }) { + if (consentPrefix.every((byte, i) => message[i] === byte)) { + throw new Error("kit/vault: refusing to sign a consent message as a transaction"); + } const acct = account(handle); const signer = requireKey(acct.solana); const parsed = parseSolanaMessage(message, acct.address); @@ -323,10 +336,28 @@ export function createVaultHandler(deps: VaultHandlerDeps): (request: VaultReque async forget({ userId, appSalt }) { // Only lets go of the unlocked keys. Deleting stored ones is not the app's // call: without a recovery factor it would destroy the wallet. - for (const chain of ["solana", "stellar", "starknet"]) accounts.delete(`${chain}:${userId}:${appSalt}`); + for (const chain of CHAINS) accounts.delete(`${chain}:${userId}:${appSalt}`); + }, + async consent({ userId, appSalt, challenge }) { + const action = consentAction(challenge); + const chain = connectedChain(userId, appSalt); + if (!chain) throw new Error("kit/vault: connect a wallet first"); + if (!(await deps.consent(action))) throw new Error("kit/vault: the user cancelled"); + const { address } = accounts.get(`${chain}:${userId}:${appSalt}`)!; + let dek: MasterDEK; + try { + dek = await readDek({ chain, userId, appSalt, address, keyScope: scope }); + } catch { + return null; + } + return signConsent(dek, chain, appSalt, challenge); + }, + async erase({ userId, appSalt }) { + for (const chain of CHAINS) accounts.delete(`${chain}:${userId}:${appSalt}`); + await eraseLocalAccount({ userId, appSalt, keyScope: scope }); }, async enrollPasskey({ userId, appSalt, userName, environment, authToken }) { - const chain = (["solana", "stellar", "starknet"] as const).find((c) => accounts.get(`${c}:${userId}:${appSalt}`)?.address); + const chain = connectedChain(userId, appSalt); if (!chain) throw new Error("kit/vault: connect a wallet before adding a passkey"); const { address } = accounts.get(`${chain}:${userId}:${appSalt}`)!; const dek = await readDek({ chain, userId, appSalt, address, keyScope: scope }); @@ -435,6 +466,7 @@ export function startVaultHost(opts: VaultHostOptions): void { ...(opts.privacySdk ? { privacySdk: opts.privacySdk } : {}), ledger: idbLedger(), confirm: (lines, context) => modal.review(lines, context.network), + consent: (action) => modal.consent(action), passkey: (user) => modal.passkey(user), }); serveVault(port, handle); diff --git a/src/vault/modal.ts b/src/vault/modal.ts index 07eb847..4af3d38 100644 --- a/src/vault/modal.ts +++ b/src/vault/modal.ts @@ -1,3 +1,4 @@ +import type { ConsentAction } from "../account/consent"; import { PasskeyPrf } from "../chains/stellar/PasskeyPrf"; import type { PasskeyPrfProvider } from "../signer/PasskeyProvider"; import { POPUP_READY, POPUP_RESULT, type PopupIntent, type PopupResult } from "./protocol"; @@ -8,6 +9,24 @@ import { armableButton, button, el, setEnabled, sheet, type Armable, type Sheet /** Approve fills in over this long once the modal is seen, so a click aimed at something else cannot land on it. */ const ARM_MS = 700; +const CONSENT_COPY: Record = { + delete_account: { + title: "Delete your account", + body: "This removes your account from this app: your wallet registration, devices, passkey backup and recovery. Your wallet stays on the blockchain with its funds. If no other device holds its key, you will lose access to those funds for good.", + confirm: "Delete account", + }, + disable_recovery: { + title: "Turn off recovery by sign-in", + body: "Cavos will stop holding a recovery copy of your key. If you lose this device and have no passkey, you lose this wallet.", + confirm: "Turn off", + }, + enable_recovery: { + title: "Turn on recovery by sign-in", + body: "Cavos will keep a recovery copy of your key, sealed in its recovery enclave, the next time you sign in.", + confirm: "Turn on", + }, +}; + type Secret = { secret: Uint8Array; credentialId?: Uint8Array }; /** The vault's own UI, drawn inside its iframe over the embedding app. */ @@ -24,6 +43,10 @@ export class VaultModal { return this.serial(() => this.showReview(lines, network)); } + consent(action: ConsentAction): Promise { + return this.serial(() => this.showConsent(action)); + } + passkey(user: { userId: string; userName: string }): PasskeyPrfProvider { return { enroll: async () => { @@ -86,6 +109,86 @@ export class VaultModal { }); } + private async showConsent(action: ConsentAction): Promise { + const copy = CONSENT_COPY[action]; + const view = await this.open(); + view.title.textContent = copy.title; + view.body.append(el("span", {}, copy.body)); + let typed: HTMLInputElement | undefined; + if (action === "delete_account") { + typed = el("input", { + display: "block", + width: "100%", + boxSizing: "border-box", + marginTop: "16px", + padding: "11px 14px", + borderRadius: "10px", + border: "1px solid rgba(255,255,255,0.32)", + background: "rgba(255,255,255,0.08)", + color: "#FFFFFF", + fontFamily: "inherit", + fontSize: "15px", + outline: "none", + }); + typed.id = "cavos-consent-confirm"; + typed.type = "text"; + typed.autocomplete = "off"; + typed.placeholder = "Type delete to confirm"; + typed.setAttribute("aria-label", "Type delete to confirm"); + view.body.append(typed); + } + return new Promise((resolve) => { + const cancel = button("Cancel", "secondary"); + let settled = false; + let unguard = () => {}; + const finish = async (approved: boolean) => { + if (settled) return; + settled = true; + unguard(); + document.removeEventListener("keydown", onKey); + await this.dismiss(view); + resolve(approved); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") void finish(false); + }; + document.addEventListener("keydown", onKey); + cancel.onclick = () => void finish(false); + + const confirm = armableButton(copy.confirm); + view.actions.append(confirm.node, cancel); + confirm.node.onclick = () => void finish(true); + + let armed = false; + let visible = !canTrackVisibility(); + const sync = () => { + const ready = visible && (!typed || typed.value.trim().toLowerCase() === "delete"); + if (ready === armed) return; + armed = ready; + if (ready) confirm.arm(ARM_MS); + else confirm.disarm(); + }; + if (typed) typed.oninput = sync; + if (canTrackVisibility()) { + unguard = guard(view.card, { + node: confirm.node, + arm: () => { + visible = true; + armed = false; + sync(); + }, + disarm: () => { + visible = false; + armed = false; + confirm.disarm(); + }, + }); + } else { + sync(); + } + }); + } + /** `create` adds a passkey to a wallet; otherwise an existing passkey opens it on this device. */ private async showPasskey( user: { userId: string; userName: string }, diff --git a/src/vault/protocol.ts b/src/vault/protocol.ts index 2342fe4..4f84858 100644 --- a/src/vault/protocol.ts +++ b/src/vault/protocol.ts @@ -1,5 +1,6 @@ import type { Call, InvocationsSignerDetails, TypedData } from "starknet"; import type { PrivacyStatus, PrivacySubmission } from "../chains/starknet/privacy/session"; +import type { ConsentProof } from "../account/consent"; import type { SocialRecoveryCredential } from "../recovery/SocialRecoveryCredential"; export const VAULT_READY = "cavos-vault:ready"; @@ -64,6 +65,13 @@ export interface VaultMethods { paymasterApiKey: string; }): PrivacySubmission; forget(params: { userId: string; appSalt: string }): void; + /** + * Ask the user to confirm the action a server challenge names, then sign it + * with the wallet's spend key. Null when this browser holds no key. + */ + consent(params: { userId: string; appSalt: string; challenge: string }): ConsentProof | null; + /** Remove this browser's copy of the account: its keys and wraps. */ + erase(params: { userId: string; appSalt: string }): void; /** Create a passkey that can restore the connected account on another device. */ enrollPasskey(params: { userId: string; From 51a2a9d5a0fa80fee3cc48e9a95e89445563f4bf Mon Sep 17 00:00:00 2001 From: adr!an <111903096+adrianvrj@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:18:53 -0600 Subject: [PATCH 2/2] Release as 0.5.1 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1745e..fc94b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.6.0 +## 0.5.1 Needs the current Cavos backend. diff --git a/package-lock.json b/package-lock.json index d67fee8..d646846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cavos/kit", - "version": "0.6.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cavos/kit", - "version": "0.6.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@expo/config-plugins": "^57.0.0", diff --git a/package.json b/package.json index 151b8a1..2956af1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cavos/kit", - "version": "0.6.0", + "version": "0.5.1", "description": "Embedded Stellar wallet SDK, embedded Solana wallet, and embedded Starknet wallet. Device-native self-custodial accounts for React Native and web.", "repository": { "type": "git",