feat: add Nostr NIP-07 browser extension login and invite by pubkey

- Server: nostr crate, migration 008 (nostr_pubkey column), challenge/verify
  endpoints for Schnorr-signed NIP-07 auth, invite-by-nostr endpoint
- Client: NIP-07 extension detection, relay profile fetch, Nostr login button
  on login/register pages, Nostr tab in invite modal, profile page handles
  no-email Nostr users
- Sentinel emails (nostr:<prefix>) hidden at API boundary via public_email()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-16 18:43:01 -06:00
parent 9634c275b3
commit 1a2f0e7951
17 changed files with 927 additions and 17 deletions

View File

@ -10,7 +10,18 @@
</button>
</div>
<form if={!state.inviteUrl} onsubmit={handleSubmit}>
<div class="invite-tabs">
<button
class={'invite-tab' + (state.mode === 'email' ? ' active' : '')}
onclick={() => update({ mode: 'email', error: null, nostrResult: null })}
>Email</button>
<button
class={'invite-tab' + (state.mode === 'nostr' ? ' active' : '')}
onclick={() => update({ mode: 'nostr', error: null, inviteUrl: null })}
>Nostr</button>
</div>
<form if={state.mode === 'email' && !state.inviteUrl} onsubmit={handleSubmit}>
<div class="form-group">
<label for="invite-email">Email address</label>
<input
@ -33,7 +44,7 @@
</div>
</form>
<div if={state.inviteUrl} class="invite-success">
<div if={state.mode === 'email' && state.inviteUrl} class="invite-success">
<p>Invite link generated!</p>
<div class="invite-link-box">
<code>{state.inviteUrl}</code>
@ -44,6 +55,39 @@
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
</div>
</div>
<form if={state.mode === 'nostr' && !state.nostrResult} onsubmit={handleNostrInvite}>
<div class="form-group">
<label for="invite-npub">Nostr public key</label>
<input
type="text"
id="invite-npub"
placeholder="npub1... or hex pubkey"
value={state.nostrPubkey}
oninput={e => update({ nostrPubkey: e.target.value })}
required
/>
</div>
<p if={state.error} class="error-text">{state.error}</p>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={props.cbClose}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={state.nostrLoading}>
{state.nostrLoading ? 'Adding...' : 'Add to Room'}
</button>
</div>
</form>
<div if={state.mode === 'nostr' && state.nostrResult} class="invite-success">
<p if={state.nostrResult === 'added'} class="success-msg">Added {state.nostrDisplayName} to room</p>
<p if={state.nostrResult === 'not_found'} class="info-msg">
This Nostr user hasn't joined GroupChat yet. They'll need to log in with their Nostr extension first.
</p>
<div class="modal-actions">
<button class="btn btn-primary" onclick={props.cbClose}>Done</button>
</div>
</div>
</div>
</div>
@ -150,6 +194,49 @@
font-size: var(--text-xs);
color: var(--text-muted);
}
.invite-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
border-bottom: 1px solid var(--border);
padding-bottom: var(--space-xs);
}
.invite-tab {
background: none;
border: none;
padding: var(--space-xs) var(--space-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-md) var(--radius-md) 0 0;
transition: color var(--transition-fast), background var(--transition-fast);
}
.invite-tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.invite-tab.active {
color: var(--accent);
border-bottom: 2px solid var(--accent);
font-weight: 500;
}
.success-msg {
color: var(--success);
font-weight: 500;
margin-bottom: var(--space-md);
}
.info-msg {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
margin-bottom: var(--space-md);
}
</style>
<script>
@ -157,10 +244,15 @@
export default {
state: {
mode: 'email',
email: '',
nostrPubkey: '',
error: null,
loading: false,
nostrLoading: false,
inviteUrl: null,
nostrResult: null,
nostrDisplayName: '',
},
handleOverlayClick() {
@ -185,6 +277,25 @@
}
},
async handleNostrInvite(e) {
e.preventDefault()
this.update({ nostrLoading: true, error: null })
try {
const result = await api.inviteByNostr({
room_id: this.props.roomId,
nostr_pubkey: this.state.nostrPubkey.trim(),
})
this.update({
nostrResult: result.status,
nostrDisplayName: result.display_name || '',
nostrLoading: false,
})
} catch (err) {
this.update({ error: err.message, nostrLoading: false })
}
},
copyLink() {
navigator.clipboard.writeText(this.state.inviteUrl)
},

