Add APISigner and AdminSigner classes, implement NostrSigner interface

This commit is contained in:
Alex Gleason 2024-02-12 11:52:05 -06:00
parent 1e3f637358
commit 59d53c4a2f
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
9 changed files with 146 additions and 133 deletions

View File

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

View File

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

View File

@ -81,6 +81,7 @@ export * as Comlink from 'npm:comlink@^4.4.1';
export { EventEmitter } from 'npm:tseep@^1.1.3'; export { EventEmitter } from 'npm:tseep@^1.1.3';
export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; 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 { 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 { export {
LNURL, LNURL,
type LNURLDetails, type LNURLDetails,

View File

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

View File

@ -15,7 +15,7 @@ import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts';
import { fetchWorker } from '@/workers/fetch.ts'; import { fetchWorker } from '@/workers/fetch.ts';
import { TrendsWorker } from '@/workers/trends.ts'; import { TrendsWorker } from '@/workers/trends.ts';
import { verifyEventWorker } from '@/workers/verify.ts'; import { verifyEventWorker } from '@/workers/verify.ts';
import { signAdminEvent } from '@/sign.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { lnurlCache } from '@/utils/lnurl.ts'; import { lnurlCache } from '@/utils/lnurl.ts';
const debug = Debug('ditto:pipeline'); const debug = Debug('ditto:pipeline');
@ -194,7 +194,9 @@ async function payZap(event: DittoEvent, signal: AbortSignal) {
{ fetch: fetchWorker, signal }, { fetch: fetchWorker, signal },
); );
const nwcRequestEvent = await signAdminEvent({ const signer = new AdminSigner();
const nwcRequestEvent = await signer.signEvent({
kind: 23194, kind: 23194,
content: await encryptAdmin( content: await encryptAdmin(
event.pubkey, event.pubkey,

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