Support nip98 auth
This commit is contained in:
parent
c80a9e5d9a
commit
d7d5057617
|
@ -1,4 +1,5 @@
|
||||||
import { type Context, cors, type Handler, Hono, type HonoEnv, logger, type MiddlewareHandler } from '@/deps.ts';
|
import { type Context, cors, type Handler, Hono, type HonoEnv, logger, type MiddlewareHandler } from '@/deps.ts';
|
||||||
|
import { type Event } from '@/event.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
accountController,
|
accountController,
|
||||||
|
@ -26,7 +27,8 @@ import {
|
||||||
} from './controllers/api/statuses.ts';
|
} from './controllers/api/statuses.ts';
|
||||||
import { streamingController } from './controllers/api/streaming.ts';
|
import { streamingController } from './controllers/api/streaming.ts';
|
||||||
import { indexController } from './controllers/site.ts';
|
import { indexController } from './controllers/site.ts';
|
||||||
import { requireAuth, setAuth } from './middleware/auth.ts';
|
import { auth19, requireAuth } from './middleware/auth19.ts';
|
||||||
|
import { auth98 } from './middleware/auth98.ts';
|
||||||
|
|
||||||
interface AppEnv extends HonoEnv {
|
interface AppEnv extends HonoEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
|
@ -36,6 +38,8 @@ interface AppEnv extends HonoEnv {
|
||||||
seckey?: string;
|
seckey?: string;
|
||||||
/** UUID from the access token. Used for WebSocket event signing. */
|
/** UUID from the access token. Used for WebSocket event signing. */
|
||||||
session?: string;
|
session?: string;
|
||||||
|
/** NIP-98 signed event proving the pubkey is owned by the user. */
|
||||||
|
proof?: Event<27235>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +54,7 @@ app.use('*', logger());
|
||||||
app.get('/api/v1/streaming', streamingController);
|
app.get('/api/v1/streaming', streamingController);
|
||||||
app.get('/api/v1/streaming/', streamingController);
|
app.get('/api/v1/streaming/', streamingController);
|
||||||
|
|
||||||
app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), setAuth);
|
app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98());
|
||||||
|
|
||||||
app.get('/api/v1/instance', instanceController);
|
app.get('/api/v1/instance', instanceController);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { TOKEN_REGEX } from '@/middleware/auth.ts';
|
import { TOKEN_REGEX } from '@/middleware/auth19.ts';
|
||||||
import { streamSchema, ws } from '@/stream.ts';
|
import { streamSchema, ws } from '@/stream.ts';
|
||||||
import { bech32ToPubkey } from '@/utils.ts';
|
import { bech32ToPubkey } from '@/utils.ts';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppMiddleware } from '@/app.ts';
|
import { type AppMiddleware } from '@/app.ts';
|
||||||
import { getPublicKey, HTTPException, nip19 } from '@/deps.ts';
|
import { getPublicKey, HTTPException, nip19 } from '@/deps.ts';
|
||||||
|
|
||||||
/** The token includes a Bech32 Nostr ID (npub, nsec, etc) and an optional session ID. */
|
/** The token includes a Bech32 Nostr ID (npub, nsec, etc) and an optional session ID. */
|
||||||
|
@ -7,7 +7,7 @@ const TOKEN_REGEX = new RegExp(`(${nip19.BECH32_REGEX.source})(?:_(\\w+))?`);
|
||||||
const BEARER_REGEX = new RegExp(`^Bearer (${TOKEN_REGEX.source})$`);
|
const BEARER_REGEX = new RegExp(`^Bearer (${TOKEN_REGEX.source})$`);
|
||||||
|
|
||||||
/** NIP-19 auth middleware. */
|
/** NIP-19 auth middleware. */
|
||||||
const setAuth: AppMiddleware = async (c, next) => {
|
const auth19: AppMiddleware = async (c, next) => {
|
||||||
const authHeader = c.req.headers.get('authorization');
|
const authHeader = c.req.headers.get('authorization');
|
||||||
const match = authHeader?.match(BEARER_REGEX);
|
const match = authHeader?.match(BEARER_REGEX);
|
||||||
|
|
||||||
|
@ -47,4 +47,4 @@ const requireAuth: AppMiddleware = async (c, next) => {
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
export { requireAuth, setAuth, TOKEN_REGEX };
|
export { auth19, requireAuth, TOKEN_REGEX };
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { type AppMiddleware } from '@/app.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { HTTPException } from '@/deps.ts';
|
||||||
|
import { type Event } from '@/event.ts';
|
||||||
|
import { decode64Schema, jsonSchema, signedEventSchema } from '@/schema.ts';
|
||||||
|
import { eventAge, findTag, sha256, Time } from '@/utils.ts';
|
||||||
|
|
||||||
|
const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema);
|
||||||
|
|
||||||
|
interface Auth98Opts {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-98 auth.
|
||||||
|
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
||||||
|
*/
|
||||||
|
function auth98(opts: Auth98Opts = {}): AppMiddleware {
|
||||||
|
return async (c, next) => {
|
||||||
|
const authHeader = c.req.headers.get('authorization');
|
||||||
|
const base64 = authHeader?.match(/^Nostr (.+)$/)?.[1];
|
||||||
|
const { timeout = Time.minutes(1) } = opts;
|
||||||
|
|
||||||
|
const schema = decodeEventSchema
|
||||||
|
.refine((event) => event.kind === 27235)
|
||||||
|
.refine((event) => eventAge(event) < timeout)
|
||||||
|
.refine((event) => findTag(event.tags, 'method')?.[1] === c.req.method)
|
||||||
|
.refine((event) => {
|
||||||
|
const url = findTag(event.tags, 'u')?.[1];
|
||||||
|
try {
|
||||||
|
return url === localUrl(c.req.url);
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.refine(async (event) => {
|
||||||
|
const body = await c.req.raw.clone().text();
|
||||||
|
if (!body) return true;
|
||||||
|
const hash = findTag(event.tags, 'payload')?.[1];
|
||||||
|
return hash === await sha256(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await schema.safeParseAsync(base64);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
c.set('pubkey', result.data.pubkey);
|
||||||
|
c.set('proof', result.data as Event<27235>);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUrl(url: string): string {
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
return new URL(pathname, Conf.localDomain).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireProof: AppMiddleware = async (c, next) => {
|
||||||
|
const pubkey = c.get('pubkey');
|
||||||
|
const proof = c.get('proof');
|
||||||
|
|
||||||
|
// if (!proof && hasWebsocket(c.req)) {
|
||||||
|
// // TODO: attempt to sign nip98 event through websocket
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!pubkey || !proof || proof.pubkey !== pubkey) {
|
||||||
|
throw new HTTPException(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { auth98, requireProof };
|
|
@ -17,7 +17,7 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
|
||||||
const jsonSchema = z.string().transform((value, ctx) => {
|
const jsonSchema = z.string().transform((value, ctx) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value) as unknown;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
|
||||||
return z.NEVER;
|
return z.NEVER;
|
||||||
|
@ -77,13 +77,27 @@ const eventSchema = z.object({
|
||||||
created_at: z.number(),
|
created_at: z.number(),
|
||||||
pubkey: nostrIdSchema,
|
pubkey: nostrIdSchema,
|
||||||
sig: z.string(),
|
sig: z.string(),
|
||||||
}).refine(verifySignature);
|
});
|
||||||
|
|
||||||
|
const signedEventSchema = eventSchema.refine(verifySignature);
|
||||||
|
|
||||||
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
|
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
|
||||||
|
|
||||||
|
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
||||||
|
const decode64Schema = z.string().transform((value, ctx) => {
|
||||||
|
try {
|
||||||
|
const binString = atob(value);
|
||||||
|
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch (_e) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
decode64Schema,
|
||||||
emojiTagSchema,
|
emojiTagSchema,
|
||||||
eventSchema,
|
|
||||||
filteredArray,
|
filteredArray,
|
||||||
jsonSchema,
|
jsonSchema,
|
||||||
type MetaContent,
|
type MetaContent,
|
||||||
|
@ -91,4 +105,5 @@ export {
|
||||||
parseMetaContent,
|
parseMetaContent,
|
||||||
parseRelay,
|
parseRelay,
|
||||||
relaySchema,
|
relaySchema,
|
||||||
|
signedEventSchema,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { type AppContext } from '@/app.ts';
|
import { type AppContext } from '@/app.ts';
|
||||||
import { getEventHash, getPublicKey, getSignature, HTTPException, z } from '@/deps.ts';
|
import { getEventHash, getPublicKey, getSignature, HTTPException, z } from '@/deps.ts';
|
||||||
import { eventSchema } from '@/schema.ts';
|
import { signedEventSchema } from '@/schema.ts';
|
||||||
import { ws } from '@/stream.ts';
|
import { ws } from '@/stream.ts';
|
||||||
|
|
||||||
import type { Event, EventTemplate, SignedEvent } from '@/event.ts';
|
import type { Event, EventTemplate, SignedEvent } from '@/event.ts';
|
||||||
|
@ -18,7 +18,7 @@ function getSignStream(c: AppContext): WebSocket | undefined {
|
||||||
|
|
||||||
const nostrStreamingEventSchema = z.object({
|
const nostrStreamingEventSchema = z.object({
|
||||||
type: z.literal('nostr.sign'),
|
type: z.literal('nostr.sign'),
|
||||||
data: eventSchema,
|
data: signedEventSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
38
src/utils.ts
38
src/utils.ts
|
@ -101,10 +101,46 @@ function buildLinkHeader(url: string, events: Event[]): string | undefined {
|
||||||
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the event's age in milliseconds. */
|
||||||
|
function eventAge(event: Event): number {
|
||||||
|
return new Date().getTime() - nostrDate(event.created_at).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Time = {
|
||||||
|
milliseconds: (ms: number) => ms,
|
||||||
|
seconds: (s: number) => s * 1000,
|
||||||
|
minutes: (m: number) => m * Time.seconds(60),
|
||||||
|
hours: (h: number) => h * Time.minutes(60),
|
||||||
|
days: (d: number) => d * Time.hours(24),
|
||||||
|
weeks: (w: number) => w * Time.days(7),
|
||||||
|
months: (m: number) => m * Time.days(30),
|
||||||
|
years: (y: number) => y * Time.days(365),
|
||||||
|
};
|
||||||
|
|
||||||
|
function findTag(tags: string[][], name: string): string[] | undefined {
|
||||||
|
return tags.find((tag) => tag[0] === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sha256 hash (hex) of some text.
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
|
||||||
|
*/
|
||||||
|
async function sha256(message: string): Promise<string> {
|
||||||
|
const msgUint8 = new TextEncoder().encode(message);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
bech32ToPubkey,
|
bech32ToPubkey,
|
||||||
buildLinkHeader,
|
buildLinkHeader,
|
||||||
|
eventAge,
|
||||||
eventDateComparator,
|
eventDateComparator,
|
||||||
|
findTag,
|
||||||
lookupAccount,
|
lookupAccount,
|
||||||
type Nip05,
|
type Nip05,
|
||||||
nostrDate,
|
nostrDate,
|
||||||
|
@ -113,4 +149,6 @@ export {
|
||||||
paginationSchema,
|
paginationSchema,
|
||||||
parseBody,
|
parseBody,
|
||||||
parseNip05,
|
parseNip05,
|
||||||
|
sha256,
|
||||||
|
Time,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue