diff --git a/bun.lock b/bun.lock index 89332f8..80bbcbb 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,14 @@ "@lit-app/state": "^1.0.0", "@lit-labs/motion": "^1.0.8", "@noble/ciphers": "^1.2.1", + "@noble/curves": "^1.8.1", + "@noble/hashes": "^1.3.3", "@nostr-dev-kit/ndk": "^2.12.2", "@nostr/tools": "npm:@jsr/nostr__tools", "@open-wc/lit-helpers": "^0.7.0", + "@scure/base": "^1.2.4", "@std/encoding": "npm:@jsr/std__encoding", + "iconify-icon": "^2.3.0", "lit": "^3.2.1", "markdown-it": "^14.1.0", }, @@ -105,6 +109,8 @@ "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, ""], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, ""], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, ""], @@ -497,6 +503,8 @@ "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, ""], + "iconify-icon": ["iconify-icon@2.3.0", "", { "dependencies": { "@iconify/types": "^2.0.0" } }, "sha512-C0beI9oTDxQz6voI5CKl7MiJf0Lw4UU8K4G4t6pcUDClLmCvuMOpcvd8MAztQ2SfoH0iv7WHdxBFjekKPFKH2Q=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, ""], "ieee754": ["ieee754@1.2.1", "", {}, ""], diff --git a/package.json b/package.json index efcea97..c4ec4aa 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,12 @@ "@lit-app/state": "^1.0.0", "@lit-labs/motion": "^1.0.8", "@noble/ciphers": "^1.2.1", + "@noble/curves": "^1.8.1", + "@noble/hashes": "^1.3.3", "@nostr-dev-kit/ndk": "^2.12.2", "@nostr/tools": "npm:@jsr/nostr__tools", "@open-wc/lit-helpers": "^0.7.0", + "@scure/base": "^1.2.4", "@std/encoding": "npm:@jsr/std__encoding", "iconify-icon": "^2.3.0", "lit": "^3.2.1", diff --git a/src/assets/pattern.svg b/src/assets/pattern.svg new file mode 100644 index 0000000..6686dcd --- /dev/null +++ b/src/assets/pattern.svg @@ -0,0 +1,547 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/CCNInvitation.ts b/src/components/CCNInvitation.ts new file mode 100644 index 0000000..96f90d9 --- /dev/null +++ b/src/components/CCNInvitation.ts @@ -0,0 +1,145 @@ +import logo from '@assets/logo.png'; +import pattern from '@assets/pattern.svg'; +import type { NPub } from '@nostr/tools/nip19'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import '@components/General/Button'; +import '@components/General/Card'; +import '@components/General/Input'; + +@customElement('arx-ccn-invitation') +export class CCNInvitation extends LitElement { + @property() invite = ''; + @property() ccnName = ''; + @property() ccnNpub?: NPub; + + static override styles = css` + :host { + display: block; + perspective: 1500px; + --rotateX: 0deg; + --rotateY: 0deg; + } + + arx-card { + transform: rotateY(var(--rotateY)) rotateX(var(--rotateX)); + will-change: transform, visibility; + transform-style: preserve-3d; + box-shadow: 0 4px 10px rgba(0, 0, 0, .5); + } + + h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-base-content); + margin: 0 0 0.5rem; + text-align: center; + } + + h3 { + font-size: 2rem; + font-weight: 800; + color: var(--color-accent); + margin: 0 0 1.5rem; + text-align: center; + overflow-wrap: break-word; + } + + arx-invitation-qr { + align-self: center; + margin: 1rem 0; + padding: 1rem; + } + + img { + width: 400px; + height: 400px; + padding: 25px; + object-fit: contain; + background: #272933; + } + + .pattern { + position: relative; + background-size: 100% 100%; + width: 450px; + height: 450px; + top: -450px; + filter: grayscale(100%); + mix-blend-mode: plus-lighter; + opacity: 0.8; + } + + .logo-container { + position: relative; + left: 50%; + transform: translateX(-50%); + width: 450px; + height: 450px; + } + + .button-row { + margin-top: auto; + text-align: center; + } + + arx-button { + --button-border-radius: 24px; + --button-padding: 0.75rem 1.5rem; + --button-font-weight: 600; + --button-box-shadow: 0 4px 10px rgba(85, 112, 231, 0.3); + } + + p { + text-align: center; + font-size: 1.25rem; + font-weight: 600; + background: var(--color-base-300); + padding: 1rem; + border: 3px solid var(--color-base-400); + border-radius: 1rem; + color: var(--color-base-content); + margin: 1rem 0; + text-align: center; + overflow-wrap: break-word; + } + `; + + override render() { + if (!this.ccnNpub || !this.invite) return html`
Loading...
`; + return html` + +
+

You've been invited to join a CCN

+

${this.ccnName}

+
+ +
+ +
+
+ +

${this.invite}

+
+ `; + } + + private _handleMouseMove(e: MouseEvent) { + const card = e.currentTarget as HTMLElement; + const rect = card.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const rotateY = ((x / rect.width) * 20 - 10).toFixed(2); + const rotateX = (((y / rect.height) * 20 - 10) * -1).toFixed(2); + + this.style.setProperty('--rotateY', `${rotateY}deg`); + this.style.setProperty('--rotateX', `${rotateX}deg`); + } + + private _handleMouseLeave() { + this.style.setProperty('--rotateY', '0deg'); + this.style.setProperty('--rotateX', '0deg'); + } +} diff --git a/src/components/InitialSetup.ts b/src/components/InitialSetup.ts index a968a3e..cfbf157 100644 --- a/src/components/InitialSetup.ts +++ b/src/components/InitialSetup.ts @@ -10,6 +10,9 @@ import { customElement, state } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; import { ndk, setSigner } from '@/ndk'; +import { decryptInviteResponseDM, generateDM } from '@/utils/dm'; +import { readInvite } from '@/utils/invite'; +import { unsafeRelaySet } from '@/utils/unsafeRelaySet'; import '@components/General/Button'; import '@components/General/Fieldset'; import '@components/General/Input'; @@ -22,22 +25,44 @@ import { nip19 } from '@nostr/tools'; @customElement('arx-initial-setup') export class InitialSetup extends LitElement { - @state() private currentPage = 1; - @state() private isAnimating = false; - @state() private seedPhrase = ''; - @state() private communityName = ''; - @state() private userName = ''; - @state() private profileImage = ''; - @state() private lightningAddress = ''; - @state() private relayStatus: { running: boolean; pid: number | null; logs: string[] } = { + @state() + private currentPage = 1; + @state() + private isAnimating = false; + @state() + private seedPhrase = ''; + @state() + private communityName = ''; + @state() + private userName = ''; + @state() + private profileImage = ''; + @state() + private lightningAddress = ''; + @state() + private relayStatus: { + running: boolean; + pid: number | null; + logs: string[]; + } = { running: false, pid: null, logs: [], }; - @state() private selectedCCN: string | undefined; - @state() private ccnList: { name: string; pubkey: string }[] = []; - - private readonly pageLabels = ['Welcome', 'Relay Setup', 'Seed Phrase', 'Profile', 'Complete']; + @state() + private selectedCCN: string | undefined; + @state() + private ccnList: { name: string; pubkey: string }[] = []; + @state() + private inviteCode: string | undefined; + @state() + private joinCCN = false; + @state() + private loading = false; + @state() + private loadingMessage = ''; + private readonly pageLabels = ['Welcome', 'Relay Setup', 'CCN Setup', 'Profile', 'Complete']; + private randomPrivateKey = nostrTools.generateSecretKey(); get encryptionPassphrase() { let encryptionPassphrase = localStorage.getItem('encryption_key'); @@ -83,6 +108,13 @@ export class InitialSetup extends LitElement { letter-spacing: 0.05em; } + .loading-message { + margin-top: calc(var(--spacing-unit) * 4); + font-size: 0.875rem; + color: var(--color-secondary); + text-align: center; + } + h1, h2, h3 { @@ -219,8 +251,11 @@ export class InitialSetup extends LitElement { private nextStep() { if (this.currentPage === 1) return this.handleNavigation(2); if (this.currentPage === 2) { - if (this.selectedCCN) return this.handleNavigation(4); - return this.handleNavigation(3); + setSigner(new NDKPrivateKeySigner(this.randomPrivateKey)); + ndk.connect(10000).then(() => { + if (this.selectedCCN) return this.handleNavigation(4); + return this.handleNavigation(3); + }); } if (this.currentPage === 3) return this.handleNavigation(4); if (this.currentPage === 4) return this.goToFinalStep(); @@ -244,6 +279,10 @@ export class InitialSetup extends LitElement { this.seedPhrase = nip06.generateSeedWords(); } + private onInviteCodeInput(event: ArxInputChangeEvent) { + this.inviteCode = event.detail.value; + } + private isValidSeedPhrase() { const words = this.seedPhrase.split(' '); if (words.length !== 12) return false; @@ -258,11 +297,7 @@ export class InitialSetup extends LitElement { private renderWelcomePage() { return html` -
- +

Welcome to Eve

Your Private Community Network

@@ -296,11 +331,7 @@ export class InitialSetup extends LitElement { private renderRelaySetupPage() { return html` -
- +

Configure Eve Relay

@@ -321,7 +352,8 @@ export class InitialSetup extends LitElement { ${when( this.relayStatus.running, - () => html` + () => + html`

Relay is running with PID: ${this.relayStatus.pid} @@ -347,7 +379,7 @@ export class InitialSetup extends LitElement { @click=${() => this.nextStep()} variant="primary" ?disabled=${!this.relayStatus.running} - label=${this.selectedCCN ? 'Continue' : 'Create CCN'} + label=${this.selectedCCN ? 'Continue' : 'New CCN'} > @@ -382,59 +414,116 @@ export class InitialSetup extends LitElement { private async updateRelayStatus() { this.relayStatus = await window.relay.getStatus(); - if (this.relayStatus.running) setTimeout(() => this.updateRelayStatus(), 2000); + if (this.relayStatus.running) { + setTimeout(() => this.updateRelayStatus(), 2000); + } } private async createCCN() { - await new Promise((resolve) => { - const ws = new WebSocket('ws://localhost:6942'); - ws.onopen = () => { - ws.send( - JSON.stringify([ - 'CCN', - 'CREATE', + this.loading = true; + if (this.joinCCN) { + this.loadingMessage = 'Joining community... Redeeming invite...'; + const { npub, invite } = readInvite(this.inviteCode as `${string}1${string}`); + const dm = await generateDM(npub, invite, [['type', 'invite']]); + await dm.publish(unsafeRelaySet, 10000, 2); + this.loadingMessage = 'Waiting for community to let you in...'; + const ccnPubkeyHex = nip19.decode<'npub'>(npub).data; + const ownPubkeyHex = nostrTools.getPublicKey(this.randomPrivateKey); + // eveinvite1mm7p5m83dawq96qh58f6r7gl3fvvlp8mvp68a8ksq6ycga5r6wrmcur22dtjatpq4cy00rut2e6y563nnw8teh5xqppggzyq92gyywqstsk60 + const { privateKey, existingMembers, ccnName } = await new Promise<{ + privateKey: string; + existingMembers: string[]; + ccnName: string; + }>((resolve) => { + ndk + .subscribe( { - name: this.communityName, - seed: this.seedPhrase, + kinds: [NDKKind.GiftWrap], + '#p': [ownPubkeyHex], }, - ]), - ); - }; - ws.onmessage = ({ data }) => { - const responseData = JSON.parse(data); - if (responseData[0] !== 'OK' || responseData[1] !== 'CCN CREATED' || responseData[2] !== true) return; - resolve(true); - ws.close(); - this.selectedCCN = JSON.parse(responseData[3]).pubkey; - }; - ws.onerror = () => { - resolve(false); - }; - }); + {}, + unsafeRelaySet, + true, + ) + .on('event', async (event) => { + resolve(await decryptInviteResponseDM(event)); + }); + }); + this.loadingMessage = `You've been invited to ${ccnName}. Joining ${existingMembers.length} members...`; + await new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:6942'); + ws.onopen = () => { + ws.send( + JSON.stringify([ + 'CCN', + 'ADD', + { + name: ccnName, + allowedPubkeys: existingMembers, + privateKey, + }, + ]), + ); + }; + ws.onmessage = ({ data }) => { + const responseData = JSON.parse(data); + if (responseData[0] !== 'OK' || responseData[1] !== 'CCN ADDED' || responseData[2] !== true) return; + const responseJson = JSON.parse(responseData[3]); + if (responseJson.pubkey !== ccnPubkeyHex) return; + resolve(true); + ws.close(); + this.selectedCCN = ccnPubkeyHex; + }; + }); + } else { + this.loadingMessage = 'Creating community...'; + await new Promise((resolve) => { + const ws = new WebSocket('ws://localhost:6942'); + ws.onopen = () => { + ws.send( + JSON.stringify([ + 'CCN', + 'CREATE', + { + name: this.communityName, + seed: this.seedPhrase, + creator: nostrTools.getPublicKey(this.randomPrivateKey), + }, + ]), + ); + }; + ws.onmessage = ({ data }) => { + const responseData = JSON.parse(data); + if (responseData[0] !== 'OK' || responseData[1] !== 'CCN CREATED' || responseData[2] !== true) return; + resolve(true); + ws.close(); + this.selectedCCN = JSON.parse(responseData[3]).pubkey; + }; + ws.onerror = () => { + resolve(false); + }; + }); + } this.handleNavigation(4); + this.loading = false; + } + + private renderJoinCCNPage() { + return html` +

+

Joining a Community

+

+ If you'd like to join an existing community, please enter your invite code below. +

+ +
+ `; } private renderSeedPhrasePage() { return html` -
- -
-

Getting Started

-

Creating a Community

-

- Connect with others by joining an existing community or creating - your own. -

-

- During this alpha phase, community setup requires a few manual - steps. We're actively working to streamline this process in future - updates. -

-
- +
+

Creating a Community

Seed Phrase

@@ -466,7 +555,6 @@ export class InitialSetup extends LitElement {

-

Community Name

@@ -482,6 +570,47 @@ export class InitialSetup extends LitElement { @change=${this.onCommunityNameInput} >
+ + `; + } + + private isValidInviteCode() { + if (!this.inviteCode?.startsWith('eveinvite1')) return false; + try { + const invite = readInvite(this.inviteCode as `${string}1${string}`); + return invite.npub && invite.invite; + } catch (e) { + return false; + } + } + + private renderCCNSetupPage() { + return html` +
+
+

Getting Started

+

+ Connect with others by joining an existing community or creating + your own. +

+

+ Do you have an invite code, or would you like to create a new + community? +

+ + ${when( + this.joinCCN, + () => this.renderJoinCCNPage(), + () => this.renderSeedPhrasePage(), + )} +
`; } } + + override render() { + if (this.loading) { + return html`
+ +

${this.loadingMessage}

+
`; + } + return html` +
+ + + ${this.renderCurrentPage()} +
+ `; + } } diff --git a/src/components/InviteCodeGenerator.ts b/src/components/InviteCodeGenerator.ts new file mode 100644 index 0000000..b1b38b6 --- /dev/null +++ b/src/components/InviteCodeGenerator.ts @@ -0,0 +1,104 @@ +import { inviteKind, ndk } from '@/ndk'; +import { getActiveCCNNpub, getCCNName } from '@/utils/getCCNList'; +import { randomBytes } from '@noble/ciphers/webcrypto'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { sha512Hash } from '@utils/sha512'; +import { LitElement, css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { when } from 'lit/directives/when.js'; + +import { generateInvite } from '@/utils/invite'; +import '@components/CCNInvitation'; +import '@components/General/Button'; +import '@components/MarkdownContent'; +import { bytesToHex } from '@noble/ciphers/utils'; +import type { NPub } from '@nostr/tools/nip19'; + +// generate invite code +// broadcast invite event to CCN { "invite": sha512Hash(inviteCode) } +// invitee receives invite code +// invitee generates nsec +// invitee generates DM with their invite code to the CCN +// upon receipt of message where sha512Hash(message) matches a valid invite code, the CCN (inviter only) will broadcast an accept invite event to all peers +// CCN (inviter only) will DM CCN seed to invitee +// CCN invalidates invite code + +@customElement('arx-invite-code-generator') +export class InviteCodeGenerator extends LitElement { + static override styles = css` + :host { + display: block; + padding: 32px; + max-width: 600px; + margin: 0 auto; + } + `; + + @state() inviteCode = ''; + @state() ccnNpub?: NPub; + @state() ccnName = ''; + @state() isGenerating = false; + @state() generatedInvite = ''; + + override async connectedCallback() { + super.connectedCallback(); + const npub = getActiveCCNNpub(); + if (!npub) throw new Error('No CCN selected'); + const ccnName = await getCCNName(npub); + if (!ccnName) throw new Error('CCN name not found'); + this.ccnNpub = npub; + this.ccnName = ccnName; + } + + private async generateAndBroadcastInvite() { + if (this.inviteCode) return alert('Invite already generated'); + if (this.isGenerating) return; + this.isGenerating = true; + try { + this.inviteCode = bytesToHex(randomBytes(32)); + const event = new NDKEvent(ndk); + event.kind = inviteKind; + event.content = ''; + event.tags = [['i', sha512Hash(this.inviteCode)]]; + await event.sign(); + await event.publish(); + this.generatedInvite = generateInvite(this.ccnNpub!, this.inviteCode); + } catch (error) { + console.error('Failed to generate and broadcast invite:', error); + } finally { + this.isGenerating = false; + } + } + + private async copyToClipboard() { + if (!this.generatedInvite) return; + await navigator.clipboard.writeText(this.generatedInvite); + } + + override render() { + return html` + + ${when( + this.inviteCode, + () => html` + + + `, + )} + `; + } +} diff --git a/src/components/Setup/CCNList.ts b/src/components/Setup/CCNList.ts index de16349..b206447 100644 --- a/src/components/Setup/CCNList.ts +++ b/src/components/Setup/CCNList.ts @@ -36,7 +36,7 @@ export class CCNList extends LitElement {

We found one CCN in your relay, which likely means you've used Eve before or there was an update introducing new setup steps. You can use this CCN to proceed with the setup, or create a new one.

CCN Name: ${this.ccns[0].name}

- + `; return html` @@ -49,7 +49,7 @@ export class CCNList extends LitElement { .textMapper="${this._textMapper}" @change="${(e: CustomEvent<{ value: string }>) => this._handleCCNSelected(e.detail.value)}" > - + `; } diff --git a/src/ndk.ts b/src/ndk.ts index ef844aa..698b177 100644 --- a/src/ndk.ts +++ b/src/ndk.ts @@ -1,4 +1,4 @@ -import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; +import NDK, { type NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; import * as nip49 from '@nostr/tools/nip49'; export const ndk = new NDK({ @@ -37,3 +37,6 @@ export async function getUserProfile(npub: string | undefined = undefined) { await user.fetchProfile(); return user.profile; } + +export const inviteResponseKind = 9998 as NDKKind; +export const inviteKind = 9999 as NDKKind; diff --git a/src/routes/Home.ts b/src/routes/Home.ts index 37b5ab4..922d30e 100644 --- a/src/routes/Home.ts +++ b/src/routes/Home.ts @@ -77,7 +77,6 @@ export class Home extends LitElement { color: '#7B68EE', icon: 'arx:settings', }, - }, ]; widgets = [ diff --git a/src/routes/Settings.ts b/src/routes/Settings.ts index 56c01d8..6501c63 100644 --- a/src/routes/Settings.ts +++ b/src/routes/Settings.ts @@ -14,6 +14,7 @@ import '@components/General/Button'; import '@components/General/Card'; import '@components/General/Fieldset'; import '@components/General/Input'; +import '@components/InviteCodeGenerator'; import '@components/RelayLogs'; @customElement('arx-settings') @@ -217,6 +218,11 @@ export class EveSettings extends LitElement { + + + + +

diff --git a/src/routes/router.ts b/src/routes/router.ts index 951866f..00649ff 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -159,14 +159,14 @@ export default class EveRouter extends LitElement { ::-webkit-scrollbar-thumb:hover { background: var(--color-neutral); } - + .window { overflow: auto; grid-column: 2; grid-row: 2; position: relative; } - + .window::after { content: ''; position: absolute; @@ -179,7 +179,7 @@ export default class EveRouter extends LitElement { background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.03)); transition: opacity 0.3s ease; } - + .window:hover::after { opacity: 1; } @@ -235,7 +235,7 @@ export default class EveRouter extends LitElement { transform: perspective(1200px) translateX(50vw); filter: blur(50px); } - + .window-content.transitioning::after { opacity: 1; } @@ -500,7 +500,7 @@ export default class EveRouter extends LitElement { () => html``, )} - (array: T[]): T[] { + return [...new Set(array)]; +} diff --git a/src/utils/dm.ts b/src/utils/dm.ts new file mode 100644 index 0000000..db01e40 --- /dev/null +++ b/src/utils/dm.ts @@ -0,0 +1,65 @@ +import { inviteResponseKind, ndk } from '@/ndk'; +import { bytesToHex } from '@noble/hashes/utils'; +import { NDKEvent, NDKKind, NDKPrivateKeySigner, type NDKTag, NDKUser } from '@nostr-dev-kit/ndk'; +import type { NPub } from '@nostr/tools/nip19'; +import * as nostrTools from '@nostr/tools/pure'; +import { base64 } from '@scure/base'; +import { arrayToUnique } from './arrayToUnique'; +export async function generateDM(npub: NPub, content: string, additionalTags: NDKTag[] = []) { + if (!ndk.signer) { + throw new Error('NDK signer not initialized'); + } + + const recipient = new NDKUser({ + npub, + }); + + const randomPrivateKey = nostrTools.generateSecretKey(); + const randomSigner = new NDKPrivateKeySigner(randomPrivateKey); + + const dmEvent = new NDKEvent(ndk); + dmEvent.kind = NDKKind.PrivateDirectMessage; + dmEvent.content = content; + dmEvent.tags.push(['p', recipient.pubkey], ...additionalTags); + await dmEvent.sign(ndk.signer); + + const sealEvent = new NDKEvent(ndk); + sealEvent.kind = NDKKind.GiftWrapSeal; + sealEvent.content = await randomSigner.encrypt(recipient, JSON.stringify(dmEvent), 'nip44'); + await sealEvent.sign(randomSigner); + + const giftWrapEvent = new NDKEvent(ndk); + giftWrapEvent.kind = NDKKind.GiftWrap; + giftWrapEvent.tags.push(['p', recipient.pubkey], ...additionalTags); + giftWrapEvent.content = await randomSigner.encrypt(recipient, JSON.stringify(sealEvent), 'nip44'); + await giftWrapEvent.sign(randomSigner); + + return giftWrapEvent; +} + +export async function decryptInviteResponseDM(event: NDKEvent): Promise<{ + privateKey: string; + existingMembers: string[]; + ccnName: string; +}> { + if (event.kind !== NDKKind.GiftWrap) { + throw new Error('Event is not a gift wrap'); + } + + const seal = JSON.parse(await ndk.signer?.decrypt(event.author, event.content, 'nip44')!); + if (seal.kind !== NDKKind.GiftWrapSeal) { + throw new Error('Seal is not a gift wrap seal'); + } + + const inviteResponse = JSON.parse(await ndk.signer?.decrypt(event.author, seal.content, 'nip44')!); + if (inviteResponse.kind !== inviteResponseKind) { + throw new Error('Invite response is not of correct type'); + } + + const privateKey = base64.decode(inviteResponse.content); + return { + privateKey: bytesToHex(privateKey), + existingMembers: arrayToUnique(inviteResponse.tags.filter((tag) => tag[0] === 'p').map((tag) => tag[1])), + ccnName: inviteResponse.tags.find((tag) => tag[0] === 'name')?.[1], + }; +} diff --git a/src/utils/getCCNList.ts b/src/utils/getCCNList.ts new file mode 100644 index 0000000..a044b15 --- /dev/null +++ b/src/utils/getCCNList.ts @@ -0,0 +1,33 @@ +import { nip19 } from '@nostr/tools'; +import type { NPub } from '@nostr/tools/nip19'; + +export function getCCNList(): Promise<{ name: string; pubkey: string }[]> { + return new Promise((resolve, reject) => { + const ws = new WebSocket('ws://localhost:6942'); + ws.onopen = () => { + ws.send(JSON.stringify(['CCN', 'LIST'])); + }; + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data[0] !== 'OK' || data[1] !== 'CCN LIST' || data[2] !== true) return; + resolve(JSON.parse(data[3])); + }; + ws.onerror = (event) => { + reject(event); + }; + }); +} + +export function getActiveCCNNpub() { + const selectedCCN = localStorage.getItem('selectedCCN'); + if (!selectedCCN) return null; + const { pubkey } = JSON.parse(selectedCCN); + return nip19.npubEncode(pubkey); +} + +export async function getCCNName(npub: NPub) { + const ccnList = await getCCNList(); + const npubToPubkey = nip19.decode<'npub'>(npub); + const ccn = ccnList.find((ccn) => ccn.pubkey === npubToPubkey.data); + return ccn?.name; +} diff --git a/src/utils/invite.ts b/src/utils/invite.ts new file mode 100644 index 0000000..3ffeac6 --- /dev/null +++ b/src/utils/invite.ts @@ -0,0 +1,19 @@ +import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'; +import { nip19 } from '@nostr/tools'; +import type { NPub } from '@nostr/tools/nip19'; +import { bech32m } from '@scure/base'; + +export function generateInvite(ccn: NPub, invite: string) { + const npubHex = nip19.decode<'npub'>(ccn).data; + const combinedBytes = bech32m.toWords(new Uint8Array([...hexToBytes(npubHex), ...hexToBytes(invite)])); + return bech32m.encode('eveinvite', combinedBytes, false); +} + +export function readInvite(invite: `${string}1${string}`) { + const decoded = bech32m.decode(invite, false); + if (decoded.prefix !== 'eveinvite') return false; + const hexBytes = bech32m.fromWords(decoded.words); + const npub = nip19.npubEncode(bytesToHex(hexBytes.slice(0, 32))); + const inviteCode = bytesToHex(hexBytes.slice(32)); + return { npub, invite: inviteCode }; +} diff --git a/src/utils/sha512.ts b/src/utils/sha512.ts new file mode 100644 index 0000000..4d0a144 --- /dev/null +++ b/src/utils/sha512.ts @@ -0,0 +1,6 @@ +import { bytesToHex } from '@noble/ciphers/utils'; +import { sha512 } from '@noble/hashes/sha512'; + +export function sha512Hash(input: string) { + return bytesToHex(sha512.create().update(input).digest()); +} diff --git a/src/utils/unsafeRelaySet.ts b/src/utils/unsafeRelaySet.ts new file mode 100644 index 0000000..c860721 --- /dev/null +++ b/src/utils/unsafeRelaySet.ts @@ -0,0 +1,25 @@ +import { ndk } from '@/ndk'; +import { NDKRelay, NDKRelaySet } from '@nostr-dev-kit/ndk'; + +/** + * This is an unsafe relay set that is used to publish events publicly + * This should NEVER be used except for the initial setup + */ +export const unsafeRelaySet = new NDKRelaySet( + new Set([ + new NDKRelay('wss://nos.lol/', undefined, ndk), + new NDKRelay('wss://nostr.einundzwanzig.space/', undefined, ndk), + new NDKRelay('wss://nostr.massmux.com/', undefined, ndk), + new NDKRelay('wss://nostr.mom/', undefined, ndk), + new NDKRelay('wss://purplerelay.com/', undefined, ndk), + new NDKRelay('wss://relay.damus.io/', undefined, ndk), + new NDKRelay('wss://relay.goodmorningbitcoin.com/', undefined, ndk), + new NDKRelay('wss://relay.lexingtonbitcoin.org/', undefined, ndk), + new NDKRelay('wss://relay.nostr.band/', undefined, ndk), + new NDKRelay('wss://relay.primal.net/', undefined, ndk), + new NDKRelay('wss://relay.snort.social/', undefined, ndk), + new NDKRelay('wss://strfry.iris.to/', undefined, ndk), + new NDKRelay('wss://cache2.primal.net/v1', undefined, ndk), + ]), + ndk, +);