Skip to content

Commit 0532353

Browse files
committed
Add support for ssh keys
1 parent f75f62e commit 0532353

File tree

8 files changed

+153
-24
lines changed

8 files changed

+153
-24
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bitwarden-exporters/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ bitwarden-core = { workspace = true }
3030
bitwarden-crypto = { workspace = true }
3131
bitwarden-error = { workspace = true }
3232
bitwarden-fido = { workspace = true }
33+
bitwarden-ssh = { workspace = true }
3334
bitwarden-vault = { workspace = true }
3435
chrono = { workspace = true, features = ["std"] }
3536
credential-exchange-format = ">=0.1, <0.2"

crates/bitwarden-exporters/src/cxf/api_key.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use credential_exchange_format::ApiKeyCredential;
33
use crate::{cxf::editable_field::create_field, Field};
44

55
/// Convert API key credentials to custom fields
6-
pub fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec<Field> {
6+
pub(super) fn api_key_to_fields(api_key: &ApiKeyCredential) -> Vec<Field> {
77
[
88
api_key.key.as_ref().map(|key| create_field("API Key", key)),
99
api_key

crates/bitwarden-exporters/src/cxf/import.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use chrono::{DateTime, Utc};
22
use credential_exchange_format::{
33
Account as CxfAccount, ApiKeyCredential, BasicAuthCredential, Credential, CreditCardCredential,
4-
Item, PasskeyCredential, WifiCredential,
4+
Item, PasskeyCredential, SshKeyCredential, WifiCredential,
55
};
66

77
use crate::{
88
cxf::{
99
api_key::api_key_to_fields,
1010
login::{to_fields, to_login},
11+
ssh::to_ssh,
1112
wifi::wifi_to_fields,
1213
CxfError,
1314
},
@@ -121,6 +122,24 @@ fn parse_item(value: Item) -> Vec<ImportingCipher> {
121122
})
122123
}
123124

125+
// SSH Key credentials
126+
if let Some(ssh) = grouped.ssh.first() {
127+
let (ssh_key, fields) = to_ssh(ssh);
128+
129+
output.push(ImportingCipher {
130+
folder_id: None, // TODO: Handle folders
131+
name: value.title.clone(),
132+
notes: None,
133+
r#type: CipherType::SshKey(Box::new(ssh_key)),
134+
favorite: false,
135+
reprompt: 0,
136+
fields: [fields, scope.map(to_fields).unwrap_or_default()].concat(),
137+
revision_date,
138+
creation_date,
139+
deleted_date: None,
140+
})
141+
}
142+
124143
output
125144
}
126145

@@ -150,12 +169,16 @@ fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials
150169
Credential::BasicAuth(basic_auth) => Some(basic_auth.as_ref()),
151170
_ => None,
152171
}),
172+
credit_card: filter_credentials(&credentials, |c| match c {
173+
Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
174+
_ => None,
175+
}),
153176
passkey: filter_credentials(&credentials, |c| match c {
154177
Credential::Passkey(passkey) => Some(passkey.as_ref()),
155178
_ => None,
156179
}),
157-
credit_card: filter_credentials(&credentials, |c| match c {
158-
Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
180+
ssh: filter_credentials(&credentials, |c| match c {
181+
Credential::SshKey(ssh) => Some(ssh.as_ref()),
159182
_ => None,
160183
}),
161184
wifi: filter_credentials(&credentials, |c| match c {
@@ -168,8 +191,9 @@ fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials
168191
struct GroupedCredentials {
169192
api_key: Vec<ApiKeyCredential>,
170193
basic_auth: Vec<BasicAuthCredential>,
171-
passkey: Vec<PasskeyCredential>,
172194
credit_card: Vec<CreditCardCredential>,
195+
passkey: Vec<PasskeyCredential>,
196+
ssh: Vec<SshKeyCredential>,
173197
wifi: Vec<WifiCredential>,
174198
}
175199

crates/bitwarden-exporters/src/cxf/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ mod api_key;
1616
mod card;
1717
mod editable_field;
1818
mod login;
19+
mod ssh;
1920
mod wifi;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use bitwarden_ssh::import::import_der_key;
2+
use bitwarden_vault::FieldType;
3+
use credential_exchange_format::SshKeyCredential;
4+
5+
use crate::{cxf::editable_field::create_field, Field, SshKey};
6+
7+
pub(super) fn to_ssh(credential: &SshKeyCredential) -> (SshKey, Vec<Field>) {
8+
// Convert to OpenSSH format
9+
let encoded_key: Vec<u8> = credential.private_key.as_ref().into();
10+
let encoded_key = import_der_key(&encoded_key).expect("valid SSH key format");
11+
12+
let ssh = SshKey {
13+
private_key: encoded_key.private_key,
14+
public_key: encoded_key.public_key,
15+
fingerprint: encoded_key.fingerprint,
16+
};
17+
18+
let fields = [
19+
credential.key_comment.as_ref().map(|comment| Field {
20+
name: Some("Key Comment".into()),
21+
value: Some(comment.into()),
22+
r#type: FieldType::Text as u8,
23+
linked_id: None,
24+
}),
25+
credential
26+
.creation_date
27+
.as_ref()
28+
.map(|date| create_field("Creation Date", date)),
29+
credential
30+
.expiry_date
31+
.as_ref()
32+
.map(|date| create_field("Expiry Date", date)),
33+
credential
34+
.key_generation_source
35+
.as_ref()
36+
.map(|source| create_field("Key Generation Source", source)),
37+
]
38+
.into_iter()
39+
.flatten()
40+
.collect();
41+
42+
(ssh, fields)
43+
}
44+
45+
#[cfg(test)]
46+
mod tests {
47+
use bitwarden_vault::FieldType;
48+
use chrono::NaiveDate;
49+
use credential_exchange_format::EditableFieldDate;
50+
51+
use super::*;
52+
53+
#[test]
54+
fn test_to_ssh() {
55+
let credential = SshKeyCredential {
56+
key_type: "ssh-ed25519".into(),
57+
private_key: "MC4CAQAwBQYDK2VwBCIEID-U9VakauO4Fsv4b_znpDHcdYg74U68siZjnWLPn7Q1"
58+
.try_into()
59+
.unwrap(),
60+
key_comment: Some("Work SSH Key".into()),
61+
creation_date: Some(
62+
EditableFieldDate(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()).into(),
63+
),
64+
expiry_date: Some(
65+
EditableFieldDate(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()).into(),
66+
),
67+
key_generation_source: Some("Generated using OpenSSH".to_owned().into()),
68+
};
69+
70+
let (ssh, fields) = to_ssh(&credential);
71+
72+
assert_eq!(ssh.private_key, "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDQiCIk4t4YPC6bOSb7CLzac/vC+ZudqhYqY00cxqr8zAAAAIilFVdupRVX\nbgAAAAtzc2gtZWQyNTUxOQAAACDQiCIk4t4YPC6bOSb7CLzac/vC+ZudqhYqY00cxqr8zA\nAAAEA/lPVWpGrjuBbL+G/856Qx3HWIO+FOvLImY51iz5+0NdCIIiTi3hg8Lps5JvsIvNpz\n+8L5m52qFipjTRzGqvzMAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n");
73+
assert_eq!(
74+
ssh.public_key,
75+
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINCIIiTi3hg8Lps5JvsIvNpz+8L5m52qFipjTRzGqvzM"
76+
);
77+
assert_eq!(
78+
ssh.fingerprint,
79+
"SHA256:mZ0BOhUVicE81yPEpFJrv1rEXB2R3Y3t5nh/riicTvs"
80+
);
81+
82+
assert_eq!(fields.len(), 4);
83+
assert_eq!(
84+
fields[0],
85+
Field {
86+
name: Some("Key Comment".to_string()),
87+
value: Some("Work SSH Key".to_string()),
88+
r#type: FieldType::Text as u8,
89+
linked_id: None,
90+
}
91+
);
92+
assert_eq!(fields[1].value.as_deref(), Some("2023-01-01"));
93+
assert_eq!(fields[2].value.as_deref(), Some("2025-01-01"));
94+
assert_eq!(fields[3].value.as_deref(), Some("Generated using OpenSSH"));
95+
}
96+
}

crates/bitwarden-exporters/src/cxf/wifi.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use credential_exchange_format::WifiCredential;
33
use crate::{cxf::editable_field::create_field, Field};
44

55
/// Convert WiFi credentials to custom fields following the CXF mapping convention
6-
pub fn wifi_to_fields(wifi: &WifiCredential) -> Vec<Field> {
6+
pub(super) fn wifi_to_fields(wifi: &WifiCredential) -> Vec<Field> {
77
[
88
// SSID: Text field
99
wifi.ssid.as_ref().map(|ssid| create_field("SSID", ssid)),

crates/bitwarden-ssh/src/import.rs

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,11 @@ pub fn import_key(
3232
}
3333
}
3434

35-
fn import_pkcs8_key(
36-
encoded_key: String,
37-
password: Option<String>,
38-
) -> Result<SshKeyView, SshKeyImportError> {
39-
let doc = if let Some(password) = password {
40-
SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()).map_err(
41-
|err| match err {
42-
pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
43-
SshKeyImportError::WrongPassword
44-
}
45-
_ => SshKeyImportError::ParsingError,
46-
},
47-
)?
48-
} else {
49-
SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)?
50-
};
51-
35+
/// Import a DER encoded private key, and returns a decoded [SshKeyView]. This is primarily used for
36+
/// importing SSH keys from other Credential Managers through Credential Exchange.
37+
pub fn import_der_key(encoded_key: &[u8]) -> Result<SshKeyView, SshKeyImportError> {
5238
let private_key_info =
53-
PrivateKeyInfo::from_der(doc.as_bytes()).map_err(|_| SshKeyImportError::ParsingError)?;
39+
PrivateKeyInfo::from_der(encoded_key).map_err(|_| SshKeyImportError::ParsingError)?;
5440

5541
let private_key = match private_key_info.algorithm.oid {
5642
ed25519::pkcs8::ALGORITHM_OID => {
@@ -75,6 +61,26 @@ fn import_pkcs8_key(
7561
ssh_private_key_to_view(private_key).map_err(|_| SshKeyImportError::ParsingError)
7662
}
7763

64+
fn import_pkcs8_key(
65+
encoded_key: String,
66+
password: Option<String>,
67+
) -> Result<SshKeyView, SshKeyImportError> {
68+
let doc = if let Some(password) = password {
69+
SecretDocument::from_pkcs8_encrypted_pem(&encoded_key, password.as_bytes()).map_err(
70+
|err| match err {
71+
pkcs8::Error::EncryptedPrivateKey(pkcs5::Error::DecryptFailed) => {
72+
SshKeyImportError::WrongPassword
73+
}
74+
_ => SshKeyImportError::ParsingError,
75+
},
76+
)?
77+
} else {
78+
SecretDocument::from_pkcs8_pem(&encoded_key).map_err(|_| SshKeyImportError::ParsingError)?
79+
};
80+
81+
import_der_key(doc.as_bytes())
82+
}
83+
7884
fn import_openssh_key(
7985
encoded_key: String,
8086
password: Option<String>,

0 commit comments

Comments
 (0)