Skip to content

Commit 11b890e

Browse files
committed
Added NostrConnect support. Implemented new inactivity lock to autolock keys and autosuspend app permissions after user defined period. Additional work on connection health monitoring. Updated documentation.
1 parent a0deae7 commit 11b890e

File tree

97 files changed

+10514
-470
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+10514
-470
lines changed

CHANGELOG.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,74 @@
11
# Changelog
22

3+
## [1.5.0]
4+
5+
### Added
6+
- **Unified Connect App modal**: Two tabs for both connection methods in one place
7+
- **Bunker URI tab**: Generate and share bunker URIs with QR code, expiry countdown, copy button
8+
- **NostrConnect tab**: Paste or scan nostrconnect:// URIs from apps
9+
- Web UI: QR code scanning via camera (uses html5-qrcode library)
10+
- Android: QR code scanning via camera (existing ML Kit integration)
11+
- Both platforms support paste-from-clipboard for quick URI entry
12+
- Parses and validates URI components (client pubkey, relays, secret, permissions)
13+
- Shows app name, client info, and requested permissions before connecting
14+
- Trust level selection during connection
15+
- New API endpoint: `POST /nostrconnect` for connecting via nostrconnect:// URI
16+
- New API endpoint: `POST /connections/refresh` for forcing relay pool reset when connections are silently dead
17+
- Documentation: Added fail2ban integration guide to DEPLOYMENT.md for recovering from silent WebSocket connection failures
18+
- **Per-app relay subscriptions**: NIP-46 spec-compliant relay handling for nostrconnect apps
19+
- Apps connected via nostrconnect:// can use their own relays for NIP-46 requests
20+
- Responses are published to both daemon's relays and the app's specified relays
21+
- Subscriptions are automatically cleaned up when apps are revoked
22+
- **Inactivity Lock in System Status**: Both platforms now show lock status and "Lock Now" button
23+
- Web UI: System Status modal shows countdown timer with urgency coloring (normal/warning/critical)
24+
- Web UI: "Lock Now" button triggers passphrase confirmation dialog
25+
- Android: System Status sheet shows countdown timer and Lock Now functionality
26+
- Key selector shown when multiple active keys exist
27+
- **Activity logging for nostrconnect connections**: Apps connected via nostrconnect:// now appear in Activity
28+
- New `app_connected` admin event logged when connecting via nostrconnect:// URI
29+
- Displays in Recent widget and Activity page with Link icon
30+
- Shows app name, key name, and connection source (web UI or Android)
31+
- Provides visibility parity with bunker:// connections which already logged activity
32+
33+
### Improved
34+
- Web UI: Bundle code splitting for faster initial load
35+
- Vendor chunks for React, QR libraries, and nostr-tools
36+
- QR scanner lazy-loaded only when needed
37+
- Empty state messaging now explains both connection methods:
38+
- "Tap/Click + for NostrConnect, or share your key's bunker URI with an app"
39+
- Permissions vs trust level clarification in Connect App modal/sheet:
40+
- Added hint text: "These are what the app says it needs. Your trust level controls what actually gets auto-approved."
41+
- Partial success handling: Shows warning when app is connected but relay notification failed
42+
- Better duplicate app detection with clearer error message
43+
- **Android Settings page condensed**:
44+
- Trust level selection now uses dropdown instead of full-height rows
45+
- App Lock timeout selection now uses inline dropdown
46+
- App Lock and Inactivity Lock merged into single "Security" card
47+
- Removed Test Panic button (functionality moved to System Status sheet)
48+
- **Android Inactivity Lock screen**: Now allows key selection when multiple locked keys exist
49+
50+
### Fixed
51+
- Android: Key state changes (lock/unlock) now properly refresh available keys in Connect App sheet
52+
- Android: Activity page filter tabs now scroll horizontally on narrow screens
53+
- Android: Admin event badges and text now use blue to match web UI
54+
- Web UI: Key selection resets when selected key becomes unavailable
55+
- QR scanner feedback for invalid codes (bunker:// URIs, web URLs, other non-nostrconnect content)
56+
- Relay URL validation catches malformed URLs before connection attempt
57+
- Android: Fixed memory leak in SignetNavHost where API client wasn't closed on URL change
58+
- Android: Fixed API client resource leak in 5 screens (HomeScreen, ActivityScreen, AppsScreen, KeysScreen, SetupScreen) - client now closed in finally block
59+
- Daemon: Denied requests now include app name in activity feed (previously only showed npub)
60+
- Daemon: Fixed silent WebSocket connection failures causing NIP-46 subscriptions to stop working (#28)
61+
- Health check now recreates actual managed subscriptions instead of throwaway ping subscriptions
62+
- Each health check both tests AND refreshes one subscription (round-robin rotation)
63+
- Guarantees all subscriptions get fresh connections every N×90s (where N = number of keys)
64+
- Previously, health checks could pass while actual NIP-46 subscriptions remained dead on stale connections
65+
66+
### Security
67+
- Daemon logs warning at startup when CORS wildcard origin (*) is configured (fine for testing, but not production)
68+
- Config file permissions now set to 0600 (owner read/write only) after creation
69+
70+
---
71+
372
## [1.4.0]
473

574
### Added

README.md

Lines changed: 25 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Signet
22

3-
A modern NIP-46 remote signer for Nostr. Manages multiple keys securely with a web dashboard for administration. This project was originally forked from [nsecbunkerd](https://github.com/kind-0/nsecbunkerd), but has since received an extensive rewrite.
3+
A modern NIP-46 remote signer for Nostr. Manages multiple keys securely with a web dashboard for administration. This project was originally forked from [nsecbunkerd](https://github.com/kind-0/nsecbunkerd), but has since received an extensive rewrite and now shares very little code with it.
44

5-
An Android app is [available on ZapStore](https://zapstore.dev/apps/naddr1qvzqqqr7pvpzpk4yr0kmdpv3xcalgsrldp7tj7yuc4p76qjtka7z95kgfky02s2nqq2hgetrdqhxwet9dd6x7umgdyh8x6t8dejhgck8a3z). The Android app is a frontend only, so running the daemon is required. DO NOT run the daemon on a public machine. The preferred method is to run it on a machine sitting on a private network. Tailscale is preferred and documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
5+
Signet separates the signing back end from the front end, and ships with a web UI. A companion Android app is [available on ZapStore](https://zapstore.dev/apps/naddr1qvzqqqr7pvpzpk4yr0kmdpv3xcalgsrldp7tj7yuc4p76qjtka7z95kgfky02s2nqq2hgetrdqhxwet9dd6x7umgdyh8x6t8dejhgck8a3z). Other platforms are possible and on the roadmap.
66

77
## Web UI Screenshots
88

@@ -44,47 +44,7 @@ docker compose run --rm signet add --name main-key
4444

4545
**Upgrading:** Pull the latest changes, rebuild, and restart. Database migrations run automatically on daemon startup.
4646

47-
## Development Setup
48-
49-
**Prereqs:** Node.js 20+, pnpm
50-
51-
```bash
52-
git clone https://github.com/Letdown2491/signet
53-
cd signet
54-
pnpm install
55-
```
56-
57-
Start the daemon:
58-
59-
```bash
60-
cd apps/signet
61-
pnpm run build
62-
pnpm run prisma:migrate
63-
pnpm run signet start
64-
```
65-
66-
Optionally, you could add keys and start the daemon from the CLI with a specific key directly:
67-
68-
```bash
69-
cd apps/signet
70-
pnpm run build
71-
pnpm run prisma:migrate
72-
# Add a key via CLI (prompts for passphrase and nsec, optional if using web dashboard or Android app)
73-
pnpm run signet add --name main-key
74-
# Start with a key already unlocked (prompts for passphrase, optional if using web dashboard or Android app)
75-
pnpm run signet start --key main-key
76-
```
77-
78-
Start the UI dev server (in a separate terminal, optional if using Android app):
79-
80-
```bash
81-
cd apps/signet-ui
82-
pnpm run dev
83-
```
84-
85-
Open `http://localhost:4174` to access the dashboard. From there you can add keys, connect apps, and manage signing requests. Note that this is not required if you plan to manage keys and signing approvals via the Android app.
86-
87-
## Production Setup (Without Docker)
47+
## Quick Start (PNPM)
8848

8949
Build and run the daemon:
9050

@@ -123,9 +83,30 @@ Config is auto-generated on first boot at `~/.signet-config/signet.json`.
12383

12484
See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for all options.
12585

86+
## Connecting Apps
87+
88+
There are two ways to connect a Nostr app to Signet:
89+
90+
**bunker:// (Signet initiates)**
91+
1. Go to the Keys page and select a key
92+
2. Click "Generate bunker URI" to get a one-time connection link
93+
3. Paste the bunker URI into your Nostr app's remote signer settings
94+
4. The app connects automatically
95+
96+
**nostrconnect:// (App initiates)**
97+
1. In your Nostr app, look for "Connect via remote signer" or similar
98+
2. The app displays a nostrconnect:// URI or QR code
99+
3. In Signet, click + on the Apps page (or scan QR on Android)
100+
4. Paste the nostrconnect:// URI and choose a key and trust level
101+
5. Click Connect to complete the handshake
102+
103+
Both methods result in the same secure connection. Use whichever your app supports.
104+
126105
## Security
127106

128-
Keys are encrypted with AES-256-GCM (PBKDF2, 600k iterations). API endpoints require JWT auth with CORS and rate limiting.
107+
Keys are encrypted with AES-256-GCM (PBKDF2, 600k iterations). API endpoints require JWT auth with CORS and rate limiting. An optional **Inactivity Lock** (Dead Man's Switch) automatically locks all keys and suspends all apps if not reset within a configurable timeframe. Additional, an optional admin npub can be set to allow sending DM commands (NIP-04/NIP-17) to Signet without access to any UI.
108+
109+
DO NOT run the daemon on a public machine. We recommend private network access only. Tailscale is preferred and documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
129110

130111
See [docs/SECURITY.md](docs/SECURITY.md) for the full security model.
131112

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4.0
1+
1.5.0

apps/signet-android/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ android {
3030
applicationId = "tech.geektoshi.signet"
3131
minSdk = 26
3232
targetSdk = 35
33-
versionCode = 6
33+
versionCode = 7
3434
versionName = appVersion
3535

3636
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
-8.05 KB
Loading
-406 Bytes
Loading
136 Bytes
Loading

apps/signet-android/app/src/main/kotlin/tech/geektoshi/signet/SignetApplication.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,28 @@ class SignetApplication : Application() {
3535
enableVibration(true)
3636
}
3737

38+
// Inactivity lock channel (high priority for urgent warnings)
39+
val inactivityChannel = NotificationChannel(
40+
INACTIVITY_CHANNEL_ID,
41+
getString(R.string.inactivity_channel_name),
42+
NotificationManager.IMPORTANCE_HIGH
43+
).apply {
44+
description = getString(R.string.inactivity_channel_description)
45+
setShowBadge(true)
46+
enableVibration(true)
47+
}
48+
3849
notificationManager.createNotificationChannel(serviceChannel)
3950
notificationManager.createNotificationChannel(alertChannel)
51+
notificationManager.createNotificationChannel(inactivityChannel)
4052
}
4153

4254
companion object {
4355
const val SERVICE_CHANNEL_ID = "signet_service"
4456
const val ALERT_CHANNEL_ID = "signet_alerts"
57+
const val INACTIVITY_CHANNEL_ID = "signet_inactivity"
4558
const val SERVICE_NOTIFICATION_ID = 1
4659
const val ALERT_NOTIFICATION_ID = 2
60+
const val INACTIVITY_NOTIFICATION_ID = 100
4761
}
4862
}

apps/signet-android/app/src/main/kotlin/tech/geektoshi/signet/data/api/SignetApiClient.kt

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@ import tech.geektoshi.signet.data.model.AppsResponse
77
import tech.geektoshi.signet.data.model.ConnectionTokenResponse
88
import tech.geektoshi.signet.data.model.SuspendAppBody
99
import tech.geektoshi.signet.data.model.DashboardResponse
10+
import tech.geektoshi.signet.data.model.DeadManSwitchActionBody
11+
import tech.geektoshi.signet.data.model.DeadManSwitchResponse
12+
import tech.geektoshi.signet.data.model.DeadManSwitchStatus
13+
import tech.geektoshi.signet.data.model.DisableDeadManSwitchBody
14+
import tech.geektoshi.signet.data.model.EnableDeadManSwitchBody
1015
import tech.geektoshi.signet.data.model.HealthStatus
1116
import tech.geektoshi.signet.data.model.KeysResponse
17+
import tech.geektoshi.signet.data.model.NostrconnectRequest
18+
import tech.geektoshi.signet.data.model.NostrconnectResponse
1219
import tech.geektoshi.signet.data.model.OperationResponse
1320
import tech.geektoshi.signet.data.model.RelaysResponse
1421
import tech.geektoshi.signet.data.model.RequestsResponse
22+
import tech.geektoshi.signet.data.model.UpdateDeadManSwitchBody
1523
import io.ktor.client.HttpClient
1624
import io.ktor.client.call.body
1725
import io.ktor.client.engine.okhttp.OkHttp
@@ -23,6 +31,7 @@ import io.ktor.client.request.get
2331
import io.ktor.client.request.parameter
2432
import io.ktor.client.request.patch
2533
import io.ktor.client.request.post
34+
import io.ktor.client.request.put
2635
import io.ktor.client.request.header
2736
import io.ktor.client.request.setBody
2837
import io.ktor.http.ContentType
@@ -227,6 +236,25 @@ class SignetApiClient(
227236
}.body()
228237
}
229238

239+
/**
240+
* Connect to an app via nostrconnect:// URI.
241+
*/
242+
suspend fun connectViaNostrconnect(
243+
uri: String,
244+
keyName: String,
245+
trustLevel: String,
246+
description: String? = null
247+
): NostrconnectResponse {
248+
return client.post("/nostrconnect") {
249+
setBody(NostrconnectRequest(
250+
uri = uri,
251+
keyName = keyName,
252+
trustLevel = trustLevel,
253+
description = description
254+
))
255+
}.body()
256+
}
257+
230258
/**
231259
* Get relay status
232260
*/
@@ -267,6 +295,90 @@ class SignetApiClient(
267295
}
268296
}
269297

298+
// ==================== Dead Man's Switch (Inactivity Lock) ====================
299+
300+
/**
301+
* Get Dead Man's Switch status
302+
*/
303+
suspend fun getDeadManSwitchStatus(): DeadManSwitchStatus {
304+
return client.get("/dead-man-switch").body()
305+
}
306+
307+
/**
308+
* Enable the Dead Man's Switch
309+
*/
310+
suspend fun enableDeadManSwitch(timeframeSec: Int? = null): DeadManSwitchResponse {
311+
return client.put("/dead-man-switch") {
312+
setBody(EnableDeadManSwitchBody(
313+
enabled = true,
314+
timeframeSec = timeframeSec
315+
))
316+
}.body()
317+
}
318+
319+
/**
320+
* Disable the Dead Man's Switch
321+
*/
322+
suspend fun disableDeadManSwitch(
323+
keyName: String,
324+
passphrase: String
325+
): DeadManSwitchResponse {
326+
return client.put("/dead-man-switch") {
327+
setBody(DisableDeadManSwitchBody(
328+
enabled = false,
329+
keyName = keyName,
330+
passphrase = passphrase
331+
))
332+
}.body()
333+
}
334+
335+
/**
336+
* Update the Dead Man's Switch timeframe
337+
*/
338+
suspend fun updateDeadManSwitchTimeframe(
339+
keyName: String,
340+
passphrase: String,
341+
timeframeSec: Int
342+
): DeadManSwitchResponse {
343+
return client.put("/dead-man-switch") {
344+
setBody(UpdateDeadManSwitchBody(
345+
timeframeSec = timeframeSec,
346+
keyName = keyName,
347+
passphrase = passphrase
348+
))
349+
}.body()
350+
}
351+
352+
/**
353+
* Reset the Dead Man's Switch timer
354+
*/
355+
suspend fun resetDeadManSwitch(
356+
keyName: String,
357+
passphrase: String
358+
): DeadManSwitchResponse {
359+
return client.post("/dead-man-switch/reset") {
360+
setBody(DeadManSwitchActionBody(
361+
keyName = keyName,
362+
passphrase = passphrase
363+
))
364+
}.body()
365+
}
366+
367+
/**
368+
* Test the panic functionality (for testing)
369+
*/
370+
suspend fun testDeadManSwitchPanic(
371+
keyName: String,
372+
passphrase: String
373+
): DeadManSwitchResponse {
374+
return client.post("/dead-man-switch/test-panic") {
375+
setBody(DeadManSwitchActionBody(
376+
keyName = keyName,
377+
passphrase = passphrase
378+
))
379+
}.body()
380+
}
381+
270382
/**
271383
* Close the client
272384
*/

0 commit comments

Comments
 (0)