Skip to content
Open

init #1214

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
160 changes: 160 additions & 0 deletions .changeset/tasty-dingos-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
"@turnkey/sdk-browser": major
"@turnkey/sdk-server": major
"@turnkey/core": major
"@turnkey/sdk-types": minor
"@turnkey/http": minor
---

## End-to-end encrypted OTP

The OTP flow is now encrypted end-to-end. OTP codes never leave the client unencrypted — they are encrypted to Turnkey's secure enclaves before submission. Login and signup now require a `clientSignature` proving possession of the session private key.

### What changed

- `InitOtp` now returns `{ otpId, otpEncryptionTargetBundle }` (previously just `{ otpId }`)
- `VerifyOtp` now takes `encryptedOtpBundle` instead of a plaintext `otpCode`
- `OtpLogin` now **requires** a `clientSignature` (previously optional)
- `SecuritySettings` gains `socialLinkingClientIds`: OAuth client IDs whitelisted for social account linking

Key lifecycle:

- `loginWithOtp` no longer deletes caller-provided keys on failure — callers retain ownership
- `verifyOtp`, `signUpWithOtp`, and `completeOtp` now track auto-generated keys separately and only clean them up on failure; caller-provided keys are never deleted
- `verifyOtp` no longer leaks auto-generated keys when verification fails

Activity type updates:

- `ACTIVITY_TYPE_INIT_OTP_V2` → `ACTIVITY_TYPE_INIT_OTP_V3`
- `ACTIVITY_TYPE_VERIFY_OTP` → `ACTIVITY_TYPE_VERIFY_OTP_V2`
- `ACTIVITY_TYPE_OTP_LOGIN` → `ACTIVITY_TYPE_OTP_LOGIN_V2`

---

## Migration Guide

### If you use `@turnkey/react-wallet-kit` or `@turnkey/sdk-react`

The `Auth` component handles the encrypted OTP flow internally — **no code changes needed** for most users.

If you use the `OtpVerification` component directly, it now requires an `otpEncryptionTargetBundle` prop:

```tsx
// Before
<OtpVerification
type={OtpType.Email}
contact={email}
otpId={otpId}
onValidateSuccess={handleSuccess}
onResendCode={handleResend}
/>

// After
<OtpVerification
type={OtpType.Email}
contact={email}
otpId={otpId}
otpEncryptionTargetBundle={otpEncryptionTargetBundle}
onValidateSuccess={handleSuccess}
onResendCode={handleResend}
/>
```

The `otpEncryptionTargetBundle` comes from the `sendOtp()` response alongside `otpId`.

### If you use `@turnkey/core` directly

The high-level `completeOtp()` method handles encryption and signing internally. If you call `verifyOtp()` / `loginWithOtp()` / `signUpWithOtp()` individually, here's the new flow:

#### Step 1: Init OTP (response shape changed)

```typescript
// Before
const { otpId } = await client.initOtp({ otpType, contact });

// After — response now includes the encryption target bundle
const { otpId, otpEncryptionTargetBundle } = await client.initOtp({
otpType,
contact,
});
```

#### Step 2: Encrypt & verify OTP (replaces plaintext submission)

```typescript
import { encryptOtpCode } from "@turnkey/core";

// encryptOtpCode handles HPKE encryption, snake_case field formatting,
// and verifies the bundle's enclave signature before trusting targetPublic.
const encryptedOtpBundle = await encryptOtpCode(
otpCode,
otpEncryptionTargetBundle,
publicKey,
);

// Before
const { verificationToken } = await client.verifyOtp({
otpId,
otpCode: "123456",
});

// After
const { verificationToken } = await client.verifyOtp({
otpId,
encryptedOtpBundle,
});
```

#### Step 3: Login (client signature is built internally)

```typescript
// Before
await client.loginWithOtp({ verificationToken, publicKey });

// After — loginWithOtp now builds the clientSignature internally
// using the verification token key for signing. Pass the same publicKey
// that was encrypted into the OTP bundle during verifyOtp.
await client.loginWithOtp({ verificationToken, publicKey });
```

If `publicKey` is omitted, `loginWithOtp` reuses the verification token key as the session key.

### If you use `@turnkey/sdk-server`