View File

@ -38,6 +38,17 @@
</button>
</form>
<div if={state.hasNostr} class="nostr-divider">
<span>or</span>
</div>
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
{state.nostrLoading ? 'Connecting...' : 'Login with Nostr'}
</button>
<p class="auth-footer">
Don't have an account?
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Register</a>
@ -104,6 +115,47 @@
margin-bottom: var(--space-sm);
}
.nostr-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-lg) 0;
color: var(--text-muted);
font-size: var(--text-sm);
}
.nostr-divider::before,
.nostr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.btn-nostr {
background: #8B5CF6;
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.btn-nostr:hover:not(:disabled) {
background: #7C3AED;
}
.btn-nostr:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: var(--space-lg);
@ -114,6 +166,7 @@
<script>
import { api } from '../services/api.js'
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
export default {
state: {
@ -121,6 +174,15 @@
password: '',
error: null,
loading: false,
hasNostr: false,
nostrLoading: false,
},
onMounted() {
// Detect after a tick — extensions inject window.nostr asynchronously
setTimeout(() => {
this.update({ hasNostr: hasNostrExtension() })
}, 100)
},
async handleSubmit(e) {
@ -137,6 +199,47 @@
this.update({ error: err.message, loading: false })
}
},
async handleNostrLogin() {
this.update({ nostrLoading: true, error: null })
try {
// 1. Get challenge
const { challenge } = await api.nostrChallenge()
// Decode JWT payload to get nonce (middle segment)
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
// 2. Get pubkey
const pubkey = await getPublicKey()
// 3. Fetch profile (best-effort)
const profile = await fetchNostrProfile(pubkey)
// 4. Build unsigned event (NIP-07 compatible)
const unsignedEvent = {
kind: 27235,
content: nonce,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', window.location.origin]],
}
// 5. Sign via extension
const signed = await signEvent(unsignedEvent)
// 6. Verify with server
const data = await api.nostrVerify({
signed_event: JSON.stringify(signed),
challenge,
profile_name: profile?.name || null,
profile_picture: profile?.picture || null,
})
this.props.cbLogin(data)
} catch (err) {
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
}
},
}
</script>
</login-page>

View File

@ -41,8 +41,8 @@
<div class="form-group">
<label>Email</label>
<input type="email" value={props.user?.email} disabled class="input-disabled" />
<span class="form-hint">Email cannot be changed</span>
<input type="email" value={props.user?.email || 'Nostr user'} disabled class="input-disabled" />
<span class="form-hint">{props.user?.email ? 'Email cannot be changed' : 'Logged in via Nostr'}</span>
</div>
<p if={state.error} class="error-text">{state.error}</p>

View File

