mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Rework auth tokens table to use hashed/encrypted data
This commit is contained in:
parent
e73a8d71dc
commit
432857c2ff
6 changed files with 102 additions and 44 deletions
|
|
@ -1,14 +1,14 @@
|
|||
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
||||
import { bech32 } from '@scure/base';
|
||||
import { escape } from 'entities';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { encryptSecretKey, generateToken } from '@/utils/auth.ts';
|
||||
|
||||
const passwordGrantSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
|
|
@ -82,38 +82,30 @@ async function getToken(
|
|||
{ pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
|
||||
): Promise<`token1${string}`> {
|
||||
const kysely = await Storages.kysely();
|
||||
const token = generateToken();
|
||||
const { token, hash } = await generateToken();
|
||||
|
||||
const serverSeckey = generateSecretKey();
|
||||
const serverPubkey = getPublicKey(serverSeckey);
|
||||
const nip46Seckey = generateSecretKey();
|
||||
|
||||
const signer = new NConnectSigner({
|
||||
pubkey,
|
||||
signer: new NSecSigner(serverSeckey),
|
||||
signer: new NSecSigner(nip46Seckey),
|
||||
relay: await Storages.pubsub(), // TODO: Use the relays from the request.
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
await signer.connect(secret);
|
||||
|
||||
await kysely.insertInto('nip46_tokens').values({
|
||||
api_token: token,
|
||||
user_pubkey: pubkey,
|
||||
server_seckey: serverSeckey,
|
||||
server_pubkey: serverPubkey,
|
||||
relays: JSON.stringify(relays),
|
||||
connected_at: new Date(),
|
||||
await kysely.insertInto('auth_tokens').values({
|
||||
token_hash: hash,
|
||||
pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
||||
nip46_relays: relays,
|
||||
created_at: new Date(),
|
||||
}).execute();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Generate a bech32 token for the API. */
|
||||
function generateToken(): `token1${string}` {
|
||||
const words = bech32.toWords(generateSecretKey());
|
||||
return bech32.encode('token', words);
|
||||
}
|
||||
|
||||
/** Display the OAuth form. */
|
||||
const oauthController: AppController = (c) => {
|
||||
const encodedUri = c.req.query('redirect_uri');
|
||||
|
|
|
|||
|
|
@ -14,6 +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 { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
|
@ -233,14 +234,15 @@ 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 { user_pubkey } = await kysely
|
||||
.selectFrom('nip46_tokens')
|
||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
||||
.where('api_token', '=', token)
|
||||
const { pubkey } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select('pubkey')
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return user_pubkey;
|
||||
return pubkey;
|
||||
} else {
|
||||
return bech32ToPubkey(token);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { NPostgresSchema } from '@nostrify/db';
|
|||
|
||||
export interface DittoTables extends NPostgresSchema {
|
||||
nostr_events: NostrEventsRow;
|
||||
nip46_tokens: NIP46TokenRow;
|
||||
auth_tokens: AuthTokenRow;
|
||||
author_stats: AuthorStatsRow;
|
||||
event_stats: EventStatsRow;
|
||||
pubkey_domains: PubkeyDomainRow;
|
||||
|
|
@ -33,13 +33,12 @@ interface EventStatsRow {
|
|||
zaps_amount: number;
|
||||
}
|
||||
|
||||
interface NIP46TokenRow {
|
||||
api_token: string;
|
||||
user_pubkey: string;
|
||||
server_seckey: Uint8Array;
|
||||
server_pubkey: string;
|
||||
relays: string;
|
||||
connected_at: Date;
|
||||
interface AuthTokenRow {
|
||||
token_hash: Uint8Array;
|
||||
pubkey: string;
|
||||
nip46_sk_enc: Uint8Array;
|
||||
nip46_relays: string[];
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface PubkeyDomainRow {
|
||||
|
|
|
|||
52
src/db/migrations/037_auth_tokens.ts
Normal file
52
src/db/migrations/037_auth_tokens.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
|
||||
interface DB {
|
||||
nip46_tokens: {
|
||||
api_token: `token1${string}`;
|
||||
user_pubkey: string;
|
||||
server_seckey: Uint8Array;
|
||||
server_pubkey: string;
|
||||
relays: string;
|
||||
connected_at: Date;
|
||||
};
|
||||
auth_tokens: {
|
||||
token_hash: Uint8Array;
|
||||
pubkey: string;
|
||||
nip46_sk_enc: Uint8Array;
|
||||
nip46_relays: string[];
|
||||
created_at: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export async function up(db: Kysely<DB>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('auth_tokens')
|
||||
.addColumn('token_hash', 'bytea', (col) => col.primaryKey())
|
||||
.addColumn('pubkey', 'char(64)', (col) => col.notNull())
|
||||
.addColumn('nip46_sk_enc', 'bytea', (col) => col.notNull())
|
||||
.addColumn('nip46_relays', 'jsonb', (col) => col.defaultTo('[]'))
|
||||
.addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||
.execute();
|
||||
|
||||
// There are probably not that many tokens in the database yet, so this should be fine.
|
||||
const tokens = await db.selectFrom('nip46_tokens').selectAll().execute();
|
||||
|
||||
for (const token of tokens) {
|
||||
await db.insertInto('auth_tokens').values({
|
||||
token_hash: await getTokenHash(token.api_token),
|
||||
pubkey: token.user_pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, token.server_seckey),
|
||||
nip46_relays: JSON.parse(token.relays),
|
||||
created_at: token.connected_at,
|
||||
}).execute();
|
||||
}
|
||||
|
||||
await db.schema.dropTable('nip46_tokens').execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<DB>): Promise<void> {
|
||||
await db.schema.dropTable('auth_tokens').execute();
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ import { NSecSigner } from '@nostrify/nostrify';
|
|||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { AppMiddleware } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
/** We only accept "Bearer" type. */
|
||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||
|
|
@ -21,14 +23,17 @@ export const signerMiddleware: AppMiddleware = async (c, next) => {
|
|||
if (bech32.startsWith('token1')) {
|
||||
try {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||
|
||||
const { user_pubkey, server_seckey, relays } = await kysely
|
||||
.selectFrom('nip46_tokens')
|
||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
||||
.where('api_token', '=', bech32)
|
||||
const { pubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select(['pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
c.set('signer', new ConnectSigner(user_pubkey, new NSecSigner(server_seckey), JSON.parse(relays)));
|
||||
const nep46Seckey = await decryptSecretKey(Conf.seckey, nip46_sk_enc);
|
||||
|
||||
c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays));
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { encodeHex } from '@std/encoding/hex';
|
||||
import { EventTemplate, nip13 } from 'nostr-tools';
|
||||
|
||||
import { decode64Schema } from '@/schema.ts';
|
||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
||||
import { eventAge, findTag, nostrNow, sha256 } from '@/utils.ts';
|
||||
import { eventAge, findTag, nostrNow } from '@/utils.ts';
|
||||
import { Time } from '@/utils/time.ts';
|
||||
|
||||
/** Decode a Nostr event from a base64 encoded string. */
|
||||
|
|
@ -41,11 +42,10 @@ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthReque
|
|||
.refine((event) => pow ? nip13.getPow(event.id) >= pow : true, 'Insufficient proof of work')
|
||||
.refine(validateBody, 'Event payload does not match request body');
|
||||
|
||||
function validateBody(event: NostrEvent) {
|
||||
async function validateBody(event: NostrEvent): Promise<boolean> {
|
||||
if (!validatePayload) return true;
|
||||
return req.clone().text()
|
||||
.then(sha256)
|
||||
.then((hash) => hash === tagValue(event, 'payload'));
|
||||
const payload = await getPayload(req);
|
||||
return payload === tagValue(event, 'payload');
|
||||
}
|
||||
|
||||
return schema.safeParseAsync(event);
|
||||
|
|
@ -62,7 +62,7 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts =
|
|||
];
|
||||
|
||||
if (validatePayload) {
|
||||
const payload = await req.clone().text().then(sha256);
|
||||
const payload = await getPayload(req);
|
||||
tags.push(['payload', payload]);
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +74,14 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts =
|
|||
};
|
||||
}
|
||||
|
||||
/** Get a SHA-256 hash of the request body encoded as a hex string. */
|
||||
async function getPayload(req: Request): Promise<string> {
|
||||
const text = await req.clone().text();
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const buffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return encodeHex(buffer);
|
||||
}
|
||||
|
||||
/** Get the value for the first matching tag name in the event. */
|
||||
function tagValue(event: NostrEvent, tagName: string): string | undefined {
|
||||
return findTag(event.tags, tagName)?.[1];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue