Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 401, not a redirect. A redirect to a login page is indistinguishable from the app itself redirecting, and machine clients would follow it and parse HTML as their API response. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against is a gate that binds nothing, logs at debug, and reports success while every app stays exactly as open as before — worse than no gate, because it stops anyone looking. Same reasoning that ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
7.0 KiB
Rust
193 lines
7.0 KiB
Rust
//! Companion device tokens — long-lived bearer credentials minted from an
|
|
//! authenticated session, so the pairing QR can log a phone in without
|
|
//! carrying the admin password (which the browser never has anyway).
|
|
//!
|
|
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
|
|
//! data dir); the plaintext is returned exactly once at mint time and rides
|
|
//! the QR as the `tok` param. Verification goes through `auth.login`'s
|
|
//! `token` param and is covered by the same login rate limiter as passwords.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
|
|
const TOKENS_FILE: &str = "device-tokens.json";
|
|
|
|
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
|
|
/// so this only limits the number of *distinct* device names.
|
|
const MAX_TOKENS: usize = 32;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DeviceToken {
|
|
pub name: String,
|
|
/// Hex SHA-256 of the plaintext token.
|
|
pub hash: String,
|
|
/// Unix seconds at mint time.
|
|
pub created: u64,
|
|
/// App ids this token may reach through the app gate.
|
|
///
|
|
/// `None` means node-wide, which is what every companion pairing token
|
|
/// is and what tokens minted before scoping existed remain — the field
|
|
/// is absent from their stored JSON and deserialises to `None`. A
|
|
/// migration that guessed a scope for them would silently revoke access
|
|
/// the operator never asked to revoke.
|
|
///
|
|
/// `Some(list)` restricts the token to exactly those apps, which is the
|
|
/// point of scoping: a token handed to Home Assistant so it can poll one
|
|
/// app's API should not also open every other app on the node.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub apps: Option<Vec<String>>,
|
|
}
|
|
|
|
impl DeviceToken {
|
|
/// Whether this token may reach `app_id`.
|
|
pub fn allows_app(&self, app_id: &str) -> bool {
|
|
match &self.apps {
|
|
None => true,
|
|
Some(apps) => apps.iter().any(|a| a == app_id),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tokens_path(data_dir: &Path) -> PathBuf {
|
|
data_dir.join(TOKENS_FILE)
|
|
}
|
|
|
|
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
|
|
match fs::read(tokens_path(data_dir)).await {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
|
|
let bytes = serde_json::to_vec_pretty(tokens)?;
|
|
fs::write(tokens_path(data_dir), bytes)
|
|
.await
|
|
.context("write device-tokens.json")
|
|
}
|
|
|
|
fn hash_hex(token: &str) -> String {
|
|
hex::encode(Sha256::digest(token.as_bytes()))
|
|
}
|
|
|
|
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
|
}
|
|
|
|
/// Mint a new token for `name`. An existing token with the same name is
|
|
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
|
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
|
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
|
create_scoped(data_dir, name, None).await
|
|
}
|
|
|
|
/// Mint a token limited to `apps`, for a machine client that needs one app's
|
|
/// HTTP API and nothing else. `None` mints the node-wide token `create` does.
|
|
pub async fn create_scoped(
|
|
data_dir: &Path,
|
|
name: &str,
|
|
apps: Option<Vec<String>>,
|
|
) -> Result<String> {
|
|
// An empty list would be indistinguishable from "no restriction" to a
|
|
// careless reader while actually authorising nothing — reject it rather
|
|
// than mint a token whose behaviour nobody can predict from its record.
|
|
if apps.as_ref().is_some_and(|a| a.is_empty()) {
|
|
anyhow::bail!("a scoped device token must name at least one app");
|
|
}
|
|
// KEY-05: a device token is a bearer credential — its unpredictability is
|
|
// the whole of its security — so the source is named and the draw guarded.
|
|
let mut token_bytes = [0u8; 32];
|
|
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| {
|
|
anyhow::anyhow!("Refusing to mint a device token from degenerate entropy: {e}")
|
|
})?;
|
|
let token = hex::encode(token_bytes);
|
|
|
|
let mut tokens = load(data_dir).await;
|
|
tokens.retain(|t| t.name != name);
|
|
if tokens.len() >= MAX_TOKENS {
|
|
tokens.remove(0);
|
|
}
|
|
tokens.push(DeviceToken {
|
|
name: name.to_string(),
|
|
hash: hash_hex(&token),
|
|
created: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0),
|
|
apps,
|
|
});
|
|
save(data_dir, &tokens).await?;
|
|
Ok(token)
|
|
}
|
|
|
|
/// Verify a candidate token. Returns the device name it was minted for.
|
|
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
|
let candidate_hash = hash_hex(candidate);
|
|
load(data_dir)
|
|
.await
|
|
.iter()
|
|
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
|
|
.map(|t| t.name.clone())
|
|
}
|
|
|
|
/// Verify a candidate token **for a specific app**, as the app gate does.
|
|
/// Returns the device name when the token is valid *and* in scope.
|
|
///
|
|
/// Separate from `verify` on purpose: `verify` answers "is this a real
|
|
/// token", which is the right question for node login, and would be the
|
|
/// wrong question here — a token scoped to one app would otherwise open
|
|
/// every app.
|
|
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
|
|
let candidate_hash = hash_hex(candidate);
|
|
load(data_dir)
|
|
.await
|
|
.iter()
|
|
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id))
|
|
.map(|t| t.name.clone())
|
|
}
|
|
|
|
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
|
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
|
load(data_dir).await
|
|
}
|
|
|
|
/// Remove the token minted for `name`. Returns whether one existed.
|
|
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
|
|
let mut tokens = load(data_dir).await;
|
|
let before = tokens.len();
|
|
tokens.retain(|t| t.name != name);
|
|
let removed = tokens.len() != before;
|
|
if removed {
|
|
save(data_dir, &tokens).await?;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn mint_verify_replace_remove() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let token = create(dir.path(), "phone").await.unwrap();
|
|
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
|
|
assert!(verify(dir.path(), "not-a-token").await.is_none());
|
|
|
|
// Re-minting the same name invalidates the old token.
|
|
let token2 = create(dir.path(), "phone").await.unwrap();
|
|
assert!(verify(dir.path(), &token).await.is_none());
|
|
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
|
|
assert_eq!(list(dir.path()).await.len(), 1);
|
|
|
|
assert!(remove(dir.path(), "phone").await.unwrap());
|
|
assert!(verify(dir.path(), &token2).await.is_none());
|
|
}
|
|
}
|