@ -51,6 +51,17 @@
</button>
</form>
<div if={state.hasNostr} class="nostr-divider">
<span>or</span>
</div>
<button if={state.hasNostr} class="btn btn-nostr btn-full" onclick={handleNostrLogin} disabled={state.nostrLoading}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px; vertical-align: -2px;">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
{state.nostrLoading ? 'Connecting...' : 'Sign up with Nostr'}
</button>
<p class="auth-footer">
Already have an account?
<a href="#" onclick={e => { e.preventDefault(); props.cbSwitch() }}>Sign in</a>
@ -117,6 +128,47 @@
margin-bottom: var(--space-sm);
}
.nostr-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-lg) 0;
color: var(--text-muted);
font-size: var(--text-sm);
}
.nostr-divider::before,
.nostr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.btn-nostr {
background: #8B5CF6;
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.btn-nostr:hover:not(:disabled) {
background: #7C3AED;
}
.btn-nostr:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: var(--space-lg);
@ -127,6 +179,7 @@
<script>
import { api } from '../services/api.js'
import { hasNostrExtension, getPublicKey, signEvent, fetchNostrProfile } from '../services/nostr.js'
export default {
state: {
@ -135,6 +188,14 @@
password: '',
error: null,
loading: false,
hasNostr: false,
nostrLoading: false,
},
onMounted() {
setTimeout(() => {
this.update({ hasNostr: hasNostrExtension() })
}, 100)
},
async handleSubmit(e) {
@ -152,6 +213,38 @@
this.update({ error: err.message, loading: false })
}
},
async handleNostrLogin() {
this.update({ nostrLoading: true, error: null })
try {
const { challenge } = await api.nostrChallenge()
const nonce = JSON.parse(atob(challenge.split('.')[1])).nonce
const pubkey = await getPublicKey()
const profile = await fetchNostrProfile(pubkey)
const unsignedEvent = {
kind: 27235,
content: nonce,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', window.location.origin]],
}
const signed = await signEvent(unsignedEvent)
const data = await api.nostrVerify({
signed_event: JSON.stringify(signed),
challenge,
profile_name: profile?.name || null,
profile_picture: profile?.picture || null,
})
// Nostr login auto-creates — use same callback as register
this.props.cbRegister(data)
} catch (err) {
this.update({ error: err.message || 'Nostr login failed', nostrLoading: false })
}
},
}
</script>
</register-page>

View File

@ -33,7 +33,7 @@ async function request(method, path, body) {
if (!res.ok) {
// Auto-logout on 401 for any authenticated request (not login/register)
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register') {
if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register' && path !== '/auth/nostr/verify') {
if (onUnauthorized) onUnauthorized()
throw new Error('Session expired — please log in again')
}
@ -110,6 +110,11 @@ export const api = {
// Invites
createInvite: (data) => request('POST', '/invites', data),
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
inviteByNostr: (data) => request('POST', '/invites/nostr', data),
// Nostr auth
nostrChallenge: () => request('GET', '/auth/nostr/challenge'),
nostrVerify: (data) => request('POST', '/auth/nostr/verify', data),
}
export function saveAuth(token, user) {

View File

@ -0,0 +1,75 @@
/**
* NIP-07 browser extension helpers + relay profile fetch
*/
export function hasNostrExtension() {
return typeof window !== 'undefined' && !!window.nostr
}
export async function getPublicKey() {
return window.nostr.getPublicKey()
}
export async function signEvent(event) {
return window.nostr.signEvent(event)
}
/**
* Fetch a Nostr kind:0 profile from a relay via WebSocket.
* Returns { name, picture } or null on timeout/error.
*/
export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
return new Promise((resolve) => {
const relay = 'wss://relay.damus.io'
let ws
let timer
const cleanup = () => {
clearTimeout(timer)
try { ws?.close() } catch {}
}
timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
try {
ws = new WebSocket(relay)
} catch {
cleanup()
resolve(null)
return
}
ws.onopen = () => {
const subId = 'profile_' + Math.random().toString(36).slice(2, 8)
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
const profile = JSON.parse(data[2].content)
cleanup()
resolve({
name: profile.name || profile.display_name || null,
picture: profile.picture || null,
})
} else if (data[0] === 'EOSE') {
// End of stored events — no profile found
cleanup()
resolve(null)
}
} catch {
// ignore parse errors, wait for timeout
}
}
ws.onerror = () => {
cleanup()
resolve(null)
}
})
}

239
server/Cargo.lock generated
View File

