diff --git a/Cargo.lock b/Cargo.lock index f96fab44..f3317f47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.3.2" @@ -768,6 +774,8 @@ dependencies = [ name = "radicle" version = "0.2.0" dependencies = [ + "base64", + "byteorder", "crossbeam-channel", "ed25519-compact", "fastrand", @@ -782,12 +790,14 @@ dependencies = [ "quickcheck", "quickcheck_macros", "radicle-git-ext", + "radicle-ssh", "serde", "serde_json", "sha2 0.10.6", "siphasher", "tempfile", "thiserror", + "zeroize", ] [[package]] @@ -830,6 +840,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "radicle-ssh" +version = "0.2.0" +dependencies = [ + "byteorder", + "log", + "thiserror", + "zeroize", +] + [[package]] name = "radicle-std-ext" version = "0.1.0" @@ -1197,3 +1217,9 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zeroize" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" diff --git a/Cargo.toml b/Cargo.toml index 64fb76f9..b452afbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["radicle", "radicle-node", "radicle-tools"] +members = ["radicle", "radicle-node", "radicle-tools", "radicle-ssh"] [patch.crates-io.radicle-git-ext] git = "https://github.com/radicle-dev/radicle-link" diff --git a/radicle-ssh/Cargo.toml b/radicle-ssh/Cargo.toml new file mode 100644 index 00000000..7e54164d --- /dev/null +++ b/radicle-ssh/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "radicle-ssh" +license = "Apache-2.0" +version = "0.2.0" +authors = [ + "Fintan Halpenny ", + "Pierre-Étienne Meunier ", + "Alexis Sellier " +] +edition = "2021" + +[dependencies] +byteorder = "1.4" +log = "0.4" +thiserror = "1.0" +zeroize = "1.5.7" diff --git a/radicle-ssh/LICENSE b/radicle-ssh/LICENSE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/radicle-ssh/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/radicle-ssh/src/agent.rs b/radicle-ssh/src/agent.rs new file mode 100644 index 00000000..fd54bf85 --- /dev/null +++ b/radicle-ssh/src/agent.rs @@ -0,0 +1,15 @@ +/// Write clients for SSH agents. +pub mod client; + +mod msg; + +/// Constraints on how keys can be used. +#[derive(Debug, PartialEq, Eq)] +pub enum Constraint { + /// The key shall disappear from the agent's memory after that many seconds. + KeyLifetime { seconds: u32 }, + /// Signatures need to be confirmed by the agent (for instance using a dialog). + Confirm, + /// Custom constraints + Extensions { name: Vec, details: Vec }, +} diff --git a/radicle-ssh/src/agent/client.rs b/radicle-ssh/src/agent/client.rs new file mode 100644 index 00000000..2ff9f931 --- /dev/null +++ b/radicle-ssh/src/agent/client.rs @@ -0,0 +1,419 @@ +use std::fmt; +use std::io::{Read, Write}; +use std::ops::DerefMut; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; +use log::*; +use thiserror::Error; +use zeroize::Zeroize as _; + +use crate::agent::msg; +use crate::agent::Constraint; +use crate::encoding; +use crate::encoding::{Buffer, Encoding, Reader}; +use crate::key::{Private, Public}; + +/// An ed25519 Signature. +pub type Signature = [u8; 64]; + +#[derive(Debug, Error)] +pub enum Error { + /// Agent protocol error. + #[error("Agent protocol error")] + AgentProtocolError, + #[error("Agent failure")] + AgentFailure, + #[error("Unable to connect to ssh-agent. The environment variable `SSH_AUTH_SOCK` was set, but it points to a nonexistent file or directory.")] + BadAuthSock, + #[error(transparent)] + Encoding(#[from] encoding::Error), + #[error("Environment variable `{0}` not found")] + EnvVar(&'static str), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Private(Box), + #[error(transparent)] + Public(Box), + #[error(transparent)] + Signature(Box), +} + +/// SSH agent client. +pub struct AgentClient { + stream: S, + buf: Buffer, +} + +// https://tools.ietf.org/html/draft-miller-ssh-agent-00#section-4.1 +impl AgentClient { + /// Connect to an SSH agent via the provided stream (on Unix, usually a Unix-domain socket). + pub fn connect(stream: S) -> Self { + AgentClient { + stream, + buf: Vec::new().into(), + } + } +} + +pub trait ClientStream: Sized + Send + Sync { + /// How to read the response from the stream + fn read_response(&mut self, buf: &mut Buffer) -> Result<(), Error>; + + /// How to connect the streaming socket + fn connect_socket

