> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pinaivu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# chat-relayer Authentication

> How a client signs requests to /v1/chat — Ed25519 over canonical bytes, no API key

chat-relayer doesn't use API keys. End users authenticate by signing each
request with a delegate Ed25519 key (in production, derived from their
wallet via the MemWal client SDK). This is the auth scheme most likely to
trip up a new client integration, so this page documents it field-by-field
against a known-working run.

## The scheme

1. Generate (or load) an Ed25519 keypair. The **raw 32-byte public key**,
   hex-encoded, is `delegate_pubkey_hex`.
2. Build the canonical byte string **exactly**:
   ```
   <role1>:<content1>\n<role2>:<content2>\n...\nmodel:<model>\nowner:<owner_address>\nns:<namespace>
   ```
   — one `role:content\n` line per message in `messages`, in order, then a
   final line `model:...\nowner:...\nns:...` with **no trailing newline**.
3. Sign those exact bytes with Ed25519 — no prehashing, no re-encoding the
   string first. Sign the raw UTF-8 bytes directly. The **raw 64-byte
   signature**, hex-encoded, is `signature_hex`.
4. Send `delegate_pubkey_hex` and `signature_hex` as plain JSON fields
   alongside `messages` / `model` / `owner_address` / `namespace`.

Server-side verification lives in `chat-relayer/src/relayer/src/http.rs::canonical_chat_req`
(builds the identical string) and `src/relayer/src/auth.rs::verify_signature`
(decodes both hex fields, requires exactly 32 and 64 bytes respectively,
rejects anything that doesn't decode or verify with `401 Unauthorized` — not
a `400`, so check auth first if a client gets rejected before reaching the
model).

<Warning>
  **Common client bugs:**

  * Hashing the canonical string before signing — Ed25519 here signs the
    raw message directly, which most libraries simply call "sign", not
    "sign the hash of."
  * An off-by-one on the newline — there must be **no** trailing newline
    after the final `ns:<namespace>` line.
  * Sending the DER/SPKI-wrapped public key instead of the raw 32-byte key
    (Node's `crypto.generateKeyPairSync` exports DER by default — see the
    script below for how to strip the wrapper).
  * Sending the signature as base64 instead of hex.
</Warning>

## Reference implementation

```js theme={null}
import crypto from "node:crypto";

const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const rawPub = publicKey.export({ type: "spki", format: "der" }).slice(-32);
const delegatePubkeyHex = Buffer.from(rawPub).toString("hex");

function canonicalChatReq({ messages, model, owner_address, namespace }) {
  let s = "";
  for (const m of messages) s += `${m.role}:${m.content}\n`;
  s += `model:${model}\nowner:${owner_address}\nns:${namespace}`;
  return Buffer.from(s, "utf8");
}

function sign(bytes) {
  const sig = crypto.sign(null, bytes, privateKey);
  return Buffer.from(sig).toString("hex");
}

async function call(base, body) {
  const canonical = canonicalChatReq(body);
  const signature_hex = sign(canonical);
  const payload = { ...body, delegate_pubkey_hex: delegatePubkeyHex, signature_hex };
  const resp = await fetch(`${base}/v1/chat`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  return resp.json();
}
```

## Worked example

Canonical bytes signed for a first message:

```
user:Remember that my favorite color is teal and I'm building Pinaivu in Rust.
model:gemma4-e4b-128k:latest
owner:0xpersisttest1
ns:default
```

(rendered with line breaks above for readability — the actual signed bytes
use `\n`, not real newlines plus a trailing one)

Resulting request:

```json theme={null}
{
  "messages": [{ "role": "user", "content": "Remember that my favorite color is teal and I'm building Pinaivu in Rust." }],
  "model": "gemma4-e4b-128k:latest",
  "owner_address": "0xpersisttest1",
  "namespace": "default",
  "delegate_pubkey_hex": "79b5370a255aa5a8364510bf602be3b3970b4f2a2242617dc88e10804b53fe71",
  "signature_hex": "2c8cef80690bc2d865185f07c2de51602c1fff7825145cf102cbd5940ae9b8e376123de5f4db3cffdb2945b222d8ba4cb6575629034813ed241d57b3b9870d0b"
}
```

Resulting response:

```json theme={null}
{
  "content": "I remember that your favorite color is teal and that you are building Pinaivu using Rust.",
  "session_id": "b7c0bee8-c01f-4392-acfb-0e1a333d386b",
  "session_key": "gavubc6GtzOCw2OEmp+U+YH680f+wXidfy5DJc2EUNc=",
  "request_id": "430bb3aa-2f1a-4e6f-80e0-cd8b8734ba23",
  "recalled_facts": [],
  "latency_ms": 3250
}
```

## `owner_address` is permanent

`owner_address` is a key into long-term, cross-session memory — it is
**not** reset between requests, sessions, or days. Re-running a test with
the same `owner_address` surfaces accumulated `recalled_facts` from every
prior call, which is correct behavior (that's the point of the
[cross-session memory layer](/architecture/memory-layers)), but it makes
isolated test runs hard to read. Use a fresh, unique `owner_address` (e.g. a
UUID) per test run if you want a clean before/after comparison.

<Card title="Full chat-relayer API reference" icon="message" href="/api-reference/chat-relayer">
  Endpoint, request/response shapes, and the two automatic memory layers
</Card>