@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -78,6 +88,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-compression"
version = "0.4.41"
@ -235,6 +251,40 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"serde",
"unicode-normalization",
]
[[package]]
name = "bitcoin-io"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
[[package]]
name = "bitcoin_hashes"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [
"bitcoin-io",
"hex-conservative",
"serde",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@ -262,6 +312,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "8.0.2"
@ -301,6 +360,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.56"
@ -317,6 +385,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -331,6 +423,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
@ -436,6 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
@ -858,6 +962,7 @@ dependencies = [
"futures",
"jsonwebtoken",
"md-5",
"nostr",
"rand",
"reqwest",
"scraper",
@ -971,6 +1076,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec",
]
[[package]]
name = "hkdf"
version = "0.12.4"
@ -1285,6 +1399,28 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1564,6 +1700,30 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nostr"
version = "0.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
dependencies = [
"base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
"cbc",
"chacha20",
"chacha20poly1305",
"getrandom 0.2.17",
"hex",
"instant",
"scrypt",
"secp256k1",
"serde",
"serde_json",
"unicode-normalization",
"url",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -1641,6 +1801,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.75"
@ -1725,6 +1891,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "pem"
version = "3.0.6"
@ -1847,6 +2023,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@ -2117,6 +2304,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "schannel"
version = "0.1.28"
@ -2147,6 +2343,38 @@ dependencies = [
"tendril",
]
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"password-hash",
"pbkdf2",
"salsa20",
"sha2",
]
[[package]]
name = "secp256k1"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"rand",
"secp256k1-sys",
"serde",
]
[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
dependencies = [
"cc",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@ -3164,6 +3392,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -3180,6 +3418,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]

View File

@ -27,3 +27,4 @@ scraper = "0.22"
md-5 = "0.10"
sha2 = "0.10"
base64 = "0.22"
nostr = { version = "0.44", default-features = false, features = ["std"] }

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN nostr_pubkey TEXT UNIQUE;

View File

@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::{create_token, AuthUser},
models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
models::{self, AuthResponse, LoginRequest, RegisterRequest, UserPublic},
AppState,
};
@ -61,7 +61,7 @@ pub async fn register(
token,
user: UserPublic {
id: user_id,
email,
email: models::public_email(&email),
display_name,
avatar_url: None,
},
@ -100,7 +100,7 @@ pub async fn login(
token,
user: UserPublic {
id: user_id,
email,
email: models::public_email(&email),
display_name,
avatar_url,
},
@ -121,7 +121,7 @@ pub async fn me(
Ok(Json(UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url,
}))

View File

@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
models::CreateInviteRequest,
models::{CreateInviteRequest, NostrInviteRequest},
AppState,
};
@ -118,3 +118,73 @@ pub async fn accept_invite(
Ok(Json(AcceptInviteResponse { room_id }))
}
#[derive(serde::Serialize)]
pub struct NostrInviteResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
pub async fn invite_by_nostr(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<NostrInviteRequest>,
) -> Result<Json<NostrInviteResponse>, (StatusCode, String)> {
// Normalize pubkey: if it starts with "npub", decode bech32
let pubkey_hex = if body.nostr_pubkey.starts_with("npub") {
nostr::prelude::PublicKey::parse(&body.nostr_pubkey)
.map(|pk| pk.to_hex())
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid npub format".to_string()))?
} else {
// Validate it's valid hex
if body.nostr_pubkey.len() != 64 || !body.nostr_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err((StatusCode::BAD_REQUEST, "Invalid pubkey: must be 64-char hex or npub".to_string()));
}
body.nostr_pubkey.clone()
};
// Verify inviter is a member of the room
let is_member = sqlx::query_scalar::<_, String>(
"SELECT user_id FROM room_members WHERE room_id = ? AND user_id = ?",
)
.bind(&body.room_id)
.bind(&auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if is_member.is_none() {
return Err((StatusCode::FORBIDDEN, "Not a member of this room".into()));
}
// Lookup user by nostr_pubkey
let target_user = sqlx::query_as::<_, (String, String)>(
"SELECT id, display_name FROM users WHERE nostr_pubkey = ?",
)
.bind(&pubkey_hex)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match target_user {
Some((user_id, display_name)) => {
// Add to room
sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)")
.bind(&body.room_id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(NostrInviteResponse {
status: "added".to_string(),
display_name: Some(display_name),
}))
}
None => Ok(Json(NostrInviteResponse {
status: "not_found".to_string(),
display_name: None,
})),
}
}

View File

