Skip to content

koteitan/key-rest

Repository files navigation

English | Japanese

Caution

This application has not been sufficiently tested yet.

key-rest

key-rest

A proxy that embeds credentials such as APP keys into REST API calls without exposing them to the agent.

For example, suppose you want an LLM to call a REST API using an API key sk-ant-api03-abcdefg. Normally you would need to expose the API key directly to the LLM, but with key-rest, you have the LLM use key-rest://user1/claude/api-key instead of sk-ant-api03-abcdefg, and for example in Node.js, use this instead of fetch:

import { createFetch } from 'key-rest';
const fetch = createFetch();

Then key-rest replaces key-rest://user1/claude/api-key with sk-ant-api03-abcdefg, calls the REST API, and returns the response as usual.

Block Diagram

Block Diagram

Sequence Diagram

sequenceDiagram
    participant U as USER
    participant A as LLM agent
    participant K as key-rest
    participant D as key-rest-daemon
    participant S as services

    Note over U,D: Setup Phase
    U->>D: ./key-rest start
    D->>U: Enter the passphrase
    U->>D: (enter passphrase)
    D->>D: Hold passphrase in memory<br/>Decrypt encrypted keys
    D->>U: daemon started

    U->>D: ./key-rest add --allow-only-header X-Subscription-Token user1/brave/api-key https://api.search.brave.com/
    D->>U: Enter the key value
    U->>D: (enter key value)
    D->>D: Encrypt key and save to file<br/>Also hold in memory

    Note over A,S: API Call Phase
    A->>K: fetch(url, {headers: {key-rest://...}})
    K->>D: Forward request (Unix socket)
    D->>D: Replace key-rest:// URI with actual key value
    D->>S: HTTP request (with real credentials)
    S-->>D: HTTP response
    D-->>K: Forward response (Unix socket)
    K-->>A: Return Response object
Loading

key-rest-daemon

key-rest-daemon is a daemon for calling REST APIs. It holds APP KEYs and receives requests from key-rest to call REST APIs.

key-rest-daemon Control Commands

  • ./key-rest start : Starts the key-rest-daemon.
    • At startup, you will be prompted to enter a passphrase. The entered passphrase is stored in memory. It is not saved to a file.
  • ./key-rest status : Checks the status of the key-rest-daemon.
  • ./key-rest stop : Stops the key-rest-daemon.
  • ./key-rest add [options] <key-uri> <url-prefix> : Adds a key to the key-rest-daemon. The key is specified by key-uri, and the corresponding URL prefix is specified by url-prefix.
    • When the key-rest-daemon is not in the running state, you will be prompted to enter the passphrase.
    • When the key-rest-daemon is in the running state, entering the passphrase is not required.
    • After that, you will be prompted to enter the key value. The entered key is encrypted and saved to a file.
    • Options:
      • --allow-only-header <name> : Allows replacement only in the specified header (e.g., Authorization, X-Api-Key)
      • --allow-only-query <name> : Allows replacement only in the specified URL query parameter (e.g., key, token)
      • --allow-only-field <name> : Allows replacement only in the specified JSON body field (e.g., api_key)
      • --allow-only-url : Allows replacement anywhere in the URL (for path embedding: Telegram, etc.)
      • --allow-only-body : Allows replacement anywhere in the request body
      • Multiple --allow-only-header/query/field flags can be specified
      • By default (no flags), replacement is only allowed within headers (legacy mode)
  • ./key-rest remove <key> : Removes a key from the key-rest-daemon.
  • ./key-rest list : Displays a list of keys registered in the key-rest-daemon.
    • Output example
      key1: url-prefix1
      key2: url-prefix2
      

key-rest-daemon State

stateDiagram-v2
    [*] --> stopped
    stopped --> running : start (enter passphrase)
    running --> stopped : stop
Loading
State Description
stopped The daemon process is stopped. The socket does not exist.
running The daemon process is running. The passphrase is held in memory, and the encrypted keys are decrypted. Listening for requests on the Unix socket.

Commands available in each state:

Command stopped running
start OK NG (already running)
stop NG (not running) OK
status OK (displays stopped) OK (displays running)
add OK (passphrase required) OK (passphrase not required)
remove OK OK
list OK OK

key-rest:// URI Replacement Rules

See examples/ (2963592) for usage examples.

key-rest URI Format

key-rest://<key-uri>

The path separator for key-uri is /, and valid characters for each segment are [a-zA-Z0-9_.-]. There is no limit on the number of segments.

Example: key-rest://user1/service/key-name, key-rest://team/project/group/key

Unenclosed and Enclosed

Two notations are supported.

Unenclosed: key-rest://user1/service/key-name

  • The end of the URI is determined by a character not in [a-zA-Z0-9/_.-], or the end of the string
  • Can be used in contexts where the URI is not followed by /, such as header values or query parameters

Enclosed: {{ key-rest://user1/service/key-name }}

  • Double curly braces {{ }} explicitly delimit the URI boundaries
  • Required in contexts where the URI is immediately followed by / or other valid characters
  • Transform functions can be applied: {{ transform_function(args, ...) }}
# Unenclosed: No ambiguity since the URI is followed by = or end of line
Authorization: Bearer key-rest://user1/openai/api-key

# Enclosed: Enclosure needed since /sendMessage follows the URI
https://api.telegram.org/bot{{ key-rest://user1/telegram/bot-token }}/sendMessage

# Enclosed + transform function: When base64 encoding is needed
Authorization: Basic {{ base64(key-rest://user1/atlassian/email, ":", key-rest://user1/atlassian/token) }}

Transform Functions

Function Description Example
base64(...) Concatenates arguments and base64 encodes them {{ base64(key-rest://user1/email, ":", key-rest://user1/token) }}
  • Arguments are comma-separated
  • String literals are enclosed in double quotes (e.g., ":")
  • key-rest:// URIs use the replaced values
  • Additional transform functions can be added in the future

Injection Target Pattern Classification

Pattern Injection target Example Notation
URL query parameter url ?key=key-rest://user1/gemini/api-key unenclosed
Custom header value headers X-Subscription-Token: key-rest://... unenclosed
Authorization header headers Authorization: Bearer key-rest://... unenclosed
Authorization Basic headers Basic {{ base64(key-rest://..., ":", key-rest://...) }} enclosed + transform
URL path embedding url https://.../bot{{ key-rest://... }}/method enclosed
Request body body {"api_key": "key-rest://..."} unenclosed

Replacement Procedure

  1. For all fields in the request (url, each header value, body), search for the following 2 patterns:
    • Enclosed: \{\{.*?\}\} → Parse the content within {{ }}, extract the function and arguments if a transform function exists, otherwise extract the key-uri
    • Unenclosed: key-rest://[a-zA-Z0-9/_.-]+ → Extract the key-uri as-is
    • Process Enclosed first, and exclude already-replaced positions from Unenclosed targets
  2. For each key-rest:// URI found in a match: a. Verify that the key-uri is registered b. Verify that the request URL prefix-matches the url_prefix associated with the key-uri (security constraint) c. Verify that the field containing the match is allowed for that key (field restriction)
    • headers: Always allowed
    • url: Allowed only when allow_url is true
    • body: Allowed only when allow_body is true
  3. Replace the key-rest:// URI with the actual key value
  4. If a transform function exists, apply it (e.g., base64(...) → concatenate arguments and base64 encode)
  5. Replace the entire match (including {{ }} for Enclosed) with the final result

key-rest clients

key-rest receives REST API calls with key-rest URIs from the LLM agent, forwards requests to the key-rest-daemon, and returns responses from the key-rest-daemon to the LLM agent.

key-rest has various interfaces.

Node.js

key-rest-fetch

A fetch-compatible interface. It accepts the same arguments as fetch and forwards requests to the key-rest-daemon. Responses are also returned in a fetch Response-compatible format.

import { createFetch } from 'key-rest';

// Create a fetch function that connects to key-rest-daemon
const fetch = createFetch();  // Default: ~/.key-rest/key-rest.sock

// Can be used with the same API as regular fetch
const response = await fetch('https://api.example.com/data', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer key-rest://user1/example/api-key',
    'Content-Type': 'application/json'
  }
});
const data = await response.json();

key-rest-ws

A WebSocket-compatible interface. It accepts the same arguments as WebSocket, injects keys, and establishes a WebSocket connection.

import { createWebSocket } from 'key-rest';

const WebSocket = createWebSocket();

const ws = new WebSocket('wss://api.example.com/ws', {
  headers: {
    'Authorization': 'Bearer key-rest://user1/example/api-key'
  }
});

ws.on('message', (data) => {
  console.log(data);
});

For WebSocket, the key-rest-daemon maintains the WebSocket connection and relays messages between the client.

Go

key-rest-http

A net/http-compatible interface. It provides an API similar to http.Client and forwards requests to the key-rest-daemon. Responses are also returned in an *http.Response-compatible format.

package main

import (
    "fmt"
    keyrest "github.com/koteitan/key-rest/go"
)

func main() {
    client := keyrest.NewClient()  // Default: ~/.key-rest/key-rest.sock

    req, _ := keyrest.NewRequest("GET", "https://api.example.com/data", nil)
    req.Header.Set("Authorization", "Bearer key-rest://user1/example/api-key")

    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    fmt.Println(resp.StatusCode)
}

Python

key-rest-requests

A requests-compatible interface.

from key_rest import requests

response = requests.get(
    'https://api.example.com/data',
    headers={
        'Authorization': 'Bearer key-rest://user1/example/api-key',
        'Content-Type': 'application/json'
    }
)
data = response.json()

key-rest-httpx

An httpx-compatible interface. Supports async/await.

from key_rest import httpx

async with httpx.AsyncClient() as client:
    response = await client.get(
        'https://api.example.com/data',
        headers={
            'Authorization': 'Bearer key-rest://user1/example/api-key',
        }
    )
    data = response.json()

curl

key-rest-curl

A curl wrapper command. It accepts the same arguments as curl, resolves key-rest:// URIs, and executes the request.

./clients/curl/key-rest-curl https://api.example.com/data \
  -H "Authorization: Bearer key-rest://user1/example/api-key"

Security

API Key

  • The following describes where API keys are stored in plaintext:
    • During key-rest add:
      • Entered from standard input and held in memory.
      • Encrypted with the master key and saved to a file.
    • During key-rest-daemon startup:
      • Encrypted API keys stored in the file are decrypted and held in memory.
      • Encrypted for HTTPS transmission.

Master Key

  • The following describes where the master key is stored in plaintext:
    • The master key is entered from standard input at key-rest-daemon startup and held in memory.
    • Cleared from memory when key-rest-daemon terminates.

Response Masking

key-rest-daemon masks credential values in responses to prevent credential exfiltration through APIs that echo back authentication data.

  • Raw credentials: If a response body or header contains a raw credential value, it is replaced with the corresponding key-rest:// URI.
  • Transform outputs: {{ base64(...) }} and other transform expressions are resolved before sending the request. If the upstream echoes back the transformed value (e.g., a base64-encoded credential), it is replaced with the original template string in the response.
  • JSON-escaped credentials: Credentials containing special characters (e.g., ", \) are also masked in their JSON-escaped form.

As a result, responses from echo/debug endpoints may appear as if templates were never expanded (e.g., {{ base64(...) }} appears in the response body), even though the upstream server received the correctly expanded values.

Requirements

  • Linux (tested on WSL2)
  • Go 1.24+
  • socat (for curl wrapper client)
# Go (https://go.dev/doc/install)
wget https://go.dev/dl/go1.24.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc && source ~/.bashrc

# socat
sudo apt install socat

REST API Usage Examples

See examples/.

Testing

Requirements for Testing

  • Node.js 18+ (for Node.js client tests)
  • Python 3.9+ (for Python client tests)
sudo apt install nodejs npm python3
cd clients/node && npm install

Test Targets

make test                    # Run all tests
├── make test-unit           # Unit tests
│   ├── make test-go         #   Go (internal + client)
│   ├── make test-python     #   Python client
│   └── make test-node       #   Node.js client
└── make test-system         # System tests (all 26 services end-to-end)
    ├── go                   #   via go test
    ├── curl                 #   via key-rest-curl
    ├── python               #   via key_rest.requests
    └── node                 #   via node:net Unix socket
Command What it runs
make test All tests below
make test-unit test-go + test-python + test-node
make test-go go test ./... -count=1 (excludes system-test/)
make test-python cd clients/python && python3 -m unittest test_requests -v
make test-node cd clients/node && npm run build && npm test
make test-system All 4 system tests below
cd system-test/go && go test -v -count=1
system-test/curl/system-test.sh
python3 system-test/python/system_test.py
node system-test/node/system_test.mjs

System tests use test-server/ — a mock HTTPS server that mimics the authentication of all 26 supported services. See system-test/ for details.

For Developers

How to Build

git clone https://github.com/koteitan/key-rest.git
cd key-rest
make build

The key-rest binary will be created in the project root.

Hacking Challenge

We are running a hacking challenge to find credential exfiltration vulnerabilities in key-rest. Bounties are paid in BTC Lightning Network sats.

About

No description or website provided.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors