diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f3f78ff..90bc31c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,11 +5,96 @@ on: branches: [main] jobs: + # ── Job 1: Build & sign the Swift binary on macOS ────────────── + build-swift: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install Apple certificate + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 + echo -n "$APPLE_CERTIFICATE_P12" | base64 --decode -o $CERTIFICATE_PATH + + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -hex 24) + + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + security import $CERTIFICATE_PATH \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k $KEYCHAIN_PATH + + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" \ + $KEYCHAIN_PATH + + security list-keychains -d user -s $KEYCHAIN_PATH $(security list-keychains -d user | tr -d '"') + + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV + + - name: Install provisioning profile + env: + APPLE_PROVISIONING_PROFILE: ${{ secrets.APPLE_PROVISIONING_PROFILE }} + run: | + PROFILE_PATH=$RUNNER_TEMP/embedded.provisionprofile + echo -n "$APPLE_PROVISIONING_PROFILE" | base64 --decode -o $PROFILE_PATH + echo "PROVISIONING_PROFILE=$PROFILE_PATH" >> $GITHUB_ENV + + - name: Build and sign Swift binary + env: + SIGN_IDENTITY: "Developer ID Application: HyperPlay Labs Inc (RTGU82X53W)" + run: | + mkdir -p dist + bash swift/SecureEnclaveSigner/build.sh + + - name: Verify code signature + run: | + codesign --verify --deep --strict dist/secure-enclave-signer.app + codesign -dvv dist/secure-enclave-signer.app + + - name: Notarize + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + ditto -c -k --keepParent dist/secure-enclave-signer.app \ + $RUNNER_TEMP/secure-enclave-signer.zip + + xcrun notarytool submit $RUNNER_TEMP/secure-enclave-signer.zip \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + xcrun stapler staple dist/secure-enclave-signer.app + + - name: Upload signed binary + uses: actions/upload-artifact@v4 + with: + name: signed-swift-binary + path: dist/secure-enclave-signer.app + retention-days: 1 + + - name: Cleanup keychain + if: always() + run: security delete-keychain $KEYCHAIN_PATH 2>/dev/null || true + + # ── Job 2: Build JS + publish npm package ────────────────────── publish: + needs: build-swift runs-on: ubuntu-latest permissions: contents: read - id-token: write # enables npm provenance + id-token: write steps: - uses: actions/checkout@v4 @@ -30,8 +115,19 @@ jobs: - name: Typecheck run: pnpm codecheck - - name: Build - run: pnpm build + - name: Build JS bundle + run: pnpm exec vite build + + - name: Download signed binary + uses: actions/download-artifact@v4 + with: + name: signed-swift-binary + path: dist/secure-enclave-signer.app + + - name: Create binary symlink + run: | + ln -sf secure-enclave-signer.app/Contents/MacOS/secure-enclave-signer \ + dist/secure-enclave-signer - name: Check if version is already published id: version_check diff --git a/.gitignore b/.gitignore index 3b5be4a..eaf4157 100644 --- a/.gitignore +++ b/.gitignore @@ -140,4 +140,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -dist \ No newline at end of file +dist + +swift/SecureEnclaveSigner/.build +**/.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ca573d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:agent-cli}/swift/SecureEnclaveSigner", + "name": "Debug SecureEnclaveSigner (swift/SecureEnclaveSigner)", + "target": "SecureEnclaveSigner", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug SecureEnclaveSigner (swift/SecureEnclaveSigner)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:agent-cli}/swift/SecureEnclaveSigner", + "name": "Release SecureEnclaveSigner (swift/SecureEnclaveSigner)", + "target": "SecureEnclaveSigner", + "configuration": "release", + "preLaunchTask": "swift: Build Release SecureEnclaveSigner (swift/SecureEnclaveSigner)" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index ba49df7..f59cb82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coinfello/agent-cli", - "version": "0.1.3", + "version": "0.1.4", "description": "", "type": "module", "main": "dist/index.js", @@ -14,8 +14,9 @@ "access": "public" }, "scripts": { - "build": "vite build", + "build": "vite build && pnpm run build:swift", "build:swift": "bash swift/SecureEnclaveSigner/build.sh", + "build:signed": "vite build && SIGN_IDENTITY=\"Apple Development: Brett Cleary (WF45KCA35Q)\" PROVISIONING_PROFILE=\"$HOME/Agent_Cli_Dev_Profile_1.provisionprofile\" pnpm run build:swift", "codecheck": "tsc --noEmit", "lint": "eslint src", "prettier-fix": "prettier --write src coinfello", @@ -34,6 +35,8 @@ "viem": "^2.45.1" }, "devDependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "@types/node": "^25.2.1", "dotenv": "^17.3.1", "eslint": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c84d64f..4fda479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: specifier: ^2.45.1 version: 2.45.1(typescript@5.9.3) devDependencies: + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 '@types/node': specifier: ^25.2.1 version: 25.2.1 @@ -324,6 +330,10 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -332,6 +342,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1344,10 +1358,16 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/hashes@1.4.0': {} '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true diff --git a/src/account.ts b/src/account.ts index 13f75ef..781f94a 100644 --- a/src/account.ts +++ b/src/account.ts @@ -7,9 +7,11 @@ import { type CreateDelegationOptions, } from '@metamask/smart-accounts-kit' import { PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts' +import { toWebAuthnAccount } from 'viem/account-abstraction' import { createPublicClient, http, type Hex, type Chain } from 'viem' import * as chains from 'viem/chains' import { randomBytes } from 'node:crypto' +import { generateKey, createSecureEnclaveGetFn } from './secure-enclave/index.js' export type HybridSmartAccount = ToMetaMaskSmartAccountReturnType export type DelegationScope = CreateDelegationOptions['scope'] @@ -94,3 +96,105 @@ export function createSubdelegation({ salt: `0x${randomBytes(32).toString('hex')}` as Hex, }) } + +// ── Secure Enclave P256 account functions ──────────────────────── + +export async function createSmartAccountWithSecureEnclave(chainInput: string | number): Promise<{ + smartAccount: HybridSmartAccount + address: string + keyTag: string + publicKeyX: string + publicKeyY: string + keyId: Hex +}> { + const chain = resolveChainInput(chainInput) + const publicClient = createPublicClient({ chain, transport: http() }) + + // Generate P256 key in Secure Enclave + const keyPair = await generateKey() + + // On-chain key identifier + const keyId = `0x${randomBytes(32).toString('hex')}` as Hex + + // Encode uncompressed public key: 0x04 || x (32 bytes) || y (32 bytes) + const xHex = keyPair.x.toString(16).padStart(64, '0') + const yHex = keyPair.y.toString(16).padStart(64, '0') + const publicKeyHex = `0x04${xHex}${yHex}` as Hex + const credentialId = Buffer.from(keyPair.tag).toString('base64url') + + const webAuthnAccount = toWebAuthnAccount({ + credential: { + id: credentialId, + publicKey: publicKeyHex, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getFn: createSecureEnclaveGetFn(keyPair.tag) as any, + rpId: 'localhost', + }) + + const smartAccount = await toMetaMaskSmartAccount({ + client: publicClient, + implementation: Implementation.Hybrid, + deployParams: [ + '0x0000000000000000000000000000000000000000' as Hex, + [keyId], + [keyPair.x], + [keyPair.y], + ], + deploySalt: '0x', + signer: { webAuthnAccount, keyId }, + }) + + const address = await smartAccount.getAddress() + + return { + smartAccount, + address, + keyTag: keyPair.tag, + publicKeyX: `0x${xHex}`, + publicKeyY: `0x${yHex}`, + keyId, + } +} + +export async function getSmartAccountFromSecureEnclave( + keyTag: string, + publicKeyX: string, + publicKeyY: string, + keyId: Hex, + chainInput: string | number +): Promise { + const chain = resolveChainInput(chainInput) + const publicClient = createPublicClient({ chain, transport: http() }) + + const xBigInt = BigInt(publicKeyX) + const yBigInt = BigInt(publicKeyY) + + const xHex = xBigInt.toString(16).padStart(64, '0') + const yHex = yBigInt.toString(16).padStart(64, '0') + const publicKeyHex = `0x04${xHex}${yHex}` as Hex + const credentialId = Buffer.from(keyTag).toString('base64url') + + const webAuthnAccount = toWebAuthnAccount({ + credential: { + id: credentialId, + publicKey: publicKeyHex, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getFn: createSecureEnclaveGetFn(keyTag) as any, + rpId: 'localhost', + }) + + return toMetaMaskSmartAccount({ + client: publicClient, + implementation: Implementation.Hybrid, + deployParams: [ + '0x0000000000000000000000000000000000000000' as Hex, + [keyId], + [xBigInt], + [yBigInt], + ], + deploySalt: '0x', + signer: { webAuthnAccount, keyId }, + }) +} diff --git a/src/config.ts b/src/config.ts index 12d285b..ec9afd3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,13 @@ export interface Config { chain?: string delegation?: Delegation session_token?: string + signer_type?: 'privateKey' | 'secureEnclave' + secure_enclave?: { + key_tag: string + public_key_x: string // hex + public_key_y: string // hex + key_id: string // hex, on-chain P256 key identifier + } } const CONFIG_DIR = join(homedir(), '.clawdbot', 'skills', 'coinfello') diff --git a/src/index.ts b/src/index.ts index 87fd5ca..f51d4ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,11 @@ import { Command } from 'commander' import { createSmartAccount, getSmartAccount, + createSmartAccountWithSecureEnclave, + getSmartAccountFromSecureEnclave, createSubdelegation, resolveChainInput, + type HybridSmartAccount, } from './account.js' import { loadConfig, saveConfig, CONFIG_PATH } from './config.js' import { @@ -20,6 +23,7 @@ import { createPublicClient, http, serializeErc6492Signature, type Hex } from 'v import { generatePrivateKey } from 'viem/accounts' import type { Delegation } from '@metamask/smart-accounts-kit' import { SignedSubdelegation } from './types.js' +import { isSecureEnclaveAvailable } from './secure-enclave/index.js' const program = new Command() @@ -33,26 +37,80 @@ program .command('create_account') .description('Create a MetaMask smart account and save its address to local config') .argument('', 'Chain name (e.g. sepolia, mainnet, polygon, arbitrum)') - .action(async (chain: string) => { - try { - console.log(`Creating smart account on ${chain}...`) - const privateKey = generatePrivateKey() - const { address } = await createSmartAccount(privateKey, chain) + .option( + '--use-unsafe-private-key', + 'Use a raw private key instead of hardware-backed key (Secure Enclave / TPM 2.0)' + ) + .option('--delete-existing-private-key', 'Delete the existing account and create a new one') + .action( + async ( + chain: string, + opts: { useUnsafePrivateKey?: boolean; deleteExistingPrivateKey?: boolean } + ) => { + try { + const config = await loadConfig() + if (config.smart_account_address) { + if (!opts.deleteExistingPrivateKey) { + console.error( + `Error: An account already exists (${config.smart_account_address}). ` + + 'Use --delete-existing-private-key to overwrite it.' + ) + process.exit(1) + } + console.warn('Deleting existing account and creating a new one...') + } - const config = await loadConfig() - config.private_key = privateKey - config.smart_account_address = address - config.chain = chain - await saveConfig(config) + const useHardwareKey = !opts.useUnsafePrivateKey && isSecureEnclaveAvailable() - console.log('Smart account created successfully.') - console.log(`Address: ${address}`) - console.log(`Config saved to: ${CONFIG_PATH}`) - } catch (err) { - console.error(`Failed to create account: ${(err as Error).message}`) - process.exit(1) + if (useHardwareKey) { + console.log(`Creating Secure Enclave-backed smart account on ${chain}...`) + const { address, keyTag, publicKeyX, publicKeyY, keyId } = + await createSmartAccountWithSecureEnclave(chain) + + const config = await loadConfig() + config.signer_type = 'secureEnclave' + config.smart_account_address = address + config.chain = chain + config.secure_enclave = { + key_tag: keyTag, + public_key_x: publicKeyX, + public_key_y: publicKeyY, + key_id: keyId, + } + delete config.private_key + await saveConfig(config) + + console.log('Secure Enclave smart account created successfully.') + console.log(`Address: ${address}`) + console.log(`Key tag: ${keyTag}`) + console.log(`Config saved to: ${CONFIG_PATH}`) + } else { + if (!opts.useUnsafePrivateKey) { + console.warn( + 'Warning: No hardware key support detected. Falling back to raw private key.' + ) + } + console.log(`Creating smart account on ${chain}...`) + const privateKey = generatePrivateKey() + const { address } = await createSmartAccount(privateKey, chain) + + const config = await loadConfig() + config.private_key = privateKey + config.signer_type = 'privateKey' + config.smart_account_address = address + config.chain = chain + await saveConfig(config) + + console.log('Smart account created successfully.') + console.log(`Address: ${address}`) + console.log(`Config saved to: ${CONFIG_PATH}`) + } + } catch (err) { + console.error(`Failed to create account: ${(err as Error).message}`) + process.exit(1) + } } - }) + ) // ── get_account ───────────────────────────────────────────────── program @@ -125,10 +183,6 @@ program .action(async (prompt: string) => { try { const config = await loadConfig() - if (!config.private_key) { - console.error("Error: No private key found in config. Run 'create_account' first.") - process.exit(1) - } if (!config.smart_account_address) { console.error("Error: No smart account found. Run 'create_account' first.") process.exit(1) @@ -137,6 +191,10 @@ program console.error("Error: No chain found in config. Run 'create_account' first.") process.exit(1) } + if (config.signer_type !== 'secureEnclave' && !config.private_key) { + console.error("Error: No private key found in config. Run 'create_account' first.") + process.exit(1) + } // Load persisted session token into cookie jar if (config.session_token) { @@ -183,7 +241,22 @@ program // 5. Rebuild smart account using chainId from tool call console.log('Loading smart account...') - const smartAccount = await getSmartAccount(config.private_key as Hex, args.chainId) + let smartAccount: HybridSmartAccount + if (config.signer_type === 'secureEnclave') { + if (!config.secure_enclave) { + console.error("Error: Secure Enclave config missing. Run 'create_account' first.") + process.exit(1) + } + smartAccount = await getSmartAccountFromSecureEnclave( + config.secure_enclave.key_tag, + config.secure_enclave.public_key_x, + config.secure_enclave.public_key_y, + config.secure_enclave.key_id as Hex, + args.chainId + ) + } else { + smartAccount = await getSmartAccount(config.private_key as Hex, args.chainId) + } // 6. Parse scope and create subdelegation const scope = parseScope(args.scope) diff --git a/src/secure-enclave/bridge.test.ts b/src/secure-enclave/bridge.test.ts new file mode 100644 index 0000000..d32cd18 --- /dev/null +++ b/src/secure-enclave/bridge.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { p256 } from '@noble/curves/nist.js' +import { + isSecureEnclaveBinaryAvailable, + generateKey, + signPayload, + type SecureEnclaveKeyPair, +} from './bridge.js' + +// Skip entire suite on non-macOS (Secure Enclave is macOS-only) +describe.skipIf(process.platform !== 'darwin')('Secure Enclave Bridge Integration', () => { + beforeAll(async () => { + const binaryAvailable = await isSecureEnclaveBinaryAvailable() + if (!binaryAvailable) { + throw new Error('Secure Enclave binary not found. Run `npm run build:swift` first.') + } + }) + + it('signs 0xdeadbeef and verifies signature against public key', async (ctx) => { + // Persistent SE keys require a Developer ID-signed binary (Team ID entitlement). + // Skip gracefully in dev environments without proper code signing. + let keyPair: SecureEnclaveKeyPair + try { + keyPair = await generateKey() + } catch { + ctx.skip() + return + } + + const { derSignature } = await signPayload(keyPair.tag, '0xdeadbeef' as `0x${string}`) + expect(derSignature).toMatch(/^0x[0-9a-f]+$/i) + + // p256.verify applies SHA-256 internally (matching Swift's ecdsaSignatureMessageX962SHA256), + // so pass the raw payload bytes — not a pre-computed hash. + const rawPayloadBytes = Uint8Array.from(Buffer.from('deadbeef', 'hex')) + const sigBytes = Uint8Array.from(Buffer.from(derSignature.slice(2), 'hex')) + + // Build uncompressed public key bytes: 04 || x (32 bytes) || y (32 bytes) + const xHex = keyPair.x.toString(16).padStart(64, '0') + const yHex = keyPair.y.toString(16).padStart(64, '0') + const pubKeyBytes = Uint8Array.from(Buffer.from('04' + xHex + yHex, 'hex')) + + // Verify (Swift produces DER-encoded signatures) + const isValid = p256.verify(sigBytes, rawPayloadBytes, pubKeyBytes, { format: 'der' }) + expect(isValid).toBe(true) + }) +}) diff --git a/src/secure-enclave/bridge.ts b/src/secure-enclave/bridge.ts new file mode 100644 index 0000000..40a4e8a --- /dev/null +++ b/src/secure-enclave/bridge.ts @@ -0,0 +1,93 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { platform } from 'node:os' +import { access, constants } from 'node:fs/promises' +import type { Hex } from 'viem' + +const execFileAsync = promisify(execFile) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +export interface SecureEnclaveKeyPair { + tag: string + x: bigint + y: bigint +} + +export interface SecureEnclaveSignature { + derSignature: Hex +} + +function getBinaryPath(): string { + // The binary lives inside a .app bundle so macOS AMFI can find the + // embedded provisioning profile for keychain-access-groups entitlements. + // When running from dist/index.js, __dirname is the dist/ directory. + return join(__dirname, 'secure-enclave-signer.app', 'Contents', 'MacOS', 'secure-enclave-signer') +} + +export function isSecureEnclaveAvailable(): boolean { + return platform() === 'darwin' +} + +export async function isSecureEnclaveBinaryAvailable(): Promise { + if (!isSecureEnclaveAvailable()) return false + try { + await access(getBinaryPath(), constants.X_OK) + return true + } catch { + return false + } +} + +async function runCommand(args: string[]): Promise> { + const binaryPath = getBinaryPath() + + try { + const { stdout } = await execFileAsync(binaryPath, args, { + timeout: 30_000, + }) + return JSON.parse(stdout.trim()) as Record + } catch (err: unknown) { + const error = err as { stderr?: string; message?: string } + if (error.stderr) { + try { + const parsed = JSON.parse(error.stderr.trim()) as { error: string; message: string } + throw new Error(`SecureEnclave [${parsed.error}]: ${parsed.message}`) + } catch (parseErr) { + if (parseErr instanceof SyntaxError) { + throw new Error(`SecureEnclave error: ${error.stderr}`) + } + throw parseErr + } + } + throw new Error(`SecureEnclave command failed: ${error.message ?? 'Unknown error'}`) + } +} + +export async function generateKey(): Promise { + const result = await runCommand(['generate']) + return { + tag: result.tag, + x: BigInt(`0x${result.x}`), + y: BigInt(`0x${result.y}`), + } +} + +export async function signPayload(tag: string, payload: Hex): Promise { + const hex = payload.startsWith('0x') ? payload.slice(2) : payload + const result = await runCommand(['sign', '--tag', tag, '--payload', hex]) + return { + derSignature: `0x${result.signature}` as Hex, + } +} + +export async function getPublicKey(tag: string): Promise<{ x: bigint; y: bigint }> { + const result = await runCommand(['get-public-key', '--tag', tag]) + return { + x: BigInt(`0x${result.x}`), + y: BigInt(`0x${result.y}`), + } +} diff --git a/src/secure-enclave/getFn.ts b/src/secure-enclave/getFn.ts new file mode 100644 index 0000000..0121539 --- /dev/null +++ b/src/secure-enclave/getFn.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import { signPayload } from './bridge.js' +import type { Hex } from 'viem' + +const RPID = 'localhost' +const ORIGIN = 'https://localhost' + +function sha256(data: Buffer): Buffer { + return createHash('sha256').update(data).digest() +} + +function toBase64Url(buf: Buffer): string { + return buf.toString('base64url') +} + +/** + * Creates a custom `getFn` for `toWebAuthnAccount` that signs using the macOS + * Secure Enclave instead of browser WebAuthn APIs. + * + * The flow: + * 1. ox's `WebAuthnP256.sign` calls this `getFn` with credential request options + * 2. We construct authenticatorData and clientDataJSON manually + * 3. We pass `authenticatorData || sha256(clientDataJSON)` to the Secure Enclave + * which applies SHA-256 internally (ecdsaSignatureMessageX962SHA256) + * 4. We return a synthetic PublicKeyCredential that ox can parse + */ +export function createSecureEnclaveGetFn(keyTag: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async function getFn(options?: any): Promise { + // 1. Extract challenge from credential request options + const challengeBuffer = options?.publicKey?.challenge + if (!challengeBuffer) { + throw new Error('No challenge in credential request options') + } + const challenge = Buffer.from(challengeBuffer) + const challengeBase64Url = toBase64Url(challenge) + + // 2. Construct authenticatorData + // Format: rpIdHash (32 bytes) || flags (1 byte) || signCount (4 bytes) + // flags: 0x05 = UP (bit 0) + UV (bit 2) + const rpIdHash = sha256(Buffer.from(RPID, 'utf-8')) + const flags = Buffer.from([0x05]) + const signCount = Buffer.alloc(4) // 0x00000000 + const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]) + + // 3. Construct clientDataJSON + const clientDataObj = { + type: 'webauthn.get', + challenge: challengeBase64Url, + origin: ORIGIN, + crossOrigin: false, + } + const clientDataJSON = JSON.stringify(clientDataObj) + const clientDataBuffer = Buffer.from(clientDataJSON, 'utf-8') + + // 4. Build signing payload: authenticatorData || sha256(clientDataJSON) + // The Secure Enclave uses ecdsaSignatureMessageX962SHA256 which will + // SHA-256 this payload internally, producing: + // sha256(authenticatorData || sha256(clientDataJSON)) + // This matches what the on-chain P256 verifier expects. + const clientDataHash = sha256(clientDataBuffer) + const payload = Buffer.concat([authenticatorData, clientDataHash]) + const payloadHex = `0x${payload.toString('hex')}` as Hex + + // 5. Sign with Secure Enclave + const { derSignature } = await signPayload(keyTag, payloadHex) + + // 6. Convert DER signature to raw bytes + const sigHex = derSignature.startsWith('0x') ? derSignature.slice(2) : derSignature + const signatureBuffer = Buffer.from(sigHex, 'hex') + + // 7. Return synthetic PublicKeyCredential + // ox's WebAuthnP256.sign accesses: + // - credential.response.authenticatorData (ArrayBuffer) + // - credential.response.clientDataJSON (ArrayBuffer) + // - credential.response.signature (ArrayBuffer, DER/ASN.1 encoded) + const credentialId = Buffer.from(keyTag, 'utf-8').toString('base64url') + + return { + id: credentialId, + type: 'public-key', + rawId: toArrayBuffer(Buffer.from(keyTag, 'utf-8')), + authenticatorAttachment: 'platform', + response: { + authenticatorData: toArrayBuffer(authenticatorData), + clientDataJSON: toArrayBuffer(clientDataBuffer), + signature: toArrayBuffer(signatureBuffer), + }, + getClientExtensionResults: () => ({}), + } + } +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + const ab = new ArrayBuffer(buf.byteLength) + const view = new Uint8Array(ab) + view.set(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)) + return ab +} diff --git a/src/secure-enclave/index.ts b/src/secure-enclave/index.ts new file mode 100644 index 0000000..ef1b6a5 --- /dev/null +++ b/src/secure-enclave/index.ts @@ -0,0 +1,2 @@ +export { isSecureEnclaveAvailable, generateKey, getPublicKey } from './bridge.js' +export { createSecureEnclaveGetFn } from './getFn.js' diff --git a/src/siwe.ts b/src/siwe.ts index daa95c5..0eee054 100644 --- a/src/siwe.ts +++ b/src/siwe.ts @@ -1,7 +1,8 @@ import { createSiweMessage } from 'viem/siwe' import { type Hex, type Address } from 'viem' import { Config, saveConfig } from './config.js' -import { createSmartAccount, resolveChain } from './account.js' +import { createSmartAccount, getSmartAccountFromSecureEnclave, resolveChain } from './account.js' +import type { HybridSmartAccount } from './account.js' import { fetchWithCookies, cookieJar } from './cookies.js' export interface SignInResult { @@ -15,21 +16,36 @@ export interface SignInResult { } export async function signInWithAgent(baseUrl: string, config: Config): Promise { - if (!config.private_key) { - throw new Error("No private key found in config. Run 'create_account' first.") - } if (!config.smart_account_address) { throw new Error("No smart account address found in config. Run 'create_account' first.") } if (!config.chain) { throw new Error("No chain found in config. Run 'create_account' first.") } + if (config.signer_type !== 'secureEnclave' && !config.private_key) { + throw new Error("No private key found in config. Run 'create_account' first.") + } const chain = resolveChain(config.chain) const chainId = chain.id const walletAddress = config.smart_account_address - const { smartAccount } = await createSmartAccount(config.private_key as Hex, config.chain) + let smartAccount: HybridSmartAccount + if (config.signer_type === 'secureEnclave') { + if (!config.secure_enclave) { + throw new Error("Secure Enclave config missing. Run 'create_account --secure-enclave' first.") + } + smartAccount = await getSmartAccountFromSecureEnclave( + config.secure_enclave.key_tag, + config.secure_enclave.public_key_x, + config.secure_enclave.public_key_y, + config.secure_enclave.key_id as Hex, + config.chain + ) + } else { + const result = await createSmartAccount(config.private_key as Hex, config.chain) + smartAccount = result.smartAccount + } // Extract domain info from baseUrl const url = new URL(baseUrl) diff --git a/swift/SecureEnclaveSigner/Package.swift b/swift/SecureEnclaveSigner/Package.swift new file mode 100644 index 0000000..0f1bac4 --- /dev/null +++ b/swift/SecureEnclaveSigner/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "SecureEnclaveSigner", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget(name: "SecureEnclaveSigner", path: "Sources") + ] +) diff --git a/swift/SecureEnclaveSigner/Sources/main.swift b/swift/SecureEnclaveSigner/Sources/main.swift new file mode 100644 index 0000000..048bbce --- /dev/null +++ b/swift/SecureEnclaveSigner/Sources/main.swift @@ -0,0 +1,236 @@ +import Foundation +import Security + +// MARK: - Helpers + +func hexString(from data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() +} + +func dataFromHex(_ hex: String) -> Data? { + var hex = hex.hasPrefix("0x") ? String(hex.dropFirst(2)) : hex + guard hex.count % 2 == 0 else { return nil } + var data = Data() + while !hex.isEmpty { + let byte = hex.prefix(2) + hex = String(hex.dropFirst(2)) + guard let b = UInt8(byte, radix: 16) else { return nil } + data.append(b) + } + return data +} + +func outputJSON(_ dict: [String: Any]) { + if let data = try? JSONSerialization.data(withJSONObject: dict), + let str = String(data: data, encoding: .utf8) { + print(str) + } +} + +func exitWithError(_ code: String, _ message: String) -> Never { + let err = ["error": code, "message": message] + if let data = try? JSONSerialization.data(withJSONObject: err), + let str = String(data: data, encoding: .utf8) { + FileHandle.standardError.write(str.data(using: .utf8)!) + FileHandle.standardError.write("\n".data(using: .utf8)!) + } + exit(1) +} + +// MARK: - Secure Enclave Operations + +func generateKey() { + let uuid = UUID().uuidString.lowercased() + let tag = "com.coinfello.agent-cli.\(uuid)" + let tagData = tag.data(using: .utf8)! + + let access = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + .privateKeyUsage, + nil + ) + + guard let access = access else { + exitWithError("access_control_failed", "Failed to create access control") + } + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: tagData, + kSecAttrAccessControl as String: access, + ] as [String: Any], + ] + + var error: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + let msg = error?.takeRetainedValue().localizedDescription ?? "Unknown error" + exitWithError("key_generation_failed", "Failed to generate key: \(msg)") + } + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + exitWithError("public_key_extraction_failed", "Failed to extract public key") + } + + var exportError: Unmanaged? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else { + let msg = exportError?.takeRetainedValue().localizedDescription ?? "Unknown error" + exitWithError("public_key_export_failed", "Failed to export public key: \(msg)") + } + + // SEC1 uncompressed point: 0x04 || x (32 bytes) || y (32 bytes) + guard publicKeyData.count == 65, publicKeyData[0] == 0x04 else { + exitWithError("invalid_public_key", "Unexpected public key format (expected 65-byte uncompressed point)") + } + + let x = publicKeyData[1...32] + let y = publicKeyData[33...64] + + outputJSON([ + "tag": tag, + "x": hexString(from: Data(x)), + "y": hexString(from: Data(y)), + ]) +} + +func loadPrivateKey(tag: String) -> SecKey { + let tagData = tag.data(using: .utf8)! + + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: tagData, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let key = item else { + exitWithError("key_not_found", "No Secure Enclave key found with tag: \(tag) (status: \(status))") + } + + return key as! SecKey +} + +func signPayload(tag: String, payloadHex: String) { + guard let payload = dataFromHex(payloadHex) else { + exitWithError("invalid_payload", "Invalid hex payload") + } + + let privateKey = loadPrivateKey(tag: tag) + + // Try ecdsaSignatureMessageX962SHA256 first (SE hashes the payload internally) + // This is the standard algorithm supported by all Secure Enclaves + let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 + + guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else { + exitWithError("algorithm_unsupported", "ECDSA P256 SHA256 signing not supported on this device") + } + + var error: Unmanaged? + guard let signature = SecKeyCreateSignature( + privateKey, + algorithm, + payload as CFData, + &error + ) as Data? else { + let msg = error?.takeRetainedValue().localizedDescription ?? "Unknown error" + exitWithError("signing_failed", "Failed to sign: \(msg)") + } + + outputJSON([ + "signature": hexString(from: signature), + ]) +} + +func getPublicKey(tag: String) { + let privateKey = loadPrivateKey(tag: tag) + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + exitWithError("public_key_extraction_failed", "Failed to extract public key") + } + + var exportError: Unmanaged? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else { + let msg = exportError?.takeRetainedValue().localizedDescription ?? "Unknown error" + exitWithError("public_key_export_failed", "Failed to export public key: \(msg)") + } + + guard publicKeyData.count == 65, publicKeyData[0] == 0x04 else { + exitWithError("invalid_public_key", "Unexpected public key format") + } + + let x = publicKeyData[1...32] + let y = publicKeyData[33...64] + + outputJSON([ + "x": hexString(from: Data(x)), + "y": hexString(from: Data(y)), + ]) +} + +// MARK: - CLI Entry Point + +let args = CommandLine.arguments + +guard args.count >= 2 else { + exitWithError("usage", "Usage: SecureEnclaveSigner [options]") +} + +let command = args[1] + +switch command { +case "generate": + generateKey() + +case "sign": + var tag: String? + var payload: String? + var i = 2 + while i < args.count { + switch args[i] { + case "--tag": + i += 1 + guard i < args.count else { exitWithError("usage", "Missing value for --tag") } + tag = args[i] + case "--payload": + i += 1 + guard i < args.count else { exitWithError("usage", "Missing value for --payload") } + payload = args[i] + default: + exitWithError("usage", "Unknown option: \(args[i])") + } + i += 1 + } + guard let tag = tag, let payload = payload else { + exitWithError("usage", "Usage: SecureEnclaveSigner sign --tag --payload ") + } + signPayload(tag: tag, payloadHex: payload) + +case "get-public-key": + var tag: String? + var i = 2 + while i < args.count { + switch args[i] { + case "--tag": + i += 1 + guard i < args.count else { exitWithError("usage", "Missing value for --tag") } + tag = args[i] + default: + exitWithError("usage", "Unknown option: \(args[i])") + } + i += 1 + } + guard let tag = tag else { + exitWithError("usage", "Usage: SecureEnclaveSigner get-public-key --tag ") + } + getPublicKey(tag: tag) + +default: + exitWithError("usage", "Unknown command: \(command). Use generate, sign, or get-public-key.") +} diff --git a/swift/SecureEnclaveSigner/build.sh b/swift/SecureEnclaveSigner/build.sh new file mode 100755 index 0000000..2589418 --- /dev/null +++ b/swift/SecureEnclaveSigner/build.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +if [[ "$(uname)" != "Darwin" ]]; then + echo "Skipping Swift build (not macOS)" + exit 0 +fi + +cd "$(dirname "$0")" +swift build -c release + +SIGN_IDENTITY="${SIGN_IDENTITY:--}" +PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}" +APP_BUNDLE="../../dist/secure-enclave-signer.app" +BINARY_OUT="../../dist/secure-enclave-signer" + +if [ "$SIGN_IDENTITY" = "-" ]; then + # Ad-hoc sign — SE key generation will fail at runtime + # with errSecMissingEntitlement (-34018); integration tests skip gracefully. + mkdir -p ../../dist + cp .build/release/SecureEnclaveSigner "$BINARY_OUT" + codesign --force --sign - "$BINARY_OUT" +else + # Create a minimal .app bundle so macOS AMFI can find the embedded + # provisioning profile for restricted entitlements (keychain-access-groups). + MACOS_DIR="$APP_BUNDLE/Contents/MacOS" + mkdir -p "$MACOS_DIR" + cp .build/release/SecureEnclaveSigner "$MACOS_DIR/secure-enclave-signer" + + # Write a minimal Info.plist + cat > "$APP_BUNDLE/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.coinfello.agent-cli + CFBundleExecutable + secure-enclave-signer + CFBundleName + secure-enclave-signer + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + LSMinimumSystemVersion + 13.0 + + +PLIST + + # Embed provisioning profile + if [ -n "$PROVISIONING_PROFILE" ]; then + cp "$PROVISIONING_PROFILE" "$APP_BUNDLE/Contents/embedded.provisionprofile" + fi + + codesign --force --sign "$SIGN_IDENTITY" \ + --entitlements entitlements.plist \ + --options runtime \ + "$APP_BUNDLE" + + # Symlink the binary at the expected flat path for the TS bridge + rm -f "$BINARY_OUT" + ln -s "secure-enclave-signer.app/Contents/MacOS/secure-enclave-signer" "$BINARY_OUT" +fi + +echo "Built secure-enclave-signer -> dist/secure-enclave-signer (signed: ${SIGN_IDENTITY})" diff --git a/swift/SecureEnclaveSigner/entitlements.plist b/swift/SecureEnclaveSigner/entitlements.plist new file mode 100644 index 0000000..b0b3cd7 --- /dev/null +++ b/swift/SecureEnclaveSigner/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.application-identifier + RTGU82X53W.com.coinfello.agent-cli + com.apple.developer.team-identifier + RTGU82X53W + keychain-access-groups + + RTGU82X53W.com.coinfello.agent-cli + + + diff --git a/vite.config.ts b/vite.config.ts index 9855f1e..cf94911 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ "viem", "viem/accounts", "viem/chains", + "viem/siwe", + "viem/account-abstraction", "@metamask/smart-accounts-kit", ], output: {