mirror of
https://github.com/aljazceru/ditto.git
synced 2026-01-15 11:24:26 +01:00
Merge branch 'main' into translate-status
This commit is contained in:
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,5 +1,5 @@
|
||||
import { LNURL, LNURLDetails } from '@nostrify/nostrify/ln';
|
||||
import Debug from '@soapbox/stickynotes/debug';
|
||||
import { Stickynotes } from '@soapbox/stickynotes';
|
||||
|
||||
import { cachedLnurlsSizeGauge } from '@/metrics.ts';
|
||||
import { SimpleLRU } from '@/utils/SimpleLRU.ts';
|
||||
@@ -7,17 +7,17 @@ import { Time } from '@/utils/time.ts';
|
||||
import { fetchWorker } from '@/workers/fetch.ts';
|
||||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const debug = Debug('ditto:lnurl');
|
||||
const console = new Stickynotes('ditto:lnurl');
|
||||
|
||||
const lnurlCache = new SimpleLRU<string, LNURLDetails>(
|
||||
async (lnurl, { signal }) => {
|
||||
debug(`Lookup ${lnurl}`);
|
||||
console.debug(`Lookup ${lnurl}`);
|
||||
try {
|
||||
const result = await LNURL.lookup(lnurl, { fetch: fetchWorker, signal });
|
||||
debug(`Found: ${lnurl}`);
|
||||
console.debug(`Found: ${lnurl}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
debug(`Not found: ${lnurl}`);
|
||||
console.debug(`Not found: ${lnurl}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,10 @@ Deno.test('getUrlMediaType', () => {
|
||||
assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html');
|
||||
assertEquals(getUrlMediaType('https://example.com/yolo'), undefined);
|
||||
assertEquals(getUrlMediaType('https://example.com/'), undefined);
|
||||
assertEquals(
|
||||
getUrlMediaType('https://gitlab.com/soapbox-pub/nostrify/-/blob/main/packages/policies/WoTPolicy.ts'),
|
||||
'application/typescript',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('isPermittedMediaType', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { typeByExtension } from '@std/media-types';
|
||||
import { typeByExtension as _typeByExtension } from '@std/media-types';
|
||||
|
||||
/** Get media type of the filename in the URL by its extension, if any. */
|
||||
export function getUrlMediaType(url: string): string | undefined {
|
||||
@@ -22,3 +22,13 @@ export function isPermittedMediaType(mediaType: string, permitted: string[]): bo
|
||||
const [baseType, _subType] = mediaType.split('/');
|
||||
return permitted.includes(baseType);
|
||||
}
|
||||
|
||||
/** Custom type-by-extension with overrides. */
|
||||
function typeByExtension(ext: string): string | undefined {
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
return 'application/typescript';
|
||||
default:
|
||||
return _typeByExtension(ext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user