Skip to content

Commit 66940c4

Browse files
Add NIP-98 authentication plumbing
- Add Schnorr signing via @noble/curves - Add NIP-98 token generation (kind 27235) - Add settings modal for private key configuration - Add test PUT button to verify auth with melvin.me - Shows derived pubkey from private key
1 parent 4688679 commit 66940c4

1 file changed

Lines changed: 243 additions & 9 deletions

File tree

index.html

Lines changed: 243 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,49 @@
215215
font-size: 13px;
216216
z-index: 200;
217217
}
218+
.header-actions {
219+
display: flex;
220+
align-items: center;
221+
gap: 16px;
222+
}
223+
.settings-btn {
224+
background: none;
225+
border: none;
226+
color: #fff;
227+
cursor: pointer;
228+
font-size: 18px;
229+
padding: 4px 8px;
230+
opacity: 0.8;
231+
}
232+
.settings-btn:hover { opacity: 1; }
233+
.test-btn {
234+
background: #238636;
235+
border: none;
236+
color: #fff;
237+
padding: 6px 12px;
238+
border-radius: 4px;
239+
cursor: pointer;
240+
font-size: 12px;
241+
}
242+
.test-btn:hover { background: #2ea043; }
243+
.test-btn:disabled { opacity: 0.5; cursor: not-allowed; }
244+
.pubkey-display {
245+
font-family: monospace;
246+
font-size: 11px;
247+
background: #f6f8fa;
248+
padding: 8px;
249+
border-radius: 4px;
250+
word-break: break-all;
251+
margin-top: 4px;
252+
}
253+
.test-result {
254+
margin-top: 12px;
255+
padding: 8px;
256+
border-radius: 4px;
257+
font-size: 12px;
258+
}
259+
.test-result.success { background: #dafbe1; color: #1a7f37; }
260+
.test-result.error { background: #ffebe9; color: #cf222e; }
218261
</style>
219262
</head>
220263
<body>
@@ -228,9 +271,84 @@
228271
import git from 'https://esm.sh/isomorphic-git@1.25.3';
229272
import http from 'https://esm.sh/isomorphic-git@1.25.3/http/web';
230273
import LightningFS from 'https://esm.sh/@isomorphic-git/lightning-fs@4.6.0';
274+
import { schnorr } from 'https://esm.sh/@noble/curves@1.3.0/secp256k1';
275+
import { bytesToHex, hexToBytes } from 'https://esm.sh/@noble/hashes@1.3.3/utils';
276+
import { sha256 } from 'https://esm.sh/@noble/hashes@1.3.3/sha256';
231277

232278
const html = htm.bind(h);
233279

280+
// === NIP-98 Auth ===
281+
function getPrivkey() {
282+
return localStorage.getItem('nostr-git-privkey') || '';
283+
}
284+
285+
function setPrivkey(key) {
286+
localStorage.setItem('nostr-git-privkey', key);
287+
}
288+
289+
function getPubkeyFromPrivkey(privkey) {
290+
try {
291+
const pubkeyBytes = schnorr.getPublicKey(privkey);
292+
return bytesToHex(pubkeyBytes);
293+
} catch {
294+
return '';
295+
}
296+
}
297+
298+
function serializeEvent(event) {
299+
return JSON.stringify([
300+
0,
301+
event.pubkey,
302+
event.created_at,
303+
event.kind,
304+
event.tags,
305+
event.content
306+
]);
307+
}
308+
309+
function getEventHash(event) {
310+
const serialized = serializeEvent(event);
311+
const encoder = new TextEncoder();
312+
return bytesToHex(sha256(encoder.encode(serialized)));
313+
}
314+
315+
async function signEvent(event, privkey) {
316+
const pubkey = getPubkeyFromPrivkey(privkey);
317+
const eventWithPubkey = { ...event, pubkey };
318+
const id = getEventHash(eventWithPubkey);
319+
const sig = bytesToHex(schnorr.sign(id, privkey));
320+
return { ...eventWithPubkey, id, sig };
321+
}
322+
323+
async function createNip98Token(url, method, privkey) {
324+
const event = {
325+
kind: 27235,
326+
created_at: Math.floor(Date.now() / 1000),
327+
tags: [
328+
['u', url],
329+
['method', method]
330+
],
331+
content: ''
332+
};
333+
const signed = await signEvent(event, privkey);
334+
return 'Nostr ' + btoa(JSON.stringify(signed));
335+
}
336+
337+
async function nip98Fetch(url, method, privkey, body = null) {
338+
const auth = await createNip98Token(url, method, privkey);
339+
const options = {
340+
method,
341+
headers: {
342+
'Authorization': auth
343+
}
344+
};
345+
if (body !== null) {
346+
options.body = body;
347+
options.headers['Content-Type'] = 'text/plain';
348+
}
349+
return fetch(url, options);
350+
}
351+
234352
// === Storage ===
235353
const fs = new LightningFS('nostr-git-browser');
236354
const pfs = fs.promises;
@@ -394,17 +512,20 @@
394512
}
395513

396514
// === Components ===
397-
function Header({ relayStatus }) {
515+
function Header({ relayStatus, onSettings }) {
398516
return html`
399517
<header>
400518
<h1>🔗 Nostr Git Browser</h1>
401-
<div class="status">
402-
${relayStatus.map(r => html`
403-
<span key=${r.url}>
404-
<span class="status-dot ${r.status}"></span>
405-
${new URL(r.url).host}
406-
</span>
407-
`)}
519+
<div class="header-actions">
520+
<div class="status">
521+
${relayStatus.map(r => html`
522+
<span key=${r.url}>
523+
<span class="status-dot ${r.status}"></span>
524+
${new URL(r.url).host}
525+
</span>
526+
`)}
527+
</div>
528+
<button class="settings-btn" onClick=${onSettings} title="Settings">⚙️</button>
408529
</div>
409530
</header>
410531
`;
@@ -567,6 +688,112 @@ <h2>Add Repository</h2>
567688
return html`<div class="toast">${message}</div>`;
568689
}
569690

691+
function SettingsModal({ onClose, onToast }) {
692+
const [privkey, setPrivkeyState] = useState(getPrivkey);
693+
const [testUrl, setTestUrl] = useState('https://melvin.me/public/git/sync/20260202/counter.txt');
694+
const [testResult, setTestResult] = useState(null);
695+
const [testing, setTesting] = useState(false);
696+
697+
const pubkey = useMemo(() => getPubkeyFromPrivkey(privkey), [privkey]);
698+
const isValid = pubkey.length === 64;
699+
700+
const handleSave = () => {
701+
setPrivkey(privkey);
702+
onToast('Settings saved');
703+
onClose();
704+
};
705+
706+
const handleTest = async () => {
707+
if (!isValid) {
708+
setTestResult({ success: false, message: 'Invalid private key' });
709+
return;
710+
}
711+
712+
setTesting(true);
713+
setTestResult(null);
714+
715+
try {
716+
// First GET the current value
717+
let currentValue = 0;
718+
try {
719+
const getRes = await fetch(testUrl + '?' + Date.now());
720+
if (getRes.ok) {
721+
const text = await getRes.text();
722+
currentValue = parseInt(text.trim()) || 0;
723+
}
724+
} catch (e) {
725+
// File doesn't exist yet, start at 0
726+
}
727+
728+
// PUT the incremented value
729+
const newValue = currentValue + 1;
730+
const res = await nip98Fetch(testUrl, 'PUT', privkey, String(newValue));
731+
732+
if (res.ok) {
733+
setTestResult({ success: true, message: `Success! Counter: ${currentValue}${newValue}` });
734+
} else {
735+
const text = await res.text();
736+
setTestResult({ success: false, message: `HTTP ${res.status}: ${text}` });
737+
}
738+
} catch (err) {
739+
setTestResult({ success: false, message: err.message });
740+
} finally {
741+
setTesting(false);
742+
}
743+
};
744+
745+
return html`
746+
<div class="modal-overlay" onClick=${onClose}>
747+
<div class="modal" onClick=${e => e.stopPropagation()}>
748+
<h2>Settings</h2>
749+
<label>
750+
<span>Private Key (hex)</span>
751+
<input
752+
type="password"
753+
value=${privkey}
754+
onInput=${e => setPrivkeyState(e.target.value)}
755+
placeholder="64 character hex private key"
756+
/>
757+
</label>
758+
${privkey && html`
759+
<div>
760+
<span style="font-size: 12px; color: #656d76;">Public Key:</span>
761+
<div class="pubkey-display ${isValid ? '' : 'error'}">
762+
${isValid ? pubkey : 'Invalid private key'}
763+
</div>
764+
</div>
765+
`}
766+
<hr style="margin: 16px 0; border: none; border-top: 1px solid #d0d7de;" />
767+
<label>
768+
<span>Test URL (PUT)</span>
769+
<input
770+
value=${testUrl}
771+
onInput=${e => setTestUrl(e.target.value)}
772+
placeholder="https://melvin.me/path/to/file.txt"
773+
/>
774+
</label>
775+
<button
776+
class="test-btn"
777+
onClick=${handleTest}
778+
disabled=${!isValid || testing}
779+
style="margin-top: 8px;"
780+
>
781+
${testing ? 'Testing...' : 'Test NIP-98 PUT'}
782+
</button>
783+
${testResult && html`
784+
<div class="test-result ${testResult.success ? 'success' : 'error'}">
785+
${testResult.message}
786+
</div>
787+
`}
788+
<div class="buttons">
789+
<button type="button" onClick=${onClose}>Cancel</button>
790+
<button type="submit" onClick=${handleSave}>Save</button>
791+
</div>
792+
</div>
793+
</div>
794+
`;
795+
}
796+
570797
// === Main App ===
571798
function App() {
572799
const [config, setConfig] = useState(loadConfig);
@@ -578,6 +805,7 @@ <h2>Add Repository</h2>
578805
const [fileContent, setFileContent] = useState('');
579806
const [localFiles, setLocalFiles] = useState({});
580807
const [showAddModal, setShowAddModal] = useState(false);
808+
const [showSettingsModal, setShowSettingsModal] = useState(false);
581809
const [relayStatus, setRelayStatus] = useState([]);
582810
const [toast, setToast] = useState('');
583811

@@ -759,7 +987,7 @@ <h2>Add Repository</h2>
759987

760988
return html`
761989
<div class="app">
762-
<${Header} relayStatus=${relayStatus} />
990+
<${Header} relayStatus=${relayStatus} onSettings=${() => setShowSettingsModal(true)} />
763991
<div class="main">
764992
<${RepoList}
765993
repos=${config.repos}
@@ -791,6 +1019,12 @@ <h2>Add Repository</h2>
7911019
onClose=${() => setShowAddModal(false)}
7921020
/>
7931021
`}
1022+
${showSettingsModal && html`
1023+
<${SettingsModal}
1024+
onClose=${() => setShowSettingsModal(false)}
1025+
onToast=${showToast}
1026+
/>
1027+
`}
7941028
<${Toast} message=${toast} />
7951029
</div>
7961030
`;

0 commit comments

Comments
 (0)