Server-side types have been updated to match the new encrypted flow:

```typescript
// sendOtp response now includes the encryption target bundle
const { otpId, otpEncryptionTargetBundle } = await server.sendOtp({
appName: "My App",
otpType: OtpType.Email,
contact: email,
userIdentifier: publicKey,
});
// Pass otpEncryptionTargetBundle to your client for encryption

// verifyOtp now takes encryptedOtpBundle instead of otpCode
// Before
await server.verifyOtp({ otpId, otpCode: "123456" });
// After
await server.verifyOtp({ otpId, encryptedOtpBundle });

// otpLogin now requires clientSignature
// Before
await server.otpLogin({ suborgID, verificationToken, publicKey });
// After
await server.otpLogin({
suborgID,
verificationToken,
publicKey,
clientSignature,
});
```

### If you use `@turnkey/sdk-browser`

The generated client methods now use V2/V3 activity types. The input shapes mirror the changes above — `encryptedOtpBundle` replaces `otpCode` in `verifyOtp`, and `clientSignature` is required for `otpLogin`.

### `@turnkey/sdk-types` and `@turnkey/http` (minor)

Updated generated types to include the new activity types and request/response shapes. No breaking changes — new types are additive.
4 changes: 3 additions & 1 deletion examples/otp-auth/with-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "1.4.0",
"@solana/web3.js": "1.95.8",
"@tailwindcss/postcss": "4.1.13",
"@turnkey/crypto": "workspace:*",
"@turnkey/encoding": "workspace:*",
"@turnkey/react-wallet-kit": "workspace:*",
"@turnkey/sdk-server": "workspace:*",
"@turnkey/encoding": "workspace:*",
"@types/node": "20.3.1",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
Expand Down
92 changes: 79 additions & 13 deletions examples/otp-auth/with-backend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTurnkey, AuthState } from "@turnkey/react-wallet-kit";
import {
useTurnkey,
AuthState,
getClientSignatureMessageForLogin,
} from "@turnkey/react-wallet-kit";
import { generateP256KeyPair, encryptToEnclave } from "@turnkey/crypto";
import {
uint8ArrayFromHexString,
uint8ArrayToHexString,
} from "@turnkey/encoding";
import { p256 } from "@noble/curves/p256";
import { sha256 } from "@noble/hashes/sha256";
import {
getSuborgsAction,
createSuborgAction,
Expand All @@ -21,8 +32,14 @@ export default function AuthPage() {
const [err, setErr] = useState<string | null>(null);
const [working, setWorking] = useState<string | null>(null);

// The exact session public key tied to this OTP attempt
const pubKeyRef = useRef<string | null>(null);
// The key pair for this OTP attempt (need private key for client signature)
const keyPairRef = useRef<{
publicKey: string;
privateKey: string;
} | null>(null);

// The encryption target bundle from initOtp, needed to encrypt the OTP code
const encryptionTargetRef = useRef<string | null>(null);

const { storeSession, createApiKeyPair, authState } = useTurnkey();

Expand All @@ -41,9 +58,17 @@ export default function AuthPage() {

setWorking("Preparing…");

// 1) Create a fresh session key for this OTP attempt
const publicKey = await createApiKeyPair();
pubKeyRef.current = publicKey;
// 1) Generate a fresh P256 key pair for this OTP attempt.
// We need the private key later to build the client signature,
// so we generate it ourselves and register it with the stamper.
const keyPair = generateP256KeyPair();
const publicKey = await createApiKeyPair({
externalKeyPair: {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
},
});
keyPairRef.current = { publicKey, privateKey: keyPair.privateKey };

// reset any prior OTP attempt UI state
setOtpCode("");
Expand All @@ -53,7 +78,11 @@ export default function AuthPage() {

// 2) Kick off OTP with backend, binds this attempt to the current session key
// Note: publicKey here is used (optionally) for rate limiting SMS OTP requests per user
const { otpId } = await initOtpAction({ email: trimmed, publicKey });
const { otpId, otpEncryptionTargetBundle } = await initOtpAction({
email: trimmed,
publicKey,
});
encryptionTargetRef.current = otpEncryptionTargetBundle;
setOtpId(otpId);
} catch (e: any) {
console.error(e);
Expand All @@ -68,15 +97,30 @@ export default function AuthPage() {
setErr(null);
if (!otpId) throw new Error("No OTP in progress.");
if (!otpCode) throw new Error("Enter the code from your email.");
if (!pubKeyRef.current)
if (!keyPairRef.current || !encryptionTargetRef.current)
throw new Error("Session key missing. Please resend the code.");

setWorking("Verifying…");

// 1) Verify the code first (prevents suborg spaming with unverified emails)
// 1) Encrypt the OTP code to the enclave's target key, then verify.
// The OTP code never leaves the client unencrypted.
const targetBundle = JSON.parse(encryptionTargetRef.current);
const targetData = JSON.parse(
new TextDecoder().decode(uint8ArrayFromHexString(targetBundle.data)),
);
const payload = JSON.stringify({
otpCode: otpCode.trim(),
publicKey: keyPairRef.current.publicKey,
});
const encrypted = await encryptToEnclave(
targetData.targetPublic,
payload,
);
const encryptedOtpBundle = uint8ArrayToHexString(encrypted);

const { verificationToken } = await verifyOtpAction({
otpId,
otpCode: otpCode.trim(),
encryptedOtpBundle,
});

// 2) Find or create suborg for this email
Expand All @@ -87,14 +131,36 @@ export default function AuthPage() {
suborgId = created.subOrganizationId;
}

// 3) Complete login using the same public key generated before initOtp
// 3) Build the client signature proving we hold the private key
const { publicKey, privateKey } = keyPairRef.current;
const { message, publicKey: signingPublicKey } =
getClientSignatureMessageForLogin({
verificationToken,
sessionPublicKey: publicKey,
});

const messageHash = sha256(new TextEncoder().encode(message));
const signature = p256.sign(
messageHash,
uint8ArrayFromHexString(privateKey),
);

const clientSignature = {
scheme: "CLIENT_SIGNATURE_SCHEME_API_P256" as const,
publicKey: signingPublicKey,
message,
signature: signature.toCompactHex(),
};

// 4) Complete login with the client signature
const { session } = await otpLoginAction({
suborgID: suborgId!,
verificationToken,
publicKey: pubKeyRef.current!,
publicKey,
clientSignature,
});

// 4) Store session & go
// 5) Store session & go
await storeSession({ sessionToken: session });
router.replace("/dashboard");
} catch (e: any) {
Expand Down
17 changes: 14 additions & 3 deletions examples/otp-auth/with-backend/src/server/actions/turnkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,24 @@ export async function initOtpAction(params: {
userIdentifier: params.publicKey,
});
if (!res.otpId) throw new Error("Expected non-null otpId from initOtp");
return { otpId: res.otpId };
return {
otpId: res.otpId,
otpEncryptionTargetBundle: res.otpEncryptionTargetBundle,
};
}

/**
* Step 2: Verify OTP code → returns verificationToken
* - The client encrypts the OTP code into a bundle using the target key from initOtp
* - Do this before any suborg lookup/creation to prevent org spamming
*/
export async function verifyOtpAction(params: {
otpId: string;
otpCode: string;
encryptedOtpBundle: string;
}) {
const res = await turnkey.apiClient().verifyOtp({
otpId: params.otpId,
otpCode: params.otpCode,
encryptedOtpBundle: params.encryptedOtpBundle,
});
if (!res.verificationToken) {
throw new Error("Missing verificationToken from verifyOtp");
Expand All @@ -88,11 +92,18 @@ export async function otpLoginAction(params: {
suborgID: string;
verificationToken: string;
publicKey: string;
clientSignature: {
publicKey: string;
scheme: "CLIENT_SIGNATURE_SCHEME_API_P256";
message: string;
signature: string;
};
}) {
const res = await turnkey.apiClient().otpLogin({
organizationId: params.suborgID || process.env.NEXT_PUBLIC_ORGANIZATION_ID!,
verificationToken: params.verificationToken,
publicKey: params.publicKey,
clientSignature: params.clientSignature,
});
if (!res.session) throw new Error("No session returned from otpLogin");
return { session: res.session };
Expand Down
Loading
Loading