Skip to content

Commit 664fa03

Browse files
committed
Add NIP-46 signing
1 parent a4f16ba commit 664fa03

File tree

11 files changed

+254
-51
lines changed

11 files changed

+254
-51
lines changed

src/App.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,13 @@ import 'media-chrome';
1919
import "media-chrome/media-theme-element";
2020
import 'hls-video-element';
2121
import 'videojs-video-element';
22-
import { generatePrivateKey, getPublicKey, nip19 } from './lib/nTools';
23-
22+
import { nip46 } from './lib/nTools';
23+
import { generateAppKeys } from './lib/PrimalNip46';
2424

2525

2626
export const version = import.meta.env.PRIMAL_VERSION;
2727
export const APP_ID = `web_${version}_${Math.floor(Math.random()*10000000000)}`;
2828

29-
const generateAppKeys = () => {
30-
if (localStorage.getItem('appNsec')) return;
31-
32-
let sk = generatePrivateKey();
33-
let pk = getPublicKey(sk);
34-
35-
localStorage.setItem('appNsec', nip19.nsecEncode(sk));
36-
localStorage.setItem('appPubkey', pk);
37-
}
38-
39-
export const getAppPK = () => localStorage.getItem('pk');
40-
41-
4229
const App: Component = () => {
4330

4431
onMount(() => {

src/components/LiveVideo/ChatMessage.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,6 @@ const ChatMessage: Component<{
862862
const renderUserMention = (item: NoteContent) => {
863863
return <For each={item.tokens}>
864864
{(token) => {
865-
console.log('USER MENTION: ', token);
866865
let [nostr, id] = token.split(':');
867866

868867
if (!id) {

src/components/LoginModal/LoginModal.tsx

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import { useIntl } from '@cookbook/solid-intl';
2-
import { Component, createEffect, createSignal, Match, Switch } from 'solid-js';
2+
import { Component, createEffect, createSignal, Match, Show, Switch } from 'solid-js';
33

44
import { login as tLogin, actions as tActions } from '../../translations';
55

66
import styles from './LoginModal.module.scss';
77
import { hookForDev } from '../../lib/devTools';
88
import ButtonPrimary from '../Buttons/ButtonPrimary';
99
import CreatePinModal from '../CreatePinModal/CreatePinModal';
10-
import TextInput from '../TextInput/TextInput';
11-
import { nip19 } from '../../lib/nTools';
10+
import { nip19, nip46, SimplePool } from '../../lib/nTools';
1211
import { storeSec } from '../../lib/localStore';
1312
import AdvancedSearchDialog from '../AdvancedSearch/AdvancedSearchDialog';
14-
import { accountStore, loginUsingExtension, loginUsingLocalNsec, loginUsingNpub, setSec } from '../../stores/accountStore';
13+
import { accountStore, doAfterLogin, loginUsingExtension, loginUsingLocalNsec, loginUsingNpub, setLoginType, setPublicKey, setSec } from '../../stores/accountStore';
1514
import { Tabs } from '@kobalte/core/tabs';
1615

17-
import extensionIcon from '../../assets/images/extension.svg';
18-
import nsecIcon from '../../assets/images/nsec.svg';
1916
import { useToastContext } from '../Toaster/Toaster';
2017
import QrCode from '../QrCode/QrCode';
18+
import { useAppContext } from '../../contexts/AppContext';
19+
import { generateClientConnectionUrl, getAppSK, storeBunker } from '../../lib/PrimalNip46';
2120

2221
const LoginModal: Component<{
2322
id?: string,
@@ -27,11 +26,13 @@ const LoginModal: Component<{
2726

2827
const intl = useIntl();
2928
const toaster = useToastContext();
29+
const app = useAppContext();
3030

3131
const [step, setStep] = createSignal<'login' | 'pin' | 'none'>('login')
3232
const [enteredKey, setEnteredKey] = createSignal('');
3333
const [enteredNpub, setEnteredNpub] = createSignal('');
3434

35+
const [clientUrl, setClientUrl] = createSignal('');
3536

3637
const [activeTab, setActiveTab] = createSignal('simple');
3738

@@ -105,12 +106,7 @@ const LoginModal: Component<{
105106
};
106107

107108
createEffect(() => {
108-
if (props.open && step() === 'login') {
109-
nsecInput?.focus();
110-
}
111-
});
112-
113-
createEffect(() => {
109+
if (!props.open) return;
114110
if (activeTab() === 'nsec') {
115111
setTimeout(() => {
116112
nsecInput?.focus();
@@ -122,8 +118,38 @@ const LoginModal: Component<{
122118
npubInput?.focus();
123119
}, 100)
124120
}
121+
122+
if (activeTab() === 'simple') {
123+
setupSigner();
124+
}
125125
});
126126

127+
const setupSigner = async () => {
128+
const clientUrl = generateClientConnectionUrl();
129+
130+
if (clientUrl.length === 0) return;
131+
132+
setClientUrl(clientUrl);
133+
134+
const sec = getAppSK();
135+
if (!sec) return;
136+
137+
const pool = new SimplePool()
138+
const signer = await nip46.BunkerSigner.fromURI(sec, clientUrl, { pool })
139+
140+
storeBunker(signer);
141+
const pk = await signer.getPublicKey();
142+
143+
setLoginType('nip46');
144+
setPublicKey(pk);
145+
doAfterLogin(pk);
146+
147+
// cleanup
148+
await signer.close()
149+
pool.close([])
150+
151+
}
152+
127153
const onKeyUp = (e: KeyboardEvent) => {
128154
if (e.code === 'Enter' && isValidNsec()) {
129155
onLogin();
@@ -167,11 +193,13 @@ const LoginModal: Component<{
167193
<Tabs.Content value="simple" >
168194
<div class={styles.extensionLogin}>
169195
<div class={styles.qrCode}>
170-
<QrCode
171-
data="https://primal.net"
172-
width={234}
173-
height={234}
174-
/>
196+
<Show when={clientUrl().length > 0}>
197+
<QrCode
198+
data={clientUrl()}
199+
width={234}
200+
height={234}
201+
/>
202+
</Show>
175203
</div>
176204
<div class={styles.simpleDesc}>
177205
<div class={styles.loginExplain}>

src/contexts/AppContext.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,44 @@ import {
88
onMount,
99
useContext
1010
} from "solid-js";
11-
import { MediaEvent, MediaVariant, NostrBlossom, NostrEOSE, NostrEvent, NostrEventContent, NostrEvents, NostrLiveChat, PrimalArticle, PrimalDVM, PrimalNote, PrimalUser, ZapOption } from "../types/primal";
11+
import {
12+
NostrBlossom,
13+
NostrEOSE,
14+
NostrEvent,
15+
NostrEventContent,
16+
NostrEvents,
17+
NostrLiveChat,
18+
PrimalArticle,
19+
PrimalDVM,
20+
PrimalNote,
21+
PrimalUser,
22+
ZapOption,
23+
} from "../types/primal";
24+
import {
25+
connect,
26+
disconnect,
27+
isConnected,
28+
readData,
29+
refreshSocketListeners,
30+
removeSocketListeners,
31+
socket,
32+
} from "../sockets";
33+
import {
34+
accountStore,
35+
hasPublicKey,
36+
logUserIn,
37+
reconnectSuspendedRelays,
38+
suspendRelays,
39+
} from "../stores/accountStore";
1240
import { CashuMint } from "@cashu/cashu-ts";
1341
import { Tier, TierCost } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
14-
import { connect, disconnect, isConnected, isNotConnected, readData, refreshSocketListeners, removeSocketListeners, socket } from "../sockets";
15-
import { nip19, Relay } from "../lib/nTools";
42+
import { nip19, nip46 } from "../lib/nTools";
1643
import { logInfo } from "../lib/logger";
1744
import { Kind } from "../constants";
1845
import { LegendCustomizationConfig } from "../lib/premium";
19-
import { config } from "@milkdown/core";
2046
import { StreamingData } from "../lib/streaming";
21-
import { accountStore, hasPublicKey, logUserIn, reconnectSuspendedRelays, suspendRelays } from "../stores/accountStore";
2247
import { loadLegendCustomization, saveLegendCustomization } from "../lib/localStore";
2348

24-
2549
export type ReactionStats = {
2650
likes: number,
2751
zaps: number,
@@ -120,6 +144,7 @@ export type AppContextStore = {
120144
memberCohortInfo: Record<string, CohortInfo>,
121145
showProfileQr: PrimalUser | undefined,
122146
reportContent: PrimalNote | PrimalArticle | NostrLiveChat | undefined,
147+
signer: nip46.BunkerSigner | undefined,
123148
actions: {
124149
openReactionModal: (noteId: string, stats: ReactionStats) => void,
125150
closeReactionModal: () => void,
@@ -220,6 +245,7 @@ const initialData: Omit<AppContextStore, 'actions'> = {
220245
subscribeToTier: () => {},
221246
showProfileQr: undefined,
222247
reportContent: undefined,
248+
signer: undefined,
223249
};
224250

225251
export const AppContext = createContext<AppContextStore>();

src/lib/PrimalNip46.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { verifyEvent, nip46, nip19, getPublicKey, generatePrivateKey } from '../lib/nTools';
2+
import { NostrExtension, NostrRelayEvent, NostrRelays, NostrRelaySignedEvent } from '../types/primal';
3+
import { uuidv4 } from '../utils';
4+
import { logWarning } from './logger';
5+
6+
export let appSigner: nip46.BunkerSigner | undefined;
7+
8+
export const setAppSigner = (bunker: nip46.BunkerSigner) => {
9+
appSigner = bunker;
10+
}
11+
12+
export const generateAppKeys = () => {
13+
if (localStorage.getItem('appNsec')) return;
14+
15+
let sk = generatePrivateKey();
16+
let pk = getPublicKey(sk);
17+
18+
localStorage.setItem('appNsec', nip19.nsecEncode(sk));
19+
localStorage.setItem('appPubkey', pk);
20+
}
21+
22+
export const getAppPK = () => localStorage.getItem('appPubkey');
23+
24+
export const getAppSK = () => {
25+
const nsec = localStorage.getItem('appNsec');
26+
27+
if (!nsec) return;
28+
29+
try {
30+
const decoded = nip19.decode(nsec);
31+
32+
if (decoded.type !== 'nsec') return;
33+
34+
return decoded.data;
35+
} catch (e) {
36+
logWarning('Failed to decode App nsec: ', e);
37+
return;
38+
}
39+
}
40+
41+
export const generateClientConnectionUrl = (): string => {
42+
const clientPubkey = getAppPK();
43+
44+
if (!clientPubkey) return '';
45+
46+
const n = localStorage.getItem('clientConnectionUrl') ||
47+
nip46.createNostrConnectURI({
48+
clientPubkey,
49+
relays: ['wss://relay.primal.net'],
50+
secret: `sec-${uuidv4()}`, // A secret to verify the bunker's response
51+
name: `Primal_Web_App`
52+
});
53+
54+
localStorage.setItem('clientConnectionUrl', n);
55+
56+
return n;
57+
}
58+
59+
export const storeBunker = (bunker: nip46.BunkerSigner) => {
60+
const remotePubkey = bunker.bp.pubkey;
61+
const relays = bunker.bp.relays.reduce<string>((acc, r, i) => i === 0 ? `relay=${r}` : `${acc}&relay=${r}`,'');
62+
const secret = bunker.bp.secret;
63+
64+
const bunkerUrl = `bunker://${remotePubkey}?${relays}&secret=${secret}`;
65+
66+
localStorage.setItem('bunkerUrl', bunkerUrl);
67+
68+
return bunkerUrl;
69+
}
70+
71+
export const PrimalNip46: (pk?: string) => NostrExtension = (pk?: string) => {
72+
const gPk: () => Promise<string> = async () => {
73+
if (!appSigner) throw('no-bunker-found');
74+
return await appSigner?.getPublicKey();
75+
};
76+
77+
const gRl: () => Promise<NostrRelays> = () => new Promise<NostrRelays>((resolve) => {resolve({})});
78+
79+
const encrypt: (pubkey: string, message: string) => Promise<string> =
80+
async (pubkey, message) => {
81+
if (!appSigner) throw('no-bunker-found');
82+
return appSigner?.nip04Encrypt(pubkey, message);
83+
};
84+
85+
const decrypt: (pubkey: string, message: string) => Promise<string> =
86+
async (pubkey, message) => {
87+
if (!appSigner) throw('no-bunker-found');
88+
return appSigner?.nip04Decrypt(pubkey, message);
89+
};
90+
91+
const encrypt44: (pubkey: string, message: string) => Promise<string> =
92+
async (pubkey, message) => {
93+
if (!appSigner) throw('no-bunker-found');
94+
return appSigner?.nip44Encrypt(pubkey, message);
95+
};
96+
97+
const decrypt44: (pubkey: string, message: string) => Promise<string> =
98+
async (pubkey, message) => {
99+
if (!appSigner) throw('no-bunker-found');
100+
return appSigner.nip44Encrypt(pubkey, message);
101+
};
102+
103+
const signEvent = async (event: NostrRelayEvent) => {
104+
if (!appSigner) throw('no-bunker-found');
105+
106+
let evt = await appSigner.signEvent(event);
107+
108+
const isVerified = verifyEvent(evt);
109+
110+
if (!isVerified) throw('event-sig-not-verified');
111+
112+
return evt as NostrRelaySignedEvent;
113+
};
114+
115+
return {
116+
getPublicKey: gPk,
117+
getRelays: gRl,
118+
nip04: {
119+
encrypt,
120+
decrypt,
121+
},
122+
nip44: {
123+
encrypt: encrypt44,
124+
decrypt: decrypt44,
125+
},
126+
signEvent,
127+
};
128+
};

src/lib/nTools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
SimplePool
77
} from 'nostr-tools';
88

9+
import * as nip46 from 'nostr-tools/nip46'
10+
911
// @ts-ignore
1012
import { AbstractRelay as Relay } from 'nostr-tools/abstract-relay';
1113
import { Relay as RelayFactory } from 'nostr-tools';
@@ -32,6 +34,7 @@ export {
3234
nip05,
3335
nip19,
3436
nip44,
37+
nip46,
3538
nip47,
3639
nip57,
3740
utils,

src/lib/nostrAPI.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
SendPaymentResponse,
99
WebLnExtension,
1010
} from "../types/primal";
11+
import { PrimalNip46 } from "./PrimalNip46";
1112
import { PrimalNostr } from "./PrimalNostr";
1213

1314

@@ -100,8 +101,7 @@ const enqueueNostr = async <T>(action: (nostr: NostrExtension) => Promise<T>) =>
100101
}
101102

102103
if (loginType === 'nip46') {
103-
// TODO actually implement nip46 signer
104-
nostr = PrimalNostr();
104+
nostr = PrimalNip46();
105105

106106
if (nostr === undefined) {
107107
throw('no_nostr_nip46');

0 commit comments

Comments
 (0)