Merge branch 'api-signer' into 'main'
APISigner, AdminSigner, implement NostrSigner interface See merge request soapbox-pub/ditto!112
This commit is contained in:
commit
d60bf9cbcc
|
@ -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: [],
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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],
|
||||
|
|
10
src/deps.ts
10
src/deps.ts
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
121
src/sign.ts
121
src/sign.ts
|
@ -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 };
|
|
@ -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' }),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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: [],
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue