Skip to content

Commit 9a264ea

Browse files
feat: NIP-46 bunker, web UI setup flow, and developer quickstart
Add a working NIP-46 remote signing bunker that runs on a Raspberry Pi. Confirmed working with NostrHub bunker login. - Bunker mode: import existing nsec, preserve your npub - nsec-tree modes: derive new identities from mnemonic or nsec - NIP-46 sidecar (Node.js) handles relay subscriptions and signing - Web UI: setup wizard, relay config, bunker URI with copy button, password protection, Tor toggle (off by default) - Hardened systemd units for both services - Developer quickstart guide - Reset flow to reconfigure the device
1 parent 31c2816 commit 9a264ea

16 files changed

Lines changed: 1270 additions & 66 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
/target
22
.idea
33
Cargo.lock
4+
5+
# Private design docs (public docs like QUICKSTART.md are allowed)
6+
docs/specs/
7+
docs/plans/
8+
9+
# Node dependencies
10+
bunker/node_modules/

bunker/index.mjs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* Heartwood NIP-46 Bunker — remote signing sidecar.
3+
*
4+
* Standalone daemon that holds the user's nsec and responds to signing
5+
* requests from NIP-46 clients (Amber, NostrHub, etc.) over Nostr relays.
6+
* Clients never see the nsec — only signatures and public keys leave the Pi.
7+
*
8+
* Reads secrets from /var/lib/heartwood/ (shared with heartwood-device).
9+
*/
10+
11+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
12+
import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
13+
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure'
14+
import { decode as nip19decode } from 'nostr-tools/nip19'
15+
import { SimplePool } from 'nostr-tools/pool'
16+
import WebSocket from 'ws'
17+
18+
globalThis.WebSocket = WebSocket
19+
20+
const DATA_DIR = '/var/lib/heartwood'
21+
const DEFAULT_RELAYS = [
22+
'wss://relay.damus.io',
23+
'wss://relay.nostr.band',
24+
'wss://nos.lol',
25+
'wss://relay.trotters.cc',
26+
]
27+
28+
// --- 1. Read nsec from master.secret ---
29+
30+
const secretPath = `${DATA_DIR}/master.secret`
31+
if (!existsSync(secretPath)) {
32+
console.error('FATAL: master.secret not found — is heartwood-device configured?')
33+
process.exit(1)
34+
}
35+
36+
const secretPayload = readFileSync(secretPath, 'utf-8').trim()
37+
if (!secretPayload.startsWith('bunker:')) {
38+
console.error('FATAL: master.secret is not in bunker mode (expected "bunker:<nsec>")')
39+
process.exit(1)
40+
}
41+
42+
const nsec = secretPayload.slice('bunker:'.length)
43+
const { type, data: userSk } = nip19decode(nsec)
44+
if (type !== 'nsec') {
45+
console.error('FATAL: invalid nsec in master.secret')
46+
process.exit(1)
47+
}
48+
49+
const userPk = getPublicKey(userSk)
50+
51+
// --- 2. Read relay list from config.json ---
52+
53+
let relays = DEFAULT_RELAYS
54+
const configPath = `${DATA_DIR}/config.json`
55+
if (existsSync(configPath)) {
56+
try {
57+
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
58+
if (Array.isArray(config.relays) && config.relays.length > 0) {
59+
relays = config.relays
60+
}
61+
} catch {
62+
console.warn('WARN: could not parse config.json, using default relays')
63+
}
64+
}
65+
66+
// --- 3. Load or generate bunker keypair ---
67+
68+
const bunkerKeyPath = `${DATA_DIR}/bunker.key`
69+
let bunkerSk
70+
71+
if (existsSync(bunkerKeyPath)) {
72+
const hex = readFileSync(bunkerKeyPath, 'utf-8').trim()
73+
bunkerSk = Uint8Array.from(Buffer.from(hex, 'hex'))
74+
} else {
75+
bunkerSk = generateSecretKey()
76+
const hex = Buffer.from(bunkerSk).toString('hex')
77+
writeFileSync(bunkerKeyPath, hex, { mode: 0o600 })
78+
console.log('Generated new bunker keypair')
79+
}
80+
81+
const bunkerPk = getPublicKey(bunkerSk)
82+
83+
// --- 4. Connect to relays and subscribe ---
84+
85+
const pool = new SimplePool()
86+
87+
pool.subscribeMany(
88+
relays,
89+
{ kinds: [24133], '#p': [bunkerPk] },
90+
{
91+
onevent: async (event) => {
92+
try {
93+
await handleRequest(event)
94+
} catch (e) {
95+
console.error(`Error handling request: ${e.message}`)
96+
}
97+
},
98+
},
99+
)
100+
101+
// --- 5. Write bunker URI ---
102+
103+
const relayParams = relays.map((r) => `relay=${encodeURIComponent(r)}`).join('&')
104+
const bunkerUri = `bunker://${bunkerPk}?${relayParams}`
105+
106+
writeFileSync(`${DATA_DIR}/bunker-uri.txt`, bunkerUri)
107+
108+
console.log(`Bunker started`)
109+
console.log(` URI: ${bunkerUri}`)
110+
console.log(` Signing: ${userPk.slice(0, 12)}...`)
111+
console.log(` Relays: ${relays.join(', ')}`)
112+
113+
// --- 6. Request handler ---
114+
115+
async function handleRequest(event) {
116+
const clientPk = event.pubkey
117+
const conversationKey = getConversationKey(bunkerSk, clientPk)
118+
119+
let request
120+
try {
121+
const plaintext = decrypt(event.content, conversationKey)
122+
request = JSON.parse(plaintext)
123+
} catch {
124+
console.error('Failed to decrypt request')
125+
return
126+
}
127+
128+
console.log(`Request ${request.id}: ${request.method}`)
129+
130+
let result = ''
131+
let error
132+
133+
switch (request.method) {
134+
case 'connect':
135+
result = 'ack'
136+
break
137+
138+
case 'ping':
139+
result = 'pong'
140+
break
141+
142+
case 'get_public_key':
143+
result = userPk
144+
break
145+
146+
case 'sign_event': {
147+
const template = JSON.parse(request.params[0])
148+
const signed = finalizeEvent(template, userSk)
149+
result = JSON.stringify(signed)
150+
break
151+
}
152+
153+
case 'nip44_encrypt': {
154+
const ck = getConversationKey(userSk, request.params[0])
155+
result = encrypt(request.params[1], ck)
156+
break
157+
}
158+
159+
case 'nip44_decrypt': {
160+
const ck = getConversationKey(userSk, request.params[0])
161+
result = decrypt(request.params[1], ck)
162+
break
163+
}
164+
165+
default:
166+
error = `unsupported method: ${request.method}`
167+
}
168+
169+
// Build and publish encrypted response
170+
const response = error
171+
? JSON.stringify({ id: request.id, result: '', error })
172+
: JSON.stringify({ id: request.id, result })
173+
174+
const encrypted = encrypt(response, conversationKey)
175+
const responseEvent = finalizeEvent(
176+
{
177+
kind: 24133,
178+
created_at: Math.floor(Date.now() / 1000),
179+
tags: [['p', clientPk]],
180+
content: encrypted,
181+
},
182+
bunkerSk,
183+
)
184+
185+
await Promise.any(pool.publish(relays, responseEvent))
186+
console.log(`Response ${request.id}: ${error ?? 'ok'}`)
187+
}
188+
189+
// --- 7. Clean shutdown ---
190+
191+
function shutdown() {
192+
console.log('Shutting down...')
193+
pool.close(relays)
194+
bunkerSk.fill(0)
195+
userSk.fill(0)
196+
process.exit(0)
197+
}
198+
199+
process.on('SIGINT', shutdown)
200+
process.on('SIGTERM', shutdown)

