diff --git a/docs/domain-verification.md b/docs/domain-verification.md new file mode 100644 index 0000000..3cf1df3 --- /dev/null +++ b/docs/domain-verification.md @@ -0,0 +1,174 @@ +# Domain Verification + +Domain verification enables secure authentication for Coinbase Smart Wallet RPC requests across all platforms (web, mobile, server-side). This system uses cryptographic signatures to verify domain ownership, allowing applications to authenticate with CBSW without relying on browser APIs. + +## Key Generation + +### What is JWKS? + +JWKS (JSON Web Key Set) is an industry standard format (RFC 7517) for representing cryptographic keys in JSON. It's widely used in OAuth 2.0, OpenID Connect, and other security protocols. + +### Generate Your Key Pair + +From the root directory of the repo, run: + +```sh +yarn generate-key-script +``` + +This will create: +- `.well-known/base-jwks.json` - Your public key (host this on your domain) +- `domain-verification-private-key.txt` - Your private key (keep secure!) + +### Host Your Public Key + +Host the `base-jwks.json` file at: +``` +https://yourdomain.com/.well-known/base-jwks.json +``` + +This allows Base to verify signatures from your domain. + +### Example JWKS Output +```json +{ + "version": "1.0", + "keys": [ + { + "kty": "EC", + "crv": "secp256k1", + "x": "base64url-encoded-x-coordinate", + "y": "base64url-encoded-y-coordinate", + "use": "sig", + "kid": "base-domain-verification", + "alg": "ES256K" + } + ] +} +``` + +### JWKS Field Descriptions: +- `version`: Format version for tracking changes +- `kty`: Key type ("EC" for Elliptic Curve) +- `crv`: Curve name ("secp256k1" for Bitcoin/Ethereum curve) +- `x`, `y`: Base64URL-encoded x and y coordinates of the public key +- `use`: Key usage ("sig" for signing) +- `kid`: Key ID for identification ("base-domain-verification") +- `alg`: Algorithm ("ES256K" for ECDSA with secp256k1 and SHA-256) + +## SDK Integration + +### Basic Setup + +Add the `domainVerification` parameter to your provider configuration: + +```typescript +import { EIP1193Provider, Wallets } from '@mobile-wallet-protocol/client'; + +const provider = new EIP1193Provider({ + metadata: { + name: 'My App', + customScheme: 'myapp://', + }, + wallet: Wallets.CoinbaseSmartWallet, + domainVerification: { + domain: "www.example.com", + generateSignature: async (nonce, requestData) => { + // Developer implements signing with their private key + const dataToSign = JSON.stringify({ + domain: "www.example.com", + nonce, + requestData + }); + return await privateKey.sign(dataToSign); + } + } +}); +``` + +### Signature Implementation + +The `generateSignature` function receives: +- `nonce`: A unique identifier provided by the wallet to prevent replay attacks +- `requestData`: The RPC request being made + +You must sign the payload: `JSON.stringify({ domain, nonce, requestData })` + +### Example with secp256k1 + +```typescript +import { EIP1193Provider, Wallets } from '@mobile-wallet-protocol/client'; +import { secp256k1 } from '@noble/curves/secp256k1'; + +// Load your private key (keep this secure!) +const privateKeyBase64 = 'your-private-key-here'; +const privateKeyBytes = Buffer.from(privateKeyBase64, 'base64'); + +const provider = new EIP1193Provider({ + metadata: { + name: 'My App', + customScheme: 'myapp://', + }, + wallet: Wallets.CoinbaseSmartWallet, + domainVerification: { + domain: "www.example.com", + generateSignature: async (nonce, requestData) => { + const payload = JSON.stringify({ + domain: "www.example.com", + nonce, + requestData + }); + + // Create message hash + const messageHash = new TextEncoder().encode(payload); + + // Sign with secp256k1 + const signature = secp256k1.sign(messageHash, privateKeyBytes); + + // Return signature as hex string + return `0x${Buffer.from(signature.toCompactRawBytes()).toString('hex')}`; + } + } +}); +``` + +### Wagmi Integration + +```typescript +import { createConnectorFromWallet, Wallets } from '@mobile-wallet-protocol/wagmi-connectors'; + +const connector = createConnectorFromWallet({ + metadata: { + name: 'My App', + customScheme: 'myapp://', + }, + wallet: Wallets.CoinbaseSmartWallet, + domainVerification: { + domain: "www.example.com", + generateSignature: async (nonce, requestData) => { + // Your signature implementation + return await signWithPrivateKey(nonce, requestData); + } + } +}); +``` + +## Security Best Practices + +**Keep your private key safe!** Never share it or commit it to source control. + +- Store private keys in secure environment variables or key management systems +- Generate new key pairs periodically and update well-known files +- Limit access to private keys to authorized personnel only +- Monitor well-known file accessibility and alert on unauthorized changes +- Use HTTPS enforcement for well-known file fetches + +## How It Works + +1. **Nonce Request**: Before each RPC request, the SDK requests a nonce from the wallet +2. **Signature Generation**: Your `generateSignature` function creates a cryptographic signature +3. **Request Enhancement**: The signature is added to the RPC request +4. **Verification**: Base verifies the signature against your published public key +5. **Processing**: If verification passes, the RPC request is processed normally + +This enables secure domain verification for Base Wallet SDK integrations across all platforms. \ No newline at end of file diff --git a/package.json b/package.json index 911781e..eadd431 100644 --- a/package.json +++ b/package.json @@ -6,5 +6,8 @@ "workspaces": [ "packages/*" ], - "packageManager": "yarn@4.4.0" + "packageManager": "yarn@4.4.0", + "scripts": { + "generate-key-script": "yarn workspace @mobile-wallet-protocol/client generate-key-script --cwd \"$PWD\"" + } } diff --git a/packages/client/package.json b/packages/client/package.json index 3963a5c..0dfd05d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,7 +29,8 @@ "build": "tsc -p ./tsconfig.build.json && tsc-alias", "dev": "tsc --watch & nodemon --watch dist --delay 1 --exec tsc-alias", "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .ts,.tsx --fix" + "lint": "eslint . --ext .ts,.tsx --fix", + "generate-key-script": "ts-node scripts/generate-domain-keys.ts" }, "dependencies": { "@noble/ciphers": "^0.5.3", diff --git a/packages/client/scripts/README.md b/packages/client/scripts/README.md new file mode 100644 index 0000000..9691cad --- /dev/null +++ b/packages/client/scripts/README.md @@ -0,0 +1,23 @@ +# Scripts + +This directory contains utility scripts for the Mobile Wallet Protocol client. + +## Available Scripts + +### `generate-domain-keys.ts` + +Generates secp256k1 key pairs for Base domain verification. + +**Usage:** +```bash +yarn generate-key-script +``` + +**What it does:** +- Generates a secp256k1 key pair +- Creates a `.well-known/base-jwks.json` file in JWKS format +- Creates a `domain-verification-private-key.txt` file with the private key + +**Output:** +- `.well-known/base-jwks.json` - Public key in JWKS format for domain verification +- `domain-verification-private-key.txt` - Private key (keep secure!) \ No newline at end of file diff --git a/packages/client/scripts/generate-domain-keys.ts b/packages/client/scripts/generate-domain-keys.ts new file mode 100644 index 0000000..e4b8395 --- /dev/null +++ b/packages/client/scripts/generate-domain-keys.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { secp256k1 } from '@noble/curves/secp256k1'; +import { randomBytes } from '@noble/hashes/utils'; +import { writeFileSync } from 'fs'; +import { join } from 'path'; + +// Base64URL encoding function +function base64url(buffer: Uint8Array): string { + return Buffer.from(buffer) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function generateDomainVerificationKeys() { + // Generate a random private key + const privateKeyBytes = randomBytes(32); + const privateKey = base64url(privateKeyBytes); + + // Get the uncompressed public key from the private key + const publicKey = secp256k1.getPublicKey(privateKeyBytes, false); // false = uncompressed + + // Extract x and y coordinates from the public key + // secp256k1 uncompressed public key is 65 bytes: 1 byte prefix + 32 bytes x + 32 bytes y + const x = base64url(publicKey.slice(1, 33)); + const y = base64url(publicKey.slice(33)); + + // Create the JWK + const jwk = { + kty: 'EC', + crv: 'secp256k1', + x, + y, + use: 'sig', + kid: 'base-domain-verification', + alg: 'ES256K' + }; + + // Create the JWKS + const jwks = { + version: "1.0", + keys: [jwk] + }; + + return { jwks, privateKey }; +} + +function main() { + try { + + const { jwks, privateKey } = generateDomainVerificationKeys(); + + // Try to get the project root by going up from the current directory + let projectRoot = process.cwd(); + if (projectRoot.includes('packages/client')) { + // If we're in packages/client, go up to the project root + projectRoot = projectRoot.replace('/packages/client', ''); + } + + // Write the JWKS file in the project root directory + const jwksPath = join(projectRoot, 'base-jwks.json'); + writeFileSync(jwksPath, JSON.stringify(jwks, null, 2)); + + // Write the private key to a separate file in the project root directory + const privateKeyPath = join(projectRoot, 'domain-verification-private-key.txt'); + writeFileSync(privateKeyPath, privateKey); + } catch (error) { + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/packages/client/src/MWPClient.ts b/packages/client/src/MWPClient.ts index 3d8d980..cbacb42 100644 --- a/packages/client/src/MWPClient.ts +++ b/packages/client/src/MWPClient.ts @@ -1,4 +1,6 @@ +import { postRequestToWallet } from './components/communication/postRequestToWallet'; import { KeyManager } from './components/key/KeyManager'; +import { LIB_VERSION } from './version'; import { decryptContent, encryptContent, @@ -7,17 +9,10 @@ import { } from ':core/cipher/cipher'; import { standardErrors } from ':core/error'; import { RPCRequestMessage, RPCResponse, RPCResponseMessage } from ':core/message'; -import { AppMetadata, RequestArguments } from ':core/provider/interface'; +import { AppMetadata, DomainVerification, RequestArguments } from ':core/provider/interface'; import { ScopedAsyncStorage } from ':core/storage/ScopedAsyncStorage'; import { AddressString } from ':core/type'; import { ensureIntNumber, hexStringFromNumber } from ':core/type/util'; - -const ACCOUNTS_KEY = 'accounts'; -const ACTIVE_CHAIN_STORAGE_KEY = 'activeChain'; -const AVAILABLE_CHAINS_STORAGE_KEY = 'availableChains'; -const WALLET_CAPABILITIES_STORAGE_KEY = 'walletCapabilities'; -import { postRequestToWallet } from './components/communication/postRequestToWallet'; -import { LIB_VERSION } from './version'; import { appendMWPResponsePath, checkErrorForInvalidRequestArgs, @@ -25,6 +20,11 @@ import { } from ':core/util/utils'; import { Wallet } from ':core/wallet'; +const ACCOUNTS_KEY = 'accounts'; +const ACTIVE_CHAIN_STORAGE_KEY = 'activeChain'; +const AVAILABLE_CHAINS_STORAGE_KEY = 'availableChains'; +const WALLET_CAPABILITIES_STORAGE_KEY = 'walletCapabilities'; + type Chain = { id: number; rpcUrl?: string; @@ -33,6 +33,7 @@ type Chain = { type MWPClientOptions = { metadata: AppMetadata; wallet: Wallet; + domainVerification?: DomainVerification; }; export class MWPClient { @@ -44,7 +45,11 @@ export class MWPClient { private accounts: AddressString[]; private chain: Chain; - private constructor({ metadata, wallet }: MWPClientOptions) { + private constructor({ + metadata, + wallet, + domainVerification: _domainVerification, + }: MWPClientOptions) { this.metadata = { ...metadata, name: metadata.name || 'Dapp', @@ -52,6 +57,8 @@ export class MWPClient { }; this.wallet = wallet; + // Domain verification will be implemented in a future update + // this.domainVerification = domainVerification; this.keyManager = new KeyManager({ wallet: this.wallet }); this.storage = new ScopedAsyncStorage(this.wallet.name, 'MWPClient'); diff --git a/packages/client/src/core/provider/interface.ts b/packages/client/src/core/provider/interface.ts index 7d528da..d610fc8 100644 --- a/packages/client/src/core/provider/interface.ts +++ b/packages/client/src/core/provider/interface.ts @@ -33,6 +33,25 @@ export interface ProviderInterface extends ProviderEventEmitter { export type ProviderEventCallback = ProviderInterface['emit']; +export interface DomainVerification { + /** + * @param domain + * @type string + * @description The domain for which domain verification is enabled + * @example "www.example.com" + */ + domain: string; + /** + * @param generateSignature + * @type function + * @description Async function that generates a signature for domain verification + * @param nonce - The nonce provided by the wallet for this request + * @param requestData - The request data to be signed + * @returns Promise - The signature as a string + */ + generateSignature: (nonce: string, requestData: unknown) => Promise; +} + export interface AppMetadata { /** * @param name diff --git a/packages/client/src/interfaces/eip1193/EIP1193Provider.ts b/packages/client/src/interfaces/eip1193/EIP1193Provider.ts index 01c95f9..6126cee 100644 --- a/packages/client/src/interfaces/eip1193/EIP1193Provider.ts +++ b/packages/client/src/interfaces/eip1193/EIP1193Provider.ts @@ -3,6 +3,7 @@ import { standardErrorCodes, standardErrors } from ':core/error'; import { serializeError } from ':core/error/serialize'; import { AppMetadata, + DomainVerification, ProviderEventEmitter, ProviderInterface, RequestArguments, @@ -12,6 +13,7 @@ import { Wallet } from ':core/wallet'; type EIP1193ProviderOptions = { metadata: AppMetadata; wallet: Wallet; + domainVerification?: DomainVerification; }; export class EIP1193Provider extends ProviderEventEmitter implements ProviderInterface { diff --git a/packages/wagmi-connectors/src/createConnectorFromWallet.ts b/packages/wagmi-connectors/src/createConnectorFromWallet.ts index 947d5e2..ecacdd4 100644 --- a/packages/wagmi-connectors/src/createConnectorFromWallet.ts +++ b/packages/wagmi-connectors/src/createConnectorFromWallet.ts @@ -1,4 +1,4 @@ -import type { AppMetadata, EIP1193Provider, Wallet } from '@mobile-wallet-protocol/client'; +import type { AppMetadata, EIP1193Provider, Wallet, DomainVerification } from '@mobile-wallet-protocol/client'; import { ChainNotConfiguredError, type Connector, createConnector } from '@wagmi/core'; import type { Omit } from '@wagmi/core/internal'; import { @@ -21,6 +21,7 @@ type WagmiWallet = Wallet & { export type CreateConnectorParameters = { metadata: Omit; wallet: WagmiWallet; + domainVerification?: DomainVerification; }; export function createConnectorFromWallet(parameters: CreateConnectorParameters) { @@ -128,6 +129,7 @@ export function createConnectorFromWallet(parameters: CreateConnectorParameters) chainIds: config.chains.map((x) => x.id), }, wallet: parameters.wallet, + domainVerification: parameters.domainVerification, }); }