Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
module: "root"
bump: "minor"
title: "Add LoginUsageV2 and SignupUsageV3"
date: "2026-10-05"
---

Add `LoginUsageV2` and `SignupUsageV3`. These updated requests sign more fields, protecting your data from manipulation in transit.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ See this README in [examples](./examples/README.md) for more complete code sampl
- [API key generation](./examples/apikey/generate.go)
- [Wallets](./examples/wallets/)
- [Delegated Access](./examples/delegated_access/)
- [OTP Flows](./examples/otp/)
- [OTP Flows](examples/otp-login/)
- [Signing](./examples/signing/)

## Development
Expand Down
1 change: 1 addition & 0 deletions codegen/generators/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func canonicalizeAliasedDefs(spec *swaggerSpec, base *swaggerSpec) {
}

target := ""

for _, baseName := range sortedKeys(base.Definitions) {
if !schemasEqual(spec.Definitions[name], base.Definitions[baseName]) {
continue
Expand Down
1 change: 1 addition & 0 deletions codegen/generators/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func (g *generator) collectOperations() {

operationName = strings.TrimPrefix(operationName, "AuthProxyService_")
operationName = strings.TrimPrefix(operationName, "ExternalSignerApiService_")

if operationName == "" || strings.Contains(operationName, "NOOP") {
continue
}
Expand Down
4 changes: 2 additions & 2 deletions codegen/inputs/external_signer_api.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2170,7 +2170,7 @@
},
"clientSignature": {
"$ref": "#/definitions/v1ClientSignature",
"description": "Optional signature proving authorization for this sub-organization creation. The signature is over the verification token ID and the root user parameters for the root user associated with the verification token. Only required if a public key was provided during the verification step."
"description": "Optional signature proving authorization for this sub-organization creation. The signature is over the full intent. Only required if a public key was provided during the verification step."
}
},
"required": ["subOrganizationName", "rootUsers", "rootQuorumThreshold"]
Expand Down Expand Up @@ -6503,7 +6503,7 @@
},
"clientSignature": {
"$ref": "#/definitions/v1ClientSignature",
"description": "Required signature proving authorization for this login. The signature is over the verification token ID and the public key. Required for secure OTP login process."
"description": "Required signature proving authorization for this login. The signature is over the full login intent. Required for secure OTP login process."
},
"expirationSeconds": {
"type": "string",
Expand Down
74 changes: 72 additions & 2 deletions codegen/inputs/public_api.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -8996,7 +8996,7 @@
},
"clientSignature": {
"$ref": "#/definitions/v1ClientSignature",
"description": "Optional signature proving authorization for this sub-organization creation. The signature is over the verification token ID and the root user parameters for the root user associated with the verification token. Only required if a public key was provided during the verification step."
"description": "Optional signature proving authorization for this sub-organization creation. The signature is over the full intent. Only required if a public key was provided during the verification step."
}
},
"required": ["subOrganizationName", "rootUsers", "rootQuorumThreshold"]
Expand Down Expand Up @@ -17028,6 +17028,27 @@
},
"required": ["publicKey"]
},
"v1LoginUsageV2": {
"type": "object",
"properties": {
"organizationId": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"invalidateExisting": {
"type": "boolean"
},
"expirationSeconds": {
"type": "string"
},
"sessionProfileId": {
"type": "string"
}
},
"required": ["organizationId", "publicKey"]
},
"v1MfaPolicy": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -17624,7 +17645,7 @@
},
"clientSignature": {
"$ref": "#/definitions/v1ClientSignature",
"description": "Required signature proving authorization for this login. The signature is over the verification token ID and the public key. Required for secure OTP login process."
"description": "Required signature proving authorization for this login. The signature is over the full login intent. Required for secure OTP login process."
},
"expirationSeconds": {
"type": "string",
Expand Down Expand Up @@ -19455,6 +19476,49 @@
}
}
},
"v1SignupUsageV3": {
"type": "object",
"properties": {
"parentOrganizationId": {
"type": "string"
},
"subOrganizationName": {
"type": "string"
},
"rootUsers": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/v1RootUserParamsV5"
}
},
"rootQuorumThreshold": {
"type": "integer",
"format": "int32"
},
"wallet": {
"$ref": "#/definitions/v1WalletParams"
},
"disableEmailRecovery": {
"type": "boolean"
},
"disableEmailAuth": {
"type": "boolean"
},
"disableSmsAuth": {
"type": "boolean"
},
"disableOtpEmailAuth": {
"type": "boolean"
}
},
"required": [
"parentOrganizationId",
"subOrganizationName",
"rootUsers",
"rootQuorumThreshold"
]
},
"v1SimpleClientExtensionResults": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -20558,6 +20622,12 @@
},
"signupV2": {
"$ref": "#/definitions/v1SignupUsageV2"
},
"loginV2": {
"$ref": "#/definitions/v1LoginUsageV2"
},
"signupV3": {
"$ref": "#/definitions/v1SignupUsageV3"
}
},
"required": ["type", "tokenId"]
Expand Down
2 changes: 1 addition & 1 deletion crypto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Standalone functions for encrypting to and decrypting from Turnkey enclave bundl
See the examples directory in the root SDK for runnable code:

