Skip to content

@shutter-network/concorde/nostr-auth

The Nostr Auth component authenticates a person by a NIP-98 signature on every request, using the Nostr key they already message the shared agent from. It is an Auth, so the Public server holds it beside every other scheme a deployment accepts and composes them into the one hook a protected route takes. There is no login, no Token and no route of any kind: a client signs a kind 27235 event naming the URL and the method, sends it as Authorization: Nostr <base64>, and this answers with the User the Operator granted that key to.

createNostrAuth makes one, and NostrAuthOptions is what it takes. NostrAuth is what comes back, carrying recordPublicKey and nothing else: that is the whole of admission to this scheme, it is called from the Operator's own code, and no route anywhere does the same thing.

Two options decide whether a first deployment works. externalBaseUrl is what clients reach you at, and the u tag of every request is compared against it rather than against what the server received, because a reverse proxy rewrites the difference. windowMs is how far either side of now a signature may be dated, and it is applied in both directions, so a client with a fast clock is refused rather than holding a credential that never expires.

Construct Users first, whose record every outcome carries. A grant here is not the Nostr Channel's addressing and neither reads the other: @shutter-network/concorde/nostr-channel records the one key the agent writes to, and this records every key that may act as a User, so a person may be reachable over Nostr without being allowed to drive the HTTP API, and the reverse. A deployment running both writes both, and nothing checks that they agree.

The tables are on @shutter-network/concorde/nostr-auth/schema and nowhere else. grants.user_id points a foreign key at the users table, so a configuration listing that subpath without @shutter-network/concorde/users/schema generates a constraint onto a table it never creates.

Example

A Gateway whose Users all hold Nostr keys, with one key granted from the Operator's own code.

ts
import { createGateway } from "@shutter-network/concorde/gateway";
import { createNostrAuth } from "@shutter-network/concorde/nostr-auth";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { createUsers } from "@shutter-network/concorde/users";

const gateway = createGateway({
  databaseUrl: process.env.DATABASE_URL ?? "",
  runtime: createPiRuntime({ image: "my-agent:1" }),
  // Not loopback: the agent reaches this server from a container of its own.
  agentListen: { host: "0.0.0.0", port: 8081 },
  publicListen: { host: "0.0.0.0", port: 8080 },
  extend: ({ db, agentServer, publicServer }) => {
    const users = createUsers({ db, agentServer, publicServer });
    return {
      users,
      nostrAuth: createNostrAuth({
        db,
        users,
        publicServer,
        // What a client typed, not what the proxy forwarded.
        externalBaseUrl: "https://agent.example.com",
      }),
    };
  },
  handlers: () => ({}),
});

await gateway.start();

// One transaction, so a User nobody can authenticate as never reaches the table. A second key
// for the same person is an ordinary second call.
const { db, users, nostrAuth } = gateway.components;
await db.tx(async (tx) => {
  const user = await users.create(tx);
  await nostrAuth.recordPublicKey(tx, user.id, "ab".repeat(32));
});

// A route of the Operator's own, behind the schemes this deployment accepts.
gateway.components.publicServer.fastify.get(
  "/whoami",
  { preHandler: gateway.components.publicServer.requireUser },
  async (request) => ({ id: request.concordeUser.id }),
);

Type Aliases

NostrAuth

ts
type NostrAuth = Auth & {
  recordPublicKey: <TSchema>(tx: Handle<TSchema>, userId: string, publicKey: string) => Promise<void>;
  start: () => Promise<void>;
  stop: () => Promise<void>;
};

The Nostr Auth component as an Auth: one authenticate, one grant, and no route.

It keeps one row per granted public key and one row per admitted event. A person signs every request with a Nostr key, so there is no login and nothing is issued: authenticate reads Authorization: Nostr <base64>, checks the event by hand, and answers with the User the Operator granted that key to. A request with no such header carries nothing of this scheme, and the server asks the next Auth.

Every mechanical failure is told apart and one refusal is not. A credential that does not decode, a signature that does not verify, a wrong kind, an event outside the freshness window, a u tag or a method that names a different call, a payload tag that is not the hash of the body, and a credential that was presented before each reach the Logger the server was built with, carrying their own reason. A key nobody granted is refused with the code alone, because a reason would tell a stranger which keys are enrolled.

A request under this scheme is a write. Every admitted event id is recorded, so that a captured header cannot be sent twice, and the rows past the window are deleted in the same transaction. The table therefore holds the last window's traffic rather than every request ever made, and nothing has to be reaped, configured or remembered.

