Merge branch 'api-signer' into 'main'

APISigner, AdminSigner, implement NostrSigner interface

See merge request soapbox-pub/ditto!112
This commit is contained in:
Alex Gleason 2024-02-12 17:56:02 +00:00
commit d60bf9cbcc
15 changed files with 166 additions and 153 deletions

View File

@ -1,5 +1,5 @@
import * as pipeline from '@/pipeline.ts';
import { signAdminEvent } from '@/sign.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { type EventStub } from '@/utils/api.ts';
import { nostrNow } from '@/utils.ts';
@ -12,7 +12,9 @@ switch (Deno.args[0]) {
}
async function publish(t: EventStub) {
const event = await signAdminEvent({
const signer = new AdminSigner();
const event = await signer.signEvent({
content: '',
created_at: nostrNow(),
tags: [],

View File

@ -88,7 +88,7 @@ interface AppEnv extends HonoEnv {
/** Hex pubkey for the current user. If provided, the user is considered "logged in." */
pubkey?: string;
/** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */
seckey?: string;
seckey?: Uint8Array;
/** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent;
/** User associated with the pubkey, if any. */

View File

@ -1,4 +1,4 @@
import { dotenv, getPublicKey, nip19, secp, z } from '@/deps.ts';
import { dotenv, getPublicKey, nip19, z } from '@/deps.ts';
/** Load environment config from `.env` */
await dotenv.load({
@ -32,7 +32,7 @@ const Conf = {
get cryptoKey() {
return crypto.subtle.importKey(
'raw',
secp.etc.hexToBytes(Conf.seckey),
Conf.seckey,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],

View File

@ -1,7 +1,7 @@
import { Conf } from '@/config.ts';
import { Debug, type NostrFilter } from '@/deps.ts';
import * as pipeline from '@/pipeline.ts';
import { signAdminEvent } from '@/sign.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { eventsDB } from '@/storages.ts';
const debug = Debug('ditto:users');
@ -15,8 +15,9 @@ interface User {
function buildUserEvent(user: User) {
const { origin, host } = Conf.url;
const signer = new AdminSigner();
return signAdminEvent({
return signer.signEvent({
kind: 30361,
tags: [
['d', user.pubkey],

View File

@ -12,10 +12,9 @@ export { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts';
export { RelayPoolWorker } from 'npm:nostr-relaypool2@0.6.34';
export {
type EventTemplate,
finishEvent,
finalizeEvent,
getEventHash,
getPublicKey,
getSignature,
matchFilter,
matchFilters,
nip04,
@ -25,8 +24,8 @@ export {
nip21,
type UnsignedEvent,
type VerifiedEvent,
verifySignature,
} from 'npm:nostr-tools@^1.17.0';
verifyEvent,
} from 'npm:nostr-tools@^2.1.5';
export { parseFormData } from 'npm:formdata-helper@^0.3.0';
// @deno-types="npm:@types/lodash@4.14.194"
export { default as lodash } from 'https://esm.sh/lodash@4.17.21';
@ -82,6 +81,7 @@ export * as Comlink from 'npm:comlink@^4.4.1';
export { EventEmitter } from 'npm:tseep@^1.1.3';
export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0';
export { default as Debug } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.2.0/debug.ts';
export { Stickynotes } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.2.0/mod.ts';
export {
LNURL,
type LNURLDetails,
@ -89,6 +89,8 @@ export {
NIP05,
type NostrEvent,
type NostrFilter,
type NostrSigner,
NSecSigner,
NSet,
type NStore,
type NStoreOpts,

View File

@ -7,7 +7,7 @@ import {
validateAuthEvent,
} from '@/utils/nip98.ts';
import { localRequest } from '@/utils/api.ts';
import { signEvent } from '@/sign.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { findUser, User } from '@/db/users.ts';
/**
@ -91,7 +91,7 @@ function withProof(
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
const req = localRequest(c);
const reqEvent = await buildAuthEventTemplate(req, opts);
const resEvent = await signEvent(reqEvent, c, opts);
const resEvent = await new APISigner(c).signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) {

View File

@ -14,8 +14,8 @@ import { getTagSet } from '@/tags.ts';
import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts';
import { TrendsWorker } from '@/workers/trends.ts';
import { verifySignatureWorker } from '@/workers/verify.ts';
import { signAdminEvent } from '@/sign.ts';
import { verifyEventWorker } from '@/workers/verify.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { lnurlCache } from '@/utils/lnurl.ts';
const debug = Debug('ditto:pipeline');
@ -25,7 +25,7 @@ const debug = Debug('ditto:pipeline');
* It is idempotent, so it can be called multiple times for the same event.
*/
async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
if (!(await verifySignatureWorker(event))) return;
if (!(await verifyEventWorker(event))) return;
const wanted = reqmeister.isWanted(event);
if (await encounterEvent(event, signal)) return;
debug(`NostrEvent<${event.kind}> ${event.id}`);
@ -194,7 +194,9 @@ async function payZap(event: DittoEvent, signal: AbortSignal) {
{ fetch: fetchWorker, signal },
);
const nwcRequestEvent = await signAdminEvent({
const signer = new AdminSigner();
const nwcRequestEvent = await signer.signEvent({
kind: 23194,
content: await encryptAdmin(
event.pubkey,

View File

@ -1,6 +1,5 @@
import { getEventHash, verifySignature, z } from '@/deps.ts';
import { jsonSchema, safeUrlSchema } from '../schema.ts';
import { getEventHash, verifyEvent, z } from '@/deps.ts';
import { jsonSchema, safeUrlSchema } from '@/schema.ts';
/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */
const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/);
@ -21,7 +20,7 @@ const eventSchema = z.object({
/** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = eventSchema
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
.refine(verifySignature, 'Event signature is invalid');
.refine(verifyEvent, 'Event signature is invalid');
/** Nostr relay filter schema. */
const filterSchema = z.object({

View File

@ -1,121 +0,0 @@
import { type AppContext } from '@/app.ts';
import { Conf } from '@/config.ts';
import { decryptAdmin, encryptAdmin } from '@/crypto.ts';
import { Debug, type EventTemplate, finishEvent, HTTPException, type NostrEvent } from '@/deps.ts';
import { connectResponseSchema } from '@/schemas/nostr.ts';
import { jsonSchema } from '@/schema.ts';
import { Sub } from '@/subs.ts';
import { eventMatchesTemplate } from '@/utils.ts';
import { createAdminEvent } from '@/utils/api.ts';
const debug = Debug('ditto:sign');
interface SignEventOpts {
/** Target proof-of-work difficulty for the signed event. */
pow?: number;
}
/**
* Sign Nostr event using the app context.
*
* - If a secret key is provided, it will be used to sign the event.
* - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event.
*/
async function signEvent(
event: EventTemplate,
c: AppContext,
opts: SignEventOpts = {},
): Promise<NostrEvent> {
const seckey = c.get('seckey');
const header = c.req.header('x-nostr-sign');
if (seckey) {
debug(`Signing Event<${event.kind}> with secret key`);
return finishEvent(event, seckey);
}
if (header) {
debug(`Signing Event<${event.kind}> with NIP-46`);
return await signNostrConnect(event, c, opts);
}
throw new HTTPException(400, {
res: c.json({ id: 'ditto.sign', error: 'Unable to sign event' }, 400),
});
}
/** Sign event with NIP-46, waiting in the background for the signed event. */
async function signNostrConnect(
event: EventTemplate,
c: AppContext,
opts: SignEventOpts = {},
): Promise<NostrEvent> {
const pubkey = c.get('pubkey');
if (!pubkey) {
throw new HTTPException(401, { message: 'Missing pubkey' });
}
const messageId = crypto.randomUUID();
createAdminEvent({
kind: 24133,
content: await encryptAdmin(
pubkey,
JSON.stringify({
id: messageId,
method: 'sign_event',
params: [event, {
pow: opts.pow,
}],
}),
),
tags: [['p', pubkey]],
}, c);
return awaitSignedEvent(pubkey, messageId, event, c);
}
/** Wait for signed event to be sent through Nostr relay. */
async function awaitSignedEvent(
pubkey: string,
messageId: string,
template: EventTemplate,
c: AppContext,
): Promise<NostrEvent> {
const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]);
function close(): void {
Sub.close(messageId);
c.req.raw.signal.removeEventListener('abort', close);
}
c.req.raw.signal.addEventListener('abort', close);
for await (const event of sub) {
const decrypted = await decryptAdmin(event.pubkey, event.content);
const result = jsonSchema
.pipe(connectResponseSchema)
.refine((msg) => msg.id === messageId, 'Message ID mismatch')
.refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch')
.safeParse(decrypted);
if (result.success) {
close();
return result.data.result;
}
}
throw new HTTPException(408, {
res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }),
});
}
/** Sign event as the Ditto server. */
// deno-lint-ignore require-await
async function signAdminEvent(event: EventTemplate): Promise<NostrEvent> {
return finishEvent(event, Conf.seckey);
}
export { signAdminEvent, signEvent };

114
src/signers/APISigner.ts Normal file
View File

@ -0,0 +1,114 @@
import { type AppContext } from '@/app.ts';
import { Conf } from '@/config.ts';
import { decryptAdmin, encryptAdmin } from '@/crypto.ts';
import { HTTPException, type NostrEvent, type NostrSigner, NSecSigner, Stickynotes } from '@/deps.ts';
import { connectResponseSchema } from '@/schemas/nostr.ts';
import { jsonSchema } from '@/schema.ts';
import { Sub } from '@/subs.ts';
import { eventMatchesTemplate } from '@/utils.ts';
import { createAdminEvent } from '@/utils/api.ts';
/**
* Sign Nostr event using the app context.
*
* - If a secret key is provided, it will be used to sign the event.
* - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event.
*/
export class APISigner implements NostrSigner {
#c: AppContext;
#console = new Stickynotes('ditto:sign');
constructor(c: AppContext) {
this.#c = c;
}
// deno-lint-ignore require-await
async getPublicKey(): Promise<string> {
const pubkey = this.#c.get('pubkey');
if (pubkey) {
return pubkey;
} else {
throw new HTTPException(401, { message: 'Missing pubkey' });
}
}
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
const seckey = this.#c.get('seckey');
const header = this.#c.req.header('x-nostr-sign');
if (seckey) {
this.#console.debug(`Signing Event<${event.kind}> with secret key`);
return new NSecSigner(seckey).signEvent(event);
}
if (header) {
this.#console.debug(`Signing Event<${event.kind}> with NIP-46`);
return await this.#signNostrConnect(event);
}
throw new HTTPException(400, {
res: this.#c.json({ id: 'ditto.sign', error: 'Unable to sign event' }, 400),
});
}
/** Sign event with NIP-46, waiting in the background for the signed event. */
async #signNostrConnect(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
const pubkey = this.#c.get('pubkey');
if (!pubkey) {
throw new HTTPException(401, { message: 'Missing pubkey' });
}
const messageId = crypto.randomUUID();
createAdminEvent({
kind: 24133,
content: await encryptAdmin(
pubkey,
JSON.stringify({
id: messageId,
method: 'sign_event',
params: [event],
}),
),
tags: [['p', pubkey]],
}, this.#c);
return this.#awaitSignedEvent(pubkey, messageId, event);
}
/** Wait for signed event to be sent through Nostr relay. */
async #awaitSignedEvent(
pubkey: string,
messageId: string,
template: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>,
): Promise<NostrEvent> {
const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]);
const close = (): void => {
Sub.close(messageId);
this.#c.req.raw.signal.removeEventListener('abort', close);
};
this.#c.req.raw.signal.addEventListener('abort', close);
for await (const event of sub) {
const decrypted = await decryptAdmin(event.pubkey, event.content);
const result = jsonSchema
.pipe(connectResponseSchema)
.refine((msg) => msg.id === messageId, 'Message ID mismatch')
.refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch')
.safeParse(decrypted);
if (result.success) {
close();
return result.data.result;
}
}
throw new HTTPException(408, {
res: this.#c.json({ id: 'ditto.timeout', error: 'Signing timeout' }),
});
}
}

View File

@ -0,0 +1,9 @@
import { Conf } from '@/config.ts';
import { NSecSigner } from '@/deps.ts';
/** Sign events as the Ditto server. */
export class AdminSigner extends NSecSigner {
constructor() {
super(Conf.seckey);
}
}

View File

@ -12,7 +12,8 @@ import {
z,
} from '@/deps.ts';
import * as pipeline from '@/pipeline.ts';
import { signAdminEvent, signEvent } from '@/sign.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { eventsDB } from '@/storages.ts';
import { nostrNow } from '@/utils.ts';
@ -29,12 +30,14 @@ async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
throw new HTTPException(401);
}
const event = await signEvent({
const signer = new APISigner(c);
const event = await signer.signEvent({
content: '',
created_at: nostrNow(),
tags: [],
...t,
}, c);
});
return publishEvent(event, c);
}
@ -70,7 +73,9 @@ function updateListEvent(
/** Publish an admin event through the pipeline. */
async function createAdminEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
const event = await signAdminEvent({
const signer = new AdminSigner();
const event = await signer.signEvent({
content: '',
created_at: nostrNow(),
tags: [],

View File

@ -50,7 +50,7 @@ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthReque
}
/** Create an auth EventTemplate from a Request. */
async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise<EventTemplate<27235>> {
async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise<EventTemplate> {
const { validatePayload = true } = opts;
const { method, url } = req;

View File

@ -6,8 +6,8 @@ const worker = Comlink.wrap<typeof VerifyWorker>(
new Worker(new URL('./verify.worker.ts', import.meta.url), { type: 'module' }),
);
function verifySignatureWorker(event: NostrEvent): Promise<boolean> {
return worker.verifySignature(event);
function verifyEventWorker(event: NostrEvent): Promise<boolean> {
return worker.verifyEvent(event);
}
export { verifySignatureWorker };
export { verifyEventWorker };

View File

@ -1,8 +1,8 @@
import { Comlink, type NostrEvent, type VerifiedEvent, verifySignature } from '@/deps.ts';
import { Comlink, type NostrEvent, type VerifiedEvent, verifyEvent } from '@/deps.ts';
export const VerifyWorker = {
verifySignature(event: NostrEvent): event is VerifiedEvent {
return verifySignature(event);
verifyEvent(event: NostrEvent): event is VerifiedEvent {
return verifyEvent(event);
},
};