- [API key generation](../examples/apikey/generate.go)
- [OTP flow](../examples/otp/main.go)
- [OTP flow](../examples/otp-login/main.go)
- [Import wallet](../examples/wallets/import_wallet/main.go)
- [Export wallet](../examples/wallets/export_wallet/main.go)

Expand Down
4 changes: 3 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ Refer to each example's README for the exact commands.

- [delegated_access](./delegated_access/): Shows how to set up delegated access for a sub-organization.

- [otp](./otp/): Example of using one-time password (OTP) flows for authentication.
- [otp](otp-login/): Example of using one-time password (OTP) flows for authentication.

- [otp-signup](./otp-signup/): Creates a sub-organization through the strict OTP signup flow.

- [private_keys](./private_keys/): Shows how to manage standalone private keys.
- [import_private_key](./private_keys/import_private_key/): Example of importing a raw private key.
Expand Down
File renamed without changes.
6 changes: 3 additions & 3 deletions examples/otp/README.md → examples/otp-login/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Example: `otp`
# Example: `otp-login`

A sample script demonstrating the OTP (one-time password) email authentication flow:

Expand All @@ -21,13 +21,13 @@ Follow the [Quickstart](https://docs.turnkey.com/getting-started/quickstart) to
Copy `.env.example` to `.env` and fill in the values:

```bash
cp examples/otp/.env.example examples/otp/.env
cp examples/otp-login/.env.example examples/otp-login/.env
```

Then run from the repo root:

```bash
set -a && source examples/otp/.env && set +a && go run ./examples/otp
set -a && source examples/otp-login/.env && set +a && go run ./examples/otp-login
```

The script will send an OTP to the email address, prompt you to enter the code, and print the session JWT on success.
85 changes: 56 additions & 29 deletions examples/otp/main.go → examples/otp-login/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
Expand Down Expand Up @@ -119,40 +120,27 @@ func main() {
log.Fatal("id claim missing from verification token")
}

tokenUsage := turnkey.TokenUsage{
TokenID: claims.ID,
TypeValue: turnkey.UsageTypeLogin,
Login: &turnkey.LoginUsage{
PublicKey: clientPubHex,
},
loginRequest := turnkey.OTPLoginRequest{
OrganizationID: subOrgID,
VerificationToken: verificationToken,
PublicKey: clientPubHex,
}
tokenUsageJSON, err := json.Marshal(tokenUsage)
tokenUsageJSON, sigHex, err := marshalAndSignTokenUsage(
rand.Reader,
clientPrivKey,
tokenUsageForOTPLogin(claims.ID, loginRequest),
)
if err != nil {
log.Fatal("failed to marshal TokenUsage:", err)
log.Fatal("failed to sign TokenUsage:", err)
}

hash := sha256.Sum256(tokenUsageJSON)
r, s, err := ecdsa.Sign(rand.Reader, clientPrivKey, hash[:])
if err != nil {
log.Fatal("failed to sign message:", err)
loginRequest.ClientSignature = turnkey.ClientSignature{
Scheme: turnkey.ClientSignatureSchemeApip256,
PublicKey: clientPubHex,
Message: string(tokenUsageJSON),
Signature: sigHex,
}
rBytes, sBytes := r.Bytes(), s.Bytes()
rPadded, sPadded := make([]byte, 32), make([]byte, 32)
copy(rPadded[32-len(rBytes):], rBytes)
copy(sPadded[32-len(sBytes):], sBytes)
sigHex := fmt.Sprintf("%x%x", rPadded, sPadded)

loginResult, err := client.OTPLogin(ctx, turnkey.OTPLoginRequest{
OrganizationID: subOrgID,
VerificationToken: verificationToken,
PublicKey: clientPubHex,
ClientSignature: turnkey.ClientSignature{
Scheme: turnkey.ClientSignatureSchemeApip256,
PublicKey: clientPubHex,
Message: string(tokenUsageJSON),
Signature: sigHex,
},
})
loginResult, err := client.OTPLogin(ctx, loginRequest)
if err != nil {
log.Fatal("OTP_LOGIN failed:", err)
}
Expand All @@ -167,3 +155,42 @@ func main() {
}
fmt.Println("Session JWT signature verified.")
}

// tokenUsageForOTPLogin binds an OTP login signature to the exact request fields.
func tokenUsageForOTPLogin(tokenID string, request turnkey.OTPLoginRequest) turnkey.TokenUsage {
return turnkey.TokenUsage{
TokenID: tokenID,
TypeValue: turnkey.UsageTypeLogin,
LoginV2: &turnkey.LoginUsageV2{
ExpirationSeconds: request.ExpirationSeconds,
InvalidateExisting: request.InvalidateExisting,
OrganizationID: request.OrganizationID,
PublicKey: request.PublicKey,
SessionProfileID: request.SessionProfileID,
},
}
}

// marshalAndSignTokenUsage signs the exact JSON message with a raw P-256 r||s signature.
func marshalAndSignTokenUsage(
random io.Reader,
privateKey *ecdsa.PrivateKey,
tokenUsage turnkey.TokenUsage,
) ([]byte, string, error) {
tokenUsageJSON, err := json.Marshal(tokenUsage)
if err != nil {
return nil, "", err
}

hash := sha256.Sum256(tokenUsageJSON)
r, s, err := ecdsa.Sign(random, privateKey, hash[:])
if err != nil {
return nil, "", err
}
rBytes, sBytes := r.Bytes(), s.Bytes()
rPadded, sPadded := make([]byte, 32), make([]byte, 32)
copy(rPadded[32-len(rBytes):], rBytes)
copy(sPadded[32-len(sBytes):], sBytes)

return tokenUsageJSON, fmt.Sprintf("%x%x", rPadded, sPadded), nil
}
74 changes: 74 additions & 0 deletions examples/otp-login/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"encoding/hex"
"math/big"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

turnkey "github.com/tkhq/go-sdk/v2"
)

func TestMarshalAndSignTokenUsageUsesDeterministicRawP256Signature(t *testing.T) {
const (
finalOrganizationID = "org-final"
clientPublicKey = "client-public-key"
tokenID = "token-id"
)

privateKey := deterministicP256PrivateKey(t)
usage := tokenUsageForOTPLogin(
tokenID,
turnkey.OTPLoginRequest{
OrganizationID: finalOrganizationID,
PublicKey: clientPublicKey,
},
)
entropy := bytes.Repeat([]byte{0x42}, 64)

message, signature, err := marshalAndSignTokenUsage(
bytes.NewReader(entropy),
privateKey,
usage,
)
require.NoError(t, err)
secondMessage, secondSignature, err := marshalAndSignTokenUsage(
bytes.NewReader(entropy),
privateKey,
usage,
)
require.NoError(t, err)
assert.Equal(t, message, secondMessage)
assert.Equal(t, signature, secondSignature)

signatureBytes, err := hex.DecodeString(signature)
require.NoError(t, err)
require.Len(t, signatureBytes, 64)

hash := sha256.Sum256(message)
r := new(big.Int).SetBytes(signatureBytes[:32])
s := new(big.Int).SetBytes(signatureBytes[32:])
assert.True(
t,
ecdsa.Verify(&privateKey.PublicKey, hash[:], r, s),
"raw P-256 signature did not verify",
)
}

func deterministicP256PrivateKey(t *testing.T) *ecdsa.PrivateKey {
t.Helper()

privateKey, err := ecdsa.GenerateKey(
elliptic.P256(),
bytes.NewReader(bytes.Repeat([]byte{0x07}, 64)),
)
require.NoError(t, err)

return privateKey
}
4 changes: 4 additions & 0 deletions examples/otp-signup/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
TURNKEY_API_PRIVATE_KEY='YOUR_API_PRIVATE_KEY'
TURNKEY_ORGANIZATION_ID='YOUR_PARENT_ORGANIZATION_ID'
TURNKEY_EMAIL='YOUR_EMAIL'
TURNKEY_SUB_ORGANIZATION_NAME='OTP Signup Example'
Loading
Loading