crypto/ssh/keystore: Explicit paths
The implementation of `Keystore` requires a particular layout and naming of keys. This is a rather strong limitation. Lift it and allow specifying an arbitrary path for the secret and public key. As it is possible to derive the public key from the secret key, make the public key in `Keystore` optional. Also allow constructing `MemorySigner` can now be initialized without a public key, by deriving it.
This commit is contained in:
parent
9e1d6b1feb
commit
5887edf93b
|
|
@ -2,5 +2,5 @@ This test assumes that one of the two keys in `$RAD_HOME/keys` was swapped so th
|
||||||
|
|
||||||
``` (fail)
|
``` (fail)
|
||||||
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
|
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
|
||||||
✗ Error: secret and public keys in '[..]/.radicle/keys' do not match
|
✗ Error: secret key '[..]/.radicle/keys/radicle' and public key '[..]/.radicle/keys/radicle.pub' do not match
|
||||||
```
|
```
|
||||||
|
|
@ -240,7 +240,7 @@ pub fn register(
|
||||||
e.into()
|
e.into()
|
||||||
}
|
}
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;
|
.ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;
|
||||||
|
|
||||||
agent.register(&secret)?;
|
agent.register(&secret)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ pub enum Error {
|
||||||
Ssh(#[from] ssh_key::Error),
|
Ssh(#[from] ssh_key::Error),
|
||||||
#[error("invalid key type, expected ed25519 key")]
|
#[error("invalid key type, expected ed25519 key")]
|
||||||
InvalidKeyType,
|
InvalidKeyType,
|
||||||
#[error("keystore already initialized")]
|
#[error("keystore already initialized, file '{exists}' exists")]
|
||||||
AlreadyInitialized,
|
AlreadyInitialized { exists: PathBuf },
|
||||||
#[error("keystore is encrypted; a passphrase is required")]
|
#[error("keystore is encrypted; a passphrase is required")]
|
||||||
PassphraseMissing,
|
PassphraseMissing,
|
||||||
}
|
}
|
||||||
|
|
@ -38,28 +38,52 @@ impl Error {
|
||||||
/// Stores keys on disk, in OpenSSH format.
|
/// Stores keys on disk, in OpenSSH format.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Keystore {
|
pub struct Keystore {
|
||||||
path: PathBuf,
|
path_secret: PathBuf,
|
||||||
|
path_public: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Keystore {
|
impl Keystore {
|
||||||
/// Create a new keystore pointing to the given path. Use [`Keystore::init`] to initialize.
|
/// Create a new keystore pointing to the given path.
|
||||||
|
///
|
||||||
|
/// Use [`Keystore::init`] to initialize.
|
||||||
pub fn new<P: AsRef<Path>>(path: &P) -> Self {
|
pub fn new<P: AsRef<Path>>(path: &P) -> Self {
|
||||||
|
const DEFAULT_SECRET_KEY_FILE_NAME: &str = "radicle";
|
||||||
|
const DEFAULT_PUBLIC_KEY_FILE_NAME: &str = "radicle.pub";
|
||||||
|
|
||||||
|
let keys = path.as_ref().to_path_buf();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
path: path.as_ref().to_path_buf(),
|
path_secret: keys.join(DEFAULT_SECRET_KEY_FILE_NAME),
|
||||||
|
path_public: Some(keys.join(DEFAULT_PUBLIC_KEY_FILE_NAME)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the path to the keystore.
|
/// Create a new keystore pointing to the given paths.
|
||||||
pub fn path(&self) -> &Path {
|
///
|
||||||
self.path.as_path()
|
/// Use [`Keystore::init`] to initialize.
|
||||||
|
pub fn from_secret_path<P: AsRef<Path>>(secret: &P) -> Self {
|
||||||
|
Self {
|
||||||
|
path_secret: secret.as_ref().to_path_buf(),
|
||||||
|
path_public: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize a keystore by generating a key pair and storing the secret and public key
|
/// Get the path to the secret key backing the keystore.
|
||||||
/// at the given path.
|
pub fn secret_key_path(&self) -> &Path {
|
||||||
|
self.path_secret.as_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to the public key backing the keystore, if present.
|
||||||
|
pub fn public_key_path(&self) -> Option<&Path> {
|
||||||
|
self.path_public.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a keystore by generating a key pair and storing the secret
|
||||||
|
/// and public key at the given path.
|
||||||
///
|
///
|
||||||
/// The `comment` is associated with the private key.
|
/// The `comment` is associated with the private key. The `passphrase` is
|
||||||
/// The `passphrase` is used to encrypt the private key.
|
/// used to encrypt the private key. The `seed` is used to derive the
|
||||||
/// The `seed` is used to derive the private key and should almost always be generated.
|
/// private key and should almost always be generated.
|
||||||
///
|
///
|
||||||
/// If `passphrase` is `None`, the key is not encrypted.
|
/// If `passphrase` is `None`, the key is not encrypted.
|
||||||
pub fn init(
|
pub fn init(
|
||||||
|
|
@ -71,7 +95,7 @@ impl Keystore {
|
||||||
self.store(KeyPair::from_seed(seed), comment, passphrase)
|
self.store(KeyPair::from_seed(seed), comment, passphrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a keypair on disk. Returns an error if the key already exists.
|
/// Store a keypair on disk. Returns an error if any of the two key files already exist.
|
||||||
pub fn store(
|
pub fn store(
|
||||||
&self,
|
&self,
|
||||||
keypair: KeyPair,
|
keypair: KeyPair,
|
||||||
|
|
@ -87,13 +111,25 @@ impl Keystore {
|
||||||
secret
|
secret
|
||||||
};
|
};
|
||||||
let public = secret.public_key();
|
let public = secret.public_key();
|
||||||
let path = self.path.join("radicle");
|
|
||||||
|
|
||||||
if path.exists() {
|
if self.path_secret.exists() {
|
||||||
return Err(Error::AlreadyInitialized);
|
return Err(Error::AlreadyInitialized {
|
||||||
|
exists: self.path_secret.to_path_buf(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
if let Some(path_public) = &self.path_public {
|
||||||
|
if path_public.exists() {
|
||||||
|
return Err(Error::AlreadyInitialized {
|
||||||
|
exists: path_public.to_path_buf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: If [`PathBuf::parent`] returns `None`,
|
||||||
|
// then the path is at root or empty, so don't
|
||||||
|
// attempt to create any parents.
|
||||||
|
self.path_secret.parent().map_or(Ok(()), |parent| {
|
||||||
let mut builder = fs::DirBuilder::new();
|
let mut builder = fs::DirBuilder::new();
|
||||||
builder.recursive(true);
|
builder.recursive(true);
|
||||||
|
|
||||||
|
|
@ -103,27 +139,43 @@ impl Keystore {
|
||||||
builder.mode(0o700);
|
builder.mode(0o700);
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.create(&self.path)?;
|
builder.create(parent)
|
||||||
}
|
})?;
|
||||||
|
secret.write_openssh_file(&self.path_secret, ssh_key::LineEnding::default())?;
|
||||||
|
|
||||||
secret.write_openssh_file(&path, ssh_key::LineEnding::default())?;
|
if let Some(path_public) = &self.path_public {
|
||||||
public.write_openssh_file(&path.with_extension("pub"))?;
|
path_public.parent().map_or(Ok(()), |parent| {
|
||||||
|
let mut builder = fs::DirBuilder::new();
|
||||||
|
builder.recursive(true);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt as _;
|
||||||
|
builder.mode(0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.create(parent)
|
||||||
|
})?;
|
||||||
|
public.write_openssh_file(path_public)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(keypair.pk.into())
|
Ok(keypair.pk.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the public key from the store. Returns `None` if it wasn't found.
|
/// Load the public key from the store. Returns `None` if it wasn't found.
|
||||||
pub fn public_key(&self) -> Result<Option<PublicKey>, Error> {
|
pub fn public_key(&self) -> Result<Option<PublicKey>, Error> {
|
||||||
let path = self.path.join("radicle.pub");
|
let Some(path_public) = &self.path_public else {
|
||||||
if !path.exists() {
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path_public.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let public = ssh_key::PublicKey::read_openssh_file(&path)?;
|
let public = ssh_key::PublicKey::read_openssh_file(path_public)?;
|
||||||
match public.try_into() {
|
PublicKey::try_from(public)
|
||||||
Ok(public) => Ok(Some(public)),
|
.map(Some)
|
||||||
_ => Err(Error::InvalidKeyType),
|
.map_err(|_| Error::InvalidKeyType)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the secret key from the store, decrypting it with the given passphrase.
|
/// Load the secret key from the store, decrypting it with the given passphrase.
|
||||||
|
|
@ -132,12 +184,13 @@ impl Keystore {
|
||||||
&self,
|
&self,
|
||||||
passphrase: Option<Passphrase>,
|
passphrase: Option<Passphrase>,
|
||||||
) -> Result<Option<Zeroizing<SecretKey>>, Error> {
|
) -> Result<Option<Zeroizing<SecretKey>>, Error> {
|
||||||
let path = self.path.join("radicle");
|
let path = &self.path_secret;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
|
let secret = ssh_key::PrivateKey::read_openssh_file(path)?;
|
||||||
|
|
||||||
let secret = if let Some(p) = passphrase {
|
let secret = if let Some(p) = passphrase {
|
||||||
secret.decrypt(p)?
|
secret.decrypt(p)?
|
||||||
} else if secret.is_encrypted() {
|
} else if secret.is_encrypted() {
|
||||||
|
|
@ -155,12 +208,11 @@ impl Keystore {
|
||||||
|
|
||||||
/// Check that the passphrase is valid.
|
/// Check that the passphrase is valid.
|
||||||
pub fn is_valid_passphrase(&self, passphrase: &Passphrase) -> Result<bool, Error> {
|
pub fn is_valid_passphrase(&self, passphrase: &Passphrase) -> Result<bool, Error> {
|
||||||
let path = self.path.join("radicle");
|
if !self.path_secret.exists() {
|
||||||
if !path.exists() {
|
|
||||||
return Err(Error::Io(io::ErrorKind::NotFound.into()));
|
return Err(Error::Io(io::ErrorKind::NotFound.into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
|
let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;
|
||||||
let valid = secret.decrypt(passphrase).is_ok();
|
let valid = secret.decrypt(passphrase).is_ok();
|
||||||
|
|
||||||
Ok(valid)
|
Ok(valid)
|
||||||
|
|
@ -168,8 +220,7 @@ impl Keystore {
|
||||||
|
|
||||||
/// Check whether the secret key is encrypted.
|
/// Check whether the secret key is encrypted.
|
||||||
pub fn is_encrypted(&self) -> Result<bool, Error> {
|
pub fn is_encrypted(&self) -> Result<bool, Error> {
|
||||||
let path = self.path.join("radicle");
|
let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;
|
||||||
let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
|
|
||||||
|
|
||||||
Ok(secret.is_encrypted())
|
Ok(secret.is_encrypted())
|
||||||
}
|
}
|
||||||
|
|
@ -183,8 +234,8 @@ pub enum MemorySignerError {
|
||||||
NotFound(PathBuf),
|
NotFound(PathBuf),
|
||||||
#[error("invalid passphrase")]
|
#[error("invalid passphrase")]
|
||||||
InvalidPassphrase,
|
InvalidPassphrase,
|
||||||
#[error("secret and public keys in '{path}' do not match")]
|
#[error("secret key '{secret}' and public key '{public}' do not match")]
|
||||||
KeyMismatch { path: PathBuf },
|
KeyMismatch { secret: PathBuf, public: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An in-memory signer that keeps its secret key internally
|
/// An in-memory signer that keeps its secret key internally
|
||||||
|
|
@ -260,9 +311,6 @@ impl MemorySigner {
|
||||||
keystore: &Keystore,
|
keystore: &Keystore,
|
||||||
passphrase: Option<Passphrase>,
|
passphrase: Option<Passphrase>,
|
||||||
) -> Result<Self, MemorySignerError> {
|
) -> Result<Self, MemorySignerError> {
|
||||||
let public = keystore
|
|
||||||
.public_key()?
|
|
||||||
.ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
|
|
||||||
let secret = keystore
|
let secret = keystore
|
||||||
.secret_key(passphrase)
|
.secret_key(passphrase)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|
@ -272,17 +320,37 @@ impl MemorySigner {
|
||||||
e.into()
|
e.into()
|
||||||
}
|
}
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
|
.ok_or_else(|| MemorySignerError::NotFound(keystore.secret_key_path().to_path_buf()))?;
|
||||||
|
|
||||||
|
let Some(public_path) = keystore.public_key_path() else {
|
||||||
|
// There is no public key in the key store, so there's nothing
|
||||||
|
// to validate. Derive it from the secret key.
|
||||||
|
return Ok(Self::from_secret(secret));
|
||||||
|
};
|
||||||
|
|
||||||
|
let public = keystore
|
||||||
|
.public_key()?
|
||||||
|
.ok_or_else(|| MemorySignerError::NotFound(public_path.to_path_buf()))?;
|
||||||
|
|
||||||
secret
|
secret
|
||||||
.validate_public_key(&public)
|
.validate_public_key(&public)
|
||||||
.map_err(|_| MemorySignerError::KeyMismatch {
|
.map_err(|_| MemorySignerError::KeyMismatch {
|
||||||
path: keystore.path().to_path_buf(),
|
secret: keystore.secret_key_path().to_path_buf(),
|
||||||
|
public: public_path.to_path_buf(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self { public, secret })
|
Ok(Self { public, secret })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new memory signer from the given secret key, deriving
|
||||||
|
/// the public key from the secret key.
|
||||||
|
pub fn from_secret(secret: Zeroizing<SecretKey>) -> Self {
|
||||||
|
Self {
|
||||||
|
public: PublicKey(secret.public_key()),
|
||||||
|
secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Box this signer into a trait object.
|
/// Box this signer into a trait object.
|
||||||
pub fn boxed(self) -> Box<dyn Signer> {
|
pub fn boxed(self) -> Box<dyn Signer> {
|
||||||
Box::new(self)
|
Box::new(self)
|
||||||
|
|
@ -320,7 +388,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_init_passphrase() {
|
fn test_init_passphrase() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let store = Keystore::new(&tmp.path());
|
let store = Keystore::new(&tmp);
|
||||||
|
|
||||||
let public = store
|
let public = store
|
||||||
.init(
|
.init(
|
||||||
|
|
@ -346,7 +414,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_init_no_passphrase() {
|
fn test_init_no_passphrase() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let store = Keystore::new(&tmp.path());
|
let store = Keystore::new(&tmp);
|
||||||
|
|
||||||
let public = store.init("test", None, ec25519::Seed::default()).unwrap();
|
let public = store.init("test", None, ec25519::Seed::default()).unwrap();
|
||||||
assert_eq!(public, store.public_key().unwrap().unwrap());
|
assert_eq!(public, store.public_key().unwrap().unwrap());
|
||||||
|
|
@ -359,7 +427,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_signer() {
|
fn test_signer() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let store = Keystore::new(&tmp.path());
|
let store = Keystore::new(&tmp);
|
||||||
|
|
||||||
let public = store
|
let public = store
|
||||||
.init(
|
.init(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue