clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

feat: harden machine enrollment and live authorization

Dami committed 94693f8
Bind machine credentials to Ed25519 endpoints, revoke active streams when authorization changes, pin authenticated CLI traffic to its enrolled origin, and publish the v0.3.0 upgrade notes.
commit
94693f867da01b06306942884b656c86e83e0a3f
parent
29954bd465cbce89a833f81e5e9cd554a74afed2
author
Dami <dami.etoile@gmail.com>
14 files changed, +675 -137
M.github/release-intro.md57+++++++--
MCargo.lock10+-
MCargo.toml2+-
MREADME.md21++-
Mcrates/devsite-cli/src/main.rs187+++++++++++++++++++++--------
Mcrates/devsite-daemon/src/config.rs53+++++---
Mcrates/devsite-daemon/src/lib.rs80+++++++++++-
Mcrates/devsite-daemon/tests/authz.rs25++++
Mcrates/devsite-proto/src/lib.rs8+
Mcrates/devsite-server/src/api.rs155+++++++++++++++++++++---
Mcrates/devsite-server/src/auth.rs7+
Mcrates/devsite-server/src/db.rs195++++++++++++++++++++++++++++--
Mdocs/json-output.md2+-
Mweb/app.js10+-
M .github/release-intro.md +46 -11
@@ -1,17 +1,55 @@
1-dev.site puts links and private TCP services on one developer profile. Services stay on2-the machines that own them, and approved users connect through capability-gated,3-end-to-end encrypted Iroh QUIC. The dev.site control plane never carries service bytes.1+dev.site v0.3.0 hardens machine enrollment and live service authorization while improving2+profile organization and installation guidance. Services continue to stay on the machines3+that own them; the dev.site control plane never carries service bytes.4+5+## important upgrade note6+7+This release intentionally replaces the v0.2.0 machine-enrollment flow. Existing v0.2.08+machine credentials will no longer authenticate after the control plane is upgraded.9+10+After installing v0.3.0, sign in at [dev.site](https://dev.site), create a new single-use11+machine ticket, and enroll the machine again:12+13+```sh14+devsite login dmt_...15+```16+17+Then restart `devsite daemon run` or its service-manager unit.18+19+## security hardening20+21+- Dashboard-created machine tickets are plaintext, single-use enrollment secrets. On use,22+ the server rotates the ticket into a persistent credential stored only as a hash.23+- Machine credentials are bound to the enrolling machine's Ed25519 endpoint. Daemon24+ registration requires proof of the matching private key, preventing a copied credential25+ from redirecting service traffic to another endpoint.26+- Revoking a share, credential, or hosted resource now closes affected active streams. The27+ daemon also fails closed when its authorization snapshot remains unavailable.28+- Authenticated CLI operations use the control-plane origin saved at enrollment instead of29+ combining a saved credential with a command-line server override.30+- The durable endpoint public key is available as `identity.pub`; private key storage is31+ unchanged.32+33+## workflow improvements34+35+- Profiles support declarative folder order and initial open/closed state through bounded36+ theme properties.37+- Platform-aware installation guidance is now available from both the landing page and the38+ signed-in dashboard.39+- Service-ticket and native client terminology now matches the current device-to-device40+ architecture, with fixes for interactive accordion theme state.441 542 ## install643 7-macOS users should install with Homebrew:44+Apple Silicon macOS users can install or upgrade with Homebrew:845 946 ```sh1047 brew install FelineStateMachine/tap/devsite48+# Existing installations:49+brew update && brew upgrade FelineStateMachine/tap/devsite1150 ```1251 13-Linux and Windows users can download the matching archive below. The Apple Silicon archive14-is also provided for direct installation.52+Linux and Windows users can download the matching archive below.1553 1654 | archive target | system |1755 | --- | --- |
@@ -34,16 +72,13 @@ On Windows, extract the `.zip` and put the directory containing `devsite.exe` on
3472 3573 ## first service3674 37-Sign in at [dev.site](https://dev.site), create a machine credential on the dashboard,38-then run:39-4075 ```sh41-devsite login dsm_...4276 devsite service host 5432 --name postgres4377 devsite daemon run4478 ```4579 46-An approved user can open the service on dev.site, get a one-use ticket, and connect with:80+An approved user can open the service on dev.site, get a one-use connection ticket, and81+connect with:4782 4883 ```sh4984 devsite connect dst_...
M Cargo.lock +5 -5
M Cargo.toml +1 -1
M README.md +12 -9
@@ -24,11 +24,11 @@ is one binary; `devsite daemon run` is its long-running host mode.
2424 2525 ## Quick start2626 27-Sign in at [dev.site](https://dev.site), create a machine credential on the dashboard, and28-use the one-time value to log in:27+Sign in at [dev.site](https://dev.site), create a single-use machine ticket on the dashboard,28+and use it to bind this machine's Ed25519 identity:2929 3030 ```bash31-devsite login dsm_...31+devsite login dmt_...3232 ```3333 3434 Host a named local service and keep the daemon running:
@@ -156,10 +156,11 @@ Native Linux packages may install `packaging/systemd/devsite.service` as a user
156156 systemctl --user enable --now devsite.service157157 ```158158 159-The endpoint identity persists in the devsite config directory. The daemon registers that160-public endpoint id with the control plane once at startup, publishes its address through161-Iroh, and reloads service targets from the local config every two seconds. Adding or162-removing a service does not require restarting it.159+The endpoint identity persists in the devsite config directory, with its Ed25519 public key160+written to `identity.pub`. The daemon registers that public endpoint id with the control161+plane once at startup, publishes its address through Iroh, and reloads service targets and162+authorizations every two seconds. Adding or removing a service does not require restarting163+it.163164 164165 `devsite daemon status` reports whether this config directory has a live daemon. The same165166 liveness appears in `devsite status` alongside the config location, pinned signing key, and
@@ -235,7 +236,7 @@ either is intentionally disruptive.
235236 - A peer supplies only a signed capability. It never supplies the daemon's target address.236237 - `devsite service host PORT` always stores `127.0.0.1:PORT`; port zero is rejected.237238 - `devsite connect` listens only on a loopback address.238-- Browser-minted tickets are short-lived, stored only as hashes, and consumed once.239+- Browser-minted connection tickets are short-lived, stored only as hashes, and consumed once.239240 Redemption creates a client-key-bound session; its raw credential exists only in CLI240241 memory, while the control plane stores its hash until expiry or disconnect.241242 - Capabilities are bound to the authenticated Iroh client endpoint and cannot be replayed
@@ -247,8 +248,10 @@ either is intentionally disruptive.
247248 state are checked whenever a new capability is requested.248249 - The control plane rate-limits capability issuance and bounds profiles, names, links,249250 folders, credentials, and share lists.251+- Plaintext machine enrollment tickets are consumed once. Enrollment binds the resulting252+ machine credential to the machine's Ed25519 endpoint identity.250253 - Machine credentials and browser sessions are stored only as SHA-256 hashes and can be251- revoked from the dashboard.254+ revoked from the dashboard. Revocation also closes affected active service streams.252255 253256 ## License254257
M crates/devsite-cli/src/main.rs +137 -50
@@ -7,10 +7,12 @@ use clap::{error::ErrorKind, Parser, Subcommand};
77 use devsite_client::{ClientEndpoint, ServiceStream};88 use devsite_daemon::config::{DaemonConfig, HostedService, Paths, Visibility};99 use devsite_daemon::Daemon;10-use devsite_proto::SignedCapability;10+use devsite_proto::{AccountId, ResourceId, SignedCapability};1111 use serde::{Deserialize, Serialize};12+use std::collections::HashSet;1213 use std::net::{IpAddr, Ipv4Addr, SocketAddr};1314 use std::sync::Arc;15+use std::time::{Duration, Instant};1416 use tokio::io::{copy, AsyncWriteExt};1517 use tokio::net::{TcpListener, TcpStream};1618
@@ -21,7 +23,7 @@ use crate::client::ControlPlane;
2123 name = "devsite",2224 version,2325 about = "Share public work and reach your local services",24- after_help = "Typical workflows:\n devsite login dsm_...\n devsite link set --name docs --url https://example.com --public\n devsite service host 3000 --name app\n devsite connect dst_...\n\nUse `devsite <command> --help` for command-specific arguments."26+ after_help = "Typical workflows:\n devsite login dmt_...\n devsite link set --name docs --url https://example.com --public\n devsite service host 3000 --name app\n devsite connect dst_...\n\nUse `devsite <command> --help` for command-specific arguments."2527 )]2628 struct Cli {2729 /// Control plane base URL.
@@ -45,7 +47,7 @@ struct Cli {
4547 enum Command {4648 /// Store a revocable machine credential and pin the control plane's signing key.4749 Login {48- /// Machine credential created on the signed-in dashboard.50+ /// Single-use machine enrollment ticket created on the signed-in dashboard.4951 token: Option<String>,5052 },5153 /// Manage ordinary external links.
@@ -223,7 +225,7 @@ fn runtime_suggestions(command: &str, err: &anyhow::Error) -> Vec<String> {
223225 let message = format!("{err:#}");224226 if message.contains("not signed in") || message.contains("run `devsite login` first") {225227 suggestions.push(226- "Create a machine credential on dev.site, then run `devsite login TOKEN`.".to_string(),228+ "Create a machine ticket on dev.site, then run `devsite login TICKET`.".to_string(),227229 );228230 }229231 suggestions.push(help_suggestion(Some(command)));
@@ -276,6 +278,23 @@ struct RedeemTicketResponse {
276278 expires_at: u64,277279 }278280 281+#[derive(Deserialize)]282+struct EnrollMachineResponse {283+ machine_credential: String,284+ endpoint_id: String,285+}286+287+#[derive(Deserialize)]288+struct AuthorizationListing {289+ authorizations: Vec<ServiceAuthorization>,290+}291+292+#[derive(Deserialize)]293+struct ServiceAuthorization {294+ viewer_id: String,295+ resource_id: String,296+}297+279298 #[derive(Deserialize)]280299 struct ResourceListing {281300 resources: Vec<Resource>,
@@ -448,30 +467,16 @@ async fn run(cli: Cli, output: Output) -> Result<()> {
448467 public,449468 share,450469 folder,451- }) => {452- set_link(453- &paths,454- &cli.server,455- &name,456- &url,457- public,458- share,459- folder,460- output,461- )462- .await463- }464- Command::Link(LinkCommand::Remove { name }) => {465- remove_link(&paths, &cli.server, &name, output).await466- }470+ }) => set_link(&paths, &name, &url, public, share, folder, output).await,471+ Command::Link(LinkCommand::Remove { name }) => remove_link(&paths, &name, output).await,467472 Command::Service(ServiceCommand::Host {468473 port,469474 name,470475 share,471476 folder,472- }) => host_service(&paths, &cli.server, port, name, share, folder, output).await,477+ }) => host_service(&paths, port, name, share, folder, output).await,473478 Command::Service(ServiceCommand::Remove { name }) => {474- remove_service(&paths, &cli.server, &name, output).await479+ remove_service(&paths, &name, output).await475480 }476481 Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen, output).await,477482 Command::Theme(command) => theme(&paths, &cli.server, command, output).await,
@@ -489,13 +494,20 @@ fn load_authenticated(paths: &Paths) -> Result<DaemonConfig> {
489494 Ok(config)490495 }491496 497+fn enrolled_server(config: &DaemonConfig) -> Result<&str> {498+ config499+ .server_url500+ .as_deref()501+ .context("this credential is not bound to a control-plane origin — log in again")502+}503+492504 async fn login(paths: &Paths, server: &str, token: Option<String>, output: Output) -> Result<()> {493- let token = match token {494- Some(token) => token,505+ let ticket = match token {506+ Some(ticket) => ticket,495507 None if output.json => bail!("TOKEN is required with --json"),496508 None => {497- println!("Open {server}, create a machine credential, and paste it here.");498- print!("token: ");509+ println!("Open {server}, create a machine ticket, and paste it here.");510+ print!("ticket: ");499511 use std::io::Write;500512 std::io::stdout().flush()?;501513 let mut line = String::new();
@@ -503,29 +515,46 @@ async fn login(paths: &Paths, server: &str, token: Option<String>, output: Outpu
503515 line.trim().to_string()504516 }505517 };506- if token.is_empty() {507- bail!("no token provided");518+ if ticket.is_empty() {519+ bail!("no ticket provided");508520 }509521 510- let api = ControlPlane::new(server, Some(token.clone()));511- let server_config: PublicConfig = api.get("/api/config").await?;512- if server_config.api_version != 2 {522+ let bootstrap = ControlPlane::new(server, None);523+ let server_config: PublicConfig = bootstrap.get("/api/config").await?;524+ if server_config.api_version != 3 {513525 bail!(514- "this CLI speaks API version 2, but the server reports version {}",526+ "this CLI speaks API version 3, but the server reports version {}",515527 server_config.api_version516528 );517529 }518- // Confirm the credential works before storing it, so a typo fails here519- // rather than at the first API call.530+ let pubkey: PubKeyResponse = bootstrap.get("/api/pubkey").await?;531+ let identity = paths.load_or_create_identity()?;532+ let endpoint_id = identity.public().to_string();533+ let enrollment = ControlPlane::new(server, Some(ticket));534+ let enrolled: EnrollMachineResponse = enrollment535+ .put(536+ "/api/machine/enroll",537+ &serde_json::json!({538+ "endpoint_id": endpoint_id,539+ "proof": endpoint_proof(&identity),540+ }),541+ )542+ .await543+ .context("enrolling this machine ticket")?;544+ if enrolled.endpoint_id != endpoint_id {545+ bail!("the server enrolled a different endpoint identity");546+ }547+548+ // Confirm the rotated credential works before storing it.549+ let api = ControlPlane::new(server, Some(enrolled.machine_credential.clone()));520550 let me: serde_json::Value = api521551 .get("/api/me")522552 .await523- .context("that token was not accepted")?;524- let pubkey: PubKeyResponse = api.get("/api/pubkey").await?;553+ .context("the enrolled machine credential was not accepted")?;525554 526555 let mut config = paths.load_config()?;527556 config.server_url = Some(server.trim_end_matches('/').to_string());528- config.machine_credential = Some(token);557+ config.machine_credential = Some(enrolled.machine_credential);529558 // Pinned now, and every capability is checked against it from here on.530559 config.control_plane_key = Some(pubkey.public_key);531560 paths.save_config(&config)?;
@@ -556,10 +585,15 @@ async fn login(paths: &Paths, server: &str, token: Option<String>, output: Outpu
556585 Ok(())557586 }558587 588+fn endpoint_proof(secret: &iroh::SecretKey) -> String {589+ let endpoint = *secret.public().as_bytes();590+ let signature = secret.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));591+ data_encoding::BASE64URL_NOPAD.encode(&signature.to_bytes())592+}593+559594 #[allow(clippy::too_many_arguments)]560595 async fn set_link(561596 paths: &Paths,562- server: &str,563597 name: &str,564598 url: &str,565599 public: bool,
@@ -576,6 +610,7 @@ async fn set_link(
576610 };577611 let share = normalized_handles(&share);578612 let config = load_authenticated(paths)?;613+ let server = enrolled_server(&config)?;579614 let api = ControlPlane::new(server, config.machine_credential.clone());580615 let update = warn_on_upsert(581616 &api,
@@ -636,8 +671,9 @@ async fn set_link(
636671 /// everything by id, and an endpoint that deleted by name would have to invent a637672 /// rule for a link and a service sharing one. Listing first also means the "no638673 /// such thing" case is answered with the names that do exist.639-async fn remove_resource(paths: &Paths, server: &str, name: &str, kind: &str) -> Result<Resource> {674+async fn remove_resource(paths: &Paths, name: &str, kind: &str) -> Result<Resource> {640675 let config = load_authenticated(paths)?;676+ let server = enrolled_server(&config)?;641677 let api = ControlPlane::new(server, config.machine_credential.clone());642678 643679 let listing: ResourceListing = api.get("/api/resources").await?;
@@ -654,8 +690,8 @@ async fn remove_resource(paths: &Paths, server: &str, name: &str, kind: &str) ->
654690 Ok(resource)655691 }656692 657-async fn remove_link(paths: &Paths, server: &str, name: &str, output: Output) -> Result<()> {658- let removed = remove_resource(paths, server, name, "link").await?;693+async fn remove_link(paths: &Paths, name: &str, output: Output) -> Result<()> {694+ let removed = remove_resource(paths, name, "link").await?;659695 let url = removed.url.unwrap_or_default();660696 if output.json {661697 output.success(
@@ -674,8 +710,8 @@ async fn remove_link(paths: &Paths, server: &str, name: &str, output: Output) ->
674710 /// machine does. A daemon that kept serving a resource the control plane has675711 /// deleted is harmless — no capability can be issued for it any more — but a676712 /// config that still lists it would re-register the name the next time it is hosted.677-async fn remove_service(paths: &Paths, server: &str, name: &str, output: Output) -> Result<()> {678- let removed = remove_resource(paths, server, name, "service").await?;713+async fn remove_service(paths: &Paths, name: &str, output: Output) -> Result<()> {714+ let removed = remove_resource(paths, name, "service").await?;679715 680716 let mut config = paths.load_config()?;681717 let before = config.resources.len();
@@ -705,7 +741,6 @@ async fn remove_service(paths: &Paths, server: &str, name: &str, output: Output)
705741 706742 async fn host_service(707743 paths: &Paths,708- server: &str,709744 port: u16,710745 name: Option<String>,711746 share: Vec<String>,
@@ -726,6 +761,7 @@ async fn host_service(
726761 let share = normalized_handles(&share);727762 728763 let config = load_authenticated(paths)?;764+ let server = enrolled_server(&config)?;729765 let api = ControlPlane::new(server, config.machine_credential.clone());730766 let update = warn_on_upsert(731767 &api,
@@ -927,6 +963,7 @@ async fn theme(paths: &Paths, server: &str, command: ThemeCommand, output: Outpu
927963 }928964 929965 let config = load_authenticated(paths)?;966+ let server = enrolled_server(&config)?;930967 let api = ControlPlane::new(server, config.machine_credential.clone());931968 932969 match command {
@@ -1249,6 +1286,7 @@ async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {
12491286 let token = config.machine_credential.clone();12501287 12511288 let secret_key = paths.load_or_create_identity()?;1289+ let proof = endpoint_proof(&secret_key);12521290 let daemon = Arc::new(Daemon::bind(secret_key, config).await?);12531291 12541292 let endpoint_id = daemon.endpoint().id().to_string();
@@ -1269,12 +1307,9 @@ async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {
12691307 // clients resolve it from there. The control plane holds permissions; it is12701308 // not a directory service.12711309 let api = ControlPlane::new(&server_url, token);1272- api.put_empty(1273- "/api/daemon",1274- &serde_json::json!({ "endpoint_id": endpoint_id }),1275- )1276- .await1277- .context("registering this daemon with the control plane")?;1310+ api.put_empty("/api/daemon", &serde_json::json!({ "proof": proof }))1311+ .await1312+ .context("registering this daemon with the control plane")?;12781313 12791314 if output.json {12801315 output.event(
@@ -1291,9 +1326,11 @@ async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {
12911326 let reload_paths = Paths {12921327 root: paths.root.clone(),12931328 };1329+ let authorization_api = api.clone();12941330 tokio::select! {12951331 result = Arc::clone(&daemon).serve() => result?,12961332 result = reload_config(reload_paths, Arc::clone(&daemon), output) => result?,1333+ result = sync_authorizations(authorization_api, Arc::clone(&daemon)) => result?,12971334 _ = tokio::signal::ctrl_c() => {12981335 if output.json {12991336 output.event("daemon.run", "shutdown", serde_json::json!({}));
@@ -1306,6 +1343,56 @@ async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {
13061343 Ok(())13071344 }13081345 1346+async fn sync_authorizations(api: ControlPlane, daemon: Arc<Daemon>) -> Result<()> {1347+ const REFRESH: Duration = Duration::from_secs(2);1348+ const STALE_LEASE: Duration = Duration::from_secs(15);1349+1350+ let mut interval = tokio::time::interval(REFRESH);1351+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);1352+ let mut last_success = Instant::now();1353+ let mut failed_closed = false;1354+1355+ loop {1356+ interval.tick().await;1357+ match api1358+ .get::<AuthorizationListing>("/api/daemon/authorizations")1359+ .await1360+ {1361+ Ok(listing) => {1362+ let allowed = listing1363+ .authorizations1364+ .into_iter()1365+ .map(|entry| {1366+ Ok((1367+ entry.viewer_id.parse::<AccountId>()?,1368+ entry.resource_id.parse::<ResourceId>()?,1369+ ))1370+ })1371+ .collect::<Result<HashSet<_>>>()?;1372+ let revoked = daemon.replace_authorizations(allowed).await;1373+ if revoked > 0 {1374+ tracing::info!(revoked, "closed streams whose authorization was revoked");1375+ }1376+ last_success = Instant::now();1377+ failed_closed = false;1378+ }1379+ Err(err) => {1380+ tracing::warn!("could not refresh active-stream authorizations: {err:#}");1381+ if last_success.elapsed() >= STALE_LEASE {1382+ let revoked = daemon.revoke_all_active().await;1383+ if !failed_closed || revoked > 0 {1384+ tracing::warn!(1385+ revoked,1386+ "authorization lease expired; closed active streams"1387+ );1388+ }1389+ failed_closed = true;1390+ }1391+ }1392+ }1393+ }1394+}1395+13091396 async fn reload_config(paths: Paths, daemon: Arc<Daemon>, output: Output) -> Result<()> {13101397 let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));13111398 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
M crates/devsite-daemon/src/config.rs +35 -18
@@ -37,9 +37,8 @@ pub struct DaemonConfig {
3737 /// Base URL of the control plane, e.g. `https://dev.site`.3838 pub server_url: Option<String>,3939 /// Revocable machine credential from `devsite login`, used for CLI calls and40- /// for registering this daemon's endpoint id when it starts. The alias reads41- /// configs written before machine credentials were introduced.42- #[serde(default, alias = "session_token")]40+ /// for registering this daemon's endpoint id when it starts.41+ #[serde(default)]4342 pub machine_credential: Option<String>,4443 /// The control plane's capability-signing public key, pinned at login.4544 ///
@@ -107,6 +106,12 @@ impl Paths {
107106 self.root.join("identity.key")108107 }109108 109+ /// Public half of [`Self::identity`]. Public Ed25519 material follows the110+ /// conventional `.pub` naming used by the rest of the CLI handoff files.111+ pub fn identity_public(&self) -> PathBuf {112+ self.root.join("identity.pub")113+ }114+110115 pub fn config(&self) -> PathBuf {111116 self.root.join("config.json")112117 }
@@ -162,19 +167,27 @@ impl Paths {
162167 /// audience — so it must survive restarts and must not be world-readable.163168 pub fn load_or_create_identity(&self) -> Result<SecretKey> {164169 let path = self.identity();165- if path.exists() {170+ let key = if path.exists() {166171 let raw =167172 std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;168173 let bytes: [u8; 32] = raw169174 .as_slice()170175 .try_into()171176 .map_err(|_| anyhow::anyhow!("{} is not a 32 byte key", path.display()))?;172- return Ok(SecretKey::from_bytes(&bytes));173- }177+ SecretKey::from_bytes(&bytes)178+ } else {179+ std::fs::create_dir_all(&self.root)?;180+ let key = SecretKey::generate();181+ write_private(&path, &key.to_bytes())?;182+ key183+ };174184 175- std::fs::create_dir_all(&self.root)?;176- let key = SecretKey::generate();177- write_private(&path, &key.to_bytes())?;185+ let public = format!("{}\n", key.public());186+ let public_path = self.identity_public();187+ if std::fs::read_to_string(&public_path).ok().as_deref() != Some(public.as_str()) {188+ std::fs::write(&public_path, public.as_bytes())189+ .with_context(|| format!("writing {}", public_path.display()))?;190+ }178191 Ok(key)179192 }180193 }
@@ -248,15 +261,6 @@ mod tests {
248261 }249262 }250263 251- #[test]252- fn reads_pre_machine_credential_configs() {253- let config: DaemonConfig = serde_json::from_str(254- r#"{"session_token":"old-token","control_plane_key":null,"resources":[]}"#,255- )256- .unwrap();257- assert_eq!(config.machine_credential.as_deref(), Some("old-token"));258- }259-260264 #[test]261265 fn daemon_lock_reports_only_a_live_holder() {262266 let paths = test_paths();
@@ -270,4 +274,17 @@ mod tests {
270274 assert!(!paths.daemon_running().unwrap());271275 std::fs::remove_dir_all(&paths.root).unwrap();272276 }277+278+ #[test]279+ fn identity_writes_its_public_half_as_a_pub_file() {280+ let paths = test_paths();281+ let key = paths.load_or_create_identity().unwrap();282+283+ assert_eq!(284+ std::fs::read_to_string(paths.identity_public()).unwrap(),285+ format!("{}\n", key.public())286+ );287+288+ std::fs::remove_dir_all(&paths.root).unwrap();289+ }273290 }
M crates/devsite-daemon/src/lib.rs +72 -8
@@ -7,19 +7,21 @@
77 pub mod config;88 pub mod verify;99 10+use std::collections::{HashMap, HashSet};11+use std::sync::atomic::{AtomicU64, Ordering};1012 use std::sync::Arc;1113 use std::time::{SystemTime, UNIX_EPOCH};1214 1315 use anyhow::{Context, Result};1416 use devsite_proto::capability::KeyBytes;1517 use devsite_proto::wire::{self, ErrorCode, Request, Response};16-use devsite_proto::ALPN;18+use devsite_proto::{AccountId, ResourceId, ALPN};1719 use ed25519_dalek::VerifyingKey;1820 use iroh::endpoint::Connection;1921 use iroh::{Endpoint, SecretKey};2022 use tokio::io::{copy, AsyncWriteExt};2123 use tokio::net::TcpStream;22-use tokio::sync::{Mutex, RwLock};24+use tokio::sync::{oneshot, Mutex, RwLock};2325 2426 use crate::config::{DaemonConfig, HostedService};2527 use crate::verify::{Denied, ReplayGuard};
@@ -36,6 +38,14 @@ pub struct Daemon {
3638 config: RwLock<DaemonConfig>,3739 control_plane_key: VerifyingKey,3840 replay: Arc<Mutex<ReplayGuard>>,41+ active: Mutex<HashMap<u64, ActiveStream>>,42+ next_stream_id: AtomicU64,43+}44+45+struct ActiveStream {46+ viewer: AccountId,47+ resource: ResourceId,48+ cancel: oneshot::Sender<()>,3949 }4050 4151 impl Daemon {
@@ -59,6 +69,8 @@ impl Daemon {
5969 config: RwLock::new(config),6070 control_plane_key,6171 replay: Arc::new(Mutex::new(ReplayGuard::default())),72+ active: Mutex::new(HashMap::new()),73+ next_stream_id: AtomicU64::new(1),6274 })6375 }6476
@@ -81,14 +93,49 @@ impl Daemon {
8193 /// open connections. This is intentionally platform-neutral: service8294 /// managers only need to keep `devsite daemon run` alive.8395 pub async fn replace_resources(&self, resources: Vec<HostedService>) -> bool {84- let mut config = self.config.write().await;85- if config.resources == resources {86- return false;96+ let allowed = resources97+ .iter()98+ .map(|resource| resource.resource_id)99+ .collect::<HashSet<_>>();100+ {101+ let mut config = self.config.write().await;102+ if config.resources == resources {103+ return false;104+ }105+ config.resources = resources;87106 }88- config.resources = resources;107+ self.cancel_where(|stream| !allowed.contains(&stream.resource))108+ .await;89109 true90110 }91111 112+ /// Replace the control plane's current authorization snapshot and terminate113+ /// streams whose viewer/resource pair has since been revoked.114+ pub async fn replace_authorizations(&self, allowed: HashSet<(AccountId, ResourceId)>) -> usize {115+ self.cancel_where(|stream| !allowed.contains(&(stream.viewer, stream.resource)))116+ .await117+ }118+119+ /// Fail closed after the control plane authorization lease has gone stale.120+ pub async fn revoke_all_active(&self) -> usize {121+ self.cancel_where(|_| true).await122+ }123+124+ async fn cancel_where(&self, denied: impl Fn(&ActiveStream) -> bool) -> usize {125+ let mut active = self.active.lock().await;126+ let ids = active127+ .iter()128+ .filter_map(|(id, stream)| denied(stream).then_some(*id))129+ .collect::<Vec<_>>();130+ let count = ids.len();131+ for id in ids {132+ if let Some(stream) = active.remove(&id) {133+ let _ = stream.cancel.send(());134+ }135+ }136+ count137+ }138+92139 /// Accept connections until the endpoint closes.93140 pub async fn serve(self: Arc<Self>) -> Result<()> {94141 let resources = self.config.read().await.resources.len();
@@ -231,6 +278,17 @@ impl Daemon {
231278 .await232279 .context("writing connect response")?;233280 281+ let stream_id = self.next_stream_id.fetch_add(1, Ordering::Relaxed);282+ let (cancel, mut cancelled) = oneshot::channel();283+ self.active.lock().await.insert(284+ stream_id,285+ ActiveStream {286+ viewer: authorized.claims.viewer,287+ resource: authorized.claims.resource,288+ cancel,289+ },290+ );291+234292 let (mut upstream_read, mut upstream_write) = upstream.into_split();235293 let client_to_service = async {236294 copy(&mut recv, &mut upstream_write)
@@ -246,8 +304,14 @@ impl Daemon {
246304 send.finish().context("finishing service stream")?;247305 Ok::<_, anyhow::Error>(())248306 };249- tokio::try_join!(client_to_service, service_to_client)?;250- Ok(())307+ let result = tokio::select! {308+ result = async { tokio::try_join!(client_to_service, service_to_client) } => {309+ result.map(|_| ())310+ }311+ _ = &mut cancelled => Ok(()),312+ };313+ self.active.lock().await.remove(&stream_id);314+ result251315 }252316 }253317
M crates/devsite-daemon/tests/authz.rs +25 -0
M crates/devsite-proto/src/lib.rs +8 -0
M crates/devsite-server/src/api.rs +138 -17
@@ -16,7 +16,7 @@ use devsite_proto::{AccountId, ResourceId};
1616 use serde::{Deserialize, Serialize};1717 1818 use crate::auth::{self, ShooVerifier};19-use crate::db::{Db, ResourceKind, ShareStatus};19+use crate::db::{Db, MachineAuthentication, ResourceKind, ShareStatus};2020 use crate::issuer::Issuer;2121 use crate::policy::{can_view, Visibility};2222 use crate::theme;
@@ -142,8 +142,9 @@ fn current_account(state: &AppState, headers: &HeaderMap) -> ApiResult<Option<Ac
142142 // Browser sessions remain accepted as bearer tokens for local operator143143 // workflows, but installed CLIs use independently revocable credentials.144144 let account = db145- .machine_account(&hash, now)145+ .machine_authentication(&hash, now)146146 .map_err(ApiError::internal)?147+ .map(|machine| machine.account)147148 .or(db.session_account(&hash, now).map_err(ApiError::internal)?);148149 return Ok(account.map(|a| a.id));149150 }
@@ -172,6 +173,17 @@ fn require_browser_account(state: &AppState, headers: &HeaderMap) -> ApiResult<A
172173 .ok_or_else(ApiError::unauthorized)173174 }174175 176+fn require_machine(state: &AppState, headers: &HeaderMap) -> ApiResult<MachineAuthentication> {177+ let token = bearer_token(headers).ok_or_else(ApiError::unauthorized)?;178+ state179+ .db180+ .lock()181+ .unwrap()182+ .machine_authentication(&auth::hash_token(&token), now_secs())183+ .map_err(ApiError::internal)?184+ .ok_or_else(ApiError::unauthorized)185+}186+175187 fn check_rate(state: &AppState, account: AccountId, class: RateClass) -> ApiResult<()> {176188 if state177189 .rate_limits
@@ -247,7 +259,13 @@ pub struct CreateResourceResponse {
247259 248260 #[derive(Deserialize)]249261 pub struct RegisterDaemonRequest {262+ pub proof: String,263+}264+265+#[derive(Deserialize)]266+pub struct EnrollMachineRequest {250267 pub endpoint_id: String,268+ pub proof: String,251269 }252270 253271 #[derive(Deserialize)]
@@ -356,6 +374,8 @@ pub fn router(state: Shared) -> Router {
356374 delete(decline_share_invitation),357375 )358376 .route("/api/daemon", put(register_daemon))377+ .route("/api/daemon/authorizations", get(daemon_authorizations))378+ .route("/api/machine/enroll", put(enroll_machine))359379 .route(360380 "/api/profile/settings",361381 get(profile_settings).put(set_profile_settings),
@@ -384,8 +404,8 @@ async fn config(State(state): State<Shared>) -> impl IntoResponse {
384404 "issuer": auth::ISSUER,385405 "public_origin": state.public_origin,386406 "redirect_uri": format!("{}/auth/callback", state.public_origin),387- "api_version": 2,388- "minimum_cli_version": "0.2.0",407+ "api_version": 3,408+ "minimum_cli_version": "0.3.0",389409 "server_version": env!("CARGO_PKG_VERSION"),390410 "daemon_protocol": String::from_utf8_lossy(devsite_proto::ALPN),391411 }))
@@ -854,22 +874,104 @@ async fn register_daemon(
854874 headers: HeaderMap,855875 Json(body): Json<RegisterDaemonRequest>,856876 ) -> ApiResult<impl IntoResponse> {857- let account = require_account(&state, &headers)?;858- check_rate(&state, account, RateClass::Mutation)?;859- // Validated before storing: a malformed id would be handed to browsers as860- // something to go looking for, and fail somewhere much less obvious.861- if parse_endpoint_id(&body.endpoint_id).is_none() {862- return Err(ApiError::bad_request(863- "endpoint_id is not a 32 byte hex key",864- ));865- }877+ let machine = require_machine(&state, &headers)?;878+ check_rate(&state, machine.account.id, RateClass::Mutation)?;879+ verify_endpoint_proof(&machine.endpoint_id, &body.proof)?;866880 867881 let db = state.db.lock().unwrap();868- db.register_daemon(account, &body.endpoint_id)882+ db.register_daemon(machine.account.id, &machine.endpoint_id)869883 .map_err(ApiError::internal)?;870884 Ok(StatusCode::NO_CONTENT)871885 }872886 887+async fn daemon_authorizations(888+ State(state): State<Shared>,889+ headers: HeaderMap,890+) -> ApiResult<impl IntoResponse> {891+ let machine = require_machine(&state, &headers)?;892+ let pairs = state893+ .db894+ .lock()895+ .unwrap()896+ .service_authorizations(machine.account.id)897+ .map_err(ApiError::internal)?;898+ Ok(Json(serde_json::json!({899+ "authorizations": pairs.into_iter().map(|(viewer, resource)| serde_json::json!({900+ "viewer_id": viewer.to_string(),901+ "resource_id": resource.to_string(),902+ })).collect::<Vec<_>>()903+ })))904+}905+906+/// Consume a one-use browser ticket, bind it to the daemon's Ed25519 identity,907+/// and rotate it into the persistent machine credential stored by the CLI.908+async fn enroll_machine(909+ State(state): State<Shared>,910+ headers: HeaderMap,911+ Json(body): Json<EnrollMachineRequest>,912+) -> ApiResult<impl IntoResponse> {913+ let ticket = bearer_token(&headers).ok_or_else(ApiError::unauthorized)?;914+ if !valid_prefixed_token(&ticket, "dmt_") {915+ return Err(ApiError::unauthorized());916+ }917+ verify_endpoint_proof(&body.endpoint_id, &body.proof)?;918+919+ let now = now_secs();920+ let ticket_hash = auth::hash_token(&ticket);921+ let token = auth::generate_machine_token();922+ let token_hash = auth::hash_token(&token);923+ let (credential_id, account) = {924+ state925+ .db926+ .lock()927+ .unwrap()928+ .machine_enrollment(&ticket_hash)929+ .map_err(ApiError::internal)?930+ .ok_or_else(ApiError::unauthorized)?931+ };932+ check_rate(&state, account, RateClass::Credential)?;933+ if !state934+ .db935+ .lock()936+ .unwrap()937+ .enroll_machine(938+ &credential_id,939+ account,940+ &ticket_hash,941+ &token_hash,942+ &body.endpoint_id,943+ now,944+ )945+ .map_err(ApiError::internal)?946+ {947+ return Err(ApiError::unauthorized());948+ }949+950+ Ok(Json(serde_json::json!({951+ "machine_credential": token,952+ "endpoint_id": body.endpoint_id,953+ })))954+}955+956+fn verify_endpoint_proof(endpoint_id: &str, proof: &str) -> ApiResult<()> {957+ let endpoint = parse_endpoint_id(endpoint_id)958+ .ok_or_else(|| ApiError::bad_request("endpoint_id is not a 32 byte Ed25519 key"))?;959+ let signature = data_encoding::BASE64URL_NOPAD960+ .decode(proof.as_bytes())961+ .map_err(|_| ApiError::bad_request("endpoint proof is not base64url"))?;962+ let signature: [u8; 64] = signature963+ .try_into()964+ .map_err(|_| ApiError::bad_request("endpoint proof is not a 64 byte signature"))?;965+ let key = ed25519_dalek::VerifyingKey::from_bytes(&endpoint)966+ .map_err(|_| ApiError::bad_request("endpoint_id is not a valid Ed25519 key"))?;967+ use ed25519_dalek::Verifier;968+ key.verify(969+ &devsite_proto::machine_endpoint_proof_message(&endpoint),970+ &ed25519_dalek::Signature::from_bytes(&signature),971+ )972+ .map_err(|_| ApiError::bad_request("endpoint proof did not verify"))973+}974+873975 // -- dashboard ---------------------------------------------------------------874976 875977 async fn profile_settings(
@@ -934,6 +1036,8 @@ async fn list_machine_credentials(
9341036 "name": credential.name,9351037 "created_at": credential.created_at,9361038 "last_used_at": credential.last_used_at,1039+ "endpoint_id": credential.endpoint_id,1040+ "enrolled_at": credential.enrolled_at,9371041 })).collect::<Vec<_>>()9381042 })))9391043 }
@@ -946,7 +1050,7 @@ async fn create_machine_credential(
9461050 let account = require_browser_account(&state, &headers)?;9471051 check_rate(&state, account, RateClass::Credential)?;9481052 let name = validate_machine_name(&body.name)?;949- let token = auth::generate_machine_token();1053+ let ticket = auth::generate_machine_ticket();9501054 let id = auth::generate_machine_credential_id();9511055 let now = now_secs();9521056
@@ -960,7 +1064,7 @@ async fn create_machine_credential(
9601064 "an account may have at most {MAX_MACHINE_CREDENTIALS} active machine credentials"9611065 )));9621066 }963- db.create_machine_credential(&id, account, name, &auth::hash_token(&token), now)1067+ db.create_machine_credential(&id, account, name, &auth::hash_token(&ticket), now)9641068 .map_err(ApiError::internal)?;9651069 Ok((9661070 StatusCode::CREATED,
@@ -971,7 +1075,7 @@ async fn create_machine_credential(
9711075 "created_at": now,9721076 "last_used_at": null,9731077 },974- "token": token,1078+ "ticket": ticket,9751079 })),9761080 ))9771081 }
@@ -1417,6 +1521,23 @@ mod tests {
14171521 }14181522 }14191523 1524+ #[test]1525+ fn endpoint_proof_requires_the_matching_private_key() {1526+ use ed25519_dalek::{Signer, SigningKey};1527+1528+ let key = SigningKey::from_bytes(&[7; 32]);1529+ let endpoint = key.verifying_key().to_bytes();1530+ let endpoint_id = data_encoding::HEXLOWER.encode(&endpoint);1531+ let signature = key.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));1532+ let proof = data_encoding::BASE64URL_NOPAD.encode(&signature.to_bytes());1533+ assert!(verify_endpoint_proof(&endpoint_id, &proof).is_ok());1534+1535+ let attacker = SigningKey::from_bytes(&[8; 32]);1536+ let forged = attacker.sign(&devsite_proto::machine_endpoint_proof_message(&endpoint));1537+ let forged = data_encoding::BASE64URL_NOPAD.encode(&forged.to_bytes());1538+ assert!(verify_endpoint_proof(&endpoint_id, &forged).is_err());1539+ }1540+14201541 #[test]14211542 fn bootstrap_and_session_tokens_have_distinct_bounded_shapes() {14221543 let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);
M crates/devsite-server/src/auth.rs +7 -0
M crates/devsite-server/src/db.rs +183 -12
@@ -131,6 +131,13 @@ const MIGRATIONS: &[&str] = &[
131131 expires_at INTEGER NOT NULL132132 );133133 CREATE INDEX tunnel_sessions_expiry ON tunnel_sessions(expires_at);",134+ // A browser-created machine secret is now an enrollment ticket, not the135+ // long-lived credential itself. Enrollment binds the row to the daemon's136+ // Ed25519 identity, rotates the hash, and records when that happened.137+ "ALTER TABLE machine_credentials ADD COLUMN endpoint_id TEXT;138+ ALTER TABLE machine_credentials ADD COLUMN enrolled_at INTEGER;139+ CREATE UNIQUE INDEX machine_credentials_endpoint140+ ON machine_credentials(endpoint_id) WHERE endpoint_id IS NOT NULL;",134141 ];135142 136143 fn migrate(conn: &Connection) -> Result<()> {
@@ -172,6 +179,14 @@ pub struct MachineCredential {
172179 pub name: String,173180 pub created_at: u64,174181 pub last_used_at: Option<u64>,182+ pub endpoint_id: Option<String>,183+ pub enrolled_at: Option<u64>,184+}185+186+#[derive(Debug, Clone)]187+pub struct MachineAuthentication {188+ pub account: Account,189+ pub endpoint_id: String,175190 }176191 177192 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -427,16 +442,21 @@ impl Db {
427442 428443 /// Resolve an active machine credential. Last-used writes are coalesced to429444 /// at most once per hour so ordinary CLI and daemon traffic stays read-mostly.430- pub fn machine_account(&self, token_hash: &str, now: u64) -> Result<Option<Account>> {431- let id: Option<String> = self445+ pub fn machine_authentication(446+ &self,447+ token_hash: &str,448+ now: u64,449+ ) -> Result<Option<MachineAuthentication>> {450+ let row: Option<(String, String)> = self432451 .conn433452 .query_row(434- "SELECT account_id FROM machine_credentials WHERE token_hash = ?1",453+ "SELECT account_id, endpoint_id FROM machine_credentials454+ WHERE token_hash = ?1 AND endpoint_id IS NOT NULL",435455 params![token_hash],436- |row| row.get(0),456+ |row| Ok((row.get(0)?, row.get(1)?)),437457 )438458 .optional()?;439- let Some(id) = id else {459+ let Some((account_id, endpoint_id)) = row else {440460 return Ok(None);441461 };442462
@@ -446,12 +466,59 @@ impl Db {
446466 AND (last_used_at IS NULL OR last_used_at < ?3)",447467 params![now as i64, token_hash, now.saturating_sub(60 * 60) as i64],448468 )?;449- self.account_by_id(AccountId::from_str(&id)?)469+ let account = self470+ .account_by_id(AccountId::from_str(&account_id)?)?471+ .context("machine credential refers to a missing account")?;472+ Ok(Some(MachineAuthentication {473+ account,474+ endpoint_id,475+ }))476+ }477+478+ /// Resolve an unconsumed enrollment ticket without making it a generally479+ /// usable account credential.480+ pub fn machine_enrollment(&self, token_hash: &str) -> Result<Option<(String, AccountId)>> {481+ self.conn482+ .query_row(483+ "SELECT id, account_id FROM machine_credentials484+ WHERE token_hash = ?1 AND endpoint_id IS NULL",485+ params![token_hash],486+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),487+ )488+ .optional()?489+ .map(|(id, account)| Ok((id, AccountId::from_str(&account)?)))490+ .transpose()491+ }492+493+ /// Consume the bootstrap ticket by rotating its hash and permanently binding494+ /// this credential to one daemon public key.495+ pub fn enroll_machine(496+ &self,497+ credential_id: &str,498+ account: AccountId,499+ old_token_hash: &str,500+ new_token_hash: &str,501+ endpoint_id: &str,502+ now: u64,503+ ) -> Result<bool> {504+ Ok(self.conn.execute(505+ "UPDATE machine_credentials506+ SET token_hash = ?1, endpoint_id = ?2, enrolled_at = ?3, last_used_at = ?3507+ WHERE id = ?4 AND account_id = ?5 AND token_hash = ?6 AND endpoint_id IS NULL",508+ params![509+ new_token_hash,510+ endpoint_id,511+ now as i64,512+ credential_id,513+ account.to_string(),514+ old_token_hash515+ ],516+ )? > 0)450517 }451518 452519 pub fn machine_credentials(&self, account: AccountId) -> Result<Vec<MachineCredential>> {453520 let mut stmt = self.conn.prepare(454- "SELECT id, name, created_at, last_used_at521+ "SELECT id, name, created_at, last_used_at, endpoint_id, enrolled_at455522 FROM machine_credentials WHERE account_id = ?1 ORDER BY created_at DESC",456523 )?;457524 let rows = stmt
@@ -461,6 +528,8 @@ impl Db {
461528 name: row.get(1)?,462529 created_at: row.get::<_, i64>(2)? as u64,463530 last_used_at: row.get::<_, Option<i64>>(3)?.map(|n| n as u64),531+ endpoint_id: row.get(4)?,532+ enrolled_at: row.get::<_, Option<i64>>(5)?.map(|n| n as u64),464533 })465534 })?466535 .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -484,6 +553,16 @@ impl Db {
484553 id: &str,485554 _now: u64,486555 ) -> Result<bool> {556+ // If this credential owns the account's currently registered endpoint,557+ // stop issuing capabilities to it before removing the credential itself.558+ self.conn.execute(559+ "DELETE FROM daemons560+ WHERE account_id = ?1 AND endpoint_id = (561+ SELECT endpoint_id FROM machine_credentials562+ WHERE id = ?2 AND account_id = ?1563+ )",564+ params![account.to_string(), id],565+ )?;487566 let changed = self.conn.execute(488567 "DELETE FROM machine_credentials WHERE id = ?1 AND account_id = ?2",489568 params![id, account.to_string()],
@@ -998,6 +1077,34 @@ impl Db {
9981077 .optional()9991078 .map_err(Into::into)10001079 }1080+1081+ /// Current viewer/resource pairs for every service owned by this account.1082+ /// Daemons refresh this bounded snapshot to revoke already-open streams.1083+ pub fn service_authorizations(&self, owner: AccountId) -> Result<Vec<(AccountId, ResourceId)>> {1084+ let mut stmt = self.conn.prepare(1085+ "SELECT r.owner_id, r.id1086+ FROM resources r1087+ WHERE r.owner_id = ?1 AND r.kind = 'service'1088+ UNION ALL1089+ SELECT s.viewer_id, r.id1090+ FROM resources r1091+ JOIN shares s ON s.resource_id = r.id1092+ WHERE r.owner_id = ?1 AND r.kind = 'service' AND r.visibility = 'shared'",1093+ )?;1094+ let rows = stmt1095+ .query_map(params![owner.to_string()], |row| {1096+ Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))1097+ })?1098+ .collect::<rusqlite::Result<Vec<_>>>()?;1099+ rows.into_iter()1100+ .map(|(viewer, resource)| {1101+ Ok((1102+ AccountId::from_str(&viewer)?,1103+ ResourceId::from_str(&resource)?,1104+ ))1105+ })1106+ .collect()1107+ }10011108 }10021109 10031110 fn row_to_resource(row: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Resource>> {
@@ -1351,6 +1458,33 @@ mod tests {
13511458 );13521459 }13531460 1461+ #[test]1462+ fn daemon_authorization_snapshot_tracks_share_revocation() {1463+ let (mut db, alice, bob) = seeded();1464+ let service = db1465+ .create_resource(1466+ alice.id,1467+ "Agent",1468+ ResourceKind::Service,1469+ Visibility::Shared,1470+ None,1471+ None,1472+ 0,1473+ )1474+ .unwrap();1475+ db.set_shares(service, &[bob.id]).unwrap();1476+ assert!(db.accept_share(bob.id, service).unwrap());1477+1478+ let allowed = db.service_authorizations(alice.id).unwrap();1479+ assert!(allowed.contains(&(alice.id, service)));1480+ assert!(allowed.contains(&(bob.id, service)));1481+1482+ db.set_shares(service, &[]).unwrap();1483+ let allowed = db.service_authorizations(alice.id).unwrap();1484+ assert!(allowed.contains(&(alice.id, service)));1485+ assert!(!allowed.contains(&(bob.id, service)));1486+ }1487+13541488 #[test]13551489 fn owners_do_not_see_their_own_resources_as_shared_with_them() {13561490 let (mut db, alice, bob) = seeded();
@@ -1632,25 +1766,62 @@ mod tests {
16321766 }16331767 16341768 #[test]1635- fn machine_credentials_are_named_resolvable_and_revocable() {1769+ fn machine_tickets_enroll_once_and_become_endpoint_bound_credentials() {16361770 let (db, alice, _) = seeded();1637- db.create_machine_credential("machine_one", alice.id, "Laptop", "hash", 10)1771+ db.create_machine_credential("machine_one", alice.id, "Laptop", "ticket-hash", 10)16381772 .unwrap();16391773 16401774 assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 1);1775+ assert!(db1776+ .machine_authentication("ticket-hash", 20)1777+ .unwrap()1778+ .is_none());16411779 assert_eq!(1642- db.machine_account("hash", 20).unwrap().unwrap().id,1643- alice.id1780+ db.machine_enrollment("ticket-hash").unwrap(),1781+ Some(("machine_one".to_string(), alice.id))16441782 );1783+ assert!(db1784+ .enroll_machine(1785+ "machine_one",1786+ alice.id,1787+ "ticket-hash",1788+ "credential-hash",1789+ "endpoint",1790+ 20,1791+ )1792+ .unwrap());1793+ assert!(!db1794+ .enroll_machine(1795+ "machine_one",1796+ alice.id,1797+ "ticket-hash",1798+ "other-hash",1799+ "attacker",1800+ 21,1801+ )1802+ .unwrap());1803+ let authenticated = db1804+ .machine_authentication("credential-hash", 30)1805+ .unwrap()1806+ .unwrap();1807+ assert_eq!(authenticated.account.id, alice.id);1808+ assert_eq!(authenticated.endpoint_id, "endpoint");16451809 let credentials = db.machine_credentials(alice.id).unwrap();16461810 assert_eq!(credentials.len(), 1);16471811 assert_eq!(credentials[0].name, "Laptop");1812+ assert_eq!(credentials[0].endpoint_id.as_deref(), Some("endpoint"));1813+ assert_eq!(credentials[0].enrolled_at, Some(20));16481814 assert_eq!(credentials[0].last_used_at, Some(20));1815+ db.register_daemon(alice.id, "endpoint").unwrap();16491816 16501817 assert!(db16511818 .revoke_machine_credential(alice.id, "machine_one", 30)16521819 .unwrap());1653- assert!(db.machine_account("hash", 40).unwrap().is_none());1820+ assert!(db.daemon_endpoint(alice.id).unwrap().is_none());1821+ assert!(db1822+ .machine_authentication("credential-hash", 40)1823+ .unwrap()1824+ .is_none());16541825 assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 0);16551826 assert!(db.machine_credentials(alice.id).unwrap().is_empty());16561827 }
M docs/json-output.md +1 -1
M web/app.js +5 -5
@@ -591,8 +591,8 @@ function renderSignedOut() {
591591 <p>Store useful URLs, shared services, and temporary access</p>592592 <article>593593 <h3>Machine Enrollment</h3>594- <pre><code>devsite login dsm_***</code></pre>595- <blockquote>Use a revocable machine credential from your dashboard</blockquote>594+ <pre><code>devsite login dmt_***</code></pre>595+ <blockquote>A single-use ticket creates a revocable, identity-bound machine credential</blockquote>596596 </article>597597 <article>598598 <h3>Add Links to your site</h3>
@@ -863,7 +863,7 @@ async function showDashboard(newCredential = null) {
863863 <article>864864 <details${newCredential ? ' open' : ''}>865865 <summary><strong>Machine credentials</strong></summary>866- <p>Each credential remains valid until you revoke it. Its secret is shown once.</p>866+ <p>Create a single-use ticket that enrolls one Ed25519 machine identity.</p>867867 <form id="credential-form">868868 <fieldset role="group">869869 <input id="credential-name" name="name" maxlength="60"
@@ -916,7 +916,7 @@ async function showDashboard(newCredential = null) {
916916 renderSecretCommand(917917 $('new-machine-token').querySelector('.secret-command-slot'),918918 'login',919- newCredential.token,919+ newCredential.ticket,920920 );921921 }922922
@@ -951,7 +951,7 @@ async function showDashboard(newCredential = null) {
951951 method: 'POST',952952 body: JSON.stringify({ name: $('credential-name').value }),953953 });954- await showDashboard({ name: created.credential.name, token: created.token });954+ await showDashboard({ name: created.credential.name, ticket: created.ticket });955955 } catch (err) {956956 button.removeAttribute('aria-busy');957957 note.innerHTML = `<span class="error">${esc(err.message)}</span>`;