A credential is used once, and an event id is what "once" counts. That id is the hash of the event's fields and created_at counts whole seconds, so a client that signs the same URL and method twice inside one second signs one event and is refused the second time. A client that repeats a call adds a tag of its own to make the two events two, which NIP-98 leaves it free to do and which is what every client that retries has to do anyway.

start and stop do nothing. A grant is a row and survives a shutdown; so does the replay record, which is what makes a restart no help to somebody holding a captured header.

Type Declaration

recordPublicKey()
ts
recordPublicKey<TSchema>(
  tx: Handle<TSchema>,
  userId: string,
  publicKey: string
): Promise<void>;

Records that one Nostr public key may act as one User, and proves nothing.

The Operator establishes out of band that the key is that person's, and this stores what they decided. It is the whole of admission to this scheme, and deliberately the whole: no route on either server records a key, so an injected prompt cannot grant itself a User's identity. The cost is that nobody enrols themselves, and a deployment that wants a logged-in User to prove control of a key writes that route itself out of this method.

A User holds as many keys as they have signers, so recording a second key for the same User is ordinary. A key already granted is refused rather than moved, because moving one silently is how one person's key becomes another person's identity.

A write, so it takes the caller's transaction first: the grant and whatever the Operator records about it commit together or not at all. publicKey is 64 lowercase hex characters, which is what a Nostr public key is on the wire, and an npub is not one.

Type Parameters
TSchema

TSchema extends Record<string, unknown>

Parameters
tx

Handle<TSchema>

userId

string

publicKey

string

Returns

Promise<void>

Throws

If publicKey is not 64 lowercase hex characters.

Throws

If no User has that id.

Throws

If that key is already granted, to this User or to another. Every refusal here runs in a savepoint, so none of them aborts the caller's transaction.

start()
ts
start(): Promise<void>;
Returns

Promise<void>

stop()
ts
stop(): Promise<void>;
Returns

Promise<void>


NostrAuthOptions

ts
type NostrAuthOptions = {
  readonly db: Db;
  readonly externalBaseUrl: string;
  readonly publicServer: {
    registerAuth: (auth: Auth) => void;
  };
  readonly users: Users;
  readonly windowMs?: number;
};

Properties

db
ts
readonly db: Db;
externalBaseUrl
ts
readonly externalBaseUrl: string;

The origin, and any path prefix, that clients reach this deployment at, such as https://agent.example.com or https://example.com/agent.

The u tag of every request is compared against this plus the path Fastify received, so it has to be what the client typed and not what your reverse proxy forwarded. Behind a proxy the two differ, and every request is refused with a reason that reaches your log and never the client.

A trailing slash is ignored. The scheme and host are compared case-insensitively and a default port may be written or left out, because a URL says so; the path is compared exactly.

publicServer
ts
readonly publicServer: {
  registerAuth: (auth: Auth) => void;
};

The server this registers itself with as an Auth, and the only wiring the constructor does.

Registration order is the order the server asks the schemes in, and it is the order they are named in a 401. No route is registered on it, or on any other server.

Structural: anything carrying a registerAuth satisfies it, which is what serverComponent answers with.

registerAuth()
ts
registerAuth(auth: Auth): void;
Parameters
auth

Auth

Returns

void

users
ts
readonly users: Users;

Answers with the User record an authenticated request names, and with nothing else.

An Auth reports a User rather than an id, because the server that walks the Auths is built before any component and can resolve nothing itself. Construct Users first.

windowMs?
ts
readonly windowMs?: number;

How far either side of now an event's created_at may sit, in milliseconds. Defaults to 60000, which is NIP-98's own window.

Applied in both directions, so an event stamped in the future is refused rather than valid forever. Raise it for clients whose clocks you do not control, and know that it is also how long a captured request stays replayable against a Gateway that has forgotten it, and twice how long a row lives in the replay record.

Functions

createNostrAuth()

ts
function createNostrAuth(options: NostrAuthOptions): NostrAuth;

Builds the Nostr Auth component and registers it with the Public server as an Auth.

It registers no route, on that server or on any other. Nothing here connects, listens or applies DDL.

Parameters

options

NostrAuthOptions

Returns

NostrAuth

Throws

If externalBaseUrl is not an absolute URL.

Throws

If windowMs is not a positive number of milliseconds.