Add NIP-46 connection initiated by client

This commit is contained in:
hzrd149
2024-04-18 08:53:32 -05:00
parent 368cc04614
commit 24c664eb08
4 changed files with 214 additions and 84 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add NIP-46 connection initiated by client

View File

@@ -8,7 +8,6 @@ import { getPubkeyFromDecodeResult, isHexKey, normalizeToHexPubkey } from "../he
import { logger } from "../helpers/debug"; import { logger } from "../helpers/debug";
import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event"; import { DraftNostrEvent, NostrEvent, isPTag } from "../types/nostr-event";
import createDefer, { Deferred } from "../classes/deferred"; import createDefer, { Deferred } from "../classes/deferred";
import { truncatedId } from "../helpers/nostr/event";
import { NostrConnectAccount } from "./account"; import { NostrConnectAccount } from "./account";
import { safeRelayUrl } from "../helpers/relay"; import { safeRelayUrl } from "../helpers/relay";
import { alwaysVerify } from "./verify-event"; import { alwaysVerify } from "./verify-event";
@@ -65,7 +64,7 @@ export class NostrConnectClient {
log = logger.extend("NostrConnectClient"); log = logger.extend("NostrConnectClient");
isConnected = false; isConnected = false;
pubkey: string; pubkey?: string;
provider?: string; provider?: string;
relays: string[]; relays: string[];
@@ -74,8 +73,8 @@ export class NostrConnectClient {
supportedMethods: NostrConnectMethod[] | undefined; supportedMethods: NostrConnectMethod[] | undefined;
constructor(pubkey: string, relays: string[], secretKey?: string, provider?: string) { constructor(pubkey?: string, relays: string[] = [], secretKey?: string, provider?: string) {
this.sub = new NostrMultiSubscription(`${pubkey}-nostr-connect`); this.sub = new NostrMultiSubscription();
this.pubkey = pubkey; this.pubkey = pubkey;
this.relays = relays; this.relays = relays;
this.provider = provider; this.provider = provider;
@@ -122,6 +121,17 @@ export class NostrConnectClient {
try { try {
const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content); const responseStr = await nip04.decrypt(this.secretKey, event.pubkey, event.content);
const response = JSON.parse(responseStr); const response = JSON.parse(responseStr);
// handle client connections
if (!this.pubkey && response.result === "ack") {
this.log("Got ack response from", event.pubkey);
this.pubkey = event.pubkey;
this.isConnected = true;
this.listenPromise?.resolve(response.result);
this.listenPromise = null;
return;
}
if (response.id) { if (response.id) {
const p = this.requests.get(response.id); const p = this.requests.get(response.id);
if (!p) return; if (!p) return;
@@ -146,6 +156,7 @@ export class NostrConnectClient {
} }
private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) { private createEvent(content: string, target = this.pubkey, kind = kinds.NostrConnect) {
if (!target) throw new Error("invalid target pubkey");
return finalizeEvent( return finalizeEvent(
{ {
kind, kind,
@@ -161,6 +172,7 @@ export class NostrConnectClient {
params: RequestParams[T], params: RequestParams[T],
kind = kinds.NostrConnect, kind = kinds.NostrConnect,
): Promise<ResponseResults[T]> { ): Promise<ResponseResults[T]> {
if (!this.pubkey) throw new Error("pubkey not set");
const id = nanoid(8); const id = nanoid(8);
const request: NostrConnectRequest<T> = { id, method, params }; const request: NostrConnectRequest<T> = { id, method, params };
const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request)); const encrypted = await nip04.encrypt(this.secretKey, this.pubkey, JSON.stringify(request));
@@ -191,6 +203,7 @@ export class NostrConnectClient {
} }
async connect(token?: string) { async connect(token?: string) {
if (!this.pubkey) throw new Error("pubkey not set");
await this.open(); await this.open();
try { try {
const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]); const result = await this.makeRequest(NostrConnectMethod.Connect, [this.pubkey, token || "", Perms]);
@@ -203,6 +216,14 @@ export class NostrConnectClient {
} }
} }
listenPromise: Deferred<"ack"> | null = null;
listen(): Promise<"ack"> {
if (this.pubkey) throw new Error("Cant listen if there is already a pubkey");
this.open();
this.listenPromise = createDefer();
return this.listenPromise;
}
async createAccount(name: string, domain: string, email?: string) { async createAccount(name: string, domain: string, email?: string) {
await this.open(); await this.open();
@@ -228,6 +249,8 @@ export class NostrConnectClient {
disconnect() { disconnect() {
return this.makeRequest(NostrConnectMethod.Disconnect, []); return this.makeRequest(NostrConnectMethod.Disconnect, []);
} }
// methods
getPublicKey() { getPublicKey() {
return this.makeRequest(NostrConnectMethod.GetPublicKey, []); return this.makeRequest(NostrConnectMethod.GetPublicKey, []);
} }

View File

@@ -1,13 +1,14 @@
import { useState } from "react"; import { ReactNode, useCallback, useMemo, useRef, useState } from "react";
import { import {
Button, Button,
Divider,
Flex, Flex,
FormControl, FormControl,
FormHelperText,
FormLabel, FormLabel,
IconButton,
Input, Input,
Spinner,
Text, Text,
useDisclosure,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -15,11 +16,87 @@ import { useNavigate } from "react-router-dom";
import accountService from "../../services/account"; import accountService from "../../services/account";
import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect"; import nostrConnectService, { NostrConnectClient } from "../../services/nostr-connect";
import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button"; import QRCodeScannerButton from "../../components/qr-code/qr-code-scanner-button";
import { RelayUrlInput } from "../../components/relay-url-input";
import QrCodeSvg from "../../components/qr-code/qr-code-svg";
import { CopyIconButton } from "../../components/copy-icon-button";
function ClientConnectForm() {
const navigate = useNavigate();
const urlInputRef = useRef<HTMLInputElement | null>(null);
const [relay, setRelay] = useState("wss://relay.nsec.app/");
const [client, setClient] = useState<NostrConnectClient>();
const [listening, setListening] = useState(false);
const connectionURL = useMemo(() => {
if (!client || !relay) return "";
const host = location.protocol + "//" + location.host;
const params = new URLSearchParams();
params.set("relay", relay);
params.set("name", "noStrudel");
params.set("url", host);
params.set("image", new URL("/apple-touch-icon.png", host).toString());
return `nostrconnect://${client.publicKey}?` + params.toString();
}, [relay, client]);
const create = useCallback(() => {
const c = new NostrConnectClient(undefined, [relay]);
setClient(c);
c.listen().then(() => {
nostrConnectService.saveClient(c);
accountService.addFromNostrConnect(c);
accountService.switchAccount(c.pubkey!);
});
setListening(true);
}, [relay]);
return (
<>
{client ? (
<>
<QrCodeSvg content={connectionURL} />
<Flex gap="2">
<Input
value={connectionURL}
ref={urlInputRef}
onFocus={() => urlInputRef.current?.select()}
onChange={() => {}}
/>
<CopyIconButton value={connectionURL} aria-label="Copy connection URL" />
</Flex>
{listening && (
<Flex gap="2" justifyContent="center" alignItems="center" py="2">
<Spinner />
<Text>Waiting for connection</Text>
</Flex>
)}
</>
) : (
<>
<FormControl>
<FormLabel htmlFor="input">Connection Relay</FormLabel>
<RelayUrlInput value={relay} onChange={(e) => setRelay(e.target.value)} />
</FormControl>
<Button colorScheme="primary" onClick={create}>
Create Connection URL
</Button>
</>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")} py="2">
Back
</Button>
</Flex>
</>
);
}
export default function LoginNostrConnectView() { export default function LoginNostrConnectView() {
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const [uri, setUri] = useState(""); const [connection, setConnection] = useState("");
const fromClient = useDisclosure();
const [loading, setLoading] = useState<string | undefined>(); const [loading, setLoading] = useState<string | undefined>();
const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => { const handleSubmit: React.FormEventHandler<HTMLDivElement> = async (e) => {
@@ -28,55 +105,74 @@ export default function LoginNostrConnectView() {
try { try {
setLoading("Connecting..."); setLoading("Connecting...");
let client: NostrConnectClient; let client: NostrConnectClient;
if (uri.startsWith("bunker://")) { if (connection.startsWith("bunker://")) {
if (uri.includes("@")) client = nostrConnectService.fromBunkerAddress(uri); if (connection.includes("@")) client = nostrConnectService.fromBunkerAddress(connection);
else client = nostrConnectService.fromBunkerURI(uri); else client = nostrConnectService.fromBunkerURI(connection);
await client.connect(new URL(uri).searchParams.get('secret') ?? undefined); await client.connect(new URL(connection).searchParams.get("secret") ?? undefined);
} else if (uri.startsWith("npub")) { } else if (connection.startsWith("npub")) {
client = nostrConnectService.fromBunkerToken(uri); client = nostrConnectService.fromBunkerToken(connection);
const [npub, hexToken] = uri.split("#"); const [npub, hexToken] = connection.split("#");
await client.connect(hexToken); await client.connect(hexToken);
} else throw new Error("Unknown format"); } else throw new Error("Unknown format");
nostrConnectService.saveClient(client); nostrConnectService.saveClient(client);
accountService.addFromNostrConnect(client); accountService.addFromNostrConnect(client);
accountService.switchAccount(client.pubkey); accountService.switchAccount(client.pubkey!);
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message }); if (e instanceof Error) toast({ status: "error", description: e.message });
} }
setLoading(undefined); setLoading(undefined);
}; };
let content: ReactNode = null;
if (fromClient.isOpen) {
content = <ClientConnectForm />;
} else {
content = (
<>
{loading && <Text fontSize="lg">{loading}</Text>}
{!loading && (
<FormControl>
<FormLabel htmlFor="input">Remote Signer URL</FormLabel>
<Flex gap="2">
<Input
id="nostr-connect"
name="nostr-connect"
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={connection}
onChange={(e) => setConnection(e.target.value)}
autoComplete="off"
/>
<QRCodeScannerButton onData={(v) => setConnection(v)} />
</Flex>
</FormControl>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")} py="2">
Back
</Button>
<Button colorScheme="primary" type="submit" isLoading={!!loading}>
Connect
</Button>
</Flex>
<Flex w="full" alignItems="center" gap="4">
<Divider />
<Text fontWeight="bold">OR</Text>
<Divider />
</Flex>
<Button variant="outline" onClick={fromClient.onOpen}>
Create Connection URL
</Button>
</>
);
}
return ( return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit} w="full"> <Flex as="form" direction="column" gap="2" onSubmit={handleSubmit} w="full">
{loading && <Text fontSize="lg">{loading}</Text>} {content}
{!loading && (
<FormControl>
<FormLabel htmlFor="input">Connect URI</FormLabel>
<Flex gap="2">
<Input
id="nostr-connect"
name="nostr-connect"
placeholder="bunker://<pubkey>?relay=wss://relay.example.com"
isRequired
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
/>
<QRCodeScannerButton onData={(v) => setUri(v)} />
</Flex>
<FormHelperText>A bunker connect URI</FormHelperText>
</FormControl>
)}
<Flex justifyContent="space-between" gap="2">
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
<Button colorScheme="primary" ml="auto" type="submit" isLoading={!!loading}>
Connect
</Button>
</Flex>
</Flex> </Flex>
); );
} }

View File

@@ -1,22 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { import { Badge, Button, ButtonGroup, Divider, Flex, IconButton, Link, Spinner, Text, useToast } from "@chakra-ui/react";
Badge,
Button,
ButtonGroup,
Flex,
IconButton,
Link,
Spinner,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom"; import { Link as RouterLink, useLocation } from "react-router-dom";
import Key01 from "../../components/icons/key-01"; import Key01 from "../../components/icons/key-01";
import Diamond01 from "../../components/icons/diamond-01"; import Diamond01 from "../../components/icons/diamond-01";
import ChevronDown from "../../components/icons/chevron-down";
import ChevronUp from "../../components/icons/chevron-up";
import UsbFlashDrive from "../../components/icons/usb-flash-drive"; import UsbFlashDrive from "../../components/icons/usb-flash-drive";
import HelpCircle from "../../components/icons/help-circle"; import HelpCircle from "../../components/icons/help-circle";
@@ -27,12 +14,13 @@ import amberSignerService from "../../services/amber-signer";
import { AtIcon } from "../../components/icons"; import { AtIcon } from "../../components/icons";
import { getRelaysFromExt } from "../../helpers/nip07"; import { getRelaysFromExt } from "../../helpers/nip07";
import { safeRelayUrls } from "../../helpers/relay"; import { safeRelayUrls } from "../../helpers/relay";
import Package from "../../components/icons/package";
import Eye from "../../components/icons/eye";
export default function LoginStartView() { export default function LoginStartView() {
const location = useLocation(); const location = useLocation();
const toast = useToast(); const toast = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const advanced = useDisclosure();
const signinWithExtension = async () => { const signinWithExtension = async () => {
if (window.nostr) { if (window.nostr) {
@@ -150,31 +138,49 @@ export default function LoginStartView() {
/> />
</ButtonGroup> </ButtonGroup>
)} )}
<Button <Flex w="full" alignItems="center" gap="4">
variant="link" <Divider />
onClick={advanced.onToggle} <Text fontWeight="bold">OR</Text>
py="2" <Divider />
w="full" </Flex>
rightIcon={advanced.isOpen ? <ChevronUp /> : <ChevronDown />} <Flex gap="2">
> <Button
Show Advanced flexDirection="column"
</Button> h="auto"
{advanced.isOpen && ( p="4"
<> as={RouterLink}
<Button as={RouterLink} to="./nostr-connect" state={location.state} w="full"> to="./nostr-connect"
Nostr Connect / Bunker state={location.state}
</Button> variant="outline"
<Button as={RouterLink} to="./npub" state={location.state} w="full"> >
Public key (npub) <Package boxSize={12} />
<Badge ml="2" colorScheme="blue"> Nostr Connect
read-only </Button>
</Badge> <Button
</Button> flexDirection="column"
<Button as={RouterLink} to="./nsec" state={location.state} w="full"> h="auto"
Secret key (nsec) p="4"
</Button> as={RouterLink}
</> to="./nsec"
)} state={location.state}
variant="outline"
>
<Key01 boxSize={12} />
Private key
</Button>
<Button
flexDirection="column"
h="auto"
p="4"
as={RouterLink}
to="./npub"
state={location.state}
variant="outline"
>
<Eye boxSize={12} />
Public key
</Button>
</Flex>
<Text fontWeight="bold" mt="4"> <Text fontWeight="bold" mt="4">
Don't have an account? Don't have an account?
</Text> </Text>