Skip to content

Commit 8b8d9df

Browse files
feat: connect slots with reusable secrets for named client pairing
New POST /api/slots/create endpoint generates a random secret, stores it in slots.json, and returns a bunker URI with &secret= embedded. Clients connecting with a matching secret are auto-approved with the slot label. Secrets persist after use so the same URI can be shared across multiple devices (e.g. Nostrudel on desktop and phone). GET /api/slots returns all slots with their bunker URIs and connected client pubkeys. Bunker sidecar updated to check connect secrets against slots.json and record which clients connected via each slot.
1 parent b741a80 commit 8b8d9df

2 files changed

Lines changed: 173 additions & 3 deletions

File tree

bunker/index.mjs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,31 @@ if (authorizedKeys.size > 0) {
374374
console.log(` Auto-approve: ${[...authorizedKeys].map((k) => k.slice(0, 12) + '...').join(', ')}`)
375375
}
376376

377+
// --- 5b. Connect-slot secret matching ---
378+
379+
const slotsPath = `${DATA_DIR}/slots.json`
380+
381+
/** Check if a secret matches a pre-authorised connect slot. */
382+
function matchSlotSecret(secret) {
383+
const slots = loadJson(slotsPath, {})
384+
if (typeof secret !== 'string' || !secret) return null
385+
const slot = slots[secret]
386+
if (!slot) return null
387+
return { label: slot.label ?? 'unknown' }
388+
}
389+
390+
/** Record which pubkey connected via this slot. Secret persists for reuse. */
391+
function recordSlotClient(secret, clientPk) {
392+
const slots = loadJson(slotsPath, {})
393+
if (slots[secret]) {
394+
if (!Array.isArray(slots[secret].clients)) slots[secret].clients = []
395+
if (!slots[secret].clients.includes(clientPk)) {
396+
slots[secret].clients.push(clientPk)
397+
}
398+
saveJson(slotsPath, slots)
399+
}
400+
}
401+
377402
// --- 6. Request handler ---
378403

