From 0d4b9e416c577afc1217224f6f0808e908bb5886 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 13 Jul 2023 15:14:41 -0500 Subject: [PATCH 1/7] Copy some ActivityPub conversion code from Mostr --- src/schemas/activitypub.ts | 323 +++++++++++++++++++++ src/transmogrifier/nostr-to-activitypub.ts | 41 +++ 2 files changed, 364 insertions(+) create mode 100644 src/schemas/activitypub.ts create mode 100644 src/transmogrifier/nostr-to-activitypub.ts diff --git a/src/schemas/activitypub.ts b/src/schemas/activitypub.ts new file mode 100644 index 0000000..04fac39 --- /dev/null +++ b/src/schemas/activitypub.ts @@ -0,0 +1,323 @@ +import { z } from '@/deps.ts'; + +const apId = z.string().url(); +const recipients = z.array(z.string()).catch([]); +const published = () => z.string().datetime().catch(new Date().toISOString()); + +/** Validates individual items in an array, dropping any that aren't valid. */ +function filteredArray(schema: T) { + return z.any().array() + .transform((arr) => ( + arr.map((item) => { + const parsed = schema.safeParse(item); + return parsed.success ? parsed.data : undefined; + }).filter((item): item is z.infer => Boolean(item)) + )); +} + +const imageSchema = z.object({ + type: z.literal('Image').catch('Image'), + url: z.string().url(), +}); + +const attachmentSchema = z.object({ + type: z.literal('Document').catch('Document'), + mediaType: z.string().optional().catch(undefined), + url: z.string().url(), +}); + +const mentionSchema = z.object({ + type: z.literal('Mention'), + href: z.string().url(), + name: z.string().optional().catch(undefined), +}); + +const hashtagSchema = z.object({ + type: z.literal('Hashtag'), + href: z.string().url(), + name: z.string(), +}); + +const emojiSchema = z.object({ + type: z.literal('Emoji'), + icon: imageSchema, + name: z.string(), +}); + +const tagSchema = z.discriminatedUnion('type', [ + mentionSchema, + hashtagSchema, + emojiSchema, +]); + +const propertyValueSchema = z.object({ + type: z.literal('PropertyValue'), + name: z.string(), + value: z.string(), + verified_at: z.string().nullish(), +}); + +/** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */ +const proxySchema = z.object({ + protocol: z.string().url(), + proxied: z.string(), + authoritative: z.boolean().optional().catch(undefined), +}); + +const personSchema = z.object({ + type: z.literal('Person'), + id: apId, + icon: imageSchema.optional().catch(undefined), + image: imageSchema.optional().catch(undefined), + name: z.string().catch(''), + preferredUsername: z.string(), + inbox: apId, + followers: apId.optional().catch(undefined), + following: apId.optional().catch(undefined), + outbox: apId.optional().catch(undefined), + summary: z.string().catch(''), + attachment: filteredArray(propertyValueSchema).catch([]), + tag: filteredArray(emojiSchema).catch([]), + endpoints: z.object({ + sharedInbox: apId.optional(), + }).optional().catch({}), + publicKey: z.object({ + id: apId, + owner: apId, + publicKeyPem: z.string(), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const applicationSchema = personSchema.merge(z.object({ type: z.literal('Application') })); +const groupSchema = personSchema.merge(z.object({ type: z.literal('Group') })); +const organizationSchema = personSchema.merge(z.object({ type: z.literal('Organization') })); +const serviceSchema = personSchema.merge(z.object({ type: z.literal('Service') })); + +const actorSchema = z.discriminatedUnion('type', [ + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]); + +const noteSchema = z.object({ + type: z.literal('Note'), + id: apId, + to: recipients, + cc: recipients, + content: z.string(), + attachment: z.array(attachmentSchema).optional().catch(undefined), + tag: filteredArray(tagSchema).catch([]), + inReplyTo: apId.optional().catch(undefined), + attributedTo: apId, + published: published(), + sensitive: z.boolean().optional().catch(undefined), + summary: z.string().nullish().catch(undefined), + quoteUrl: apId.optional().catch(undefined), + source: z.object({ + content: z.string(), + mediaType: z.literal('text/markdown'), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const flexibleNoteSchema = noteSchema.extend({ + quoteURL: apId.optional().catch(undefined), + quoteUri: apId.optional().catch(undefined), + _misskey_quote: apId.optional().catch(undefined), +}).transform((note) => { + const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note; + return { + quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote, + ...rest, + }; +}); + +// https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781 +const objectSchema = z.union([ + flexibleNoteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]).pipe( + z.discriminatedUnion('type', [ + noteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, + ]), +); + +const createNoteSchema = z.object({ + type: z.literal('Create'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: noteSchema, + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const announceNoteSchema = z.object({ + type: z.literal('Announce'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId.or(noteSchema), + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const followSchema = z.object({ + type: z.literal('Follow'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const acceptSchema = z.object({ + type: z.literal('Accept'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: apId.or(followSchema), +}); + +const likeSchema = z.object({ + type: z.literal('Like'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const emojiReactSchema = z.object({ + type: z.literal('EmojiReact'), + id: apId, + actor: apId, + object: apId, + content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)), + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const deleteSchema = z.object({ + type: z.literal('Delete'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const updateActorSchema = z.object({ + type: z.literal('Update'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: actorSchema, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +/** + * A custom Zap activity type we made up, based on: + * https://github.com/nostr-protocol/nips/blob/master/57.md + */ +const zapSchema = z.object({ + type: z.literal('Zap'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const activitySchema = z.discriminatedUnion('type', [ + followSchema, + acceptSchema, + createNoteSchema, + announceNoteSchema, + updateActorSchema, + likeSchema, + emojiReactSchema, + deleteSchema, + zapSchema, +]).refine((activity) => { + const ids: string[] = [activity.id]; + + if (activity.type === 'Create') { + ids.push( + activity.object.id, + activity.object.attributedTo, + ); + } + + if (activity.type === 'Update') { + ids.push(activity.object.id); + } + + const { origin: actorOrigin } = new URL(activity.actor); + + // Object containment + return ids.every((id) => { + const { origin: idOrigin } = new URL(id); + return idOrigin === actorOrigin; + }); +}); + +type Activity = z.infer; +type CreateNote = z.infer; +type Announce = z.infer; +type Update = z.infer; +type Object = z.infer; +type Follow = z.infer; +type Accept = z.infer; +type Actor = z.infer; +type Note = z.infer; +type Mention = z.infer; +type Hashtag = z.infer; +type Emoji = z.infer; +type Like = z.infer; +type EmojiReact = z.infer; +type Delete = z.infer; +type Zap = z.infer; +type Proxy = z.infer; + +export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema }; +export type { + Accept, + Activity, + Actor, + Announce, + CreateNote, + Delete, + Emoji, + EmojiReact, + Follow, + Hashtag, + Like, + Mention, + Note, + Object, + Proxy, + Update, + Zap, +}; diff --git a/src/transmogrifier/nostr-to-activitypub.ts b/src/transmogrifier/nostr-to-activitypub.ts new file mode 100644 index 0000000..f7c402a --- /dev/null +++ b/src/transmogrifier/nostr-to-activitypub.ts @@ -0,0 +1,41 @@ +import { Conf } from '@/config.ts'; +import { parseMetaContent } from '@/schema.ts'; + +import type { Event } from '@/event.ts'; +import type { Actor } from '@/schemas/activitypub.ts'; + +/** Nostr metadata event to ActivityPub actor. */ +async function toActor(event: Event<0>, username: string): Promise { + const content = parseMetaContent(event); + + return { + type: 'Person', + id: Conf.local(`/users/${username}`), + name: content?.name || '', + preferredUsername: username, + inbox: Conf.local(`/users/${username}/inbox`), + followers: Conf.local(`/users/${username}/followers`), + following: Conf.local(`/users/${username}/following`), + outbox: Conf.local(`/users/${username}/outbox`), + icon: content.picture + ? { + type: 'Image', + url: content.picture, + } + : undefined, + image: content.banner + ? { + type: 'Image', + url: content.banner, + } + : undefined, + summary: content.about ?? '', + attachment: [], + tag: [], + endpoints: { + sharedInbox: Conf.local('/inbox'), + }, + }; +} + +export { toActor }; \ No newline at end of file From 97e6f1385cf7b719ee25fb40a05590227c6cd083 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 13 Jul 2023 20:17:31 -0500 Subject: [PATCH 2/7] Move transmute into transmogrify directory --- src/controllers/api/accounts.ts | 2 +- src/controllers/api/search.ts | 4 ++-- src/controllers/api/statuses.ts | 2 +- src/controllers/api/timelines.ts | 2 +- .../nostr-to-activitypub.ts | 0 .../nostr-to-mastoapi.ts} | 14 +++++++------- 6 files changed, 12 insertions(+), 12 deletions(-) rename src/{transmogrifier => transmogrify}/nostr-to-activitypub.ts (100%) rename src/{transmute.ts => transmogrify/nostr-to-mastoapi.ts} (97%) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index b45e027..142e3d2 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -3,7 +3,7 @@ import { type Filter, findReplyTag, z } from '@/deps.ts'; import { getAuthor, getFilter, getFollows, publish } from '@/client.ts'; import { parseMetaContent } from '@/schema.ts'; import { signEvent } from '@/sign.ts'; -import { toAccount, toStatus } from '@/transmute.ts'; +import { toAccount, toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; import { buildLinkHeader, eventDateComparator, lookupAccount, nostrNow, paginationSchema, parseBody } from '@/utils.ts'; const createAccountController: AppController = (c) => { diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index d6988ea..d863fbb 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,6 +1,6 @@ import { AppController } from '@/app.ts'; -import { lookupAccount } from '../../utils.ts'; -import { toAccount } from '../../transmute.ts'; +import { lookupAccount } from '@/utils.ts'; +import { toAccount } from '@/transmogrify/nostr-to-mastoapi.ts'; const searchController: AppController = async (c) => { const q = c.req.query('q'); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index abbddb5..3a36f09 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -3,7 +3,7 @@ import { getAncestors, getDescendants, getEvent, publish } from '@/client.ts'; import { ISO6391, Kind, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { signEvent } from '@/sign.ts'; -import { toStatus } from '@/transmute.ts'; +import { toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; import { nostrNow, parseBody } from '@/utils.ts'; const createStatusSchema = z.object({ diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index f724662..f012082 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -1,5 +1,5 @@ import { getFeed, getFollows, getPublicFeed } from '@/client.ts'; -import { toStatus } from '@/transmute.ts'; +import { toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; import { buildLinkHeader, paginationSchema } from '@/utils.ts'; import type { AppController } from '@/app.ts'; diff --git a/src/transmogrifier/nostr-to-activitypub.ts b/src/transmogrify/nostr-to-activitypub.ts similarity index 100% rename from src/transmogrifier/nostr-to-activitypub.ts rename to src/transmogrify/nostr-to-activitypub.ts diff --git a/src/transmute.ts b/src/transmogrify/nostr-to-mastoapi.ts similarity index 97% rename from src/transmute.ts rename to src/transmogrify/nostr-to-mastoapi.ts index c157af2..ea70df5 100644 --- a/src/transmute.ts +++ b/src/transmogrify/nostr-to-mastoapi.ts @@ -1,13 +1,13 @@ +import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; + +import { getAuthor } from '@/client.ts'; +import { Conf } from '@/config.ts'; import { findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; +import { verifyNip05Cached } from '@/nip05.ts'; +import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; import { emojiTagSchema, filteredArray, type MetaContent, parseMetaContent } from '@/schema.ts'; - -import { Conf } from './config.ts'; -import { getAuthor } from './client.ts'; -import { verifyNip05Cached } from './nip05.ts'; -import { getMediaLinks, type MediaLink, parseNoteContent } from './note.ts'; -import { type Nip05, nostrDate, parseNip05, Time } from './utils.ts'; -import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; +import { type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; From e5082ed805f37cc9b864d75e5096d7b9c11c853f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 13 Jul 2023 20:46:59 -0500 Subject: [PATCH 3/7] transmogrify --> transformers --- src/controllers/api/accounts.ts | 2 +- src/controllers/api/search.ts | 2 +- src/controllers/api/statuses.ts | 2 +- src/controllers/api/timelines.ts | 2 +- src/{transmogrify => transformers}/nostr-to-activitypub.ts | 2 +- src/{transmogrify => transformers}/nostr-to-mastoapi.ts | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename src/{transmogrify => transformers}/nostr-to-activitypub.ts (98%) rename src/{transmogrify => transformers}/nostr-to-mastoapi.ts (100%) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 142e3d2..8381607 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -3,7 +3,7 @@ import { type Filter, findReplyTag, z } from '@/deps.ts'; import { getAuthor, getFilter, getFollows, publish } from '@/client.ts'; import { parseMetaContent } from '@/schema.ts'; import { signEvent } from '@/sign.ts'; -import { toAccount, toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; +import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { buildLinkHeader, eventDateComparator, lookupAccount, nostrNow, paginationSchema, parseBody } from '@/utils.ts'; const createAccountController: AppController = (c) => { diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index d863fbb..45193f3 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,6 +1,6 @@ import { AppController } from '@/app.ts'; import { lookupAccount } from '@/utils.ts'; -import { toAccount } from '@/transmogrify/nostr-to-mastoapi.ts'; +import { toAccount } from '@/transformers/nostr-to-mastoapi.ts'; const searchController: AppController = async (c) => { const q = c.req.query('q'); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 3a36f09..88c3ab9 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -3,7 +3,7 @@ import { getAncestors, getDescendants, getEvent, publish } from '@/client.ts'; import { ISO6391, Kind, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { signEvent } from '@/sign.ts'; -import { toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; +import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { nostrNow, parseBody } from '@/utils.ts'; const createStatusSchema = z.object({ diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index f012082..260ee59 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -1,5 +1,5 @@ import { getFeed, getFollows, getPublicFeed } from '@/client.ts'; -import { toStatus } from '@/transmogrify/nostr-to-mastoapi.ts'; +import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { buildLinkHeader, paginationSchema } from '@/utils.ts'; import type { AppController } from '@/app.ts'; diff --git a/src/transmogrify/nostr-to-activitypub.ts b/src/transformers/nostr-to-activitypub.ts similarity index 98% rename from src/transmogrify/nostr-to-activitypub.ts rename to src/transformers/nostr-to-activitypub.ts index f7c402a..d6e1796 100644 --- a/src/transmogrify/nostr-to-activitypub.ts +++ b/src/transformers/nostr-to-activitypub.ts @@ -38,4 +38,4 @@ async function toActor(event: Event<0>, username: string): Promise { }; } -export { toActor }; \ No newline at end of file +export { toActor }; diff --git a/src/transmogrify/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts similarity index 100% rename from src/transmogrify/nostr-to-mastoapi.ts rename to src/transformers/nostr-to-mastoapi.ts From f8674ed05340702a7386fc90c3996f4aa6694f0e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 13 Jul 2023 22:00:27 -0500 Subject: [PATCH 4/7] Add RSA key to actors, use LRU cache --- src/config.ts | 27 +++++++++++++++++++++- src/deps.ts | 12 +++++++++- src/transformers/nostr-to-activitypub.ts | 6 +++++ src/utils/rsa.ts | 29 ++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/utils/rsa.ts diff --git a/src/config.ts b/src/config.ts index 4183d96..aa55548 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,32 @@ +import { nip19, secp } from '@/deps.ts'; + /** Application-wide configuration. */ const Conf = { get nsec() { - return Deno.env.get('DITTO_NSEC'); + const value = Deno.env.get('DITTO_NSEC'); + if (!value) { + throw new Error('Missing DITTO_NSEC'); + } + if (!value.startsWith('nsec1')) { + throw new Error('Invalid DITTO_NSEC'); + } + return value as `nsec1${string}`; + }, + get seckey() { + const result = nip19.decode(Conf.nsec); + if (result.type !== 'nsec') { + throw new Error('Invalid DITTO_NSEC'); + } + return result.data; + }, + get cryptoKey() { + return crypto.subtle.importKey( + 'raw', + secp.utils.hexToBytes(Conf.seckey), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); }, get relay() { return Deno.env.get('DITTO_RELAY'); diff --git a/src/deps.ts b/src/deps.ts index 91833db..9e8e9b7 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -21,7 +21,7 @@ export { nip19, nip21, verifySignature, -} from 'npm:nostr-tools@^1.11.2'; +} from 'npm:nostr-tools@^1.12.1'; export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" @@ -39,3 +39,13 @@ export { default as sanitizeHtml } from 'npm:sanitize-html@^2.10.0'; export { default as ISO6391 } from 'npm:iso-639-1@2.1.15'; export { Dongoose } from 'https://raw.githubusercontent.com/alexgleason/dongoose/68b7ad9dd7b6ec0615e246a9f1603123c1709793/mod.ts'; export { createPentagon } from 'https://deno.land/x/pentagon@v0.1.1/mod.ts'; +export { + type ParsedSignature, + pemToPublicKey, + publicKeyToPem, + signRequest, + verifyRequest, +} from 'https://gitlab.com/soapbox-pub/fedisign/-/raw/v0.2.1/mod.ts'; +export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts'; +export * as secp from 'npm:@noble/secp256k1@^1.7.1'; +export { LRUCache } from 'npm:lru-cache@^10.0.0'; diff --git a/src/transformers/nostr-to-activitypub.ts b/src/transformers/nostr-to-activitypub.ts index d6e1796..40a374c 100644 --- a/src/transformers/nostr-to-activitypub.ts +++ b/src/transformers/nostr-to-activitypub.ts @@ -1,5 +1,6 @@ import { Conf } from '@/config.ts'; import { parseMetaContent } from '@/schema.ts'; +import { getPublicKeyPem } from '@/utils/rsa.ts'; import type { Event } from '@/event.ts'; import type { Actor } from '@/schemas/activitypub.ts'; @@ -32,6 +33,11 @@ async function toActor(event: Event<0>, username: string): Promise { summary: content.about ?? '', attachment: [], tag: [], + publicKey: { + id: Conf.local(`/users/${username}#main-key`), + owner: Conf.local(`/users/${username}`), + publicKeyPem: await getPublicKeyPem(event.pubkey), + }, endpoints: { sharedInbox: Conf.local('/inbox'), }, diff --git a/src/utils/rsa.ts b/src/utils/rsa.ts new file mode 100644 index 0000000..820e0a4 --- /dev/null +++ b/src/utils/rsa.ts @@ -0,0 +1,29 @@ +import { Conf } from '@/config.ts'; +import { generateSeededRsa, LRUCache, publicKeyToPem, secp } from '@/deps.ts'; + +const opts = { + bits: 1024, +}; + +const rsaCache = new LRUCache>({ max: 1000 }); + +async function buildSeed(pubkey: string): Promise { + const key = await Conf.cryptoKey; + const data = new TextEncoder().encode(pubkey); + const signature = await window.crypto.subtle.sign('HMAC', key, data); + return secp.utils.bytesToHex(new Uint8Array(signature)); +} + +async function getPublicKeyPem(pubkey: string): Promise { + const cached = await rsaCache.get(pubkey); + if (cached) return cached; + + const seed = await buildSeed(pubkey); + const { publicKey } = await generateSeededRsa(seed, opts); + const promise = publicKeyToPem(publicKey); + + rsaCache.set(pubkey, promise); + return promise; +} + +export { getPublicKeyPem }; From 2d5f9db5c3715d9ab37722f49b59eade89562ac7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 23 Jul 2023 11:15:52 -0500 Subject: [PATCH 5/7] Use 2048 bit RSA, because it's more secure and won't have the same performance penalty as on the bridge --- src/utils/rsa.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/rsa.ts b/src/utils/rsa.ts index 820e0a4..b6865b4 100644 --- a/src/utils/rsa.ts +++ b/src/utils/rsa.ts @@ -2,7 +2,7 @@ import { Conf } from '@/config.ts'; import { generateSeededRsa, LRUCache, publicKeyToPem, secp } from '@/deps.ts'; const opts = { - bits: 1024, + bits: 2048, }; const rsaCache = new LRUCache>({ max: 1000 }); From 819ae61bcaccb636821b890d808ada2da5b406d0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 27 Jul 2023 10:36:19 -0500 Subject: [PATCH 6/7] Add actorController --- src/app.ts | 3 +++ src/controllers/activitypub/actor.ts | 23 +++++++++++++++++++++++ src/transformers/nostr-to-activitypub.ts | 6 +++++- src/utils.ts | 21 ++++++++++++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/controllers/activitypub/actor.ts diff --git a/src/app.ts b/src/app.ts index af95656..dadfebf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { type Context, cors, type Handler, Hono, type HonoEnv, logger, type Midd import { type Event } from '@/event.ts'; import '@/loopback.ts'; +import { actorController } from './controllers/activitypub/actor.ts'; import { accountController, accountLookupController, @@ -67,6 +68,8 @@ app.get('/.well-known/host-meta', hostMetaController); app.get('/.well-known/nodeinfo', nodeInfoController); app.get('/.well-known/nostr.json', nostrController); +app.get('/users/:username', actorController); + app.get('/nodeinfo/:version', nodeInfoSchemaController); app.get('/api/v1/instance', instanceController); diff --git a/src/controllers/activitypub/actor.ts b/src/controllers/activitypub/actor.ts new file mode 100644 index 0000000..9231344 --- /dev/null +++ b/src/controllers/activitypub/actor.ts @@ -0,0 +1,23 @@ +import { getAuthor } from '@/client.ts'; +import { db } from '@/db.ts'; +import { toActor } from '@/transformers/nostr-to-activitypub.ts'; +import { activityJson } from '@/utils.ts'; + +import type { AppController } from '@/app.ts'; + +const actorController: AppController = async (c) => { + const notFound = c.json({ error: 'Not found' }, 404); + + const username = c.req.param('username'); + const user = await db.users.findFirst({ where: { username } }); + + const event = await getAuthor(user.pubkey); + if (!event) return notFound; + + const actor = await toActor(event); + if (!actor) return notFound; + + return activityJson(c, actor); +}; + +export { actorController }; diff --git a/src/transformers/nostr-to-activitypub.ts b/src/transformers/nostr-to-activitypub.ts index 40a374c..82f328d 100644 --- a/src/transformers/nostr-to-activitypub.ts +++ b/src/transformers/nostr-to-activitypub.ts @@ -6,9 +6,13 @@ import type { Event } from '@/event.ts'; import type { Actor } from '@/schemas/activitypub.ts'; /** Nostr metadata event to ActivityPub actor. */ -async function toActor(event: Event<0>, username: string): Promise { +async function toActor(event: Event<0>): Promise { const content = parseMetaContent(event); + if (!content.nip05) return; + const [username, hostname] = content.nip05.split('@'); + if (hostname !== Conf.url.hostname) return; + return { type: 'Person', id: Conf.local(`/users/${username}`), diff --git a/src/utils.ts b/src/utils.ts index a81c0fd..ed0a640 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { getAuthor } from '@/client.ts'; import { Conf } from '@/config.ts'; -import { nip19, parseFormData, z } from '@/deps.ts'; +import { type Context, nip19, parseFormData, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { lookupNip05Cached } from '@/nip05.ts'; @@ -124,7 +124,26 @@ async function sha256(message: string): Promise { return hashHex; } +/** JSON-LD context. */ +type LDContext = (string | Record>)[]; + +/** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */ +function maybeAddContext(object: T): T & { '@context': LDContext } { + return { + '@context': ['https://www.w3.org/ns/activitystreams'], + ...object, + }; +} + +/** Like hono's `c.json()` except returns JSON-LD. */ +function activityJson(c: Context, object: T) { + const response = c.json(maybeAddContext(object)); + response.headers.set('content-type', 'application/activity+json; charset=UTF-8'); + return response; +} + export { + activityJson, bech32ToPubkey, buildLinkHeader, eventAge, From b52694679fe077162338d8a3f5b56501027dbf60 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 27 Jul 2023 11:03:46 -0500 Subject: [PATCH 7/7] actorController: refactor notFound --- src/controllers/activitypub/actor.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/controllers/activitypub/actor.ts b/src/controllers/activitypub/actor.ts index 9231344..8d09255 100644 --- a/src/controllers/activitypub/actor.ts +++ b/src/controllers/activitypub/actor.ts @@ -3,21 +3,23 @@ import { db } from '@/db.ts'; import { toActor } from '@/transformers/nostr-to-activitypub.ts'; import { activityJson } from '@/utils.ts'; -import type { AppController } from '@/app.ts'; +import type { AppContext, AppController } from '@/app.ts'; const actorController: AppController = async (c) => { - const notFound = c.json({ error: 'Not found' }, 404); - const username = c.req.param('username'); const user = await db.users.findFirst({ where: { username } }); const event = await getAuthor(user.pubkey); - if (!event) return notFound; + if (!event) return notFound(c); const actor = await toActor(event); - if (!actor) return notFound; + if (!actor) return notFound(c); return activityJson(c, actor); }; +function notFound(c: AppContext) { + return c.json({ error: 'Not found' }, 404); +} + export { actorController };