bunker/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "heartwood-bunker",
3+
"version": "0.1.0",
4+
"description": "NIP-46 remote signer sidecar for Heartwood",
5+
"type": "module",
6+
"scripts": {
7+
"start": "node index.mjs"
8+
},
9+
"dependencies": {
10+
"nostr-tools": "^2.23.0",
11+
"ws": "^8.20.0"
12+
}
13+
}

crates/heartwood-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ pub use encoding::{decode_npub, decode_nsec, encode_npub, encode_nsec};
1313
pub use persona::{derive_from_persona, derive_persona};
1414
pub use proof::{create_blind_proof, create_full_proof, verify_proof};
1515
pub use recover::recover;
16-
pub use root::{from_mnemonic, from_nsec, from_nsec_bytes};
16+
pub use root::{from_mnemonic, from_nsec, from_nsec_bytes, npub_from_nsec};
1717
pub use types::{HeartwoodError, Identity, LinkageProof, Persona, TreeRoot};

crates/heartwood-core/src/root.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ pub fn from_nsec_bytes(nsec_bytes: &[u8; 32]) -> Result<TreeRoot, HeartwoodError
3838
result
3939
}
4040

41+
/// Validate an nsec and return its corresponding npub (no HMAC, no tree derivation).
42+
///
43+
/// This is for bunker mode: the nsec is used as-is for signing, and the
44+
/// returned npub is the identity the bunker signs on behalf of.
45+
pub fn npub_from_nsec(nsec: &str) -> Result<String, HeartwoodError> {
46+
let nsec_bytes = decode_nsec(nsec)?;
47+
let signing_key = SigningKey::from_bytes(&nsec_bytes)
48+
.map_err(|e| HeartwoodError::Derivation(format!("invalid secret key: {e}")))?;
49+
let verifying_key = signing_key.verifying_key();
50+
let pubkey_bytes: [u8; 32] = verifying_key.to_bytes().into();
51+
Ok(encode_npub(&pubkey_bytes))
52+
}
53+
4154
/// Create a TreeRoot from a bech32-encoded nsec string.
4255
pub fn from_nsec(nsec: &str) -> Result<TreeRoot, HeartwoodError> {
4356
let mut nsec_bytes = decode_nsec(nsec)?;

crates/heartwood-device/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ serde_json = "1"
1919
tracing = "0.1"
2020
tracing-subscriber = "0.3"
2121
qrcode = "0.14"
22+
base64 = "0.22"

crates/heartwood-device/src/main.rs

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
// Scaffold modules -- many methods are wired up in later phases
2-
#[allow(dead_code)]
31
mod audit;
42
#[allow(dead_code)]
53
mod oled;
6-
#[allow(dead_code)]
74
mod storage;
8-
#[allow(dead_code)]
9-
mod tor;
105
mod web;
116

127
use std::sync::Arc;
@@ -20,7 +15,6 @@ async fn main() {
2015

2116
let oled = oled::Oled::new();
2217
let storage = storage::Storage::new(None);
23-
let tor = tor::TorManager::new();
2418
let audit_log = audit::AuditLog::new();
2519

2620
oled.show_text("HEARTWOOD");
@@ -29,33 +23,25 @@ async fn main() {
2923
oled.show_text("SETUP MODE");
3024
info!("No master secret found. Entering setup mode.");
3125
} else {
32-
info!("Master secret found. Waiting for PIN...");
33-
oled.show_text("Enter PIN");
34-
}
35-
36-
oled.show_text("Connecting to Tor...");
37-
if let Some(onion) = tor.wait_for_onion(120).await {
38-
// Log only a truncated form to avoid leaking the full .onion address.
39-
// .onion addresses are ASCII, but use get() to be panic-free.
40-
let truncated = onion.get(..8).unwrap_or(&onion);
41-
info!("Tor hidden service ready: {}...", truncated);
42-
oled.show_qr(&onion);
43-
} else {
44-
info!("Tor not available, running on local network only");
45-
oled.show_text("heartwood.local");
26+
info!("Master secret loaded.");
27+
oled.show_text("READY");
4628
}
4729

48-
let state = Arc::new(web::AppState { audit_log: Mutex::new(audit_log) });
30+
let state = Arc::new(web::AppState {
31+
audit_log: Mutex::new(audit_log),
32+
storage: Mutex::new(storage),
33+
});
4934
let app = web::create_router(state);
5035

51-
let listener = match tokio::net::TcpListener::bind("127.0.0.1:8080").await {
36+
let bind_addr = std::env::var("HEARTWOOD_BIND").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
37+
let listener = match tokio::net::TcpListener::bind(&bind_addr).await {
5238
Ok(l) => l,
5339
Err(e) => {
54-
error!("Failed to bind 127.0.0.1:8080: {e}");
40+
error!("Failed to bind {bind_addr}: {e}");
5541
std::process::exit(1);
5642
}
5743
};
58-
info!("Web UI listening on 127.0.0.1:8080");
44+
info!("Web UI listening on {bind_addr}");
5945
oled.show_text("READY");
6046

6147
if let Err(e) = axum::serve(listener, app).await {

crates/heartwood-device/src/storage.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ impl Storage {
5656
fs::read(self.secret_path())
5757
}
5858

59+
/// Delete the stored master secret, returning to setup mode.
60+
pub fn delete_master_secret(&self) -> io::Result<()> {
61+
let path = self.secret_path();
62+
if path.exists() {
63+
fs::remove_file(path)?;
64+
}
65+
Ok(())
66+
}
67+
5968
/// Persist a JSON config string to disk with restrictive permissions.
6069
pub fn save_config(&self, config: &str) -> io::Result<()> {
6170
self.ensure_dir()?;

crates/heartwood-device/src/tor.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,23 @@ pub struct TorManager {
1717
}
1818

1919
impl TorManager {
20-
/// Create a manager using the default onion directory
21-
/// (`/var/lib/tor/heartwood`).
20+
/// Create a manager using the default hostname file location.
21+
///
22+
/// The systemd unit copies the Tor hostname file to `/var/lib/heartwood/`
23+
/// before starting, since the Tor hidden service directory is restricted
24+
/// to the `debian-tor` user.
2225
pub fn new() -> Self {
23-
Self { onion_dir: PathBuf::from("/var/lib/tor/heartwood") }
26+
Self { onion_dir: PathBuf::from("/var/lib/heartwood") }
2427
}
2528

2629
/// Create a manager with a custom onion directory path.
2730
pub fn with_dir(onion_dir: PathBuf) -> Self {
2831
Self { onion_dir }
2932
}
3033

31-
/// Read the `.onion` hostname from Tor's `hostname` file, if present.
34+
/// Read the `.onion` hostname from the copied `tor-hostname` file, if present.
3235
pub fn onion_address(&self) -> Option<String> {
33-
let hostname_path = self.onion_dir.join("hostname");
36+
let hostname_path = self.onion_dir.join("tor-hostname");
3437
fs::read_to_string(&hostname_path)
3538
.ok()
3639
.map(|s| s.trim().to_owned())

0 commit comments

Comments
 (0)