chiark / gitweb /
sshkeys: module for trackng auth keys, still unfinished
authorIan Jackson <ijackson@chiark.greenend.org.uk>
Sat, 29 May 2021 21:56:58 +0000 (22:56 +0100)
committerIan Jackson <ijackson@chiark.greenend.org.uk>
Sun, 30 May 2021 12:44:07 +0000 (13:44 +0100)
Signed-off-by: Ian Jackson <ijackson@chiark.greenend.org.uk>
daemon/cmdlistener.rs
src/accounts.rs
src/commands.rs
src/config.rs
src/lib.rs
src/prelude.rs
src/sshkeys.rs [new file with mode: 0644]

index 5a953c51c3df0ff95fe6c03905ff5960ccb9f549..7395a695168855e70eb95538c1e824636c81579a 100644 (file)
@@ -164,8 +164,9 @@ fn execute_and_respond<W>(cs: &mut CommandStreamData, cmd: MgmtCommand,
       let layout = layout.unwrap_or_default();
       let record = AccountRecord {
         account, nick, access,
-        timezone: timezone.unwrap_or_default(),
         layout,
+        timezone: timezone.unwrap_or_default(),
+        ssh_keys: default(),
       };
       ag.insert_entry(record, auth)?;
       Fine
index 9c12ca23e7ae99a1e75404c63d28d907a5efdbf1..cf7f556023a596cc54a58da8b47493cc3b2fccdc 100644 (file)
@@ -46,6 +46,7 @@ pub struct AccountsGuard(MutexGuard<'static, Option<Accounts>>);
 pub struct Accounts {
   names: HashMap<Arc<AccountName>, AccountId>,
   records: DenseSlotMap<AccountId, AccountRecord>,
+  #[serde(default)] ssh_keys: sshkeys::Global,
 }
 
 #[derive(Serialize,Deserialize,Debug)]
@@ -55,6 +56,14 @@ pub struct AccountRecord {
   pub timezone: String,
   pub access: AccessRecord,
   pub layout: PresentationLayout,
+  #[serde(default)] // not relevant other than in entry for scope itself
+  pub ssh_keys: sshkeys::PerScope,
+}
+
+#[derive(Serialize,Deserialize,Debug)]
+pub struct AccountSshKey {
+  pub id: sshkeys::Id,
+  pub comment: sshkeys::Comment,
 }
 
 #[derive(Clone,Debug,Hash,Ord,PartialOrd,Eq,PartialEq)]
@@ -425,6 +434,10 @@ impl AccountsGuard {
     f.close()?;
     fs::rename(&tmp, &main)?;
   }
+
+  pub fn ssh_keys_mut(&mut self) -> &mut sshkeys::Global {
+    &mut self.0.get_or_insert_with(default).ssh_keys
+  }
 }
 
 //---------- load/save ----------
index 056ce1a203073056703fbb5e574fef696f227883..c3cc997ae0af804f4fb05ecb836aefc28fc7df28 100644 (file)
@@ -63,6 +63,10 @@ pub enum MgmtCommand {
 
 //---------- Accounts file ----------
 
+#[derive(Debug,Clone,Hash,Eq,PartialEq)]
+#[derive(Serialize,Deserialize)]
+pub struct SshFingerprint(pub String);
+
 #[derive(Debug,Serialize,Deserialize)]
 pub struct AccountDetails {
   pub account: AccountName,
@@ -254,6 +258,9 @@ pub enum MgmtError {
   #[error("game contains invalid UTF-8")]            GameSpecInvalidData,
   #[error("idle timeout waiting for mgmt command")]  IdleTimeout,
   #[error("upload took too long (timed out)")]       UploadTimeout,
+  #[error("ssh key not found")]                      SshKeyNotFound,
+  #[error("ssh key id default, ie invalid")]         InvalidSshKeyId,
+  #[error("ssh key invalid: {0}")] InvalidSshKey(#[from] sshkeys::KeyError),
 }
 
 impl From<InternalError> for MgmtError {
index 952c022c44c0c657ccc9d466260477234f0b9306..29784ca22a432b1dc012ed819fae19ecc0480eff 100644 (file)
@@ -38,6 +38,9 @@ pub struct ServerConfigSpec {
   pub shapelibs: Option<Vec<shapelib::Config1>>,
   pub specs_dir: Option<String>,
   pub sendmail: Option<String>,
+  pub ssh_proxy_bin: Option<String>,
+  pub authorized_keys: Option<String>,
+  pub authorized_keys_include: Option<String>,
   pub debug_js_inject_file: Option<String>,
   #[serde(default)] pub fake_rng: FakeRngSpec,
   /// Disable this for local testing only.  See LICENCE.
@@ -68,6 +71,9 @@ pub struct ServerConfig {
   pub shapelibs: Vec<shapelib::Config1>,
   pub specs_dir: String,
   pub sendmail: String,
+  pub ssh_proxy_bin: String,
+  pub authorized_keys: String,
+  pub authorized_keys_include: String,
   pub debug_js_inject: Arc<String>,
   pub check_bundled_sources: bool,
   pub game_rng: RngWrap,
@@ -120,9 +126,11 @@ impl ServerConfigSpec {
       template_dir, specs_dir, nwtemplate_dir, wasm_dir, libexec_dir, usvg_bin,
       log, bundled_sources, shapelibs, sendmail,
       debug_js_inject_file, check_bundled_sources, fake_rng,
+      ssh_proxy_bin, authorized_keys, authorized_keys_include,
     } = self;
 
     let game_rng = fake_rng.make_game_rng();
+    let home = || env::var("HOME").context("HOME");
 
     let prctx;
     if let Some(ref cd) = change_directory {
@@ -153,6 +161,16 @@ impl ServerConfigSpec {
       specd.unwrap_or_else(|| format!("{}/{}", &libexec_dir, leaf))
     };
     let usvg_bin        = in_libexec(usvg_bin,     "usvg"              );
+    let ssh_proxy_bin   = in_libexec(ssh_proxy_bin,"otter-ssh-proxy"   );
+
+    let authorized_keys = if let Some(ak) = authorized_keys { ak } else {
+      let home = home().context("for authorized_keys")?;
+      // we deliberately don't create the ~/.ssh dir
+      format!("{}/.ssh/authorized_keys", home)
+    };
+    let authorized_keys_include = authorized_keys_include.unwrap_or_else(
+      || format!("{}.static", authorized_keys)
+    );
 
     let shapelibs = shapelibs.unwrap_or_else(||{
       let glob = defpath(None, DEFAULT_LIBRARY_GLOB);
@@ -235,6 +253,7 @@ impl ServerConfigSpec {
       template_dir, specs_dir, nwtemplate_dir, wasm_dir, libexec_dir,
       bundled_sources, shapelibs, sendmail, usvg_bin,
       debug_js_inject, check_bundled_sources, game_rng, prctx,
+      ssh_proxy_bin, authorized_keys, authorized_keys_include,
     };
     trace_dbg!("config resolved", &server);
     Ok(WholeServerConfig {
index ef9209ffd6136efaf97690e40ac5b1fb5848c46d..50123225cfab071990f4db0828f860646a10f9a8 100644 (file)
@@ -35,6 +35,7 @@ pub mod pieces;
 pub mod progress;
 pub mod shapelib;
 pub mod spec;
+pub mod sshkeys;
 pub mod sse;
 pub mod termprogress;
 pub mod timedfd;
index fbb3c86bcb0173e513c53da9a53d8a2cbec52337..fe74757133553dba93504aa0deb10f58174ea9be 100644 (file)
@@ -36,6 +36,7 @@ pub use std::num::{NonZeroUsize, TryFromIntError, Wrapping};
 pub use std::os::linux::fs::MetadataExt; // todo why linux for st_mode??
 pub use std::os::unix;
 pub use std::os::unix::ffi::OsStrExt;
+pub use std::os::unix::fs::OpenOptionsExt;
 pub use std::os::unix::io::{AsRawFd, IntoRawFd, RawFd};
 pub use std::os::unix::net::UnixStream;
 pub use std::os::unix::process::{CommandExt, ExitStatusExt};
@@ -161,6 +162,7 @@ pub use crate::slotmap_slot_idx::*;
 pub use crate::spec::*;
 pub use crate::spec::piece_specs::{FaceColourSpecs, SimpleCommon};
 pub use crate::sse;
+pub use crate::sshkeys;
 pub use crate::toml_de;
 pub use crate::timedfd::*;
 pub use crate::termprogress;
diff --git a/src/sshkeys.rs b/src/sshkeys.rs
new file mode 100644 (file)
index 0000000..b72e152
--- /dev/null
@@ -0,0 +1,404 @@
+// Copyright 2020-2021 Ian Jackson and contributors to Otter
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// There is NO WARRANTY.
+
+use crate::prelude::*;
+
+visible_slotmap_key!{ Id(b'k') }
+
+static RESTRICTIONS: &str =
+  concat!("restrict,no-agent-forwarding,no-port-forwarding,",
+          "no-pty,no-user-rc,no-X11-forwarding");
+
+static MAGIC_BANNER: &str = 
+  "# WARNING - AUTOMATICALLY GENERAED FILE - DO NOT EDIT\n";
+
+#[derive(Copy,Clone,Serialize,Deserialize,Eq,PartialEq)]
+#[serde(transparent)]
+// This will detecte if the slotmap in Accounts gets rewound, without
+// updating the authorzed keys.  That might reuse Id values but it
+// can't reuse Nonces.
+pub struct Nonce([u8; 32]);
+
+// States of a key wrt a particular scope:
+//
+//                   PerScope       in authorized_keys     leftover key
+//                   core    file           a.k._dirty     refcount==0
+//                                       if us only        core   file
+//
+//    ABSENT         -       -         maybe[1] -          -      -
+//    GARBAGE        -       -         maybe[1] -          -      y [2]
+// **undesriable**   -       -         -                   y      -
+//  **illegal**      -       y
+//    UNSAVED        y       -         maybe    true       n/a    n/a
+//    BROKEN         y       y         maybe    true       n/a    n/a
+//    PRESENT        y       y         y        -          n/a    n/a
+//
+// [1] garbage in the authorised_keys is got rid of next time we
+//     write it, so it does not persist indefinitely.
+// [2] garbage in Global is deleted when we do the key hashing (which
+//     iterates over all keys), which will happen before we add any
+//     key.
+
+#[derive(Debug,Clone,Serialize,Deserialize,Default)]
+pub struct Global {
+  keys: DenseSlotMap<Id, Key>,
+  authkeys_dirty: bool,
+  #[serde(skip)] fps: Option<FingerprintMap>,
+}
+type FingerprintMap = HashMap<Arc<Fingerprint>, Id>;
+
+#[derive(Debug,Clone,Serialize,Deserialize)]
+pub struct Key {
+  refcount: usize,
+  data: PubData,
+  nonce: Nonce,
+  #[serde(skip)] fp: Option<Result<Arc<Fingerprint>, KeyError>>,
+}
+
+#[derive(Debug,Clone,Serialize,Deserialize,Default)]
+pub struct PerScope {
+  authorised: Vec<Option<ScopeKey>>,
+}
+#[derive(Debug,Clone,Serialize,Deserialize)]
+pub struct ScopeKey {
+  id: Id, // owns a refcount
+  comment: Comment,
+}
+
+mod veneer {
+  // openssh_keys's API is a little odd.  We make our own mini-API.
+  use crate::prelude::*;
+  extern crate openssh_keys;
+  use openssh_keys::errors::OpenSSHKeyError;
+
+  // A line in nssh authorized keys firmat.  Might have comment or
+  // options.  Might or might not have a newline.  String inside
+  // this newtype has not necessarily been syntax checked.
+  #[derive(Debug,Clone,Serialize,Deserialize)]
+  #[serde(transparent)]
+  pub struct AuthkeysLine(pub String);
+
+  // In nssh authorized keys firmat.  No options, no comment.
+  #[derive(Debug,Clone,Serialize,Deserialize)]
+  #[serde(transparent)]
+  pub struct PubData(String);
+
+  #[derive(Debug,Clone,Serialize,Deserialize)]
+  #[serde(transparent)]
+  pub struct Comment(String /* must not contain newline/cr */);
+
+  // Not Serialize,Deserialize, so we can change the hash, etc.
+  #[derive(Debug,Clone,Hash,Eq,PartialEq,Ord,PartialOrd)]
+  pub struct Fingerprint(String);
+
+  #[derive(Error,Debug,Clone,Serialize,Deserialize)]
+  pub enum KeyError {
+    #[error("bad key data: {0}")]                        BadData(String),
+    #[error("failed to save key data, possibly broken")] Dirty,
+  }
+
+  impl From<OpenSSHKeyError> for KeyError {
+    fn from(e: OpenSSHKeyError) -> Self { KeyError::BadData(e.to_string()) }
+  }
+
+  impl Display for PubData {
+    #[throws(fmt::Error)]
+    fn fmt(&self, f: &mut fmt::Formatter) { write!(f, "{}", &self.0)? }
+  }
+
+  impl AuthkeysLine {
+    #[throws(KeyError)]
+    pub fn parse(&self) -> (PubData, Comment) {
+      let openssh_keys::PublicKey {
+        options:_, data, comment
+      } = self.0.parse()?;
+      let data = openssh_keys::PublicKey {
+        data,
+        options: None,
+        comment: None,
+      };
+      (PubData(data.to_string()), Comment(comment.unwrap_or_default()))
+    }
+  }
+
+  impl PubData {
+    #[throws(KeyError)]
+    pub fn fingerprint(&self) -> Fingerprint {
+      let k: openssh_keys::PublicKey = self.0.parse()?;
+      Fingerprint(k.fingerprint())
+    }
+  }
+
+  impl Display for Fingerprint {
+    #[throws(fmt::Error)]
+    fn fmt(&self, f: &mut Formatter) { write!(f, "{}", self.0)? }
+  }
+
+}
+pub use veneer::*;
+
+format_by_fmt_hex!{Display, for Nonce, .0}
+impl Debug for Nonce {
+  #[throws(fmt::Error)]
+  fn fmt(&self, f: &mut Formatter) {
+    write!(f,"Nonce[")?;
+    fmt_hex(f, &self.0)?;
+    write!(f,"]")?;
+  }
+}
+
+impl PerScope {
+  pub fn check(&self, gl: &Global, id: Id, nonce: Nonce)
+               -> Option<Authorisation<AccountScope>> {
+    for sk in &self.authorised {
+      if_chain!{
+        if let Some(sk) = sk;
+        if sk.id == id;
+        if let Some(key) = gl.keys.get(sk.id);
+        if key.nonce == nonce;
+        then { return Some(Authorisation::authorise_any()) }
+      }
+    }
+    None
+  }
+}
+
+#[derive(Debug,Clone,Serialize,Deserialize)]
+pub struct MgmtKeyReport {
+  id: Id,
+  data: PubData,
+  comment: Comment,
+  problem: Option<KeyError>,
+}
+
+impl PerScope {
+  pub fn keys(&self, gl: &Global) -> Vec<MgmtKeyReport> {
+    let dirty_error =
+      if gl.authkeys_dirty { Some(KeyError::Dirty) }
+      else { None };
+    self.authorised.iter().filter_map(|sk| Some({
+      let sk = sk.as_ref()?;
+      let key = gl.keys.get(sk.id)?;
+      let problem = if let Some(Err(ref e)) = key.fp { Some(e) }
+                    else { dirty_error.as_ref() };
+      MgmtKeyReport {
+        id:      sk.id,
+        data:    key.data.clone(),
+        comment: sk.comment.clone(),
+        problem: problem.cloned(),
+      }
+    }))
+      .collect()
+  }
+
+  // not a good idea to speicfy a problem, but "whatever"
+  #[throws(ME)]
+  pub fn add_key(&mut self, accounts: &mut AccountsGuard,
+                 new_akl: AuthkeysLine) -> (usize, Id) {
+    let (data, comment) = new_akl.parse()?;
+    let fp = data.fingerprint().map_err(KeyError::from)?;
+    let gl = &mut accounts.ssh_keys_mut();
+    let fp = Arc::new(fp);
+    let _ = gl.fps();
+    let fpe = gl.fps.as_mut().unwrap().entry(fp.clone());
+    // ABSENT
+    let id = {
+      let keys = &mut gl.keys;
+      *fpe.or_insert_with(||{
+        keys.insert(Key {
+          data,
+          refcount: 0,
+          nonce: Nonce(thread_rng().gen()),
+          fp: Some(Ok(fp.clone())),
+        })
+      })
+    };
+    // **undesirable**
+    let key = gl.keys.get_mut(id)
+      .ok_or_else(|| internal_error_bydebug(&(id, &fp)))?;
+    // GARBAGE
+    key.refcount += 1;
+    let new_sk = Some(ScopeKey { id, comment });
+    let index =
+      if let Some((index,_)) = self.authorised.iter()
+          .find_position(|sk| sk.is_none())
+      {
+        self.authorised[index] = new_sk;
+        index
+      } else {
+        let index = self.authorised.len();
+        self.authorised.push(new_sk);
+        index
+      };
+    gl.authkeys_dirty = true;
+    // UNSAVED
+    accounts.save_accounts_now()?;
+    // BROKEN
+    accounts.ssh_keys_mut().rewrite_authorized_keys()?;
+    // PRESENT
+    (index, id)
+  }
+
+  #[throws(ME)]
+  pub fn remove_key(&mut self, accounts: &mut AccountsGuard,
+                    index: usize, id: Id) {
+    let gl = &mut accounts.ssh_keys_mut();
+    if id == default() {
+      throw!(ME::InvalidSshKeyId);
+    }
+    match self.authorised.get(index) {
+      Some(&Some(ScopeKey{id:tid,..})) if tid == id => { },
+      _ => throw!(ME::SshKeyNotFound), /* [ABSEMT..GARBAGE] */
+    }
+    let key = gl.keys.get_mut(id).ok_or_else(|| internal_logic_error(
+      format!("corrupted accounts db: key id {} missing", id)))?;
+
+    // [UNSAVED..PRESENT]
+    
+    key.refcount -= 1;
+    let previously = mem::take(&mut self.authorised[index]);
+    // Now **illegal**, briefly, don't leave it like this!  No-one can
+    // observe the illegal state since we have the accounts lock.  If
+    // we abort, the in-core version vanishes, leaving a legal state.
+    let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
+      || accounts.save_accounts_now()
+    ));
+    let gl = &mut accounts.ssh_keys_mut();
+    let key = gl.keys.get_mut(id).expect("aargh!");
+
+    if ! matches!(r, Ok(Ok(()))) {
+      key.refcount += 1;
+      self.authorised[index] = previously;
+      // [UNSAVED..PRESENT]
+      match r {
+        Err(payload) => std::panic::resume_unwind(payload),
+        Ok(Err(e)) => throw!(e),
+        Ok(Ok(())) => panic!(), // handled that earlier
+      }
+    }
+    // [ABSENT..GARBAGE]
+
+    if key.refcount == 0 {
+      let key = gl.keys.remove(id).unwrap();
+      if let Some(Ok(ref fp)) = key.fp {
+        gl.fps().remove(fp);
+      }
+    }
+    // ABSENT
+  }
+}
+
+impl Global {
+  fn fps(&mut self) -> &mut FingerprintMap {
+    let keys = &mut self.keys;
+    self.fps.get_or_insert_with(||{
+
+      let mut fps = FingerprintMap::default();
+      let mut garbage = vec![];
+
+      for (id, key) in keys.iter_mut() {
+        if key.refcount == 0 { garbage.push(id); continue; }
+
+        if_let!{
+          Ok(fp) = {
+            let data = &key.data;
+            key.fp.get_or_insert_with(
+              || Ok(Arc::new(data.fingerprint()?))
+            )
+          };
+          else continue;
+        }
+
+        use hash_map::Entry::*;
+        match fps.entry(fp.clone()) {
+          Vacant(ve) => { ve.insert(id); },
+          Occupied(mut oe) => {
+            error!("ssh key fingerprint collision! \
+                    fp={} newid={} oldid={} newdata={:?}",
+                   &fp, id, oe.get(), &key.data);
+            oe.insert(Id::default());
+          },
+        }
+      }
+
+      for id in garbage { keys.remove(id); }
+
+      fps
+
+    })
+  }
+
+  #[throws(InternalError)]
+  fn write_keys(&self, w: &mut BufWriter<File>) {
+    for (id, key) in &self.keys {
+      let fp = match key.fp { Some(Ok(ref fp)) => fp, _ => continue };
+      if key.refcount == 0 { continue }
+      writeln!(w, r#"{},command="{} --ssh-proxy {} {}" {} {}:{}"#, 
+               RESTRICTIONS,
+               &config().ssh_proxy_bin, id, key.nonce,
+               &key.data,
+               key.refcount, &fp)
+        .context("write new auth keys")?;
+    }
+  }
+
+  // Caller should make sure accounts are saved first, to avoid
+  // getting the authkeys_dirty bit wrong.
+  #[throws(InternalError)]
+  fn rewrite_authorized_keys(&mut self) {
+    let config = config();
+    let path = &config.authorized_keys;
+    let tmp = format!("{}.tmp", &path);
+
+    (||{
+      let f = File::open(path).context("open")?;
+      let l = BufReader::new(f).lines().next()
+        .ok_or_else(|| anyhow!("no first line!"))?
+        .context("read first line")?;
+      if l != MAGIC_BANNER {
+        throw!(anyhow!(
+          "first line is not as expected (manually written/edited?)"
+        ));
+      }
+      Ok::<_,AE>(())
+    })()
+      .with_context(|| path.clone())
+      .context("check authorized_keys magic/banner")?;
+
+    let mut f = fs::OpenOptions::new()
+      .write(true).truncate(true).create(true)
+      .mode(0o644)
+      .open(&tmp)
+      .with_context(|| tmp.clone()).context("open new auth keys file")?;
+
+    let include = &config.authorized_keys_include;
+
+    (||{
+      let mut f = BufWriter::new(&mut f);
+      write!(f, "{}", MAGIC_BANNER)?;
+      writeln!(f, "# YOU MAY EDIT {:?} INSTEAD - THAT IS INCLUDED HERE",
+               include)?;
+      f.flush()?;
+      Ok::<_,io::Error>(())
+    })().with_context(|| tmp.clone()).context("write header")?;
+
+    if let Some(mut sf) = match File::open(include) {
+      Ok(y) => Some(y),
+      Err(e) if e.kind() == ErrorKind::NotFound => None,
+      Err(e) => throw!(AE::from(e).context(include.clone())
+                       .context("open static auth keys")),
+    } {
+      io::copy(&mut sf, &mut f).context("copy data into new auth keys")?;
+    }
+
+    let mut f = BufWriter::new(f);
+    self.write_keys(&mut f)?;
+    f.flush().context("finish writing new auth keys")?;
+
+    fs::rename(&tmp, &path).with_context(|| path.clone())
+      .context("install new auth keys")?;
+
+    self.authkeys_dirty = false;
+  }
+}