@ -1,6 +1,7 @@
pub mod auth;
pub mod invites;
pub mod models;
pub mod nostr_auth;
pub mod profile;
pub mod rooms;
pub mod upload;

View File

@ -0,0 +1,167 @@
use axum::{extract::State, http::StatusCode, Json};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use nostr::prelude::*;
use std::sync::Arc;
use uuid::Uuid;
use crate::{
middleware::auth::create_token,
models::{AuthResponse, NostrChallengeResponse, NostrVerifyRequest, UserPublic},
AppState,
};
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct ChallengeClaims {
pub nonce: String,
pub exp: usize,
}
/// GET /api/auth/nostr/challenge — return a short-lived JWT containing a random nonce
pub async fn challenge(
State(state): State<Arc<AppState>>,
) -> Result<Json<NostrChallengeResponse>, (StatusCode, String)> {
// Generate 32 random bytes as hex nonce
let mut nonce_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(&nonce_bytes);
let exp = (chrono::Utc::now().timestamp() + 120) as usize; // 2 minutes
let claims = ChallengeClaims {
nonce,
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(NostrChallengeResponse { challenge: token }))
}
/// Simple hex encoder (avoid adding the `hex` crate just for this)
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
/// POST /api/auth/nostr/verify — verify signed event, create/login user
pub async fn verify(
State(state): State<Arc<AppState>>,
Json(body): Json<NostrVerifyRequest>,
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
// 1. Decode challenge JWT, verify not expired, extract nonce
let challenge_data = decode::<ChallengeClaims>(
&body.challenge,
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid or expired challenge".to_string()))?;
let nonce = &challenge_data.claims.nonce;
// 2. Deserialize signed_event as nostr::Event
let event: Event = serde_json::from_str(&body.signed_event)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid event JSON: {}", e)))?;
// 3. Verify Schnorr signature
if !event.verify_signature() {
return Err((StatusCode::UNAUTHORIZED, "Invalid event signature".to_string()));
}
// 4. Verify event.content == nonce
if event.content.as_str() != nonce.as_str() {
return Err((StatusCode::BAD_REQUEST, "Nonce mismatch".to_string()));
}
// 5. Verify event.created_at within 5 minutes
let now = chrono::Utc::now().timestamp() as u64;
let event_ts = event.created_at.as_secs();
if now.abs_diff(event_ts) > 300 {
return Err((StatusCode::BAD_REQUEST, "Event timestamp too far off".to_string()));
}
// 6. Extract pubkey hex
let pubkey_hex = event.pubkey.to_hex();
// 7. Lookup user by nostr_pubkey
let existing = sqlx::query_as::<_, (String, String, String, Option<String>)>(
"SELECT id, email, display_name, avatar_url FROM users WHERE nostr_pubkey = ?",
)
.bind(&pubkey_hex)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (user_id, email, display_name, avatar_url) = if let Some(user) = existing {
// Update avatar if provided and user doesn't have a custom one
if let Some(ref pic) = body.profile_picture {
if user.3.is_none() || user.3.as_deref() == Some("") {
let _ = sqlx::query("UPDATE users SET avatar_url = ? WHERE id = ?")
.bind(pic)
.bind(&user.0)
.execute(&state.db)
.await;
(user.0, user.1, user.2, Some(pic.clone()))
} else {
user
}
} else {
user
}
} else {
// Create new user
let user_id = Uuid::new_v4().to_string();
let sentinel_email = format!("nostr:{}", &pubkey_hex[..16]);
let display_name = body
.profile_name
.clone()
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| {
let npub = PublicKey::from_hex(&pubkey_hex)
.map(|pk| pk.to_bech32().unwrap_or_default())
.unwrap_or_default();
if npub.len() > 8 {
format!("npub...{}", &npub[npub.len() - 8..])
} else {
format!("nostr-{}", &pubkey_hex[..8])
}
});
let avatar_url = body.profile_picture.clone();
sqlx::query(
"INSERT INTO users (id, email, display_name, password_hash, nostr_pubkey, avatar_url) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&user_id)
.bind(&sentinel_email)
.bind(&display_name)
.bind("")
.bind(&pubkey_hex)
.bind(&avatar_url)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
(user_id, sentinel_email, display_name, avatar_url)
};
// 8. Issue JWT, return AuthResponse
let token = create_token(&user_id, &email, &display_name, &state.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(AuthResponse {
token,
user: UserPublic {
id: user_id,
email: crate::models::public_email(&email),
display_name,
avatar_url,
},
}))
}

View File

@ -7,7 +7,7 @@ use std::sync::Arc;
use crate::{
middleware::auth::{create_token, AuthUser},
models::{AuthResponse, UserPublic},
models::{self, AuthResponse, UserPublic},
AppState,
};
@ -52,7 +52,7 @@ pub async fn update_profile(
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name,
avatar_url,
},
@ -136,7 +136,7 @@ pub async fn upload_avatar(
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: Some(avatar_url),
},
@ -173,7 +173,7 @@ pub async fn delete_avatar(
token,
user: UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: None,
},

View File

@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
models::{CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
models::{self, CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic},
AppState,
};
@ -52,7 +52,7 @@ pub async fn create_room(
created_at: chrono::Utc::now().to_rfc3339(),
members: vec![UserPublic {
id: auth.user_id,
email: auth.email,
email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url: None,
}],
@ -94,7 +94,7 @@ pub async fn list_rooms(
.into_iter()
.map(|(id, email, display_name, avatar_url)| UserPublic {
id,
email,
email: models::public_email(&email),
display_name,
avatar_url,
})

View File

@ -253,6 +253,16 @@ async fn main() {
Err(e) => panic!("Failed to run migration 007: {}", e),
}
// Run migration 008 - nostr pubkey on users
let migration_008 = include_str!("../migrations/008_nostr.sql");
match sqlx::raw_sql(migration_008).execute(&db).await {
Ok(_) => tracing::info!("Migration 008 applied"),
Err(e) if e.to_string().contains("duplicate column") => {
tracing::debug!("Migration 008 already applied, skipping");
}
Err(e) => panic!("Failed to run migration 008: {}", e),
}
tracing::info!("Database initialized");
let (tx, _rx) = broadcast::channel::<models::BroadcastEvent>(4096);
@ -278,6 +288,9 @@ async fn main() {
.route("/api/auth/register", post(handlers::auth::register))
.route("/api/auth/login", post(handlers::auth::login))
.route("/api/auth/me", get(handlers::auth::me))
// Nostr auth routes
.route("/api/auth/nostr/challenge", get(handlers::nostr_auth::challenge))
.route("/api/auth/nostr/verify", post(handlers::nostr_auth::verify))
// Profile routes
.route("/api/auth/profile", put(handlers::profile::update_profile))
.route("/api/auth/avatar", post(handlers::profile::upload_avatar).delete(handlers::profile::delete_avatar))
@ -295,6 +308,7 @@ async fn main() {
// Invite routes
.route("/api/invites", post(handlers::invites::create_invite))
.route("/api/invites/:token/accept", post(handlers::invites::accept_invite))
.route("/api/invites/nostr", post(handlers::invites::invite_by_nostr))
// Uploaded files (avatars)
.nest_service("/uploads", ServeDir::new("uploads"))
// WebSocket

View File

@ -285,3 +285,33 @@ pub struct PaginationParams {
fn default_limit() -> i64 {
50
}
/// Returns "" if the email is a sentinel nostr: value, otherwise returns it as-is.
pub fn public_email(email: &str) -> String {
if email.starts_with("nostr:") {
String::new()
} else {
email.to_string()
}
}
// ── Nostr auth types ──
#[derive(Debug, Serialize)]
pub struct NostrChallengeResponse {
pub challenge: String,
}
#[derive(Debug, Deserialize)]
pub struct NostrVerifyRequest {
pub signed_event: String,
pub challenge: String,
pub profile_name: Option<String>,
pub profile_picture: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NostrInviteRequest {
pub room_id: String,
pub nostr_pubkey: String,
}