# FIDUCY ## Gateway API URL: /docs/gateway-api The HTTP endpoints the gateway serves. # Gateway API The SDK wraps all of these. You only need this page for custom clients. Every response is JSON `{ data, message }`, except provider proxy responses, which are passed through untouched. ## Authentication Server calls send the app secret: `authorization: Bearer fsk_...`. The widget's `POST /keys` is authenticated by the browser origin instead. ## Endpoints | Method and path | Who calls it | Purpose | | --- | --- | --- | | `GET /health` | anyone | Liveness | | `GET /attestation?nonce=` | anyone | Nitro attestation document for the running enclave | | `GET /widget?app=&origin=` | the iframe | Key entry page. CSP locks `frame-ancestors` to `origin` | | `POST /keys` | the iframe | Seal a key. Returns `{ handle }` or `{ handle, record }` | | `DELETE /keys/:keyID` | your server | Revoke (Fiducy-stored records only) | | `ANY /{provider}/...` | your server | Proxy to the provider with the user's key | ## Provider proxy | Provider | Gateway base URL | | --- | --- | | OpenAI | `/openai/v1` | | Anthropic | `/anthropic/v1` | | Gemini | `/gemini/v1beta` | | OpenRouter | `/openrouter/api/v1` | Headers: | Header | Value | | --- | --- | | `authorization` | `Bearer fsk_...` (your app secret, not the user's key) | | `x-key-handle` | `kh_...` | | `x-key-record` | base64url JSON record, only in app-owned mode | Errors from the gateway itself: | Status | Meaning | | --- | --- | | `400` | Missing or malformed handle or record | | `401` | Bad app secret | | `403` | The handle belongs to another app or cannot open its key, or a key was added outside the widget | | `404` | No key with this ID for this app | | `409` | Revoke through the gateway on an app that stores its own records. Delete the record instead | | `429` | The key reached its monthly request limit | --- ## How it works URL: /docs/how-it-works The path a key takes, from the widget to the provider. # How it works ## Saving a key 1. Your page renders ``, which loads `GET /widget?app=...&origin=...` from the gateway in an iframe. The iframe is on the gateway's origin, so your page's JavaScript cannot read what the user types. 2. The user pastes a key. The iframe sends it to `POST /keys` on the gateway over TLS. 3. Inside the enclave the gateway: - generates a random 256-bit data key and encrypts the API key with AES-GCM, - splits the data key into two shares: **share A** (random) and **share B** (data key XOR share A), - wraps share B with AWS KMS. KMS only unwraps for an enclave whose image hash matches the key policy. 4. The gateway returns a handle, `kh_.`, and stores the sealed record `{ keyID, appID, ciphertext, iv, wrappedShareB }`, or hands the record back to you in [app-owned mode](/docs/storage). 5. The iframe posts the handle to your page, and `ConnectKeyWidget` saves it to your server through `POST /api/fiducy/keys`. ## Using a key 1. Your server calls `providerSettings('openai', { userID })`. It returns a gateway `baseURL`, your app secret as `apiKey`, and the handle in the `x-key-handle` header. 2. Your AI SDK sends a normal provider request to the gateway. 3. Inside the enclave the gateway checks the app secret, finds the record, unwraps share B through attested KMS, rebuilds the data key from both shares, decrypts the API key, and forwards the request to the provider. 4. The provider's response streams straight back. The plaintext key is dropped when the request ends. ## Why two shares Neither side can open a key alone: | Who has it | What they hold | Can they decrypt? | | --- | --- | --- | | Your database | Handle (share A) | No: share B is wrapped by KMS | | Fiducy storage | Record (ciphertext, wrapped share B) | No: share A is only in your handle | | AWS account owner | KMS key | No: the key policy only allows the attested enclave | | The enclave | Code only, no stored state | Only while serving a request that carries both | ## No way back out The gateway proxy is the only way to use a stored key. There is no endpoint that returns a plaintext key to your server. If the proxy does not cover a provider you need, ask us to add it. --- ## Introduction URL: /docs Let users bring their own AI keys without your app ever holding them. # Introduction Fiducy is a key custody layer for apps where users bring their own API key (BYOK) for OpenAI, Anthropic, Gemini or OpenRouter. Your users paste their key into a small iframe served by the Fiducy gateway. The gateway encrypts it inside an AWS Nitro enclave and hands your app a **handle**. Your server points its AI SDK at the gateway with that handle. The gateway opens the key inside the enclave, calls the provider, and streams the answer back. Your app, your database and your logs never see the plaintext key. ```ts const settings = await fiducy.providerSettings('openai', { userID }); const openai = createOpenAI(settings); // calls go through the gateway ``` ## Why Storing user API keys yourself means one database leak or one bad log line exposes every key you hold. With Fiducy: - A stolen database only yields handles and ciphertext. Neither opens a key without the enclave. - The enclave only runs an image whose hash (PCR0) you can verify, and the KMS key that unwraps keys refuses every other caller, including the AWS account owner. - Users can revoke a key at any time, and you get per-key monthly limits for free. ## How you add it Fiducy fits into your app as four pieces: a server instance, a route handler, a React component, and a storage adapter you choose. | Piece | What it does | | --- | --- | | `createFiducy()` | Server instance: route handler, `providerSettings`, list, save, revoke | | `fiducy()` for Farm.js | Mounts the handler at `/api/fiducy/*` as a Farm integration | | `` | Drops the gateway iframe into your page and saves the handle | | `useFiducyKeys()` | Lists and revokes the signed-in user's keys | Start with the [Quickstart](/docs/quickstart). ## Open source and managed The whole stack is open source under the Apache-2.0 license. Self-hosting is free: you run the gateway enclave, KMS key and storage in your own AWS account. The managed service runs the enclave, KMS and dashboard for you. --- ## Quickstart URL: /docs/quickstart Add bring-your-own-key to a Farm.js app in four steps. # Quickstart This guide adds Fiducy to a Farm.js app. Other frameworks use the same server instance; see [Server SDK](/docs/server) for a plain route handler. Install the SDK and the Farm.js integration: ```bash bun add @fiducy/sdk @fiducy/farm ``` ## 1. Register your app Create an app in the Fiducy dashboard. You get: - an **app ID**, public, used by the widget - an **app secret** (`fsk_...`), server only, used for every gateway call Add your site's origin (for example `https://myapp.com`) to the app's allowed origins, so the widget can be embedded there. ```bash FIDUCY_GATEWAY_URL=https://gw.fiducy.dev FIDUCY_APP_ID=app_... FIDUCY_APP_SECRET=fsk_... ``` ## 2. Create the server instance The examples import from `@/`, which points at `src/` through `paths` in your `tsconfig.json`. ```ts title="src/lib/fiducy.ts" import { getStorage } from '@farm.js/core/storage'; import { createFiducy, keyValueStorage } from '@fiducy/sdk/server'; import { auth } from '@/lib/auth'; const { FIDUCY_GATEWAY_URL, FIDUCY_APP_SECRET } = process.env; if (!FIDUCY_GATEWAY_URL || !FIDUCY_APP_SECRET) throw new Error('Set the Fiducy env vars'); export async function getUserID(request: Request) { const session = await auth.api.getSession({ headers: request.headers }); return session?.user.id ?? null; } export const fiducyServer = createFiducy({ gatewayURL: FIDUCY_GATEWAY_URL, appSecret: FIDUCY_APP_SECRET, storage: keyValueStorage(getStorage('fiducy')), getUserID }); ``` `getUserID` is how Fiducy knows who is asking. Return your own user ID from whatever auth you use, or `null` when nobody is signed in. ## 3. Add the integration ```ts title="farm.config.ts" import { defineConfig } from '@farm.js/core'; import { localStorage } from '@farm.js/core/storage'; import { fiducy } from '@fiducy/farm'; import { fiducyServer } from './src/lib/fiducy'; export default defineConfig({ storage: { mounts: { fiducy: localStorage({ base: '.data/fiducy' }) } }, integrations: { byok: fiducy({ instance: fiducyServer }) } }); ``` This mounts `GET /api/fiducy/keys`, `POST /api/fiducy/keys` and `DELETE /api/fiducy/keys/:keyID`. ## 4. Drop in the widget and use the key The React parts run on TanStack Query, so install it and wrap your app in a `QueryClientProvider` (see [React](/docs/react#setup)): ```bash bun add @tanstack/react-query ``` ```tsx title="src/app/layout.tsx" import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; } ``` ```tsx title="src/app/settings/page.tsx" 'use client'; import { ConnectKeyWidget, useFiducyKeys } from '@fiducy/sdk/react'; export default function SettingsPage() { const { keys, refresh, revoke } = useFiducyKeys(); const openAIKey = keys.find((key) => key.provider === 'openai'); if (openAIKey) { return ; } return ( ); } ``` Then call the model from any server route: ```ts title="src/app/api/chat/route.ts" import { createOpenAI } from '@ai-sdk/openai'; import { streamText } from 'ai'; import { fiducyServer, getUserID } from '@/lib/fiducy'; export async function POST(request: Request) { const userID = await getUserID(request); const settings = userID ? await fiducyServer.providerSettings('openai', { userID }) : null; if (!settings) return new Response('Connect your API key first', { status: 400 }); const { messages } = await request.json(); const result = streamText({ model: createOpenAI(settings).chat('gpt-4o-mini'), messages }); return result.toUIMessageStreamResponse(); } ``` That is the whole integration. The plaintext key only ever exists inside the gateway's enclave. --- ## React URL: /docs/react ConnectKeyWidget and useFiducyKeys. # React ```ts import { ConnectKeyWidget, KeyWidget, useFiducyKeys } from '@fiducy/sdk/react'; ``` ## Setup `useFiducyKeys` and `ConnectKeyWidget` run on TanStack Query. Install it next to the SDK and put a `QueryClientProvider` above any component that uses them. ```bash bun add @tanstack/react-query ``` ```tsx title="src/app/providers.tsx" 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); return {children}; } ``` `KeyWidget` does not need the provider. ## ConnectKeyWidget Renders the gateway iframe and, once the user saves a key, posts the handle to your server's Fiducy route. ```tsx console.log('connected', key.keyID)} onError={(message) => setError(message)} /> ``` | Prop | Type | Notes | | --- | --- | --- | | `gatewayURL` | `string` | Your gateway | | `appID` | `string` | Public app ID | | `provider` | `'openai' \| 'anthropic' \| 'gemini' \| 'openrouter'` | Which provider this key is for | | `basePath` | `string` | Your Fiducy route. Default `/api/fiducy` | | `onConnected` | `(key) => void` | After your server saved the handle | | `onError` | `(message) => void` | Widget or save errors | | `className` | `string` | Applied to the wrapper | The iframe only loads on origins listed in your app's allowed origins. ## useFiducyKeys(basePath?) ```tsx const { keys, isLoading, error, refresh, revoke } = useFiducyKeys(); ``` | Field | Type | | --- | --- | | `keys` | `{ keyID, provider, createdAt }[]` | | `isLoading` | `boolean` | | `error` | `string \| null` | | `refresh` | `() => Promise` | | `revoke` | `(keyID) => Promise` | The list is cached under `fiducyKeysQueryKey(basePath)`, so you can invalidate it from your own mutations: ```tsx import { fiducyKeysQueryKey } from '@fiducy/sdk/react'; queryClient.invalidateQueries({ queryKey: fiducyKeysQueryKey() }); ``` ## KeyWidget The bare widget without the server save. Use it when you store handles your own way. ```tsx save(handle, record)} /> ``` Outside React, `@fiducy/widget` exports `mountKeyWidget({ gatewayURL, appID, container, onHandle, onError })`. --- ## Security URL: /docs/security What Fiducy protects against, how to verify it, and its limits. # Security ## What runs where - The gateway runs inside an **AWS Nitro enclave**: no disk, no shell, no network except a fixed egress allowlist (KMS, DynamoDB for sealed records, the instance metadata service for AWS credentials, the provider APIs, the control plane). - **Today, TLS for the gateway ends at Caddy on the parent host**, which forwards to the enclave. That means the host could see keys in transit while they are being saved or used. Moving TLS termination inside the enclave is required before real users. - The KMS key policy denies `kms:Decrypt` to everyone, account admins included, unless the request carries an attestation document whose image hash (`PCR0`) matches the published build. ## Verify the enclave ```bash curl "https://gw.fiducy.dev/attestation?nonce=$(openssl rand -hex 16)" ``` The response holds a CBOR attestation document signed by AWS. Check that: 1. the certificate chain ends at the AWS Nitro root certificate, 2. your nonce is in the document, 3. `PCR0` equals the hash produced by building the published enclave image yourself. Rebuilding the image from the tagged source gives the same `PCR0`. A different hash means different code. ## What it protects against | Threat | Result | | --- | --- | | Your database leaks | Attacker gets handles. Useless without the enclave and your app secret | | Fiducy's storage leaks | Attacker gets records. Useless without the handles | | Your server logs leak | Only handles and the app secret appear; rotate the secret | | Someone with AWS admin on the gateway account | Cannot decrypt stored keys: KMS refuses without the enclave's attestation (keys in transit are exposed until TLS moves into the enclave) | ## Limits - **Your app secret plus a handle can use a key** through the gateway. Monthly limits per key cap the damage; rotate the secret if it leaks. - **No plaintext comes back.** There is no endpoint that returns a stored key to your server. The gateway proxy is the only way to use one; ask for a provider the proxy does not cover. - **The provider sees the key**, as it always does. - **Hosting**: a flaw in Nitro or KMS would break the guarantees. Self-hosting moves that trust to your own account. --- ## Self-hosting URL: /docs/self-hosting Run the gateway enclave, KMS key and dashboard in your own AWS account. # Self-hosting Self-hosting is free. You pay AWS for one Graviton instance with enclaves (about $50 a month for `c6g.large`) and a KMS key ($1 a month). ## What gets created `infra/aws/stack.yaml` is one CloudFormation stack: - an S3 bucket for releases and logs - a KMS key whose policy only allows `kms:Decrypt` from the enclave image you built - a `c6g.large` instance with Nitro enclaves on, an Elastic IP, and ports 80 and 443 open - a DynamoDB table (`KeysTable`) for sealed key records, on demand billing, with deletion protection and point-in-time recovery - an IAM role limited to the bucket, the key and the table The host runs Postgres, the dashboard, the demo and Caddy for TLS. The gateway runs in the enclave and reads and writes sealed records in `KeysTable` directly. The landing page and these docs are not part of the stack. They deploy to Vercel from `apps/site`. ## Deploy with GitHub Actions 1. Create an IAM user with admin rights for the first deploy. 2. Add repository secrets: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_REGION` (default `us-east-1`). 3. Run the **AWS deploy** workflow. The workflow builds the enclave on the host, reads its `PCR0`, locks the KMS key to it, starts everything, and checks the endpoints. The run summary lists your URLs: ``` Gateway (in the enclave): https://gw..sslip.io Dashboard: https://app..sslip.io Demo chat: https://demo..sslip.io ``` ## Deploy from your machine ```bash export AWS_REGION=us-east-1 bash infra/aws/deploy.sh ``` Needs the AWS CLI, `jq` and admin credentials. ## Tear down Run the **AWS teardown** workflow, or `bash infra/aws/teardown.sh`. The KMS key enters a 7 day deletion window; every key it wrapped becomes unrecoverable after that. ## Run locally No AWS needed. A local master key stands in for KMS: ```bash bun install bun run prototype --mock ``` This starts the dashboard on `:3000`, the demo chat on `:3001` and the gateway on `:8787`. `--mock` uses a fake OpenAI that logs the last four characters of the key it received, so you can see decryption happen. ## Gateway environment | Variable | Purpose | | --- | --- | | `FIDUCY_KMS_KEY_ID` | KMS key ARN. When unset, `FIDUCY_MASTER_KEY` is used instead (local only) | | `FIDUCY_MASTER_KEY` | 32 random bytes, base64url. Local development only | | `AWS_KMS_ENDPOINT` | KMS proxy inside the enclave. Default `http://127.0.0.1:9999` | | `FIDUCY_CONTROL_PLANE_URL` | Dashboard URL for app configs, usage and key events | | `FIDUCY_KEYS_TABLE` | DynamoDB table for sealed key records. When unset, records stay in memory (local only) | | `AWS_REGION` | Region for KMS and DynamoDB. Default `us-east-1` | | `FIDUCY_GATEWAY_TOKEN_CIPHERTEXT` | Control plane token, KMS-encrypted | | `FIDUCY_ENCLAVE_API_URL` | Enclaver API, for `/attestation` | | `FIDUCY_APPS_JSON` | Static app list when there is no control plane | | `PORT` | Default `8787` | --- ## Server SDK URL: /docs/server createFiducy, the route handler, and the methods you call from your server. # Server SDK ```ts import { createFiducy, keyValueStorage, memoryStorage } from '@fiducy/sdk/server'; ``` ## createFiducy(options) ```ts const fiducy = createFiducy({ gatewayURL: 'https://gw.fiducy.dev', appSecret: process.env.FIDUCY_APP_SECRET!, storage: keyValueStorage(driver), getUserID: (request) => readSession(request)?.userID ?? null, basePath: '/api/fiducy' // optional, this is the default }); ``` | Option | Type | Notes | | --- | --- | --- | | `gatewayURL` | `string` | Your gateway, for example `https://gw.fiducy.dev` | | `appSecret` | `string` | `fsk_...` from the dashboard. Server only | | `storage` | `FiducyStorage` | Where handles (and records) live. See [Storage](/docs/storage) | | `getUserID` | `(request) => string \| null` | Your signed-in user. May be async | | `basePath` | `string` | Where the handler is mounted. Default `/api/fiducy` | | `fetch` | `typeof fetch` | Optional, for tests | ## fiducy.handler(request) A standard `Request => Response` handler. Mount it under `basePath` in any framework. | Route | Body | Result | | --- | --- | --- | | `GET {basePath}/keys` | | `{ data: UserKeySummary[] }` | | `POST {basePath}/keys` | `{ provider, handle, record? }` | `201 { data: UserKeySummary }` | | `DELETE {basePath}/keys/:keyID` | | `{ data: { keyID } }` | - Every route answers `401` when `getUserID` returns `null`. - `POST` and `DELETE` answer `403` when the request's `Origin` is a different site. - Saving a key for a provider replaces and revokes that user's previous key for the same provider. Without the Farm integration, mount it yourself: ```ts title="app/api/fiducy/[...path]/route.ts" export const GET = fiducy.handler; export const POST = fiducy.handler; export const DELETE = fiducy.handler; ``` ## `fiducy.providerSettings(provider, { userID })` Returns settings for the AI SDK provider factories, or `null` when the user has no key for that provider. ```ts const settings = await fiducy.providerSettings('anthropic', { userID }); // { baseURL: 'https://gw.fiducy.dev/anthropic/v1', apiKey: 'fsk_...', headers: { 'x-key-handle': 'kh_...' } } const anthropic = createAnthropic(settings!); ``` Providers: `openai`, `anthropic`, `gemini`, `openrouter`. ## Other methods ```ts await fiducy.listKeys(userID); // UserKeySummary[] await fiducy.saveKey(userID, { provider, handle }); // UserKeySummary await fiducy.revokeKey(userID, keyID); // boolean ``` `UserKeySummary` is `{ keyID, provider, createdAt }`. Handles never leave your server through these methods. ## Low-level client For scripts or custom flows, `@fiducy/sdk` exports `createGatewayClient({ gatewayURL, appSecret })` with `providerSettings(provider, handle)` and `revokeKey(handle)`. --- ## Storage URL: /docs/storage Where handles and sealed records live, and how to plug in your own database. # Storage There are two things to store: - the **handle**, always in your app, through the `storage` adapter you pass to `createFiducy` - the **sealed record**, either with Fiducy (default) or in your app ## Where sealed records live Set this per app in the dashboard under "Where sealed keys live". | Setting | Records live | Notes | | --- | --- | --- | | `fiducy` (default) | Fiducy's storage | Smallest setup. Revoke deletes the record on the gateway | | `app` | Your storage, next to the handle | You own all the data. The SDK sends the record to the gateway in the `x-key-record` header on each call | In `app` mode the gateway keeps nothing per key. A record is ciphertext plus a KMS-wrapped share, so your database still cannot open a key on its own. Revoking a key means deleting the row; the SDK does this for you. ## Adapters ### keyValueStorage(driver) Works with any unstorage-style driver, including Farm.js storage mounts. ```ts import { getStorage } from '@farm.js/core/storage'; import { keyValueStorage } from '@fiducy/sdk/server'; const storage = keyValueStorage(getStorage('fiducy')); ``` Keys are stored as `fiducy::`. ### memoryStorage() In-memory, for tests and local experiments. Everything is lost on restart. ### Your own database Implement four methods: ```ts import type { FiducyStorage } from '@fiducy/sdk/server'; export const storage: FiducyStorage = { saveKey: (key) => db.insert(userKeys).values(key), findKey: (userID, provider) => db.query.userKeys.findFirst({ where: { userID, provider } }), listKeys: (userID) => db.query.userKeys.findMany({ where: { userID } }), deleteKey: async (userID, keyID) => (await db.delete(userKeys).where({ userID, keyID })).count > 0 }; ``` A stored key is `{ userID, provider, keyID, handle, record?, createdAt }`. Treat the handle like a password hash: it is not secret enough to publish, but it is useless without the gateway. ---