mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'signer-pubkey' into 'main'
Draft: Get the pubkey from NIP-46 See merge request soapbox-pub/ditto!563
This commit is contained in:
commit
dd86c87064
6 changed files with 99 additions and 81 deletions
|
|
@ -177,7 +177,7 @@ app.use('/users/*', metricsMiddleware, logger(debug));
|
|||
app.use('/nodeinfo/*', metricsMiddleware, logger(debug));
|
||||
app.use('/oauth/*', metricsMiddleware, logger(debug));
|
||||
|
||||
app.get('/api/v1/streaming', metricsMiddleware, streamingController);
|
||||
app.get('/api/v1/streaming', metricsMiddleware, signerMiddleware, streamingController);
|
||||
app.get('/relay', metricsMiddleware, relayController);
|
||||
|
||||
app.use(
|
||||
|
|
|
|||
|
|
@ -342,6 +342,13 @@ class Conf {
|
|||
ttl: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
/** Signer cache settings. */
|
||||
get signer(): { max: number; ttl: number } {
|
||||
return {
|
||||
max: Number(Deno.env.get('DITTO_CACHE_SIGNER_MAX') || 1000),
|
||||
ttl: Number(Deno.env.get('DITTO_CACHE_SIGNER_TTL') || 1 * 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
|
|||
import { getFeedPubkeys } from '@/queries.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
import { bech32ToPubkey, Time } from '@/utils.ts';
|
||||
import { Time } from '@/utils.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
||||
|
|
@ -70,7 +69,8 @@ const connections = new Set<WebSocket>();
|
|||
|
||||
const streamingController: AppController = async (c) => {
|
||||
const upgrade = c.req.header('upgrade');
|
||||
const token = c.req.header('sec-websocket-protocol');
|
||||
const protocol = c.req.header('sec-websocket-protocol');
|
||||
const signer = c.get('signer');
|
||||
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
|
||||
const controller = new AbortController();
|
||||
|
||||
|
|
@ -78,11 +78,6 @@ const streamingController: AppController = async (c) => {
|
|||
return c.text('Please use websocket protocol', 400);
|
||||
}
|
||||
|
||||
const pubkey = token ? await getTokenPubkey(token) : undefined;
|
||||
if (token && !pubkey) {
|
||||
return c.json({ error: 'Invalid access token' }, 401);
|
||||
}
|
||||
|
||||
const ip = c.req.header('x-real-ip');
|
||||
if (ip) {
|
||||
const count = limiter.get(ip) ?? 0;
|
||||
|
|
@ -91,11 +86,12 @@ const streamingController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol, idleTimeout: 30 });
|
||||
|
||||
const store = await Storages.db();
|
||||
const pubsub = await Storages.pubsub();
|
||||
|
||||
const pubkey = await signer?.getPublicKey();
|
||||
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
|
||||
|
||||
function send(e: StreamingEvent) {
|
||||
|
|
@ -231,21 +227,4 @@ async function topicToFilter(
|
|||
}
|
||||
}
|
||||
|
||||
async function getTokenPubkey(token: string): Promise<string | undefined> {
|
||||
if (token.startsWith('token1')) {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(token as `token1${string}`);
|
||||
|
||||
const { pubkey } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select('pubkey')
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return pubkey;
|
||||
} else {
|
||||
return bech32ToPubkey(token);
|
||||
}
|
||||
}
|
||||
|
||||
export { streamingController };
|
||||
|
|
|
|||
|
|
@ -126,6 +126,11 @@ export const cachedTranslationsSizeGauge = new Gauge({
|
|||
help: 'Number of translated statuses in cache',
|
||||
});
|
||||
|
||||
export const cachedSignersSizeGauge = new Gauge({
|
||||
name: 'ditto_cached_signers_size',
|
||||
help: 'Number of signers in cache',
|
||||
});
|
||||
|
||||
export const internalSubscriptionsSizeGauge = new Gauge({
|
||||
name: 'ditto_internal_subscriptions_size',
|
||||
help: "Number of active subscriptions to Ditto's internal relay",
|
||||
|
|
|
|||
|
|
@ -1,63 +1,79 @@
|
|||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { type NostrSigner, NSecSigner } from '@nostrify/nostrify';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { AppMiddleware } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { cachedSignersSizeGauge } from '@/metrics.ts';
|
||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { aesDecrypt } from '@/utils/aes.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
/** We only accept "Bearer" type. */
|
||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||
const signerCache = new LRUCache<string, NostrSigner>(Conf.caches.signer);
|
||||
|
||||
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
|
||||
export const signerMiddleware: AppMiddleware = async (c, next) => {
|
||||
const header = c.req.header('authorization');
|
||||
const match = header?.match(BEARER_REGEX);
|
||||
const accessToken = getAccessToken(c.req.raw);
|
||||
|
||||
if (match) {
|
||||
const [_, bech32] = match;
|
||||
const cached = accessToken ? signerCache.get(accessToken) : undefined;
|
||||
|
||||
if (bech32.startsWith('token1')) {
|
||||
try {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||
if (cached) {
|
||||
c.set('signer', cached);
|
||||
} else if (accessToken?.startsWith('token1')) {
|
||||
try {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(accessToken as `token1${string}`);
|
||||
|
||||
const { pubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select(['pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
const { pubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select(['pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc);
|
||||
const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc);
|
||||
const signer = new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays);
|
||||
|
||||
c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays));
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const decoded = nip19.decode(bech32!);
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
c.set('signer', new ReadOnlySigner(decoded.data));
|
||||
break;
|
||||
case 'nprofile':
|
||||
c.set('signer', new ReadOnlySigner(decoded.data.pubkey));
|
||||
break;
|
||||
case 'nsec':
|
||||
c.set('signer', new NSecSigner(decoded.data));
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
signerCache.set(accessToken, signer);
|
||||
cachedSignersSizeGauge.set(signerCache.size);
|
||||
|
||||
c.set('signer', signer);
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
} else if (accessToken) {
|
||||
try {
|
||||
const decoded = nip19.decode(accessToken!);
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
c.set('signer', new ReadOnlySigner(decoded.data));
|
||||
break;
|
||||
case 'nprofile':
|
||||
c.set('signer', new ReadOnlySigner(decoded.data.pubkey));
|
||||
break;
|
||||
case 'nsec':
|
||||
c.set('signer', new NSecSigner(decoded.data));
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
throw new HTTPException(401, { message: 'Invalid access token' });
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
/** Extract the access token from the request headers. */
|
||||
function getAccessToken(req: Request): string | undefined {
|
||||
const upgrade = req.headers.get('upgrade');
|
||||
|
||||
// WebSockets use the `sec-websocket-protocol` header instead of `authorization`.
|
||||
if (upgrade === 'websocket') {
|
||||
return req.headers.get('sec-websocket-protocol') ?? undefined;
|
||||
}
|
||||
|
||||
return req.headers.get('authorization')?.match(/^Bearer (.+)$/)?.[1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ import { Storages } from '@/storages.ts';
|
|||
*/
|
||||
export class ConnectSigner implements NostrSigner {
|
||||
private signer: Promise<NConnectSigner>;
|
||||
private cachedPubkey: Promise<string> | undefined;
|
||||
|
||||
constructor(private pubkey: string, signer: NostrSigner, private relays?: string[]) {
|
||||
this.signer = this.init(signer);
|
||||
constructor(pubkey: string, signer: NostrSigner, private relays?: string[]) {
|
||||
this.signer = this.init(pubkey, signer);
|
||||
}
|
||||
|
||||
async init(signer: NostrSigner): Promise<NConnectSigner> {
|
||||
async init(pubkey: string, signer: NostrSigner): Promise<NConnectSigner> {
|
||||
return new NConnectSigner({
|
||||
pubkey: this.pubkey,
|
||||
pubkey,
|
||||
// TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list)
|
||||
relay: await Storages.pubsub(),
|
||||
signer,
|
||||
|
|
@ -30,8 +31,8 @@ export class ConnectSigner implements NostrSigner {
|
|||
const signer = await this.signer;
|
||||
try {
|
||||
return await signer.signEvent(event);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, { message: 'The event was not signed quickly enough' });
|
||||
} else {
|
||||
throw e;
|
||||
|
|
@ -44,8 +45,8 @@ export class ConnectSigner implements NostrSigner {
|
|||
const signer = await this.signer;
|
||||
try {
|
||||
return await signer.nip04.encrypt(pubkey, plaintext);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, {
|
||||
message: 'Text was not encrypted quickly enough',
|
||||
});
|
||||
|
|
@ -59,8 +60,8 @@ export class ConnectSigner implements NostrSigner {
|
|||
const signer = await this.signer;
|
||||
try {
|
||||
return await signer.nip04.decrypt(pubkey, ciphertext);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, {
|
||||
message: 'Text was not decrypted quickly enough',
|
||||
});
|
||||
|
|
@ -76,8 +77,8 @@ export class ConnectSigner implements NostrSigner {
|
|||
const signer = await this.signer;
|
||||
try {
|
||||
return await signer.nip44.encrypt(pubkey, plaintext);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, {
|
||||
message: 'Text was not encrypted quickly enough',
|
||||
});
|
||||
|
|
@ -91,8 +92,8 @@ export class ConnectSigner implements NostrSigner {
|
|||
const signer = await this.signer;
|
||||
try {
|
||||
return await signer.nip44.decrypt(pubkey, ciphertext);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, {
|
||||
message: 'Text was not decrypted quickly enough',
|
||||
});
|
||||
|
|
@ -103,9 +104,19 @@ export class ConnectSigner implements NostrSigner {
|
|||
},
|
||||
};
|
||||
|
||||
// Prevent unnecessary NIP-46 round-trips.
|
||||
async getPublicKey(): Promise<string> {
|
||||
return this.pubkey;
|
||||
const signer = await this.signer;
|
||||
this.cachedPubkey = this.cachedPubkey ?? signer.getPublicKey();
|
||||
try {
|
||||
return await this.cachedPubkey;
|
||||
} catch (e) {
|
||||
this.cachedPubkey = undefined;
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
throw new HTTPException(408, { message: 'Public key not received quickly enough' });
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the user's relays if they passed in an `nprofile` auth token. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue