mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'hash-tokens' into 'main'
Rework auth tokens table to use hashed/encrypted data Closes #234 See merge request soapbox-pub/ditto!526
This commit is contained in:
commit
33980d54f3
12 changed files with 247 additions and 101 deletions
30
deno.json
30
deno.json
|
|
@ -22,8 +22,15 @@
|
||||||
"clean:deps": "deno cache --reload src/app.ts",
|
"clean:deps": "deno cache --reload src/app.ts",
|
||||||
"db:populate-search": "deno run -A scripts/db-populate-search.ts"
|
"db:populate-search": "deno run -A scripts/db-populate-search.ts"
|
||||||
},
|
},
|
||||||
"unstable": ["cron", "ffi", "kv", "worker-options"],
|
"unstable": [
|
||||||
"exclude": ["./public"],
|
"cron",
|
||||||
|
"ffi",
|
||||||
|
"kv",
|
||||||
|
"worker-options"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"./public"
|
||||||
|
],
|
||||||
"imports": {
|
"imports": {
|
||||||
"@/": "./src/",
|
"@/": "./src/",
|
||||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||||
|
|
@ -68,7 +75,6 @@
|
||||||
"linkify-string": "npm:linkify-string@^4.1.1",
|
"linkify-string": "npm:linkify-string@^4.1.1",
|
||||||
"linkifyjs": "npm:linkifyjs@^4.1.1",
|
"linkifyjs": "npm:linkifyjs@^4.1.1",
|
||||||
"lru-cache": "npm:lru-cache@^10.2.2",
|
"lru-cache": "npm:lru-cache@^10.2.2",
|
||||||
"nostr-relaypool": "npm:nostr-relaypool2@0.6.34",
|
|
||||||
"nostr-tools": "npm:nostr-tools@2.5.1",
|
"nostr-tools": "npm:nostr-tools@2.5.1",
|
||||||
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
||||||
"path-to-regexp": "npm:path-to-regexp@^7.1.0",
|
"path-to-regexp": "npm:path-to-regexp@^7.1.0",
|
||||||
|
|
@ -84,14 +90,24 @@
|
||||||
"~/fixtures/": "./fixtures/"
|
"~/fixtures/": "./fixtures/"
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"include": ["src/", "scripts/"],
|
"include": [
|
||||||
|
"src/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"tags": ["recommended"],
|
"tags": [
|
||||||
"exclude": ["no-explicit-any"]
|
"recommended"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"no-explicit-any"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"include": ["src/", "scripts/"],
|
"include": [
|
||||||
|
"src/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"lineWidth": 120,
|
"lineWidth": 120,
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
|
|
|
||||||
35
deno.lock
generated
35
deno.lock
generated
|
|
@ -97,7 +97,6 @@
|
||||||
"npm:lint-staged": "npm:lint-staged@15.2.2",
|
"npm:lint-staged": "npm:lint-staged@15.2.2",
|
||||||
"npm:lru-cache@^10.2.0": "npm:lru-cache@10.2.2",
|
"npm:lru-cache@^10.2.0": "npm:lru-cache@10.2.2",
|
||||||
"npm:lru-cache@^10.2.2": "npm:lru-cache@10.2.2",
|
"npm:lru-cache@^10.2.2": "npm:lru-cache@10.2.2",
|
||||||
"npm:nostr-relaypool2@0.6.34": "npm:nostr-relaypool2@0.6.34",
|
|
||||||
"npm:nostr-tools@2.5.1": "npm:nostr-tools@2.5.1",
|
"npm:nostr-tools@2.5.1": "npm:nostr-tools@2.5.1",
|
||||||
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1",
|
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1",
|
||||||
"npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0",
|
"npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0",
|
||||||
|
|
@ -583,10 +582,6 @@
|
||||||
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
|
"integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
},
|
},
|
||||||
"@noble/ciphers@0.2.0": {
|
|
||||||
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
|
|
||||||
"dependencies": {}
|
|
||||||
},
|
|
||||||
"@noble/ciphers@0.5.3": {
|
"@noble/ciphers@0.5.3": {
|
||||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|
@ -988,12 +983,6 @@
|
||||||
"jsdom": "jsdom@24.0.0"
|
"jsdom": "jsdom@24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"isomorphic-ws@5.0.0_ws@8.17.0": {
|
|
||||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
|
||||||
"dependencies": {
|
|
||||||
"ws": "ws@8.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jsdom@24.0.0": {
|
"jsdom@24.0.0": {
|
||||||
"integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==",
|
"integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1154,25 +1143,6 @@
|
||||||
"whatwg-url": "whatwg-url@5.0.0"
|
"whatwg-url": "whatwg-url@5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nostr-relaypool2@0.6.34": {
|
|
||||||
"integrity": "sha512-e3FDh9w/wQkY513mvoJps1Hc/Y5wiWXeBM6MD+YKSyAg+px+/8uHSSHAuHhlavw7oOEOvEsIGlMDMc57DG3MOA==",
|
|
||||||
"dependencies": {
|
|
||||||
"isomorphic-ws": "isomorphic-ws@5.0.0_ws@8.17.0",
|
|
||||||
"nostr-tools": "nostr-tools@1.17.0",
|
|
||||||
"safe-stable-stringify": "safe-stable-stringify@2.4.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nostr-tools@1.17.0": {
|
|
||||||
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/ciphers": "@noble/ciphers@0.2.0",
|
|
||||||
"@noble/curves": "@noble/curves@1.1.0",
|
|
||||||
"@noble/hashes": "@noble/hashes@1.3.1",
|
|
||||||
"@scure/base": "@scure/base@1.1.1",
|
|
||||||
"@scure/bip32": "@scure/bip32@1.3.1",
|
|
||||||
"@scure/bip39": "@scure/bip39@1.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nostr-tools@2.5.1": {
|
"nostr-tools@2.5.1": {
|
||||||
"integrity": "sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ==",
|
"integrity": "sha512-bpkhGGAhdiCN0irfV+xoH3YP5CQeOXyXzUq7SYeM6D56xwTXZCPEmBlUGqFVfQidvRsoVeVxeAiOXW2c2HxoRQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1303,10 +1273,6 @@
|
||||||
"integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
|
"integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
},
|
},
|
||||||
"safe-stable-stringify@2.4.3": {
|
|
||||||
"integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==",
|
|
||||||
"dependencies": {}
|
|
||||||
},
|
|
||||||
"safer-buffer@2.1.2": {
|
"safer-buffer@2.1.2": {
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
|
|
@ -2183,7 +2149,6 @@
|
||||||
"npm:linkify-string@^4.1.1",
|
"npm:linkify-string@^4.1.1",
|
||||||
"npm:linkifyjs@^4.1.1",
|
"npm:linkifyjs@^4.1.1",
|
||||||
"npm:lru-cache@^10.2.2",
|
"npm:lru-cache@^10.2.2",
|
||||||
"npm:nostr-relaypool2@0.6.34",
|
|
||||||
"npm:nostr-tools@2.5.1",
|
"npm:nostr-tools@2.5.1",
|
||||||
"npm:nostr-wasm@^0.1.0",
|
"npm:nostr-wasm@^0.1.0",
|
||||||
"npm:path-to-regexp@^7.1.0",
|
"npm:path-to-regexp@^7.1.0",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
||||||
import { bech32 } from '@scure/base';
|
|
||||||
import { escape } from 'entities';
|
import { escape } from 'entities';
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
import { parseBody } from '@/utils/api.ts';
|
import { parseBody } from '@/utils/api.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { encryptSecretKey, generateToken } from '@/utils/auth.ts';
|
||||||
|
|
||||||
const passwordGrantSchema = z.object({
|
const passwordGrantSchema = z.object({
|
||||||
grant_type: z.literal('password'),
|
grant_type: z.literal('password'),
|
||||||
|
|
@ -82,38 +82,30 @@ async function getToken(
|
||||||
{ pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
|
{ pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
|
||||||
): Promise<`token1${string}`> {
|
): Promise<`token1${string}`> {
|
||||||
const kysely = await Storages.kysely();
|
const kysely = await Storages.kysely();
|
||||||
const token = generateToken();
|
const { token, hash } = await generateToken();
|
||||||
|
|
||||||
const serverSeckey = generateSecretKey();
|
const nip46Seckey = generateSecretKey();
|
||||||
const serverPubkey = getPublicKey(serverSeckey);
|
|
||||||
|
|
||||||
const signer = new NConnectSigner({
|
const signer = new NConnectSigner({
|
||||||
pubkey,
|
pubkey,
|
||||||
signer: new NSecSigner(serverSeckey),
|
signer: new NSecSigner(nip46Seckey),
|
||||||
relay: await Storages.pubsub(), // TODO: Use the relays from the request.
|
relay: await Storages.pubsub(), // TODO: Use the relays from the request.
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await signer.connect(secret);
|
await signer.connect(secret);
|
||||||
|
|
||||||
await kysely.insertInto('nip46_tokens').values({
|
await kysely.insertInto('auth_tokens').values({
|
||||||
api_token: token,
|
token_hash: hash,
|
||||||
user_pubkey: pubkey,
|
pubkey,
|
||||||
server_seckey: serverSeckey,
|
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
||||||
server_pubkey: serverPubkey,
|
nip46_relays: relays,
|
||||||
relays: JSON.stringify(relays),
|
created_at: new Date(),
|
||||||
connected_at: new Date(),
|
|
||||||
}).execute();
|
}).execute();
|
||||||
|
|
||||||
return token;
|
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. */
|
/** Display the OAuth form. */
|
||||||
const oauthController: AppController = (c) => {
|
const oauthController: AppController = (c) => {
|
||||||
const encodedUri = c.req.query('redirect_uri');
|
const encodedUri = c.req.query('redirect_uri');
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts';
|
||||||
import { getFeedPubkeys } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
|
import { getTokenHash } from '@/utils/auth.ts';
|
||||||
import { bech32ToPubkey, Time } from '@/utils.ts';
|
import { bech32ToPubkey, Time } from '@/utils.ts';
|
||||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
|
|
@ -233,14 +234,15 @@ async function topicToFilter(
|
||||||
async function getTokenPubkey(token: string): Promise<string | undefined> {
|
async function getTokenPubkey(token: string): Promise<string | undefined> {
|
||||||
if (token.startsWith('token1')) {
|
if (token.startsWith('token1')) {
|
||||||
const kysely = await Storages.kysely();
|
const kysely = await Storages.kysely();
|
||||||
|
const tokenHash = await getTokenHash(token as `token1${string}`);
|
||||||
|
|
||||||
const { user_pubkey } = await kysely
|
const { pubkey } = await kysely
|
||||||
.selectFrom('nip46_tokens')
|
.selectFrom('auth_tokens')
|
||||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
.select('pubkey')
|
||||||
.where('api_token', '=', token)
|
.where('token_hash', '=', tokenHash)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return user_pubkey;
|
return pubkey;
|
||||||
} else {
|
} else {
|
||||||
return bech32ToPubkey(token);
|
return bech32ToPubkey(token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { NPostgresSchema } from '@nostrify/db';
|
||||||
|
|
||||||
export interface DittoTables extends NPostgresSchema {
|
export interface DittoTables extends NPostgresSchema {
|
||||||
nostr_events: NostrEventsRow;
|
nostr_events: NostrEventsRow;
|
||||||
nip46_tokens: NIP46TokenRow;
|
auth_tokens: AuthTokenRow;
|
||||||
author_stats: AuthorStatsRow;
|
author_stats: AuthorStatsRow;
|
||||||
event_stats: EventStatsRow;
|
event_stats: EventStatsRow;
|
||||||
pubkey_domains: PubkeyDomainRow;
|
pubkey_domains: PubkeyDomainRow;
|
||||||
|
|
@ -33,13 +33,12 @@ interface EventStatsRow {
|
||||||
zaps_amount: number;
|
zaps_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NIP46TokenRow {
|
interface AuthTokenRow {
|
||||||
api_token: string;
|
token_hash: Uint8Array;
|
||||||
user_pubkey: string;
|
pubkey: string;
|
||||||
server_seckey: Uint8Array;
|
nip46_sk_enc: Uint8Array;
|
||||||
server_pubkey: string;
|
nip46_relays: string[];
|
||||||
relays: string;
|
created_at: Date;
|
||||||
connected_at: Date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PubkeyDomainRow {
|
interface PubkeyDomainRow {
|
||||||
|
|
|
||||||
62
src/db/migrations/037_auth_tokens.ts
Normal file
62
src/db/migrations/037_auth_tokens.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
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();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('nip46_tokens')
|
||||||
|
.addColumn('api_token', 'text', (col) => col.primaryKey().unique().notNull())
|
||||||
|
.addColumn('user_pubkey', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('server_seckey', 'bytea', (col) => col.notNull())
|
||||||
|
.addColumn('server_pubkey', 'text', (col) => col.notNull())
|
||||||
|
.addColumn('relays', 'text', (col) => col.defaultTo('[]'))
|
||||||
|
.addColumn('connected_at', 'timestamp', (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@ import { NSecSigner } from '@nostrify/nostrify';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { AppMiddleware } from '@/app.ts';
|
import { AppMiddleware } from '@/app.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
|
import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||||
|
|
||||||
/** We only accept "Bearer" type. */
|
/** We only accept "Bearer" type. */
|
||||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
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')) {
|
if (bech32.startsWith('token1')) {
|
||||||
try {
|
try {
|
||||||
const kysely = await Storages.kysely();
|
const kysely = await Storages.kysely();
|
||||||
|
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||||
|
|
||||||
const { user_pubkey, server_seckey, relays } = await kysely
|
const { pubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||||
.selectFrom('nip46_tokens')
|
.selectFrom('auth_tokens')
|
||||||
.select(['user_pubkey', 'server_seckey', 'relays'])
|
.select(['pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||||
.where('api_token', '=', bech32)
|
.where('token_hash', '=', tokenHash)
|
||||||
.executeTakeFirstOrThrow();
|
.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 {
|
} catch {
|
||||||
throw new HTTPException(401);
|
throw new HTTPException(401);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/utils.ts
16
src/utils.ts
|
|
@ -64,20 +64,6 @@ function findTag(tags: string[][], name: string): string[] | undefined {
|
||||||
return tags.find((tag) => tag[0] === name);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test whether the value is a Nostr ID. */
|
/** Test whether the value is a Nostr ID. */
|
||||||
function isNostrId(value: unknown): boolean {
|
function isNostrId(value: unknown): boolean {
|
||||||
return n.id().safeParse(value).success;
|
return n.id().safeParse(value).success;
|
||||||
|
|
@ -88,6 +74,6 @@ function isURL(value: unknown): boolean {
|
||||||
return z.string().url().safeParse(value).success;
|
return z.string().url().safeParse(value).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05, sha256 };
|
export { bech32ToPubkey, eventAge, findTag, isNostrId, isURL, type Nip05, nostrDate, nostrNow, parseNip05 };
|
||||||
|
|
||||||
export { Time } from '@/utils/time.ts';
|
export { Time } from '@/utils/time.ts';
|
||||||
|
|
|
||||||
28
src/utils/auth.bench.ts
Normal file
28
src/utils/auth.bench.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||||
|
|
||||||
|
Deno.bench('generateToken', async () => {
|
||||||
|
await generateToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.bench('getTokenHash', async (b) => {
|
||||||
|
const { token } = await generateToken();
|
||||||
|
b.start();
|
||||||
|
await getTokenHash(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.bench('encryptSecretKey', async (b) => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const decrypted = generateSecretKey();
|
||||||
|
b.start();
|
||||||
|
await encryptSecretKey(sk, decrypted);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.bench('decryptSecretKey', async (b) => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const decrypted = generateSecretKey();
|
||||||
|
const encrypted = await encryptSecretKey(sk, decrypted);
|
||||||
|
b.start();
|
||||||
|
await decryptSecretKey(sk, encrypted);
|
||||||
|
});
|
||||||
29
src/utils/auth.test.ts
Normal file
29
src/utils/auth.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||||
|
|
||||||
|
Deno.test('generateToken', async () => {
|
||||||
|
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||||
|
|
||||||
|
const { token, hash } = await generateToken(sk);
|
||||||
|
|
||||||
|
assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||||
|
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('getTokenHash', async () => {
|
||||||
|
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||||
|
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('encryptSecretKey & decryptSecretKey', async () => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const data = generateSecretKey();
|
||||||
|
|
||||||
|
const encrypted = await encryptSecretKey(sk, data);
|
||||||
|
const decrypted = await decryptSecretKey(sk, encrypted);
|
||||||
|
|
||||||
|
assertEquals(encodeHex(decrypted), encodeHex(data));
|
||||||
|
});
|
||||||
54
src/utils/auth.ts
Normal file
54
src/utils/auth.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { bech32 } from '@scure/base';
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an auth token for the API.
|
||||||
|
*
|
||||||
|
* Returns a bech32 encoded API token and the SHA-256 hash of the bytes.
|
||||||
|
* The token should be presented to the user, but only the hash should be stored in the database.
|
||||||
|
*/
|
||||||
|
export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> {
|
||||||
|
const words = bech32.toWords(sk);
|
||||||
|
const token = bech32.encode('token', words);
|
||||||
|
|
||||||
|
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||||
|
const hash = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
return { token, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SHA-256 hash of an API token.
|
||||||
|
* First decodes from bech32 then hashes the bytes.
|
||||||
|
* Used to identify the user in the database by the hash of their token.
|
||||||
|
*/
|
||||||
|
export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array> {
|
||||||
|
const { bytes: sk } = bech32.decodeToBytes(token);
|
||||||
|
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a secret key with AES-GCM.
|
||||||
|
* This function is used to store the secret key in the database.
|
||||||
|
*/
|
||||||
|
export async function encryptSecretKey(sk: Uint8Array, decrypted: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, decrypted);
|
||||||
|
|
||||||
|
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a secret key with AES-GCM.
|
||||||
|
* This function is used to retrieve the secret key from the database.
|
||||||
|
*/
|
||||||
|
export async function decryptSecretKey(sk: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||||
|
const iv = encrypted.slice(0, 12);
|
||||||
|
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, encrypted.slice(12));
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||||
|
import { encodeHex } from '@std/encoding/hex';
|
||||||
import { EventTemplate, nip13 } from 'nostr-tools';
|
import { EventTemplate, nip13 } from 'nostr-tools';
|
||||||
|
|
||||||
import { decode64Schema } from '@/schema.ts';
|
import { decode64Schema } from '@/schema.ts';
|
||||||
import { signedEventSchema } from '@/schemas/nostr.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';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
/** Decode a Nostr event from a base64 encoded string. */
|
/** 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((event) => pow ? nip13.getPow(event.id) >= pow : true, 'Insufficient proof of work')
|
||||||
.refine(validateBody, 'Event payload does not match request body');
|
.refine(validateBody, 'Event payload does not match request body');
|
||||||
|
|
||||||
function validateBody(event: NostrEvent) {
|
async function validateBody(event: NostrEvent): Promise<boolean> {
|
||||||
if (!validatePayload) return true;
|
if (!validatePayload) return true;
|
||||||
return req.clone().text()
|
const payload = await getPayload(req);
|
||||||
.then(sha256)
|
return payload === tagValue(event, 'payload');
|
||||||
.then((hash) => hash === tagValue(event, 'payload'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return schema.safeParseAsync(event);
|
return schema.safeParseAsync(event);
|
||||||
|
|
@ -62,7 +62,7 @@ async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts =
|
||||||
];
|
];
|
||||||
|
|
||||||
if (validatePayload) {
|
if (validatePayload) {
|
||||||
const payload = await req.clone().text().then(sha256);
|
const payload = await getPayload(req);
|
||||||
tags.push(['payload', payload]);
|
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. */
|
/** Get the value for the first matching tag name in the event. */
|
||||||
function tagValue(event: NostrEvent, tagName: string): string | undefined {
|
function tagValue(event: NostrEvent, tagName: string): string | undefined {
|
||||||
return findTag(event.tags, tagName)?.[1];
|
return findTag(event.tags, tagName)?.[1];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue