Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 0.5.1

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
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cavos/kit",
"version": "0.5.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",
Expand Down
54 changes: 54 additions & 0 deletions src/account/AccountClient.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
await this.send("DELETE", "/api/end-users/me", { challenge, ...(proof ? { proof } : {}) });
}

private async send<T>(method: "POST" | "DELETE", path: string, body: Record<string, unknown>): Promise<T> {
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;
}
}
71 changes: 71 additions & 0 deletions src/account/consent.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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);
});
});
69 changes: 69 additions & 0 deletions src/account/consent.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,4 @@ export {
bigIntTo32Bytes,
} from "./crypto/encoding";
export { VaultClient } from "./vault/VaultClient";
export type { ConsentAction, ConsentProof } from "./account/consent";
73 changes: 73 additions & 0 deletions src/react/CavosProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;
/**
* 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<void>;
/**
* 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<void>;
logout: () => void;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1324,6 +1394,9 @@ export function CavosProvider({
setupRecovery,
recover,
submitSocialRecoveryToken,
deleteAccount,
disableEnclaveRecovery,
enableEnclaveRecovery,
logout,
};

Expand Down
Loading
Loading