379404
async function handleRequest(event) {
@@ -450,9 +475,23 @@ async function handleRequest(event) {
450475
}
451476

452477
switch (request.method) {
453-
case 'connect':
478+
case 'connect': {
454479
if (!isApproved(clientPk, approvedClients)) {
455-
if (tryAutoApprove(clientPk, authorizedKeys, approvedClients)) {
480+
// Check for connect-slot secret (params: [pubkey, secret?])
481+
const connectSecret = Array.isArray(request.params) ? request.params[1] : undefined
482+
const slotMatch = connectSecret ? matchSlotSecret(connectSecret) : null
483+
484+
if (slotMatch) {
485+
// Secret matches a pre-authorised slot — auto-approve with the slot label.
486+
approvedClients[clientPk] = {
487+
approvedAt: new Date().toISOString(),
488+
label: slotMatch.label,
489+
}
490+
saveJson(clientsPath, approvedClients)
491+
// Record which client connected via this slot (secret persists for reuse).
492+
recordSlotClient(connectSecret, clientPk)
493+
console.log(`Slot-approved client ${clientPk.slice(0, 12)}... as "${slotMatch.label}"`)
494+
} else if (tryAutoApprove(clientPk, authorizedKeys, approvedClients)) {
456495
saveJson(clientsPath, approvedClients)
457496
console.log(`Auto-approved authorized client ${clientPk.slice(0, 12)}...`)
458497
} else {
@@ -464,6 +503,7 @@ async function handleRequest(event) {
464503
}
465504
result = 'ack'
466505
break
506+
}
467507

468508
case 'ping':
469509
result = 'pong'

crates/heartwood-device/src/web.rs

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use axum::{
1414
Router,
1515
};
1616
use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
17-
use rand_core::OsRng;
17+
use rand_core::{OsRng, RngCore};
1818
use serde::Deserialize;
1919
use serde_json::json;
2020
use tokio::sync::Mutex;
@@ -1667,6 +1667,134 @@ async fn api_pair(
16671667
)
16681668
}
16691669

1670+
// --- Connect slots (pre-authorised pairing with secret) ---
1671+
1672+
#[derive(Deserialize)]
1673+
struct CreateSlotRequest {
1674+
label: String,
1675+
}
1676+
1677+
/// `POST /api/slots/create` — create a pre-authorised connect slot.
1678+
///
1679+
/// Generates a random secret, stores it in `slots.json`, and returns a
1680+
/// bunker URI with `&secret=<secret>` embedded. When a NIP-46 client
1681+
/// connects with the matching secret, the bunker auto-approves it with
1682+
/// the slot's label.
1683+
async fn api_create_slot(
1684+
State(state): State<Arc<AppState>>,
1685+
axum::Json(req): axum::Json<CreateSlotRequest>,
1686+
) -> impl IntoResponse {
1687+
let label = req.label.trim().to_string();
1688+
if label.is_empty() || label.len() > 64 {
1689+
return (
1690+
StatusCode::BAD_REQUEST,
1691+
axum::Json(json!({"error": "label must be 1–64 characters"})),
1692+
);
1693+
}
1694+
1695+
// Generate random secret (32 bytes hex)
1696+
let mut secret_bytes = [0u8; 32];
1697+
rand_core::OsRng.fill_bytes(&mut secret_bytes);
1698+
let secret: String = secret_bytes.iter().map(|b| format!("{b:02x}")).collect();
1699+
1700+
// Read bunker URI
1701+
let bunker_uri = match std::fs::read_to_string(state.data_file("bunker-uri.txt")) {
1702+
Ok(uri) => uri.trim().to_string(),
1703+
Err(_) => {
1704+
return (
1705+
StatusCode::INTERNAL_SERVER_ERROR,
1706+
axum::Json(json!({"error": "bunker not running"})),
1707+
);
1708+
}
1709+
};
1710+
1711+
// Load existing slots
1712+
let slots_path = state.data_file("slots.json");
1713+
let mut slots: serde_json::Value = std::fs::read_to_string(&slots_path)
1714+
.ok()
1715+
.and_then(|s| serde_json::from_str(&s).ok())
1716+
.unwrap_or_else(|| json!({}));
1717+
1718+
if !slots.is_object() {
1719+
slots = json!({});
1720+
}
1721+
1722+
// Store slot keyed by secret
1723+
slots.as_object_mut().unwrap().insert(
1724+
secret.clone(),
1725+
json!({
1726+
"label": label,
1727+
"createdAt": chrono_now_iso(),
1728+
}),
1729+
);
1730+
1731+
if let Err(e) = std::fs::write(&slots_path, serde_json::to_string_pretty(&slots).unwrap()) {
1732+
tracing::error!("Failed to write slots.json: {e}");
1733+
return (
1734+
StatusCode::INTERNAL_SERVER_ERROR,
1735+
axum::Json(json!({"error": "failed to save slot"})),
1736+
);
1737+
}
1738+
// Restrictive permissions on slots.json (contains secrets)
1739+
#[cfg(unix)]
1740+
{
1741+
use std::os::unix::fs::PermissionsExt;
1742+
let _ = std::fs::set_permissions(&slots_path, std::fs::Permissions::from_mode(0o600));
1743+
}
1744+
1745+
// Build bunker URI with secret
1746+
let slot_uri = format!("{}&secret={}", bunker_uri, secret);
1747+
info!("Created connect slot '{}' with secret {}...", label, &secret[..12]);
1748+
1749+
(
1750+
StatusCode::OK,
1751+
axum::Json(json!({
1752+
"label": label,
1753+
"secret": secret,
1754+
"bunker_uri": slot_uri,
1755+
})),
1756+
)
1757+
}
1758+
1759+
/// `GET /api/slots` — list all connect slots with bunker URIs.
1760+
async fn api_list_slots(State(state): State<Arc<AppState>>) -> impl IntoResponse {
1761+
let slots_path = state.data_file("slots.json");
1762+
let raw: serde_json::Value = std::fs::read_to_string(&slots_path)
1763+
.ok()
1764+
.and_then(|s| serde_json::from_str(&s).ok())
1765+
.unwrap_or_else(|| json!({}));
1766+
1767+
let bunker_uri = std::fs::read_to_string(state.data_file("bunker-uri.txt"))
1768+
.ok()
1769+
.map(|s| s.trim().to_string())
1770+
.unwrap_or_default();
1771+
1772+
// Transform { secret: { label, ... } } → array of { label, secret, bunker_uri, clients }
1773+
let slots: Vec<serde_json::Value> = raw
1774+
.as_object()
1775+
.map(|obj| {
1776+
obj.iter()
1777+
.map(|(secret, info)| {
1778+
let slot_uri = if bunker_uri.is_empty() {
1779+
String::new()
1780+
} else {
1781+
format!("{}&secret={}", bunker_uri, secret)
1782+
};
1783+
json!({
1784+
"label": info.get("label").and_then(|v| v.as_str()).unwrap_or("unnamed"),
1785+
"secret": secret,
1786+
"bunker_uri": slot_uri,
1787+
"createdAt": info.get("createdAt"),
1788+
"clients": info.get("clients").cloned().unwrap_or_else(|| json!([])),
1789+
})
1790+
})
1791+
.collect()
1792+
})
1793+
.unwrap_or_default();
1794+
1795+
axum::Json(json!(slots))
1796+
}
1797+
16701798
/// Frame type bytes for the ESP32 serial protocol.
16711799
const FRAME_MAGIC: [u8; 2] = [0x48, 0x57];
16721800
const FRAME_TYPE_SET_PIN: u8 = 0x25;
@@ -1902,6 +2030,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
19022030
.route("/api/clients/revoke", post(api_revoke_client))
19032031
.route("/api/clients/clear", post(api_clear_clients))
19042032
.route("/api/pair", post(api_pair))
2033+
.route("/api/slots", get(api_list_slots))
2034+
.route("/api/slots/create", post(api_create_slot))
19052035
.route("/api/generate-mnemonic", get(api_generate_mnemonic))
19062036
.route("/api/wordlist", get(api_wordlist))
19072037
.route("/api/client-keys", get(api_list_client_keys))

0 commit comments

Comments
 (0)