feat: integrate local Blossom, reviewed nsites and scoped app access

This commit is contained in:
archipelago
2026-10-08 09:12:40 -04:00
parent 05e999b117
commit 28a92fcc9b
39 changed files with 2060 additions and 50 deletions
+152 -16
View File
@@ -18,6 +18,7 @@ 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;
static TOKEN_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceToken {
@@ -39,9 +40,14 @@ pub struct DeviceToken {
/// 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>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
impl DeviceToken {
fn active(&self) -> bool {
self.expires_at.map(|end| end > now()).unwrap_or(true)
}
/// Whether this token may reach `app_id`.
pub fn allows_app(&self, app_id: &str) -> bool {
match &self.apps {
@@ -51,22 +57,49 @@ impl DeviceToken {
}
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(u64::MAX)
}
fn tokens_path(data_dir: &Path) -> PathBuf {
data_dir.join(TOKENS_FILE)
}
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
load_strict(data_dir).await.unwrap_or_default()
}
async fn load_strict(data_dir: &Path) -> Result<Vec<DeviceToken>> {
match fs::read(tokens_path(data_dir)).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Vec::new(),
Ok(bytes) => serde_json::from_slice(&bytes)
.context("Read stored access credentials; existing file preserved"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e).context("Read stored access credentials"),
}
}
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")
use tokio::io::AsyncWriteExt;
let tmp = data_dir.join(format!(".device-tokens-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(0o600);
let mut file = options.open(&tmp).await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
fs::rename(&tmp, tokens_path(data_dir)).await?;
fs::File::open(data_dir).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(tmp).await;
}
result.context("write device-tokens.json")
}
fn hash_hex(token: &str) -> String {
@@ -94,6 +127,20 @@ pub async fn create_scoped(
name: &str,
apps: Option<Vec<String>>,
) -> Result<String> {
create_scoped_expiring(data_dir, name, apps, None).await
}
pub async fn create_scoped_expiring(
data_dir: &Path,
name: &str,
apps: Option<Vec<String>>,
expires_at: Option<u64>,
) -> Result<String> {
let _guard = TOKEN_WRITE_LOCK.lock().await;
anyhow::ensure!(
expires_at.map(|end| end > now()).unwrap_or(true),
"Access expiry must be in the future"
);
// 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.
@@ -108,10 +155,10 @@ pub async fn create_scoped(
})?;
let token = hex::encode(token_bytes);
let mut tokens = load(data_dir).await;
let mut tokens = load_strict(data_dir).await?;
tokens.retain(|t| t.name != name);
if tokens.len() >= MAX_TOKENS {
tokens.remove(0);
anyhow::bail!("Access credential limit reached. Revoke an unused credential first");
}
tokens.push(DeviceToken {
name: name.to_string(),
@@ -121,35 +168,63 @@ pub async fn create_scoped(
.map(|d| d.as_secs())
.unwrap_or(0),
apps,
expires_at,
});
save(data_dir, &tokens).await?;
Ok(token)
}
/// Verify a candidate token. Returns the device name it was minted for.
/// Verify a node-wide login token. App-only credentials must never be exchanged
/// for an administrator session through auth.login (including its password path).
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()))
.find(|t| {
t.apps.is_none() && t.active() && 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.
/// Node-wide companion credentials retain their existing app access; app-only
/// credentials work only for the recorded application(s), before their expiry.
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
verified_app_token(data_dir, candidate, app_id)
.await
.map(|t| t.name)
}
/// Return one verified snapshot so callers can distinguish a guest credential
/// from a node-wide device without racing a second read of the token file.
pub async fn verified_app_token(
data_dir: &Path,
candidate: &str,
app_id: &str,
) -> Option<DeviceToken> {
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())
.find(|t| {
t.active()
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
&& t.allows_app(app_id)
})
.cloned()
}
pub async fn verify_guest(data_dir: &Path, candidate: &str, app_id: &str) -> bool {
let candidate_hash = hash_hex(candidate);
load(data_dir).await.iter().any(|t| {
t.apps.is_some()
&& t.active()
&& t.allows_app(app_id)
&& ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())
})
}
/// List stored tokens (hashes only — plaintexts are unrecoverable).
@@ -159,7 +234,8 @@ pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
/// 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 _guard = TOKEN_WRITE_LOCK.lock().await;
let mut tokens = load_strict(data_dir).await?;
let before = tokens.len();
tokens.retain(|t| t.name != name);
let removed = tokens.len() != before;
@@ -172,6 +248,66 @@ pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_grants_survive_and_capacity_never_evicts_a_device() {
let dir = tempfile::tempdir().unwrap();
let owner = create(dir.path(), "phone").await.unwrap();
let mut tasks = tokio::task::JoinSet::new();
for i in 1..MAX_TOKENS {
let path = dir.path().to_owned();
tasks.spawn(async move { create(&path, &format!("device-{i}")).await.unwrap() });
}
while let Some(result) = tasks.join_next().await {
result.unwrap();
}
assert_eq!(list(dir.path()).await.len(), MAX_TOKENS);
assert!(create(dir.path(), "overflow").await.is_err());
assert_eq!(verify(dir.path(), &owner).await.as_deref(), Some("phone"));
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(tokens_path(dir.path()))
.await
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[tokio::test]
async fn guest_scope_expiry_and_corruption_fail_closed_without_replacing_credentials() {
let dir = tempfile::tempdir().unwrap();
let guest = create_scoped_expiring(
dir.path(),
"guest",
Some(vec!["nextcloud".into()]),
Some(now() + 3600),
)
.await
.unwrap();
assert!(verify(dir.path(), &guest).await.is_none());
assert!(verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_some());
assert!(verify_for_app(dir.path(), &guest, "lnd").await.is_none());
let mut records = load(dir.path()).await;
records[0].expires_at = Some(1);
save(dir.path(), &records).await.unwrap();
assert!(!verify_guest(dir.path(), &guest, "nextcloud").await);
assert!(verify_for_app(dir.path(), &guest, "nextcloud")
.await
.is_none());
fs::write(tokens_path(dir.path()), b"broken stored credential file")
.await
.unwrap();
assert!(create(dir.path(), "phone").await.is_err());
assert!(remove(dir.path(), "guest").await.is_err());
assert_eq!(
fs::read(tokens_path(dir.path())).await.unwrap(),
b"broken stored credential file"
);
}
#[tokio::test]
async fn mint_verify_replace_remove() {