(path: P) -> Result, Error> + where + P: AsRef + Send; + + fn connect_env() -> Result, Error> { + let var = if let Ok(var) = std::env::var("SSH_AUTH_SOCK") { + var + } else { + return Err(Error::EnvVar("SSH_AUTH_SOCK")); + }; + match Self::connect_socket(var) { + Err(Error::Io(io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => { + Err(Error::BadAuthSock) + } + owise => owise, + } + } +} + +impl AgentClient { + /// Send a key to the agent, with a (possibly empty) slice of constraints + /// to apply when using the key to sign. + pub fn add_identity(&mut self, key: &K, constraints: &[Constraint]) -> Result<(), Error> + where + K: Private, + K::Error: std::error::Error + Send + Sync + 'static, + { + self.buf.zeroize(); + self.buf.resize(4, 0); + + if constraints.is_empty() { + self.buf.push(msg::ADD_IDENTITY) + } else { + self.buf.push(msg::ADD_ID_CONSTRAINED) + } + key.write(&mut self.buf) + .map_err(|err| Error::Private(Box::new(err)))?; + + if !constraints.is_empty() { + for cons in constraints { + match *cons { + Constraint::KeyLifetime { seconds } => { + self.buf.push(msg::CONSTRAIN_LIFETIME); + self.buf.deref_mut().write_u32::(seconds)? + } + Constraint::Confirm => self.buf.push(msg::CONSTRAIN_CONFIRM), + Constraint::Extensions { + ref name, + ref details, + } => { + self.buf.push(msg::CONSTRAIN_EXTENSION); + self.buf.extend_ssh_string(name); + self.buf.extend_ssh_string(details); + } + } + } + } + self.buf.write_len(); + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Add a smart card to the agent, with a (possibly empty) set of + /// constraints to apply when signing. + pub fn add_smartcard_key( + &mut self, + id: &str, + pin: &[u8], + constraints: &[Constraint], + ) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + + if constraints.is_empty() { + self.buf.push(msg::ADD_SMARTCARD_KEY) + } else { + self.buf.push(msg::ADD_SMARTCARD_KEY_CONSTRAINED) + } + self.buf.extend_ssh_string(id.as_bytes()); + self.buf.extend_ssh_string(pin); + + if !constraints.is_empty() { + self.buf + .deref_mut() + .write_u32::(constraints.len() as u32)?; + for cons in constraints { + match *cons { + Constraint::KeyLifetime { seconds } => { + self.buf.push(msg::CONSTRAIN_LIFETIME); + self.buf.deref_mut().write_u32::(seconds)?; + } + Constraint::Confirm => self.buf.push(msg::CONSTRAIN_CONFIRM), + Constraint::Extensions { + ref name, + ref details, + } => { + self.buf.push(msg::CONSTRAIN_EXTENSION); + self.buf.extend_ssh_string(name); + self.buf.extend_ssh_string(details); + } + } + } + } + self.buf.write_len(); + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Lock the agent, making it refuse to sign until unlocked. + pub fn lock(&mut self, passphrase: &[u8]) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::LOCK); + self.buf.extend_ssh_string(passphrase); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Unlock the agent, allowing it to sign again. + pub fn unlock(&mut self, passphrase: &[u8]) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::UNLOCK); + self.buf.extend_ssh_string(passphrase); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Ask the agent for a list of the currently registered secret + /// keys. + pub fn request_identities(&mut self) -> Result, Error> + where + K: Public, + K::Error: std::error::Error + Send + Sync + 'static, + { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::REQUEST_IDENTITIES); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + debug!("identities: {:?}", &self.buf[..]); + + let mut keys = Vec::new(); + if self.buf[0] == msg::IDENTITIES_ANSWER { + let mut r = self.buf.reader(1); + let n = r.read_u32()?; + for _ in 0..n { + let key = r.read_string()?; + let _ = r.read_string()?; + let mut r = key.reader(0); + if let Some(pk) = K::read(&mut r).map_err(|err| Error::Public(Box::new(err)))? { + keys.push(pk); + } + } + } + + Ok(keys) + } + + /// Ask the agent to sign the supplied piece of data. + pub fn sign_request(&mut self, public: &K, data: Buffer) -> Result + where + K: Public + fmt::Debug, + { + self.prepare_sign_request(public, &data); + self.stream.read_response(&mut self.buf)?; + + if !self.buf.is_empty() && self.buf[0] == msg::SIGN_RESPONSE { + let mut signature: Signature = [0; 64]; + self.write_signature(&mut signature)?; + + Ok(signature) + } else if self.buf[0] == msg::FAILURE { + Err(Error::AgentFailure) + } else { + Err(Error::AgentProtocolError) + } + } + + fn prepare_sign_request(&mut self, public: &K, data: &[u8]) + where + K: Public + fmt::Debug, + { + // byte SSH_AGENTC_SIGN_REQUEST + // string key blob + // string data + // uint32 flags + + let mut pk = Vec::new().into(); + let n = public.write_blob(&mut pk); + let total = 1 + n + 4 + data.len() + 4; + + debug_assert_eq!(n, pk.len()); + + self.buf.zeroize(); + self.buf + .write_u32::(total as u32) + .expect("Writing to a vector never fails"); + self.buf.push(msg::SIGN_REQUEST); + self.buf.extend_from_slice(&pk); + self.buf.extend_ssh_string(data); + + // Signature flags should be zero for ed25519. + self.buf.write_u32::(0).unwrap(); + } + + fn write_signature(&self, data: &mut [u8]) -> Result<(), Error> { + let mut r = self.buf.reader(1); + let mut resp = r.read_string()?.reader(0); + let _t = resp.read_string()?; + let sig = resp.read_string()?; + + data.copy_from_slice(sig); + + Ok(()) + } + + /// Ask the agent to remove a key from its memory. + pub fn remove_identity(&mut self, public: &K) -> Result<(), Error> + where + K: Public, + { + let mut pk = Vec::new().into(); + let n = public.write_blob(&mut pk); + let total = 1 + n; + + debug_assert_eq!(n, pk.len()); + + self.buf.zeroize(); + self.buf.write_u32::(total as u32)?; + self.buf.push(msg::REMOVE_IDENTITY); + self.buf.extend_from_slice(&pk); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Ask the agent to remove a smartcard from its memory. + pub fn remove_smartcard_key(&mut self, id: &str, pin: &[u8]) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::REMOVE_SMARTCARD_KEY); + self.buf.extend_ssh_string(id.as_bytes()); + self.buf.extend_ssh_string(pin); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Ask the agent to forget all known keys. + pub fn remove_all_identities(&mut self) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::REMOVE_ALL_IDENTITIES); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Send a custom message to the agent. + pub fn extension(&mut self, typ: &[u8], ext: &[u8]) -> Result<(), Error> { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::EXTENSION); + self.buf.extend_ssh_string(typ); + self.buf.extend_ssh_string(ext); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + Ok(()) + } + + /// Ask the agent what extensions about supported extensions. + pub fn query_extension(&mut self, typ: &[u8], mut ext: Buffer) -> Result { + self.buf.zeroize(); + self.buf.resize(4, 0); + self.buf.push(msg::EXTENSION); + self.buf.extend_ssh_string(typ); + self.buf.write_len(); + + self.stream.read_response(&mut self.buf)?; + + let mut r = self.buf.reader(1); + ext.extend(r.read_string()?); + + Ok(!self.buf.is_empty() && self.buf[0] == msg::SUCCESS) + } +} + +#[cfg(not(unix))] +impl ClientStream for TcpStream { + fn connect_uds

(_: P) -> Result, Error> + where + P: AsRef + Send, + { + Err(Error::AgentFailure) + } + + fn read_response(&mut self, _: &mut Buffer) -> Result<(), Error> { + Err(Error::AgentFailure) + } + + fn connect_env() -> Result, Error> { + Err(Error::AgentFailure) + } +} + +#[cfg(unix)] +impl ClientStream for UnixStream { + fn connect_socket

(path: P) -> Result, Error> + where + P: AsRef + Send, + { + let stream = UnixStream::connect(path)?; + Ok(AgentClient { + stream, + buf: Vec::new().into(), + }) + } + + fn read_response(&mut self, buf: &mut Buffer) -> Result<(), Error> { + // Write the message + self.write_all(buf)?; + self.flush()?; + + // Read the length + buf.zeroize(); + buf.resize(4, 0); + self.read_exact(buf)?; + + // Read the rest of the buffer + let len = BigEndian::read_u32(buf) as usize; + buf.zeroize(); + buf.resize(len, 0); + self.read_exact(buf)?; + + Ok(()) + } +} diff --git a/radicle-ssh/src/agent/msg.rs b/radicle-ssh/src/agent/msg.rs new file mode 100644 index 00000000..d0786fd4 --- /dev/null +++ b/radicle-ssh/src/agent/msg.rs @@ -0,0 +1,23 @@ +pub const FAILURE: u8 = 5; +pub const SUCCESS: u8 = 6; +pub const IDENTITIES_ANSWER: u8 = 12; +pub const SIGN_RESPONSE: u8 = 14; +#[allow(dead_code)] +pub const EXTENSION_FAILURE: u8 = 28; + +pub const REQUEST_IDENTITIES: u8 = 11; +pub const SIGN_REQUEST: u8 = 13; +pub const ADD_IDENTITY: u8 = 17; +pub const REMOVE_IDENTITY: u8 = 18; +pub const REMOVE_ALL_IDENTITIES: u8 = 19; +pub const ADD_ID_CONSTRAINED: u8 = 25; +pub const ADD_SMARTCARD_KEY: u8 = 20; +pub const REMOVE_SMARTCARD_KEY: u8 = 21; +pub const LOCK: u8 = 22; +pub const UNLOCK: u8 = 23; +pub const ADD_SMARTCARD_KEY_CONSTRAINED: u8 = 26; +pub const EXTENSION: u8 = 27; + +pub const CONSTRAIN_LIFETIME: u8 = 1; +pub const CONSTRAIN_CONFIRM: u8 = 2; +pub const CONSTRAIN_EXTENSION: u8 = 3; diff --git a/radicle-ssh/src/encoding.rs b/radicle-ssh/src/encoding.rs new file mode 100644 index 00000000..124aa35c --- /dev/null +++ b/radicle-ssh/src/encoding.rs @@ -0,0 +1,222 @@ +// Copyright 2016 Pierre-Étienne Meunier +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +use std::mem::size_of; +use std::ops::DerefMut; + +use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; +use thiserror::Error; +use zeroize::Zeroizing; + +/// General purpose writable byte buffer we use everywhere. +pub type Buffer = Zeroizing>; + +#[derive(Debug, Error)] +pub enum Error { + /// Index out of bounds + #[error("Index out of bounds")] + IndexOutOfBounds, +} + +/// Encode in the SSH format. +pub trait Encoding { + /// Push an SSH-encoded string to `self`. + fn extend_ssh_string(&mut self, s: &[u8]) -> usize; + /// Push an SSH-encoded blank string of length `s` to `self`. + fn extend_ssh_string_blank(&mut self, s: usize) -> &mut [u8]; + /// Push an SSH-encoded multiple-precision integer. + fn extend_ssh_mpint(&mut self, s: &[u8]); + /// Push an SSH-encoded list. + fn extend_list<'a, I: Iterator>(&mut self, list: I); + /// Push an SSH-encoded empty list. + fn write_empty_list(&mut self); + /// Write the buffer length at the beginning of the buffer. + fn write_len(&mut self); +} + +/// Encoding length of the given mpint. +pub fn mpint_len(s: &[u8]) -> usize { + let mut i = 0; + while i < s.len() && s[i] == 0 { + i += 1 + } + (if s[i] & 0x80 != 0 { 5 } else { 4 }) + s.len() - i +} + +impl Encoding for Vec { + fn extend_ssh_string(&mut self, s: &[u8]) -> usize { + self.write_u32::(s.len() as u32).unwrap(); + self.extend(s); + + size_of::() + s.len() + } + + fn extend_ssh_string_blank(&mut self, len: usize) -> &mut [u8] { + self.write_u32::(len as u32).unwrap(); + let current = self.len(); + self.resize(current + len, 0u8); + + &mut self[current..] + } + + fn extend_ssh_mpint(&mut self, s: &[u8]) { + // Skip initial 0s. + let mut i = 0; + while i < s.len() && s[i] == 0 { + i += 1 + } + // If the first non-zero is >= 128, write its length (u32, BE), followed by 0. + if s[i] & 0x80 != 0 { + self.write_u32::((s.len() - i + 1) as u32) + .unwrap(); + self.push(0) + } else { + self.write_u32::((s.len() - i) as u32).unwrap(); + } + self.extend(&s[i..]); + } + + fn extend_list<'a, I: Iterator>(&mut self, list: I) { + let len0 = self.len(); + self.extend(&[0, 0, 0, 0]); + + let mut first = true; + for i in list { + if !first { + self.push(b',') + } else { + first = false; + } + self.extend(i) + } + let len = (self.len() - len0 - 4) as u32; + + BigEndian::write_u32(&mut self[len0..], len); + } + + fn write_empty_list(&mut self) { + self.extend(&[0, 0, 0, 0]); + } + + fn write_len(&mut self) { + let len = self.len() - 4; + BigEndian::write_u32(&mut self[..], len as u32); + } +} + +impl Encoding for Buffer { + fn extend_ssh_string(&mut self, s: &[u8]) -> usize { + self.deref_mut().extend_ssh_string(s) + } + + fn extend_ssh_string_blank(&mut self, len: usize) -> &mut [u8] { + self.deref_mut().extend_ssh_string_blank(len) + } + + fn extend_ssh_mpint(&mut self, s: &[u8]) { + self.deref_mut().extend_ssh_mpint(s) + } + + fn extend_list<'a, I: Iterator>(&mut self, list: I) { + self.deref_mut().extend_list(list) + } + + fn write_empty_list(&mut self) { + self.deref_mut().write_empty_list() + } + + fn write_len(&mut self) { + self.deref_mut().write_len() + } +} + +/// A cursor-like trait to read SSH-encoded things. +pub trait Reader { + /// Create an SSH reader for `self`. + fn reader(&self, starting_at: usize) -> Cursor; +} + +impl Reader for Buffer { + fn reader(&self, starting_at: usize) -> Cursor { + Cursor { + s: self, + position: starting_at, + } + } +} + +impl Reader for [u8] { + fn reader(&self, starting_at: usize) -> Cursor { + Cursor { + s: self, + position: starting_at, + } + } +} + +/// A cursor-like type to read SSH-encoded values. +#[derive(Debug)] +pub struct Cursor<'a> { + s: &'a [u8], + #[doc(hidden)] + pub position: usize, +} + +impl<'a> Cursor<'a> { + /// Read one string from this reader. + pub fn read_string(&mut self) -> Result<&'a [u8], Error> { + let len = self.read_u32()? as usize; + if self.position + len <= self.s.len() { + let result = &self.s[self.position..(self.position + len)]; + self.position += len; + Ok(result) + } else { + Err(Error::IndexOutOfBounds) + } + } + + /// Read a `u32` from this reader. + pub fn read_u32(&mut self) -> Result { + if self.position + 4 <= self.s.len() { + let u = BigEndian::read_u32(&self.s[self.position..]); + self.position += 4; + Ok(u) + } else { + Err(Error::IndexOutOfBounds) + } + } + + /// Read one byte from this reader. + pub fn read_byte(&mut self) -> Result { + if self.position < self.s.len() { + let u = self.s[self.position]; + self.position += 1; + Ok(u) + } else { + Err(Error::IndexOutOfBounds) + } + } + + /// Read one byte from this reader. + pub fn read_mpint(&mut self) -> Result<&'a [u8], Error> { + let len = self.read_u32()? as usize; + if self.position + len <= self.s.len() { + let result = &self.s[self.position..(self.position + len)]; + self.position += len; + Ok(result) + } else { + Err(Error::IndexOutOfBounds) + } + } +} diff --git a/radicle-ssh/src/key.rs b/radicle-ssh/src/key.rs new file mode 100644 index 00000000..638e7d42 --- /dev/null +++ b/radicle-ssh/src/key.rs @@ -0,0 +1,20 @@ +use crate::encoding::{Buffer, Cursor}; + +pub trait Public: Sized { + type Error; + + fn write_blob(&self, buf: &mut Buffer) -> usize; + fn read(reader: &mut Cursor) -> Result, Self::Error>; +} + +pub trait Private: Sized { + type Error; + + fn read(reader: &mut Cursor) -> Result, Self)>, Self::Error>; + fn write(&self, buf: &mut Buffer) -> Result<(), Self::Error>; + fn write_signature>( + &self, + buf: &mut Buffer, + to_sign: T, + ) -> Result<(), Self::Error>; +} diff --git a/radicle-ssh/src/lib.rs b/radicle-ssh/src/lib.rs new file mode 100644 index 00000000..96fce5a4 --- /dev/null +++ b/radicle-ssh/src/lib.rs @@ -0,0 +1,3 @@ +pub mod agent; +pub mod encoding; +pub mod key; diff --git a/radicle-tools/Cargo.toml b/radicle-tools/Cargo.toml index 523ededa..c6b8fc61 100644 --- a/radicle-tools/Cargo.toml +++ b/radicle-tools/Cargo.toml @@ -28,3 +28,7 @@ path = "src/rad-self.rs" [[bin]] name = "rad-push" path = "src/rad-push.rs" + +[[bin]] +name = "rad-agent" +path = "src/rad-agent.rs" diff --git a/radicle-tools/src/rad-agent.rs b/radicle-tools/src/rad-agent.rs new file mode 100644 index 00000000..8f0ec199 --- /dev/null +++ b/radicle-tools/src/rad-agent.rs @@ -0,0 +1,40 @@ +use radicle::{crypto, ssh}; +use std::io::prelude::*; +use std::{env, io}; + +fn main() -> anyhow::Result<()> { + let profile = radicle::Profile::load()?; + + println!("({})", ssh::fmt::key(profile.id())); + + match env::args().nth(1).as_deref() { + Some("add") => { + ssh::agent::register(&profile.signer.secret)?; + println!("ok"); + } + Some("remove") => { + ssh::agent::connect()?.remove_identity(profile.id())?; + println!("ok"); + } + Some("remove-all") => { + ssh::agent::connect()?.remove_all_identities()?; + println!("ok"); + } + Some("sign") => { + let mut stdin = Vec::new(); + io::stdin().read_to_end(&mut stdin)?; + + let mut agent = ssh::agent::connect()?; + let sig = agent.sign_request(profile.id(), stdin.into())?; + let sig = crypto::Signature::from(sig); + + println!("{}", &sig); + } + Some(other) => { + anyhow::bail!("Unknown command `{}`", other); + } + None => {} + } + + Ok(()) +} diff --git a/radicle-tools/src/rad-auth.rs b/radicle-tools/src/rad-auth.rs index fb5f0c7f..e0391702 100644 --- a/radicle-tools/src/rad-auth.rs +++ b/radicle-tools/src/rad-auth.rs @@ -1,5 +1,7 @@ fn main() -> anyhow::Result<()> { let keypair = radicle::crypto::KeyPair::generate(); + radicle::ssh::agent::register(&keypair.sk)?; + let profile = radicle::Profile::init(keypair)?; println!("id: {}", profile.id()); diff --git a/radicle-tools/src/rad-self.rs b/radicle-tools/src/rad-self.rs index f012247b..d9cf7fa0 100644 --- a/radicle-tools/src/rad-self.rs +++ b/radicle-tools/src/rad-self.rs @@ -2,6 +2,11 @@ fn main() -> anyhow::Result<()> { let profile = radicle::Profile::load()?; println!("id: {}", profile.id()); + println!("key: {}", radicle::ssh::fmt::key(profile.id())); + println!( + "fingerprint: {}", + radicle::ssh::fmt::fingerprint(profile.id()) + ); println!("home: {}", profile.home.display()); Ok(()) diff --git a/radicle/Cargo.toml b/radicle/Cargo.toml index f0596bfa..f9d72260 100644 --- a/radicle/Cargo.toml +++ b/radicle/Cargo.toml @@ -10,6 +10,8 @@ default = [] test = ["quickcheck"] [dependencies] +base64 = { version= "0.13" } +byteorder = { version = "1.4" } crossbeam-channel = { version = "0.5.6" } ed25519-compact = { version = "1.0.12", features = ["pem"] } fastrand = { version = "1.8.0" } @@ -28,6 +30,12 @@ radicle-git-ext = { version = "0", features = ["serde"] } nonempty = { version = "0.8.0", features = ["serialize"] } tempfile = { version = "3.3.0" } thiserror = { version = "1" } +zeroize = { version = "1.5.7" } + +[dependencies.radicle-ssh] +path = "../radicle-ssh" +version = "0" +default-features = false [dependencies.quickcheck] version = "1" diff --git a/radicle/src/crypto.rs b/radicle/src/crypto.rs index f24010be..ecee7d43 100644 --- a/radicle/src/crypto.rs +++ b/radicle/src/crypto.rs @@ -183,11 +183,17 @@ impl From for PublicKey { } } -impl TryFrom<[u8; 32]> for PublicKey { +impl From<[u8; 32]> for PublicKey { + fn from(other: [u8; 32]) -> Self { + Self(ed25519::PublicKey::new(other)) + } +} + +impl TryFrom<&[u8]> for PublicKey { type Error = ed25519::Error; - fn try_from(other: [u8; 32]) -> Result { - Ok(Self(ed25519::PublicKey::new(other))) + fn try_from(other: &[u8]) -> Result { + ed25519::PublicKey::from_slice(other).map(Self) } } diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index 716071f1..0da24f19 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -8,6 +8,7 @@ pub mod node; pub mod profile; pub mod rad; pub mod serde_ext; +pub mod ssh; pub mod storage; #[cfg(feature = "test")] pub mod test; diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index 7b9ec1f0..6ffb6c85 100644 --- a/radicle/src/profile.rs +++ b/radicle/src/profile.rs @@ -20,8 +20,8 @@ use crate::storage::git::Storage; #[derive(Debug)] pub struct UnsafeSigner { - public: PublicKey, - secret: SecretKey, + pub public: PublicKey, + pub secret: SecretKey, } impl Signer for UnsafeSigner { diff --git a/radicle/src/ssh.rs b/radicle/src/ssh.rs new file mode 100644 index 00000000..5fe56a32 --- /dev/null +++ b/radicle/src/ssh.rs @@ -0,0 +1,273 @@ +pub mod agent; + +use std::io; +use std::mem; +use std::ops::DerefMut; + +use byteorder::{BigEndian, WriteBytesExt}; +use thiserror::Error; +use zeroize::Zeroizing; + +use radicle_ssh::encoding; +use radicle_ssh::encoding::Encoding as _; + +use crate::crypto; +use crate::crypto::PublicKey; + +pub mod fmt { + use crate::crypto::PublicKey; + + /// Get the SSH long key from a public key. + /// This is the output of `ssh-add -L`. + pub fn key(key: &PublicKey) -> String { + use byteorder::{BigEndian, WriteBytesExt}; + + let mut buf = Vec::new(); + let key = key.as_ref(); + let len = key.len(); + + buf.write_u32::(len as u32) + .expect("Writing to vectors doesn't fail"); + buf.extend_from_slice(key); + + // Despite research, I have no idea what this string is, but it seems + // to be the same for all Ed25519 keys. + let mut encoded = String::from("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5"); + encoded.push_str(&base64::encode_config(buf, base64::STANDARD_NO_PAD)); + + encoded + } + + /// Get the SSH key fingerprint from a public key. + /// This is the output of `ssh-add -l`. + pub fn fingerprint(key: &PublicKey) -> String { + use byteorder::{BigEndian, WriteBytesExt}; + use sha2::Digest; + + let mut buf = Vec::new(); + let name = b"ssh-ed25519"; + let key = key.as_ref(); + + buf.write_u32::(name.len() as u32) + .expect("Writing to vectors doesn't fail"); + buf.extend_from_slice(name); + buf.write_u32::(key.len() as u32) + .expect("Writing to vectors doesn't fail"); + buf.extend_from_slice(key); + + let sha = sha2::Sha256::digest(&buf).to_vec(); + let encoded = base64::encode_config(sha, base64::STANDARD_NO_PAD); + + format!("SHA256:{}", encoded) + } +} + +#[derive(Debug, Error)] +pub enum PublicKeyError { + #[error(transparent)] + Invalid(#[from] crypto::Error), + #[error(transparent)] + Encoding(#[from] encoding::Error), +} + +impl radicle_ssh::key::Public for PublicKey { + type Error = PublicKeyError; + + fn write_blob(&self, buf: &mut Zeroizing>) -> usize { + let mut n = 0; + let typ = b"ssh-ed25519"; + let size = typ.len() + self.len() + mem::size_of::() * 2; + + buf.write_u32::(size as u32) + .expect("writing to a vector never fails"); + + n += mem::size_of::(); // The blob size. + n += buf.extend_ssh_string(typ); + n += buf.extend_ssh_string(&self[..]); + n + } + + fn read(r: &mut encoding::Cursor) -> Result, Self::Error> { + match r.read_string()? { + b"ssh-ed25519" => { + let s = r.read_string()?; + let p = PublicKey::try_from(s)?; + + Ok(Some(p)) + } + _ => Ok(None), + } + } +} + +// FIXME: Should zeroize, or we should be creating our own type +// in `crypto`. +struct SecretKey(crypto::SecretKey); + +impl From for SecretKey { + fn from(other: crypto::SecretKey) -> Self { + Self(other) + } +} + +#[derive(Debug, Error)] +pub enum SigningKeyError { + #[error(transparent)] + Encoding(#[from] encoding::Error), + #[error(transparent)] + Crypto(#[from] crypto::Error), + #[error(transparent)] + Io(#[from] io::Error), +} + +impl radicle_ssh::key::Private for SecretKey { + type Error = SigningKeyError; + + fn read(r: &mut encoding::Cursor) -> Result, Self)>, Self::Error> { + match r.read_string()? { + b"ssh-ed25519" => { + let public = r.read_string()?; + let pair = r.read_string()?; + let _comment = r.read_string()?; + let key = crypto::SecretKey::from_slice(pair).unwrap(); + + Ok(Some((public.to_vec(), SecretKey(key)))) + } + _ => Ok(None), + } + } + + fn write(&self, buf: &mut Zeroizing>) -> Result<(), Self::Error> { + let public = self.0.public_key(); + + buf.extend_ssh_string(b"ssh-ed25519"); + buf.extend_ssh_string(public.as_ref()); + buf.deref_mut().write_u32::(64)?; + buf.extend(&*self.0); + buf.extend_ssh_string(b"radicle"); + + Ok(()) + } + + fn write_signature>( + &self, + buf: &mut Zeroizing>, + to_sign: Bytes, + ) -> Result<(), Self::Error> { + let name = "ssh-ed25519"; + let signature: [u8; 64] = *self.0.sign(to_sign.as_ref(), None); + + buf.deref_mut() + .write_u32::((name.len() + signature.len() + 8) as u32)?; + buf.extend_ssh_string(name.as_bytes()); + buf.extend_ssh_string(&signature); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::sync::{Arc, Mutex}; + + use quickcheck_macros::quickcheck; + use zeroize::Zeroizing; + + use super::SecretKey; + use crate::crypto; + use crate::crypto::PublicKey; + use crate::test::arbitrary::ByteArray; + use radicle_ssh::agent::client::{AgentClient, ClientStream, Error}; + use radicle_ssh::encoding::Reader; + use radicle_ssh::key::Private as _; + + #[derive(Clone, Default)] + struct DummyStream { + incoming: Arc>>>, + } + + impl ClientStream for DummyStream { + fn connect_socket

(_path: P) -> Result, Error> + where + P: AsRef + Send, + { + panic!("This function should never be called!") + } + + fn read_response(&mut self, buf: &mut Zeroizing>) -> Result<(), Error> { + *self.incoming.lock().unwrap() = buf.clone(); + + Ok(()) + } + } + + #[quickcheck] + fn prop_encode_decode_sk(input: ByteArray<64>) { + let mut buf = Vec::new().into(); + let sk = crypto::SecretKey::new(input.into_inner()); + SecretKey(sk).write(&mut buf).unwrap(); + + let mut cursor = buf.reader(0); + let (_, output) = SecretKey::read(&mut cursor).unwrap().unwrap(); + + assert_eq!(sk, output.0); + } + + #[test] + fn test_agent_encoding_remove() { + use std::str::FromStr; + + let pk = PublicKey::from_str("zF4VJZgNEeL1niWmKu1NtT1B4ZyGpjACyhs2VEZvtsws5").unwrap(); + let expected = [ + 0, 0, 0, 56, // Message length + 18, // Message type (remove identity) + 0, 0, 0, 51, // Key blob length + 0, 0, 0, 11, // Key type length + 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, // Key type + 0, 0, 0, 32, // Key length + 208, 232, 92, 138, 225, 114, 116, 99, 156, 177, 148, 93, 65, 93, 198, 25, 46, 203, 79, + 37, 145, 51, 176, 174, 61, 136, 160, 107, 4, 95, 175, 144, // Key + ]; + + let stream = DummyStream::default(); + let mut agent = AgentClient::connect(stream.clone()); + + agent.remove_identity(&pk).unwrap(); + + assert_eq!( + stream.incoming.lock().unwrap().as_slice(), + expected.as_slice() + ); + } + + #[test] + fn test_agent_encoding_sign() { + use std::str::FromStr; + + let pk = PublicKey::from_str("zF4VJZgNEeL1niWmKu1NtT1B4ZyGpjACyhs2VEZvtsws5").unwrap(); + let expected = [ + 0, 0, 0, 73, // Message length + 13, // Message type (sign request) + 0, 0, 0, 51, // Key blob length + 0, 0, 0, 11, // Key type length + 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, // Key type + 0, 0, 0, 32, // Public key + 208, 232, 92, 138, 225, 114, 116, 99, 156, 177, 148, 93, 65, 93, 198, 25, 46, 203, 79, + 37, 145, 51, 176, 174, 61, 136, 160, 107, 4, 95, 175, 144, // Key + 0, 0, 0, 9, // Length of data to sign + 1, 2, 3, 4, 5, 6, 7, 8, 9, // Data to sign + 0, 0, 0, 0, // Signature flags + ]; + + let stream = DummyStream::default(); + let mut agent = AgentClient::connect(stream.clone()); + let data: Zeroizing> = vec![1, 2, 3, 4, 5, 6, 7, 8, 9].into(); + + agent.sign_request(&pk, data).ok(); + + assert_eq!( + stream.incoming.lock().unwrap().as_slice(), + expected.as_slice() + ); + } +} diff --git a/radicle/src/ssh/agent.rs b/radicle/src/ssh/agent.rs new file mode 100644 index 00000000..c75ca0c0 --- /dev/null +++ b/radicle/src/ssh/agent.rs @@ -0,0 +1,21 @@ +use radicle_ssh::agent::client::AgentClient; +use radicle_ssh::{self as ssh, agent::client::ClientStream}; + +use crate::crypto; +use crate::ssh::SecretKey; + +#[cfg(not(unix))] +use std::net::TcpStream as Stream; +#[cfg(unix)] +use std::os::unix::net::UnixStream as Stream; + +pub fn connect() -> Result, ssh::agent::client::Error> { + Stream::connect_env() +} + +pub fn register(key: &crypto::SecretKey) -> Result<(), ssh::agent::client::Error> { + let mut agent = self::connect()?; + agent.add_identity(&SecretKey::from(*key), &[])?; + + Ok(()) +}