Send nip17 encrypted messages

This commit is contained in:
koalasat
2025-05-09 12:36:16 +02:00
parent bcd36e61f2
commit 506338cb54
12 changed files with 68 additions and 19 deletions

View File

@ -267,12 +267,18 @@ class OrderDetailSerializer(serializers.ModelSerializer):
maker_hash_id = serializers.CharField( maker_hash_id = serializers.CharField(
required=False, help_text="The maker's robot hash" required=False, help_text="The maker's robot hash"
) )
maker_nostr_pubkey = serializers.CharField(
required=False, help_text="The maker's robot nostr hex pubkey"
)
taker_nick = serializers.CharField( taker_nick = serializers.CharField(
required=False, help_text="The taker's robot hash" required=False, help_text="The taker's robot hash"
) )
taker_hash_id = serializers.CharField( taker_hash_id = serializers.CharField(
required=False, help_text="The taker's robot hash" required=False, help_text="The taker's robot hash"
) )
taker_nostr_pubkey = serializers.CharField(
required=False, help_text="The taker's robot nostr hex pubkey"
)
status_message = serializers.CharField( status_message = serializers.CharField(
required=False, required=False,
help_text="The current status of the order corresponding to the `status`", help_text="The current status of the order corresponding to the `status`",
@ -444,8 +450,10 @@ class OrderDetailSerializer(serializers.ModelSerializer):
"is_seller", "is_seller",
"maker_nick", "maker_nick",
"maker_hash_id", "maker_hash_id",
"maker_nostr_pubkey",
"taker_nick", "taker_nick",
"taker_hash_id", "taker_hash_id",
"taker_nostr_pubkey",
"status_message", "status_message",
"is_fiat_sent", "is_fiat_sent",
"is_disputed", "is_disputed",

View File

@ -267,6 +267,7 @@ class OrderView(viewsets.ViewSet):
data["maker_nick"] = str(order.maker) data["maker_nick"] = str(order.maker)
data["maker_hash_id"] = str(order.maker.robot.hash_id) data["maker_hash_id"] = str(order.maker.robot.hash_id)
data["maker_nostr_pubkey"] = str(order.maker.robot.nostr_pubkey)
# Add activity status of participants based on last_seen # Add activity status of participants based on last_seen
data["maker_status"] = Logics.user_activity_status(order.maker.last_login) data["maker_status"] = Logics.user_activity_status(order.maker.last_login)
@ -308,6 +309,7 @@ class OrderView(viewsets.ViewSet):
data["taker_nick"] = str(order.taker) data["taker_nick"] = str(order.taker)
if order.taker: if order.taker:
data["taker_hash_id"] = str(order.taker.robot.hash_id) data["taker_hash_id"] = str(order.taker.robot.hash_id)
data["taker_nostr_pubkey"] = str(order.taker.robot.nostr_pubkey)
data["status_message"] = Order.Status(order.status).label data["status_message"] = Order.Status(order.status).label
data["is_fiat_sent"] = order.is_fiat_sent data["is_fiat_sent"] = order.is_fiat_sent
data["latitude"] = order.latitude data["latitude"] = order.latitude

View File

@ -34,7 +34,7 @@
"latlon-geohash": "^2.0.0", "latlon-geohash": "^2.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"light-bolt11-decoder": "^3.1.1", "light-bolt11-decoder": "^3.1.1",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.12.0",
"npm": "^11.0.0", "npm": "^11.0.0",
"openpgp": "^5.11.0", "openpgp": "^5.11.0",
"react": "^18.2.0", "react": "^18.2.0",
@ -12234,9 +12234,9 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.10.4", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.12.0.tgz",
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", "integrity": "sha512-pUWEb020gTvt1XZvTa8AKNIHWFapjsv2NKyk43Ez2nnvz6WSXsrTFE0XtkNLSRBjPn6EpxumKeNiVzLz74jNSA==",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",

View File

@ -75,7 +75,7 @@
"latlon-geohash": "^2.0.0", "latlon-geohash": "^2.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"light-bolt11-decoder": "^3.1.1", "light-bolt11-decoder": "^3.1.1",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.12.0",
"npm": "^11.0.0", "npm": "^11.0.0",
"openpgp": "^5.11.0", "openpgp": "^5.11.0",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -38,6 +38,7 @@ interface Props {
setMessages: (messages: EncryptedChatMessage[]) => void; setMessages: (messages: EncryptedChatMessage[]) => void;
turtleMode: boolean; turtleMode: boolean;
setTurtleMode: (state: boolean) => void; setTurtleMode: (state: boolean) => void;
onSendMessage: (content: string) => void;
} }
const EncryptedSocketChat: React.FC<Props> = ({ const EncryptedSocketChat: React.FC<Props> = ({
@ -51,6 +52,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
setMessages, setMessages,
turtleMode, turtleMode,
setTurtleMode, setTurtleMode,
onSendMessage,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
@ -248,6 +250,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
} }
// If input string contains '#' send unencrypted and unlogged message // If input string contains '#' send unencrypted and unlogged message
else if (connection != null && value.substring(0, 1) === '#') { else if (connection != null && value.substring(0, 1) === '#') {
onSendMessage(value);
connection.send( connection.send(
JSON.stringify({ JSON.stringify({
type: 'message', type: 'message',
@ -263,6 +266,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
setValue(''); setValue('');
setWaitingEcho(true); setWaitingEcho(true);
setLastSent(value); setLastSent(value);
onSendMessage(value);
encryptMessage(value, robot.pubKey, peerPubKey, robot.encPrivKey, slot.token) encryptMessage(value, robot.pubKey, peerPubKey, robot.encPrivKey, slot.token)
.then((encryptedMessage) => { .then((encryptedMessage) => {
if (connection != null) { if (connection != null) {

View File

@ -32,6 +32,7 @@ interface Props {
setMessages: (messages: EncryptedChatMessage[]) => void; setMessages: (messages: EncryptedChatMessage[]) => void;
turtleMode: boolean; turtleMode: boolean;
setTurtleMode: (state: boolean) => void; setTurtleMode: (state: boolean) => void;
onSendMessage: (content: string) => void;
} }
const audioPath = const audioPath =
@ -50,6 +51,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
setMessages, setMessages,
setTurtleMode, setTurtleMode,
turtleMode, turtleMode,
onSendMessage,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
@ -198,6 +200,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
const { url, basePath } = federation const { url, basePath } = federation
.getCoordinator(garage.getSlot()?.activeOrder?.shortAlias ?? '') .getCoordinator(garage.getSlot()?.activeOrder?.shortAlias ?? '')
.getEndpoint(settings.network, origin, settings.selfhostedClient, hostUrl); .getEndpoint(settings.network, origin, settings.selfhostedClient, hostUrl);
onSendMessage(value);
apiClient apiClient
.post( .post(
url + basePath, url + basePath,
@ -226,6 +229,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
else if (value !== '' && Boolean(robot?.pubKey)) { else if (value !== '' && Boolean(robot?.pubKey)) {
setWaitingEcho(true); setWaitingEcho(true);
setLastSent(value); setLastSent(value);
onSendMessage(value);
encryptMessage(value, robot?.pubKey, peerPubKey ?? '', robot?.encPrivKey, slot?.token) encryptMessage(value, robot?.pubKey, peerPubKey ?? '', robot?.encPrivKey, slot?.token)
.then((encryptedMessage) => { .then((encryptedMessage) => {
const { url, basePath } = federation const { url, basePath } = federation

View File

@ -1,7 +1,13 @@
import React, { useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { type Order, type Robot } from '../../../models'; import { type Order, type Robot } from '../../../models';
import EncryptedSocketChat from './EncryptedSocketChat'; import EncryptedSocketChat from './EncryptedSocketChat';
import EncryptedTurtleChat from './EncryptedTurtleChat'; import EncryptedTurtleChat from './EncryptedTurtleChat';
import { nip17 } from 'nostr-tools';
import { GarageContext, type UseGarageStoreType } from '../../../contexts/GarageContext';
import {
FederationContext,
type UseFederationStoreType,
} from '../../../contexts/FederationContext';
interface Props { interface Props {
order: Order; order: Order;
@ -36,11 +42,35 @@ const EncryptedChat: React.FC<Props> = ({
status, status,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [turtleMode, setTurtleMode] = useState<boolean>(false); const [turtleMode, setTurtleMode] = useState<boolean>(false);
const { garage } = useContext<UseGarageStoreType>(GarageContext);
const { federation } = useContext<UseFederationStoreType>(FederationContext);
useEffect(() => {
const coordinator = federation.getCoordinator(order.shortAlias);
federation.roboPool.connect([coordinator.getRelayUrl()]);
}, []);
const onSendMessage = (content: string): void => {
const slot = garage.getSlot();
const coordinator = federation.getCoordinator(order.shortAlias);
if (!slot?.nostrSecKey) return;
const recipient = {
publicKey: order.is_maker ? order.taker_nostr_pubkey : order.maker_nostr_pubkey,
relayUrl: coordinator.getRelayUrl(),
};
const wrappedEvent = nip17.wrapEvent(slot?.nostrSecKey, recipient, content);
federation.roboPool.sendEvent(wrappedEvent);
};
return turtleMode ? ( return turtleMode ? (
<EncryptedTurtleChat <EncryptedTurtleChat
messages={messages} messages={messages}
setMessages={setMessages} setMessages={setMessages}
onSendMessage={onSendMessage}
order={order} order={order}
takerNick={order.taker_nick} takerNick={order.taker_nick}
takerHashId={order.taker_hash_id} takerHashId={order.taker_hash_id}
@ -55,6 +85,7 @@ const EncryptedChat: React.FC<Props> = ({
status={status} status={status}
messages={messages} messages={messages}
setMessages={setMessages} setMessages={setMessages}
onSendMessage={onSendMessage}
order={order} order={order}
takerNick={order.taker_nick} takerNick={order.taker_nick}
takerHashId={order.taker_hash_id} takerHashId={order.taker_hash_id}

View File

@ -303,6 +303,10 @@ export class Coordinator {
return { url: String(this[network][origin]), basePath: '' }; return { url: String(this[network][origin]), basePath: '' };
} }
}; };
getRelayUrl = (): string => {
return `ws://${this.url.replace(/^https?:\/\//, '')}/nostr`;
};
} }
export default Coordinator; export default Coordinator;

View File

@ -65,7 +65,7 @@ export class Federation {
if (tesnetHost) settings.network = 'testnet'; if (tesnetHost) settings.network = 'testnet';
this.connection = null; this.connection = null;
this.roboPool = new RoboPool(settings, origin); this.roboPool = new RoboPool(settings, Object.values(this.coordinators));
} }
private coordinators: Record<string, Coordinator>; private coordinators: Record<string, Coordinator>;

View File

@ -93,8 +93,10 @@ class Order {
is_seller: boolean = false; is_seller: boolean = false;
maker_nick: string = ''; maker_nick: string = '';
maker_hash_id: string = ''; maker_hash_id: string = '';
maker_nostr_pubkey: string = '';
taker_nick: string = ''; taker_nick: string = '';
taker_hash_id: string = ''; taker_hash_id: string = '';
taker_nostr_pubkey: string = '';
status_message: string = ''; status_message: string = '';
is_fiat_sent: boolean = false; is_fiat_sent: boolean = false;
is_disputed: boolean = false; is_disputed: boolean = false;

View File

@ -75,6 +75,7 @@ class Robot {
last_login: data.last_login, last_login: data.last_login,
pubKey: data.public_key, pubKey: data.public_key,
encPrivKey: data.encrypted_private_key, encPrivKey: data.encrypted_private_key,
nostrPubKey: data.nostr_pubkey,
}); });
}) })
.catch((e) => { .catch((e) => {

View File

@ -1,5 +1,5 @@
import { type Event } from 'nostr-tools'; import { type Event } from 'nostr-tools';
import { type Settings } from '../../models'; import { type Coordinator, type Settings } from '../../models';
import defaultFederation from '../../../static/federation.json'; import defaultFederation from '../../../static/federation.json';
import { websocketClient, type WebsocketConnection, WebsocketState } from '../Websocket'; import { websocketClient, type WebsocketConnection, WebsocketState } from '../Websocket';
import thirdParties from '../../../static/thirdparties.json'; import thirdParties from '../../../static/thirdparties.json';
@ -10,19 +10,12 @@ interface RoboPoolEvents {
} }
class RoboPool { class RoboPool {
constructor(settings: Settings, origin: string) { constructor(settings: Settings, coordinators: Coordinator[]) {
this.network = settings.network ?? 'mainnet'; this.network = settings.network ?? 'mainnet';
this.relays = []; this.relays = [];
const federationRelays = Object.values(defaultFederation) const federationRelays = coordinators.map((coord) => coord.getRelayUrl());
.map((coord) => {
const url: string = coord[this.network]?.[settings.selfhostedClient ? 'onion' : origin];
if (!url) return undefined;
return `ws://${url.replace(/^https?:\/\//, '')}/nostr`;
})
.filter((item) => item !== undefined);
if (settings.host) { if (settings.host) {
const hostNostr = `ws://${settings.host.replace(/^https?:\/\//, '')}/nostr`; const hostNostr = `ws://${settings.host.replace(/^https?:\/\//, '')}/nostr`;
if (federationRelays.includes(hostNostr)) { if (federationRelays.includes(hostNostr)) {
@ -44,8 +37,8 @@ class RoboPool {
public webSockets: Record<string, WebsocketConnection | null> = {}; public webSockets: Record<string, WebsocketConnection | null> = {};
private readonly messageHandlers: Array<(url: string, event: MessageEvent) => void> = []; private readonly messageHandlers: Array<(url: string, event: MessageEvent) => void> = [];
connect = (): void => { connect = (relays: string[] = this.relays): void => {
this.relays.forEach((url: string) => { relays.forEach((url: string) => {
if (Object.keys(this.webSockets).find((wUrl) => wUrl === url)) return; if (Object.keys(this.webSockets).find((wUrl) => wUrl === url)) return;
this.webSockets[url] = null; this.webSockets[url] = null;