mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
PM-10393 SSH keys (#10825)
* [PM-10395] Add new item type ssh key (#10360) * Implement ssh-key cipher type * Fix linting * Fix edit and view components for ssh-keys on desktop * Fix tests * Remove ssh key type references * Remove add ssh key option * Fix typo * Add tests * [PM-10399] Add ssh key import export for bitwarden json (#10529) * Add ssh key import export for bitwarden json * Remove key type from ssh key export * [PM-10406] Add privatekey publickey and fingerprint to both add-edit and view co… (#11046) * Add privatekey publickey and fingerprint to both add-edit and view components * Remove wrong a11y title * Fix testid * [PM-10098] SSH Agent & SSH Key creation for Bitwarden Desktop (#10293) * Add ssh agent, generator & import * Move ssh agent code to bitwarden-russh crate * Remove generator component * Cleanup * Cleanup * Remove left over sshGenerator reference * Cleanup * Add documentation to sshkeyimportstatus * Fix outdated variable name * Update apps/desktop/src/platform/preload.ts Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Rename renderersshagent * Rename MainSshAgentService * Improve clarity of 'id' variables being used * Improve clarity of 'id' variables being used * Update apps/desktop/src/vault/app/vault/add-edit.component.html Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Fix outdated cipher/messageid names * Rename SSH to Ssh * Make agent syncing more reactive * Move constants to top of class * Make sshkey cipher filtering clearer * Add stricter equality check on ssh key unlock * Fix build and messages * Fix incorrect featureflag name * Replace anonymous async function with switchmap pipe * Fix build * Update apps/desktop/desktop_native/napi/src/lib.rs Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Revert incorrectly renamed 'Ssh' usages to SSH * Run cargo fmt * Clean up ssh agent sock path logic * Cleanup and split to platform specific files * Small cleanup * Pull out generator and importer into core * Rename renderersshagentservice to sshagentservice * Rename cipheruuid to cipher_id * Drop ssh dependencies from napi crate * Clean up windows build * Small cleanup * Small cleanup * Cleanup * Add rxjs pipeline for agent services * [PM-12555] Pkcs8 sshkey import & general ssh key import tests (#11048) * Add pkcs8 import and tests * Add key type unsupported error * Remove unsupported formats * Remove code for unsupported formats * Fix encrypted pkcs8 import * Add ed25519 pkcs8 unencrypted test file * SSH agent rxjs tweaks (#11148) * feat: rewrite sshagent.signrequest as purely observable * feat: fail the request when unlock times out * chore: clean up, add some clarifying comments * chore: remove unused dependency * fix: result `undefined` crashing in NAPI -> Rust * Allow concurrent SSH requests in rust * Remove unwraps * Cleanup and add init service init call * Fix windows * Fix timeout behavior on locked vault --------- Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> * Fix libc dependency being duplicated * fix SSH casing (#11840) * Move ssh agent behind feature flag (#11841) * Move ssh agent behind feature flag * Add separate flag for ssh agent * [PM-14215] fix unsupported key type error message (#11788) * Fix error message for import of unsupported ssh keys * Use triple equals in add-edit component for ssh keys --------- Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com> Co-authored-by: aj-bw <81774843+aj-bw@users.noreply.github.com>
This commit is contained in:
parent
2c914def29
commit
081fe83d83
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@ -122,6 +122,9 @@ apps/cli/src/locales/en/messages.json
|
||||
apps/desktop/src/locales/en/messages.json
|
||||
apps/web/src/locales/en/messages.json
|
||||
|
||||
## Ssh agent temporary co-codeowner
|
||||
apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-platform-dev @bitwarden/wg-ssh-keys
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre
|
||||
.github/workflows/deploy-web.yml @bitwarden/dept-bre
|
||||
|
@ -152,6 +152,15 @@
|
||||
"copyLicenseNumber": {
|
||||
"message": "Copy license number"
|
||||
},
|
||||
"copyPrivateKey": {
|
||||
"message": "Copy private key"
|
||||
},
|
||||
"copyPublicKey": {
|
||||
"message": "Copy public key"
|
||||
},
|
||||
"copyFingerprint": {
|
||||
"message": "Copy fingerprint"
|
||||
},
|
||||
"copyCustomField": {
|
||||
"message": "Copy $FIELD$",
|
||||
"placeholders": {
|
||||
@ -1764,6 +1773,9 @@
|
||||
"typeIdentity": {
|
||||
"message": "Identity"
|
||||
},
|
||||
"typeSshKey": {
|
||||
"message": "SSH key"
|
||||
},
|
||||
"newItemHeader": {
|
||||
"message": "New $TYPE$",
|
||||
"placeholders": {
|
||||
@ -4593,6 +4605,30 @@
|
||||
"enterprisePolicyRequirementsApplied": {
|
||||
"message": "Enterprise policy requirements have been applied to this setting"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"message": "Private key"
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"message": "Public key"
|
||||
},
|
||||
"sshFingerprint": {
|
||||
"message": "Fingerprint"
|
||||
},
|
||||
"sshKeyAlgorithm": {
|
||||
"message": "Key type"
|
||||
},
|
||||
"sshKeyAlgorithmED25519": {
|
||||
"message": "ED25519"
|
||||
},
|
||||
"sshKeyAlgorithmRSA2048": {
|
||||
"message": "RSA 2048-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA3072": {
|
||||
"message": "RSA 3072-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA4096": {
|
||||
"message": "RSA 4096-Bit"
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
|
@ -331,6 +331,8 @@ export class AddEditV2Component implements OnInit {
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLocaleLowerCase());
|
||||
case CipherType.SecureNote:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("note").toLocaleLowerCase());
|
||||
case CipherType.SshKey:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeSshKey").toLocaleLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,3 +88,27 @@
|
||||
[cipher]="cipher"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
|
||||
<bit-item-action *ngIf="cipher.type === CipherType.SshKey">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[appA11yTitle]="
|
||||
hasSshKeyValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
|
||||
"
|
||||
[disabled]="!hasSshKeyValues"
|
||||
[bitMenuTriggerFor]="sshKeyOptions"
|
||||
></button>
|
||||
<bit-menu #sshKeyOptions>
|
||||
<button type="button" bitMenuItem appCopyField="privateKey" [cipher]="cipher">
|
||||
{{ "copyPrivateKey" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="publicKey" [cipher]="cipher">
|
||||
{{ "copyPublicKey" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="keyFingerprint" [cipher]="cipher">
|
||||
{{ "copyFingerprint" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</bit-item-action>
|
||||
|
@ -48,5 +48,13 @@ export class ItemCopyActionsComponent {
|
||||
return !!this.cipher.notes;
|
||||
}
|
||||
|
||||
get hasSshKeyValues() {
|
||||
return (
|
||||
!!this.cipher.sshKey.privateKey ||
|
||||
!!this.cipher.sshKey.publicKey ||
|
||||
!!this.cipher.sshKey.keyFingerprint
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
@ -131,6 +131,8 @@ export class ViewV2Component {
|
||||
);
|
||||
case CipherType.SecureNote:
|
||||
return this.i18nService.t("viewItemHeader", this.i18nService.t("note").toLowerCase());
|
||||
case CipherType.SshKey:
|
||||
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeSshkey").toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -529,6 +529,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SshKey -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.privateKey" style="overflow: hidden">
|
||||
<span class="row-label"> {{ "sshPrivateKey" | i18n }}</span>
|
||||
{{ cipher.sshKey.privateKey }}
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.publicKey" style="overflow: hidden">
|
||||
<span class="row-label"> {{ "sshPublicKey" | i18n }}</span>
|
||||
{{ cipher.sshKey.publicKey }}
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row"
|
||||
*ngIf="cipher.sshKey.keyFingerprint"
|
||||
style="overflow: hidden"
|
||||
>
|
||||
<span class="row-label"> {{ "sshKeyFingerprint" | i18n }}</span>
|
||||
{{ cipher.sshKey.keyFingerprint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||
|
@ -114,6 +114,19 @@
|
||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.SecureNote) || 0 }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row"
|
||||
appStopClick
|
||||
(click)="selectType(cipherType.SshKey)"
|
||||
>
|
||||
<div class="row-main">
|
||||
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-key"></i></div>
|
||||
<span class="text">{{ "typeSshKey" | i18n }}</span>
|
||||
</div>
|
||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.SshKey) || 0 }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list" *ngIf="nestedFolders?.length">
|
||||
|
@ -106,6 +106,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
|
||||
case CipherType.SecureNote:
|
||||
this.groupingTitle = this.i18nService.t("secureNotes");
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.groupingTitle = this.i18nService.t("sshKeys");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -429,6 +429,39 @@
|
||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SshKey -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.privateKey" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.privateKey)"
|
||||
>
|
||||
{{ "sshPrivateKey" | i18n }}
|
||||
</span>
|
||||
<div [innerText]="cipher.sshKey.maskedPrivateKey" class="monospaced"></div>
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.publicKey" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.publicKey)"
|
||||
>
|
||||
{{ "sshPublicKey" | i18n }}</span
|
||||
>
|
||||
{{ cipher.sshKey.publicKey }}
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.keyFingerprint" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.keyFingerprint)"
|
||||
>
|
||||
{{ "sshFingerprint" | i18n }}</span
|
||||
>
|
||||
{{ cipher.sshKey.keyFingerprint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||
|
@ -202,6 +202,7 @@ describe("VaultPopupItemsService", () => {
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
[CipherType.SshKey]: 5,
|
||||
};
|
||||
|
||||
// Assume all ciphers are autofill ciphers to test sorting
|
||||
|
@ -277,6 +277,7 @@ export class VaultPopupItemsService {
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
[CipherType.SshKey]: 5,
|
||||
};
|
||||
|
||||
// Compare types first
|
||||
|
@ -97,6 +97,7 @@ describe("VaultPopupListFiltersService", () => {
|
||||
CipherType.Card,
|
||||
CipherType.Identity,
|
||||
CipherType.SecureNote,
|
||||
CipherType.SshKey,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -163,6 +163,11 @@ export class VaultPopupListFiltersService {
|
||||
label: this.i18nService.t("note"),
|
||||
icon: "bwi-sticky-note",
|
||||
},
|
||||
{
|
||||
value: CipherType.SshKey,
|
||||
label: this.i18nService.t("typeSshKey"),
|
||||
icon: "bwi-key",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resets `filterForm` to the original state */
|
||||
|
637
apps/desktop/desktop_native/Cargo.lock
generated
637
apps/desktop/desktop_native/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -27,21 +27,39 @@ anyhow = "=1.0.93"
|
||||
arboard = { version = "=3.4.1", default-features = false, features = [
|
||||
"wayland-data-control",
|
||||
] }
|
||||
async-stream = "0.3.5"
|
||||
base64 = "=0.22.1"
|
||||
byteorder = "1.5.0"
|
||||
cbc = { version = "=0.1.2", features = ["alloc"] }
|
||||
homedir = "0.3.3"
|
||||
libc = "=0.2.159"
|
||||
pin-project = "1.1.5"
|
||||
dirs = "=5.0.1"
|
||||
futures = "=0.3.31"
|
||||
interprocess = { version = "=2.2.1", features = ["tokio"] }
|
||||
libc = "=0.2.159"
|
||||
log = "=0.4.22"
|
||||
rand = "=0.8.5"
|
||||
retry = "=2.0.0"
|
||||
russh-cryptovec = "0.7.3"
|
||||
scopeguard = "=1.2.0"
|
||||
sha2 = "=0.10.8"
|
||||
ssh-encoding = "0.2.0"
|
||||
ssh-key = { version = "0.6.6", default-features = false, features = [
|
||||
"encryption",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"getrandom",
|
||||
] }
|
||||
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", branch = "km/pm-10098/clean-russh-implementation" }
|
||||
tokio = { version = "=1.40.0", features = ["io-util", "sync", "macros", "net"] }
|
||||
tokio-stream = { version = "=0.1.15", features = ["net"] }
|
||||
tokio-util = "=0.7.11"
|
||||
thiserror = "=1.0.68"
|
||||
tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] }
|
||||
tokio-util = "=0.7.12"
|
||||
typenum = "=1.17.0"
|
||||
rand_chacha = "=0.3.1"
|
||||
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
|
||||
rsa = "=0.9.6"
|
||||
ed25519 = { version = "=2.2.3", features = ["pkcs8"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
widestring = { version = "=1.1.0", optional = true }
|
||||
|
@ -6,8 +6,8 @@ use anyhow::{anyhow, Result};
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod biometric;
|
||||
|
||||
pub use biometric::Biometric;
|
||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||
pub use biometric::Biometric;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::crypto::{self, CipherString};
|
||||
@ -42,7 +42,6 @@ pub trait BiometricTrait {
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
||||
|
||||
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
||||
let iv = base64_engine
|
||||
.decode(iv_b64)?
|
||||
@ -77,4 +76,4 @@ impl KeyMaterial {
|
||||
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
|
||||
Ok(Sha256::digest(self.digest_material()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,13 +5,13 @@ use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine};
|
||||
use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey};
|
||||
use zbus::Connection;
|
||||
use zbus_polkit::policykit1::*;
|
||||
|
||||
use super::{decrypt, encrypt};
|
||||
use anyhow::anyhow;
|
||||
use crate::crypto::CipherString;
|
||||
use anyhow::anyhow;
|
||||
|
||||
/// The Unix implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
@ -22,13 +22,15 @@ impl super::BiometricTrait for Biometric {
|
||||
let proxy = AuthorityProxy::new(&connection).await?;
|
||||
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
|
||||
let details = std::collections::HashMap::new();
|
||||
let result = proxy.check_authorization(
|
||||
&subject,
|
||||
"com.bitwarden.Bitwarden.unlock",
|
||||
&details,
|
||||
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
||||
"",
|
||||
).await;
|
||||
let result = proxy
|
||||
.check_authorization(
|
||||
&subject,
|
||||
"com.bitwarden.Bitwarden.unlock",
|
||||
&details,
|
||||
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(result) => {
|
||||
@ -106,4 +108,4 @@ fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut challenge);
|
||||
challenge
|
||||
}
|
||||
}
|
||||
|
@ -160,7 +160,6 @@ impl super::BiometricTrait for Biometric {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut challenge);
|
||||
|
@ -11,3 +11,6 @@ pub mod password;
|
||||
pub mod process_isolation;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod powermonitor;
|
||||
#[cfg(feature = "sys")]
|
||||
|
||||
pub mod ssh_agent;
|
||||
|
@ -41,7 +41,11 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn is_available() -> Result<bool> {
|
||||
let result = password_clear_sync(Some(&get_schema()), build_attributes("bitwardenSecretsAvailabilityTest", "test"), gio::Cancellable::NONE);
|
||||
let result = password_clear_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes("bitwardenSecretsAvailabilityTest", "test"),
|
||||
gio::Cancellable::NONE,
|
||||
);
|
||||
match result {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use zbus::{Connection, MatchRule, export::futures_util::TryStreamExt};
|
||||
use zbus::{export::futures_util::TryStreamExt, Connection, MatchRule};
|
||||
struct ScreenLock {
|
||||
interface: Cow<'static, str>,
|
||||
path: Cow<'static, str>,
|
||||
@ -42,7 +42,15 @@ pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn st
|
||||
pub async fn is_lock_monitor_available() -> bool {
|
||||
let connection = Connection::session().await.unwrap();
|
||||
for monitor in SCREEN_LOCK_MONITORS {
|
||||
let res = connection.call_method(Some(monitor.interface.clone()), monitor.path.clone(), Some(monitor.interface.clone()), "GetActive", &()).await;
|
||||
let res = connection
|
||||
.call_method(
|
||||
Some(monitor.interface.clone()),
|
||||
monitor.path.clone(),
|
||||
Some(monitor.interface.clone()),
|
||||
"GetActive",
|
||||
&(),
|
||||
)
|
||||
.await;
|
||||
if res.is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use libc::{c_int, self};
|
||||
#[cfg(target_env = "gnu")]
|
||||
use libc::c_uint;
|
||||
use libc::{self, c_int};
|
||||
|
||||
// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes
|
||||
// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20
|
||||
@ -22,7 +22,10 @@ pub fn disable_coredumps() -> Result<()> {
|
||||
};
|
||||
if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to disable core dumping, memory might be persisted to disk on crashes {}", e))
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to disable core dumping, memory might be persisted to disk on crashes {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -35,7 +38,7 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
|
||||
};
|
||||
if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to get core dump limit {}", e))
|
||||
return Err(anyhow::anyhow!("failed to get core dump limit {}", e));
|
||||
}
|
||||
|
||||
Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0)
|
||||
@ -44,7 +47,10 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
|
||||
pub fn disable_memory_access() -> Result<()> {
|
||||
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to disable memory dumping, memory is dumpable by other processes {}", e))
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to disable memory dumping, memory is dumpable by other processes {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
45
apps/desktop/desktop_native/core/src/ssh_agent/generator.rs
Normal file
45
apps/desktop/desktop_native/core/src/ssh_agent/generator.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use ssh_key::{Algorithm, HashAlg, LineEnding};
|
||||
|
||||
use super::importer::SshKey;
|
||||
|
||||
pub async fn generate_keypair(key_algorithm: String) -> Result<SshKey, anyhow::Error> {
|
||||
// sourced from cryptographically secure entropy source, with sources for all targets: https://docs.rs/getrandom
|
||||
// if it cannot be securely sourced, this will panic instead of leading to a weak key
|
||||
let mut rng: ChaCha8Rng = ChaCha8Rng::from_entropy();
|
||||
|
||||
let key = match key_algorithm.as_str() {
|
||||
"ed25519" => ssh_key::PrivateKey::random(&mut rng, Algorithm::Ed25519),
|
||||
"rsa2048" | "rsa3072" | "rsa4096" => {
|
||||
let bits = match key_algorithm.as_str() {
|
||||
"rsa2048" => 2048,
|
||||
"rsa3072" => 3072,
|
||||
"rsa4096" => 4096,
|
||||
_ => return Err(anyhow::anyhow!("Unsupported RSA key size")),
|
||||
};
|
||||
let rsa_keypair = ssh_key::private::RsaKeypair::random(&mut rng, bits)
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
|
||||
let private_key = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::KeypairData::from(rsa_keypair),
|
||||
"".to_string(),
|
||||
)
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
Ok(private_key)
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!("Unsupported key algorithm"));
|
||||
}
|
||||
}
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
|
||||
let private_key_openssh = key
|
||||
.to_openssh(LineEnding::LF)
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
Ok(SshKey {
|
||||
private_key: private_key_openssh.to_string(),
|
||||
public_key: key.public_key().to_string(),
|
||||
key_fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
})
|
||||
}
|
395
apps/desktop/desktop_native/core/src/ssh_agent/importer.rs
Normal file
395
apps/desktop/desktop_native/core/src/ssh_agent/importer.rs
Normal file
@ -0,0 +1,395 @@
|
||||
use ed25519;
|
||||
use pkcs8::{
|
||||
der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument,
|
||||
};
|
||||
use ssh_key::{
|
||||
private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair},
|
||||
HashAlg, LineEnding,
|
||||
};
|
||||
|
||||
const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----";
|
||||
const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----";
|
||||
const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
|
||||
const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
||||
|
||||
pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier =
|
||||
ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
|
||||
|
||||
#[derive(Debug)]
|
||||
enum KeyType {
|
||||
Ed25519,
|
||||
Rsa,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn import_key(
|
||||
encoded_key: String,
|
||||
password: String,
|
||||
) -> Result<SshKeyImportResult, anyhow::Error> {
|
||||
match encoded_key.lines().next() {
|
||||
Some(PKCS1_HEADER) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
Some(PKCS8_UNENCRYPTED_HEADER) => {
|
||||
return match import_pkcs8_key(encoded_key, None) {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) {
|
||||
Ok(result) => {
|
||||
return Ok(result);
|
||||
}
|
||||
Err(err) => match err {
|
||||
SshKeyImportError::PasswordRequired => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::PasswordRequired,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
SshKeyImportError::WrongPassword => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
SshKeyImportError::ParsingError => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
Some(OPENSSH_HEADER) => {
|
||||
return import_openssh_key(encoded_key, password);
|
||||
}
|
||||
Some(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_pkcs8_key(
|
||||
encoded_key: String,
|
||||
password: Option<String>,
|
||||
) -> Result<SshKeyImportResult, SshKeyImportError> {
|
||||
let der = match SecretDocument::from_pem(&encoded_key) {
|
||||
Ok((_, doc)) => doc,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let decrypted_der = match password.clone() {
|
||||
Some(password) => {
|
||||
let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes())
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
match encrypted_private_key_info.decrypt(password.as_bytes()) {
|
||||
Ok(der) => der,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => der,
|
||||
};
|
||||
|
||||
let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes())
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?
|
||||
.algorithm
|
||||
.oid
|
||||
{
|
||||
ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519,
|
||||
RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa,
|
||||
_ => KeyType::Unknown,
|
||||
};
|
||||
|
||||
match key_type {
|
||||
KeyType::Ed25519 => {
|
||||
let pk: ed25519::KeypairBytes = match password {
|
||||
Some(password) => {
|
||||
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
|
||||
.map_err(|err| match err {
|
||||
ed25519::pkcs8::Error::EncryptedPrivateKey(_) => {
|
||||
SshKeyImportError::WrongPassword
|
||||
}
|
||||
_ => SshKeyImportError::ParsingError,
|
||||
})?
|
||||
}
|
||||
None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?,
|
||||
};
|
||||
let pk: Ed25519Keypair =
|
||||
Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key));
|
||||
let private_key = ssh_key::private::PrivateKey::from(pk);
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
KeyType::Rsa => {
|
||||
let pk: rsa::RsaPrivateKey = match password {
|
||||
Some(password) => {
|
||||
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
|
||||
.map_err(|err| match err {
|
||||
pkcs8::Error::EncryptedPrivateKey(_) => {
|
||||
SshKeyImportError::WrongPassword
|
||||
}
|
||||
_ => SshKeyImportError::ParsingError,
|
||||
})?
|
||||
}
|
||||
None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?,
|
||||
};
|
||||
let rsa_keypair: Result<RsaKeypair, ssh_key::Error> = RsaKeypair::try_from(pk);
|
||||
match rsa_keypair {
|
||||
Ok(rsa_keypair) => {
|
||||
let private_key = ssh_key::private::PrivateKey::from(rsa_keypair);
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key
|
||||
.to_openssh(LineEnding::LF)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_openssh_key(
|
||||
encoded_key: String,
|
||||
password: String,
|
||||
) -> Result<SshKeyImportResult, anyhow::Error> {
|
||||
let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key);
|
||||
let private_key = match private_key {
|
||||
Ok(k) => k,
|
||||
Err(err) => {
|
||||
match err {
|
||||
ssh_key::Error::AlgorithmUnknown
|
||||
| ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if private_key.is_encrypted() && password.is_empty() {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::PasswordRequired,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
let private_key = if private_key.is_encrypted() {
|
||||
match private_key.decrypt(password.as_bytes()) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
private_key
|
||||
};
|
||||
|
||||
match private_key.to_openssh(LineEnding::LF) {
|
||||
Ok(private_key_openssh) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key_openssh.to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
}),
|
||||
Err(_) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum SshKeyImportStatus {
|
||||
/// ssh key was parsed correctly and will be returned in the result
|
||||
Success,
|
||||
/// ssh key was parsed correctly but is encrypted and requires a password
|
||||
PasswordRequired,
|
||||
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
|
||||
WrongPassword,
|
||||
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
|
||||
ParsingError,
|
||||
/// ssh key type is not supported
|
||||
UnsupportedKeyType,
|
||||
}
|
||||
|
||||
pub enum SshKeyImportError {
|
||||
ParsingError,
|
||||
PasswordRequired,
|
||||
WrongPassword,
|
||||
}
|
||||
|
||||
pub struct SshKeyImportResult {
|
||||
pub status: SshKeyImportStatus,
|
||||
pub ssh_key: Option<SshKey>,
|
||||
}
|
||||
|
||||
pub struct SshKey {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted");
|
||||
let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_encrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
|
||||
let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_openssh_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_openssh_unencrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_openssh_encrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_openssh_encrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_pkcs8_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted");
|
||||
let public_key =
|
||||
include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_pkcs8_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted");
|
||||
// for whatever reason pkcs8 + rsa does not include the comment in the public key
|
||||
let public_key =
|
||||
include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_pkcs8_encrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_encrypted_wrong_password() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
|
||||
let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::WrongPassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_non_key_error() {
|
||||
let result = import_key("not a key".to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ecdsa_error() {
|
||||
let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted");
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
|
||||
}
|
||||
}
|
118
apps/desktop/desktop_native/core/src/ssh_agent/mod.rs
Normal file
118
apps/desktop/desktop_native/core/src/ssh_agent/mod.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use bitwarden_russh::ssh_agent::{self, Key};
|
||||
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "unix.rs")]
|
||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||
mod platform_ssh_agent;
|
||||
|
||||
pub mod generator;
|
||||
pub mod importer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore,
|
||||
cancellation_token: CancellationToken,
|
||||
show_ui_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
get_ui_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
request_id: Arc<Mutex<u32>>,
|
||||
}
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
async fn get_request_id(&self) -> u32 {
|
||||
let mut request_id = self.request_id.lock().await;
|
||||
*request_id += 1;
|
||||
*request_id
|
||||
}
|
||||
}
|
||||
|
||||
impl ssh_agent::Agent for BitwardenDesktopAgent {
|
||||
async fn confirm(&self, ssh_key: Key) -> bool {
|
||||
let request_id = self.get_request_id().await;
|
||||
|
||||
let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe();
|
||||
self.show_ui_request_tx
|
||||
.send((request_id, ssh_key.cipher_uuid.clone()))
|
||||
.await
|
||||
.expect("Should send request to ui");
|
||||
while let Ok((id, response)) = rx_channel.recv().await {
|
||||
if id == request_id {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub fn stop(&self) {
|
||||
self.cancellation_token.cancel();
|
||||
self.keystore
|
||||
.0
|
||||
.write()
|
||||
.expect("RwLock is not poisoned")
|
||||
.clear();
|
||||
}
|
||||
|
||||
pub fn set_keys(
|
||||
&mut self,
|
||||
new_keys: Vec<(String, String, String)>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let keystore = &mut self.keystore;
|
||||
keystore.0.write().expect("RwLock is not poisoned").clear();
|
||||
|
||||
for (key, name, cipher_id) in new_keys.iter() {
|
||||
match parse_key_safe(&key) {
|
||||
Ok(private_key) => {
|
||||
let public_key_bytes = private_key
|
||||
.public_key()
|
||||
.to_bytes()
|
||||
.expect("Cipher private key is always correctly parsed");
|
||||
keystore.0.write().expect("RwLock is not poisoned").insert(
|
||||
public_key_bytes,
|
||||
Key {
|
||||
private_key: Some(private_key),
|
||||
name: name.clone(),
|
||||
cipher_uuid: cipher_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lock(&mut self) -> Result<(), anyhow::Error> {
|
||||
let keystore = &mut self.keystore;
|
||||
keystore
|
||||
.0
|
||||
.write()
|
||||
.expect("RwLock is not poisoned")
|
||||
.iter_mut()
|
||||
.for_each(|(_public_key, key)| {
|
||||
key.private_key = None;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Error> {
|
||||
match ssh_key::private::PrivateKey::from_openssh(pem) {
|
||||
Ok(key) => match key.public_key().to_bytes() {
|
||||
Ok(_) => Ok(key),
|
||||
Err(e) => Err(anyhow::Error::msg(format!(
|
||||
"Failed to parse public key: {}",
|
||||
e
|
||||
))),
|
||||
},
|
||||
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))),
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use futures::Stream;
|
||||
use tokio::{
|
||||
net::windows::named_pipe::{NamedPipeServer, ServerOptions},
|
||||
select,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent";
|
||||
|
||||
#[pin_project::pin_project]
|
||||
pub struct NamedPipeServerStream {
|
||||
rx: tokio::sync::mpsc::Receiver<NamedPipeServer>,
|
||||
}
|
||||
|
||||
impl NamedPipeServerStream {
|
||||
pub fn new(cancellation_token: CancellationToken) -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
tokio::spawn(async move {
|
||||
println!(
|
||||
"[SSH Agent Native Module] Creating named pipe server on {}",
|
||||
PIPE_NAME
|
||||
);
|
||||
let mut listener = ServerOptions::new().create(PIPE_NAME).unwrap();
|
||||
loop {
|
||||
println!("[SSH Agent Native Module] Waiting for connection");
|
||||
select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
println!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server");
|
||||
break;
|
||||
}
|
||||
_ = listener.connect() => {
|
||||
println!("[SSH Agent Native Module] Incoming connection");
|
||||
tx.send(listener).await.unwrap();
|
||||
listener = ServerOptions::new().create(PIPE_NAME).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { rx }
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for NamedPipeServerStream {
|
||||
type Item = io::Result<NamedPipeServer>;
|
||||
|
||||
fn poll_next(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<io::Result<NamedPipeServer>>> {
|
||||
let this = self.project();
|
||||
|
||||
this.rx.poll_recv(cx).map(|v| v.map(Ok))
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
|
||||
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g
|
||||
Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ
|
||||
XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx
|
||||
IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx
|
||||
oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey
|
@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if
|
||||
fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI
|
||||
2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf
|
||||
WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5
|
||||
1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK
|
||||
NdJ8xATiIINuTy4g==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey
|
@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S
|
||||
+gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g
|
||||
AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167
|
||||
xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey
|
@ -0,0 +1,4 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz
|
||||
gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0=
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t
|
@ -0,0 +1,39 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
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
|
||||
2MmKBw==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey
|
@ -0,0 +1,38 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
|
||||
NhAAAAAwEAAQAAAYEAtVIe0gnPtD6299/roT7ntZgVe+qIqIMIruJdI2xTanLGhNpBOlzg
|
||||
WqokbQK+aXATcaB7iQL1SPxIWV2M4jEBQbZuimIgDQvKbJ4TZPKEe1VdsrfuIo+9pDK7cG
|
||||
Kc+JiWhKjqeTRMj91/qR1fW5IWOUyE1rkwhTNkwJqtYKZLVmd4TXtQsYMMC+I0cz4krfk1
|
||||
Yqmaae/gj12h8BvE3Y+Koof4JoLsqPufH+H/bVEayv63RyAQ1/tUv9l+rwJ+svWV4X3zf3
|
||||
z40hGF43L/NGl90Vutbn7b9G/RgEdiXyLZciP3XbWbLUM+r7mG9KNuSeoixe5jok15UKqC
|
||||
XXxVb5IEZ73kaubSfz9JtsqtKG/OjOq6Fbl3Ky7kjvJyGpIvesuSInlpzPXqbLUCLJJfOA
|
||||
PUZ1wi8uuuRNePzQBMMhq8UtAbB2Dy16d+HlgghzQ00NxtbQMfDZBdApfxm3shIxkUcHzb
|
||||
DSvriHVaGGoOkmHPAmsdMsMiekuUMe9ljdOhmdTxAAAFgF8XjBxfF4wcAAAAB3NzaC1yc2
|
||||
EAAAGBALVSHtIJz7Q+tvff66E+57WYFXvqiKiDCK7iXSNsU2pyxoTaQTpc4FqqJG0Cvmlw
|
||||
E3Gge4kC9Uj8SFldjOIxAUG2bopiIA0LymyeE2TyhHtVXbK37iKPvaQyu3BinPiYloSo6n
|
||||
k0TI/df6kdX1uSFjlMhNa5MIUzZMCarWCmS1ZneE17ULGDDAviNHM+JK35NWKpmmnv4I9d
|
||||
ofAbxN2PiqKH+CaC7Kj7nx/h/21RGsr+t0cgENf7VL/Zfq8CfrL1leF98398+NIRheNy/z
|
||||
RpfdFbrW5+2/Rv0YBHYl8i2XIj9121my1DPq+5hvSjbknqIsXuY6JNeVCqgl18VW+SBGe9
|
||||
5Grm0n8/SbbKrShvzozquhW5dysu5I7ychqSL3rLkiJ5acz16my1AiySXzgD1GdcIvLrrk
|
||||
TXj80ATDIavFLQGwdg8tenfh5YIIc0NNDcbW0DHw2QXQKX8Zt7ISMZFHB82w0r64h1Whhq
|
||||
DpJhzwJrHTLDInpLlDHvZY3ToZnU8QAAAAMBAAEAAAGAEL3wpRWtVTf+NnR5QgX4KJsOjs
|
||||
bI0ABrVpSFo43uxNMss9sgLzagq5ZurxcUBFHKJdF63puEkPTkbEX4SnFaa5of6kylp3a5
|
||||
fd55rXY8F9Q5xtT3Wr8ZdFYP2xBr7INQUJb1MXRMBnOeBDw3UBH01d0UHexzB7WHXcZacG
|
||||
Ria+u5XrQebwmJ3PYJwENSaTLrxDyjSplQy4QKfgxeWNPWaevylIG9vtue5Xd9WXdl6Szs
|
||||
ONfD3mFxQZagPSIWl0kYIjS3P2ZpLe8+sakRcfci8RjEUP7U+QxqY5VaQScjyX1cSYeQLz
|
||||
t+/6Tb167aNtQ8CVW3IzM2EEN1BrSbVxFkxWFLxogAHct06Kn87nPn2+PWGWOVCBp9KheO
|
||||
FszWAJ0Kzjmaga2BpOJcrwjSpGopAb1YPIoRPVepVZlQ4gGwy5gXCFwykT9WTBoJfg0BMQ
|
||||
r3MSNcoc97eBomIWEa34K0FuQ3rVjMv9ylfyLvDBbRqTJ5zebeOuU+yCQHZUKk8klRAAAA
|
||||
wAsToNZvYWRsOMTWQom0EW1IHzoL8Cyua+uh72zZi/7enm4yHPJiu2KNgQXfB0GEEjHjbo
|
||||
9peCW3gZGTV+Ee+cAqwYLlt0SMl/VJNxN3rEG7BAqPZb42Ii2XGjaxzFq0cliUGAdo6UEd
|
||||
swU8d2I7m9vIZm4nDXzsWOBWgonTKBNyL0DQ6KNOGEyj8W0BTCm7Rzwy7EKzFWbIxr4lSc
|
||||
vDrJ3t6kOd7jZTF58kRMT0nxR0bf43YzF/3/qSvLYhQm/OOAAAAMEA2F6Yp8SrpQDNDFxh
|
||||
gi4GeywArrQO9r3EHjnBZi/bacxllSzCGXAvp7m9OKC1VD2wQP2JL1VEIZRUTuGGT6itrm
|
||||
QpX8OgoxlEJrlC5W0kHumZ3MFGd33W11u37gOilmd6+VfVXBziNG2rFohweAgs8X+Sg5AA
|
||||
nIfMV6ySXUlvLzMHpGeKRRnnQq9Cwn4rDkVQENLd1i4e2nWFhaPTUwVMR8YuOT766bywr3
|
||||
7vG1PQLF7hnf2c/oPHAru+XD9gJWs5AAAAwQDWiB2G23F4Tvq8FiK2mMusSjQzHupl83rm
|
||||
o3BSNRCvCjaLx6bWhDPSA1edNEF7VuP6rSp+i+UfSORHwOnlgnrvtcJeoDuA72hUeYuqD/
|
||||
1C9gghdhKzGTVf/IGTX1tH3rn2Gq9TEyrJs/ITcoOyZprz7VbaD3bP/NEER+m1EHi2TS/3
|
||||
SXQEtRm+IIBwba+QLUcsrWdQyIO+1OCXywDrAw50s7tjgr/goHgXTcrSXaKcIEOlPgBZH3
|
||||
YPuVuEtRYgX3kAAAAHdGVzdGtleQECAwQ=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1Uh7SCc+0Prb33+uhPue1mBV76oiogwiu4l0jbFNqcsaE2kE6XOBaqiRtAr5pcBNxoHuJAvVI/EhZXYziMQFBtm6KYiANC8psnhNk8oR7VV2yt+4ij72kMrtwYpz4mJaEqOp5NEyP3X+pHV9bkhY5TITWuTCFM2TAmq1gpktWZ3hNe1CxgwwL4jRzPiSt+TViqZpp7+CPXaHwG8Tdj4qih/gmguyo+58f4f9tURrK/rdHIBDX+1S/2X6vAn6y9ZXhffN/fPjSEYXjcv80aX3RW61uftv0b9GAR2JfItlyI/ddtZstQz6vuYb0o25J6iLF7mOiTXlQqoJdfFVvkgRnveRq5tJ/P0m2yq0ob86M6roVuXcrLuSO8nIaki96y5IieWnM9epstQIskl84A9RnXCLy665E14/NAEwyGrxS0BsHYPLXp34eWCCHNDTQ3G1tAx8NkF0Cl/GbeyEjGRRwfNsNK+uIdVoYag6SYc8Cax0ywyJ6S5Qx72WN06GZ1PE= testkey
|
@ -0,0 +1,42 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey
|
@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQCn4+QiJojZ9mgc
|
||||
9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrgl
|
||||
GG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9Ez
|
||||
JGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1G
|
||||
h3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGk
|
||||
nA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oR
|
||||
M4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6
|
||||
yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVX
|
||||
EKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0CAwEAAQKCAYAA
|
||||
2SDMf7OBHw1OGM9OQa1ZS4u+ktfQHhn31+FxbrhWGp+lDt8gYABVf6Y4dKN6rMtn
|
||||
7D9gVSAlZCAn3Hx8aWAvcXHaspxe9YXiZDTh+Kd8EIXxBQn+TiDA5LH0dryABqmM
|
||||
p20vYKtR7OS3lIIXfFBSrBMwdunKzLwmKwZLWq0SWf6vVbwpxRyR9CyByodF6Djm
|
||||
ZK3QB2qQ3jqlL1HWXL0VnyArY7HLvUvfLLK4vMPqnsSH+FdHvhcEhwqMlWT44g+f
|
||||
hqWtCJNnjDgLK3FPbI8Pz9TF8dWJvOmp5Q6iSBua1e9x2LizVuNSqiFc7ZTLeoG4
|
||||
nDj7T2BtqB0E1rNUDEN1aBo+UZmHJK7LrzfW/B+ssi2WwIpfxYa1lO6HFod5/YQi
|
||||
XV1GunyH1chCsbvOFtXvAHASO4HTKlJNbWhRF1GXqnKpAaHDPCVuwp3eq6Yf0oLb
|
||||
XrL3KFZ3jwWiWbpQXRVvpqzaJwZn3CN1yQgYS9j17a9wrPky+BoJxXjZ/oImWLEC
|
||||
gcEA0lkLwiHvmTYFTCC7PN938Agk9/NQs5PQ18MRn9OJmyfSpYqf/gNp+Md7xUgt
|
||||
F/MTif7uelp2J7DYf6fj9EYf9g4EuW+SQgFP4pfiJn1+zGFeTQq1ISvwjsA4E8ZS
|
||||
t+GIumjZTg6YiL1/A79u4wm24swt7iqnVViOPtPGOM34S1tAamjZzq2eZDmAF6pA
|
||||
fmuTMdinCMR1E1kNJYbxeqLiqQCXuwBBnHOOOJofN3AkvzjRUBB9udvniqYxH3PQ
|
||||
cxPxAoHBAMxT5KwBhZhnJedYN87Kkcpl7xdMkpU8b+aXeZoNykCeoC+wgIQexnSW
|
||||
mFk4HPkCNxvCWlbkOT1MHrTAKFnaOww23Ob+Vi6A9n0rozo9vtoJig114GB0gUqE
|
||||
mtfLhO1P5AE8yzogE+ILHyp0BqXt8vGIfzpDnCkN+GKl8gOOMPrR4NAcLO+Rshc5
|
||||
nLs7BGB4SEi126Y6mSfp85m0++1QhWMz9HzqJEHCWKVcZYdCdEONP9js04EUnK33
|
||||
KtlJIWzZTQKBwAT0pBpGwmZRp35Lpx2gBitZhcVxrg0NBnaO2fNyAGPvZD8SLQLH
|
||||
AdAiov/a23Uc/PDbWLL5Pp9gwzj+s5glrssVOXdE8aUscr1b5rARdNNL1/Tos6u8
|
||||
ZUZ3sNqGaZx7a8U4gyYboexWyo9EC1C+AdkGBm7+AkM4euFwC9N6xsa/t5zKK5d6
|
||||
76hc0m+8SxivYCBkgkrqlfeGuZCQxU+mVsC0it6U+va8ojUjLGkZ80OuCwBf4xZl
|
||||
3+acU7vx9o8/gQKBwB7BrhU6MWrsc+cr/1KQaXum9mNyckomi82RFYvb8Yrilcg3
|
||||
8FBy9XqNRKeBa9MLw1HZYpHbzsXsVF7u4eQMloDTLVNUC5L6dKAI1owoyTa24uH9
|
||||
0WWTg/a8mTZMe1jhgrew+AJq27NV6z4PswR9GenDmyshDDudz7rBsflZCQRoXUfW
|
||||
RelV7BHU6UPBsXn4ASF4xnRyM6WvcKy9coKZcUqqgm3fLM/9OizCCMJgfXHBrE+x
|
||||
7nBqst746qlEedSRrQKBwQCVYwwKCHNlZxl0/NMkDJ+hp7/InHF6mz/3VO58iCb1
|
||||
9TLDVUC2dDGPXNYwWTT9PclefwV5HNBHcAfTzgB4dpQyNiDyV914HL7DFEGduoPn
|
||||
wBYjeFre54v0YjjnskjJO7myircdbdX//i+7LMUw5aZZXCC8a5BD/rdV6IKJWJG5
|
||||
QBXbe5fVf1XwOjBTzlhIPIqhNFfSu+mFikp5BRwHGBqsKMju6inYmW6YADeY/SvO
|
||||
QjDEB37RqGZxqyIx8V2ZYwU=
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey
|
77
apps/desktop/desktop_native/core/src/ssh_agent/unix.rs
Normal file
77
apps/desktop/desktop_native/core/src/ssh_agent/unix.rs
Normal file
@ -0,0 +1,77 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use bitwarden_russh::ssh_agent;
|
||||
use homedir::my_home;
|
||||
use tokio::{net::UnixListener, sync::Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::BitwardenDesktopAgent;
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub async fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let agent = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
request_id: Arc::new(tokio::sync::Mutex::new(0)),
|
||||
};
|
||||
let cloned_agent_state = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
println!("[SSH Agent Native Module] BITWARDEN_SSH_AUTH_SOCK not set, using default path");
|
||||
|
||||
let ssh_agent_directory = match my_home() {
|
||||
Ok(Some(home)) => home,
|
||||
_ => PathBuf::from("/tmp/"),
|
||||
};
|
||||
ssh_agent_directory
|
||||
.join(".bitwarden-ssh-agent.sock")
|
||||
.to_str()
|
||||
.expect("Path should be valid")
|
||||
.to_owned()
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"[SSH Agent Native Module] Starting SSH Agent server on {:?}",
|
||||
ssh_path
|
||||
);
|
||||
let sockname = std::path::Path::new(&ssh_path);
|
||||
let _ = std::fs::remove_file(sockname);
|
||||
match UnixListener::bind(sockname) {
|
||||
Ok(listener) => {
|
||||
let wrapper = tokio_stream::wrappers::UnixListenerStream::new(listener);
|
||||
let cloned_keystore = cloned_agent_state.keystore.clone();
|
||||
let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone();
|
||||
let _ = ssh_agent::serve(
|
||||
wrapper,
|
||||
cloned_agent_state,
|
||||
cloned_keystore,
|
||||
cloned_cancellation_token,
|
||||
)
|
||||
.await;
|
||||
println!("[SSH Agent Native Module] SSH Agent server exited");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[SSH Agent Native Module] Error while starting agent server: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
}
|
41
apps/desktop/desktop_native/core/src/ssh_agent/windows.rs
Normal file
41
apps/desktop/desktop_native/core/src/ssh_agent/windows.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use bitwarden_russh::ssh_agent;
|
||||
pub mod named_pipe_listener_stream;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::BitwardenDesktopAgent;
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub async fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let agent_state = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
request_id: Arc::new(tokio::sync::Mutex::new(0)),
|
||||
};
|
||||
let stream = named_pipe_listener_stream::NamedPipeServerStream::new(
|
||||
agent_state.cancellation_token.clone(),
|
||||
);
|
||||
|
||||
let cloned_agent_state = agent_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = ssh_agent::serve(
|
||||
stream,
|
||||
cloned_agent_state.clone(),
|
||||
cloned_agent_state.keystore.clone(),
|
||||
cloned_agent_state.cancellation_token.clone(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Ok(agent_state)
|
||||
}
|
||||
}
|
@ -14,12 +14,15 @@ default = []
|
||||
manual_test = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "=0.22.1"
|
||||
hex = "=0.4.3"
|
||||
anyhow = "=1.0.93"
|
||||
desktop_core = { path = "../core" }
|
||||
napi = { version = "=2.16.13", features = ["async"] }
|
||||
napi-derive = "=2.16.12"
|
||||
tokio = { version = "1.38.0" }
|
||||
tokio-util = "0.7.11"
|
||||
tokio = { version = "=1.40.0" }
|
||||
tokio-util = "=0.7.11"
|
||||
tokio-stream = "=0.1.15"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "=0.3.0"
|
||||
|
35
apps/desktop/desktop_native/napi/index.d.ts
vendored
35
apps/desktop/desktop_native/napi/index.d.ts
vendored
@ -42,6 +42,41 @@ export declare namespace clipboards {
|
||||
export function read(): Promise<string>
|
||||
export function write(text: string, password: boolean): Promise<void>
|
||||
}
|
||||
export declare namespace sshagent {
|
||||
export interface PrivateKey {
|
||||
privateKey: string
|
||||
name: string
|
||||
cipherId: string
|
||||
}
|
||||
export interface SshKey {
|
||||
privateKey: string
|
||||
publicKey: string
|
||||
keyFingerprint: string
|
||||
}
|
||||
export const enum SshKeyImportStatus {
|
||||
/** ssh key was parsed correctly and will be returned in the result */
|
||||
Success = 0,
|
||||
/** ssh key was parsed correctly but is encrypted and requires a password */
|
||||
PasswordRequired = 1,
|
||||
/** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */
|
||||
WrongPassword = 2,
|
||||
/** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */
|
||||
ParsingError = 3,
|
||||
/** ssh key type is not supported (e.g. ecdsa) */
|
||||
UnsupportedKeyType = 4
|
||||
}
|
||||
export interface SshKeyImportResult {
|
||||
status: SshKeyImportStatus
|
||||
sshKey?: SshKey
|
||||
}
|
||||
export function serve(callback: (err: Error | null, arg: string) => any): Promise<SshAgentState>
|
||||
export function stop(agentState: SshAgentState): void
|
||||
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
|
||||
export function lock(agentState: SshAgentState): void
|
||||
export function importKey(encodedKey: string, password: string): SshKeyImportResult
|
||||
export function generateKeypair(keyAlgorithm: string): Promise<SshKey>
|
||||
export class SshAgentState { }
|
||||
}
|
||||
export declare namespace processisolations {
|
||||
export function disableCoredumps(): Promise<void>
|
||||
export function isCoreDumpingDisabled(): Promise<boolean>
|
||||
|
@ -54,12 +54,16 @@ pub mod biometrics {
|
||||
hwnd: napi::bindgen_prelude::Buffer,
|
||||
message: String,
|
||||
) -> napi::Result<bool> {
|
||||
Biometric::prompt(hwnd.into(), message).await.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::prompt(hwnd.into(), message)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn available() -> napi::Result<bool> {
|
||||
Biometric::available().await.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::available()
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@ -151,6 +155,199 @@ pub mod clipboards {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod sshagent {
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
bindgen_prelude::Promise,
|
||||
threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction},
|
||||
};
|
||||
use tokio::{self, sync::Mutex};
|
||||
|
||||
#[napi]
|
||||
pub struct SshAgentState {
|
||||
state: desktop_core::ssh_agent::BitwardenDesktopAgent,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PrivateKey {
|
||||
pub private_key: String,
|
||||
pub name: String,
|
||||
pub cipher_id: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SshKey {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKey> for SshKey {
|
||||
fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self {
|
||||
SshKey {
|
||||
private_key: key.private_key,
|
||||
public_key: key.public_key,
|
||||
key_fingerprint: key.key_fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub enum SshKeyImportStatus {
|
||||
/// ssh key was parsed correctly and will be returned in the result
|
||||
Success,
|
||||
/// ssh key was parsed correctly but is encrypted and requires a password
|
||||
PasswordRequired,
|
||||
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
|
||||
WrongPassword,
|
||||
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
|
||||
ParsingError,
|
||||
/// ssh key type is not supported (e.g. ecdsa)
|
||||
UnsupportedKeyType,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKeyImportStatus> for SshKeyImportStatus {
|
||||
fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self {
|
||||
match status {
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => {
|
||||
SshKeyImportStatus::Success
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => {
|
||||
SshKeyImportStatus::PasswordRequired
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => {
|
||||
SshKeyImportStatus::WrongPassword
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => {
|
||||
SshKeyImportStatus::ParsingError
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => {
|
||||
SshKeyImportStatus::UnsupportedKeyType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SshKeyImportResult {
|
||||
pub status: SshKeyImportStatus,
|
||||
pub ssh_key: Option<SshKey>,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKeyImportResult> for SshKeyImportResult {
|
||||
fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self {
|
||||
SshKeyImportResult {
|
||||
status: result.status.into(),
|
||||
ssh_key: result.ssh_key.map(|k| k.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn serve(
|
||||
callback: ThreadsafeFunction<String, CalleeHandled>,
|
||||
) -> napi::Result<SshAgentState> {
|
||||
let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::<(u32, String)>(32);
|
||||
let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32);
|
||||
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
|
||||
tokio::spawn(async move {
|
||||
let _ = auth_response_rx;
|
||||
|
||||
while let Some((request_id, cipher_uuid)) = auth_request_rx.recv().await {
|
||||
let cloned_request_id = request_id.clone();
|
||||
let cloned_cipher_uuid = cipher_uuid.clone();
|
||||
let cloned_response_tx_arc = auth_response_tx_arc.clone();
|
||||
let cloned_callback = callback.clone();
|
||||
tokio::spawn(async move {
|
||||
let request_id = cloned_request_id;
|
||||
let cipher_uuid = cloned_cipher_uuid;
|
||||
let auth_response_tx_arc = cloned_response_tx_arc;
|
||||
let callback = cloned_callback;
|
||||
let promise_result: Result<Promise<bool>, napi::Error> =
|
||||
callback.call_async(Ok(cipher_uuid)).await;
|
||||
match promise_result {
|
||||
Ok(promise_result) => match promise_result.await {
|
||||
Ok(result) => {
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, result))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
|
||||
auth_request_tx,
|
||||
Arc::new(Mutex::new(auth_response_rx)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(state) => Ok(SshAgentState { state }),
|
||||
Err(e) => Err(napi::Error::from_reason(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn set_keys(
|
||||
agent_state: &mut SshAgentState,
|
||||
new_keys: Vec<PrivateKey>,
|
||||
) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state
|
||||
.set_keys(
|
||||
new_keys
|
||||
.iter()
|
||||
.map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state
|
||||
.lock()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn import_key(encoded_key: String, password: String) -> napi::Result<SshKeyImportResult> {
|
||||
let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn generate_keypair(key_algorithm: String) -> napi::Result<SshKey> {
|
||||
desktop_core::ssh_agent::generator::generate_keypair(key_algorithm)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
.map(|k| k.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod processisolations {
|
||||
#[napi]
|
||||
@ -172,12 +369,19 @@ pub mod processisolations {
|
||||
|
||||
#[napi]
|
||||
pub mod powermonitors {
|
||||
use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio};
|
||||
use napi::{
|
||||
threadsafe_function::{
|
||||
ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode,
|
||||
},
|
||||
tokio,
|
||||
};
|
||||
|
||||
#[napi]
|
||||
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
|
||||
desktop_core::powermonitor::on_lock(tx).await.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
desktop_core::powermonitor::on_lock(tx)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = rx.recv().await {
|
||||
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
@ -190,7 +394,6 @@ pub mod powermonitors {
|
||||
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
|
||||
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
@ -419,6 +419,23 @@
|
||||
"enableHardwareAccelerationDesc" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="showSshAgentOption">
|
||||
<div class="checkbox">
|
||||
<label for="enableSshAgent">
|
||||
<input
|
||||
id="enableSshAgent"
|
||||
type="checkbox"
|
||||
aria-describedby="enableSshAgentHelp"
|
||||
formControlName="enableSshAgent"
|
||||
(change)="saveSshAgent()"
|
||||
/>
|
||||
{{ "enableSshAgent" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small id="enableSshAgentHelp" class="help-block">{{
|
||||
"enableSshAgentDesc" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="showDuckDuckGoIntegrationOption">
|
||||
<div class="checkbox">
|
||||
<label for="enableDuckDuckGoBrowserIntegration">
|
||||
|
@ -12,7 +12,9 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@ -53,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
showSshAgentOption = false;
|
||||
isWindows: boolean;
|
||||
isLinux: boolean;
|
||||
|
||||
@ -107,6 +110,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
disabled: true,
|
||||
}),
|
||||
enableHardwareAcceleration: true,
|
||||
enableSshAgent: false,
|
||||
enableDuckDuckGoBrowserIntegration: false,
|
||||
theme: [null as ThemeType | null],
|
||||
locale: [null as string | null],
|
||||
@ -137,6 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private pinService: PinServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private nativeMessagingManifestService: NativeMessagingManifestService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
|
||||
@ -200,6 +205,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
if (activeAccount == null || activeAccount.id == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
|
||||
@ -272,6 +279,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
enableHardwareAcceleration: await firstValueFrom(
|
||||
this.desktopSettingsService.hardwareAcceleration$,
|
||||
),
|
||||
enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$),
|
||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||
locale: await firstValueFrom(this.i18nService.userSetLocale$),
|
||||
};
|
||||
@ -723,6 +731,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async saveSshAgent() {
|
||||
this.logService.debug("Saving Ssh Agent settings", this.form.value.enableSshAgent);
|
||||
await this.desktopSettingsService.setSshAgentEnabled(this.form.value.enableSshAgent);
|
||||
}
|
||||
|
||||
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
|
||||
let vaultTimeoutOptions: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
|
@ -22,6 +22,7 @@ import { SsoComponent } from "../auth/sso.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
|
||||
import { SshAgentService } from "../platform/services/ssh-agent.service";
|
||||
import { PremiumComponent } from "../vault/app/accounts/premium.component";
|
||||
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
|
||||
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
|
||||
@ -100,6 +101,7 @@ import { SendComponent } from "./tools/send/send.component";
|
||||
ViewComponent,
|
||||
ViewCustomFieldsComponent,
|
||||
],
|
||||
providers: [SshAgentService],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -21,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
|
||||
|
||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||
import { SshAgentService } from "../../platform/services/ssh-agent.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
|
||||
@Injectable()
|
||||
@ -41,11 +42,13 @@ export class InitService {
|
||||
private encryptService: EncryptService,
|
||||
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
||||
private accountService: AccountService,
|
||||
private sshAgentService: SshAgentService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
return async () => {
|
||||
await this.sshAgentService.init();
|
||||
this.nativeMessagingService.init();
|
||||
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
||||
|
||||
|
@ -26,6 +26,9 @@
|
||||
"typeSecureNote": {
|
||||
"message": "Secure note"
|
||||
},
|
||||
"typeSshKey": {
|
||||
"message": "SSH key"
|
||||
},
|
||||
"folders": {
|
||||
"message": "Folders"
|
||||
},
|
||||
@ -177,6 +180,48 @@
|
||||
"address": {
|
||||
"message": "Address"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"message": "Private key"
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"message": "Public key"
|
||||
},
|
||||
"sshFingerprint": {
|
||||
"message": "Fingerprint"
|
||||
},
|
||||
"sshKeyAlgorithm": {
|
||||
"message": "Key type"
|
||||
},
|
||||
"sshKeyAlgorithmED25519": {
|
||||
"message": "ED25519"
|
||||
},
|
||||
"sshKeyAlgorithmRSA2048": {
|
||||
"message": "RSA 2048-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA3072": {
|
||||
"message": "RSA 3072-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA4096": {
|
||||
"message": "RSA 4096-Bit"
|
||||
},
|
||||
"sshKeyGenerated": {
|
||||
"message": "A new SSH key was generated"
|
||||
},
|
||||
"sshAgentUnlockRequired": {
|
||||
"message": "Please unlock your vault to approve the SSH key request."
|
||||
},
|
||||
"sshAgentUnlockTimeout": {
|
||||
"message": "SSH key request timed out."
|
||||
},
|
||||
"enableSshAgent": {
|
||||
"message": "Enable SSH agent"
|
||||
},
|
||||
"enableSshAgentDesc": {
|
||||
"message": "Enable the SSH agent to sign SSH requests right from your Bitwarden vault."
|
||||
},
|
||||
"enableSshAgentHelp": {
|
||||
"message": "The SSH agent is a service targeted at developers that allows you to sign SSH requests directly from your Bitwarden vault."
|
||||
},
|
||||
"premiumRequired": {
|
||||
"message": "Premium required"
|
||||
},
|
||||
@ -400,6 +445,12 @@
|
||||
"copyPassword": {
|
||||
"message": "Copy password"
|
||||
},
|
||||
"regenerateSshKey": {
|
||||
"message": "Regenerate SSH key"
|
||||
},
|
||||
"copySshPrivateKey": {
|
||||
"message": "Copy SSH private key"
|
||||
},
|
||||
"copyPassphrase": {
|
||||
"message": "Copy passphrase",
|
||||
"description": "Copy passphrase to clipboard"
|
||||
@ -3225,6 +3276,36 @@
|
||||
"ssoError": {
|
||||
"message": "No free ports could be found for the sso login."
|
||||
},
|
||||
"authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
"deny": {
|
||||
"message": "Deny"
|
||||
},
|
||||
"sshkeyApprovalTitle": {
|
||||
"message": "Confirm SSH key usage"
|
||||
},
|
||||
"sshkeyApprovalMessageInfix": {
|
||||
"message": "is requesting access to"
|
||||
},
|
||||
"unknownApplication": {
|
||||
"message": "An application"
|
||||
},
|
||||
"sshKeyPasswordUnsupported": {
|
||||
"message": "Importing password protected SSH keys is not yet supported"
|
||||
},
|
||||
"invalidSshKey": {
|
||||
"message": "The SSH key is invalid"
|
||||
},
|
||||
"sshKeyTypeUnsupported": {
|
||||
"message": "The SSH key type is not supported"
|
||||
},
|
||||
"importSshKeyFromClipboard": {
|
||||
"message": "Import key from clipboard"
|
||||
},
|
||||
"sshKeyPasted": {
|
||||
"message": "SSH key imported successfully"
|
||||
},
|
||||
"fileSavedToDevice": {
|
||||
"message": "File saved to device. Manage from your device downloads."
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { app } from "electron";
|
||||
import { app, ipcMain } from "electron";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||
@ -38,6 +38,7 @@ import { WindowMain } from "./main/window.main";
|
||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
|
||||
import { MainSshAgentService } from "./platform/main/main-ssh-agent.service";
|
||||
import { DesktopSettingsService } from "./platform/services/desktop-settings.service";
|
||||
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
|
||||
import { ElectronStorageService } from "./platform/services/electron-storage.service";
|
||||
@ -71,6 +72,7 @@ export class Main {
|
||||
nativeMessagingMain: NativeMessagingMain;
|
||||
clipboardMain: ClipboardMain;
|
||||
desktopAutofillSettingsService: DesktopAutofillSettingsService;
|
||||
sshAgentService: MainSshAgentService;
|
||||
|
||||
constructor() {
|
||||
// Set paths for portable builds
|
||||
@ -240,6 +242,13 @@ export class Main {
|
||||
this.clipboardMain = new ClipboardMain();
|
||||
this.clipboardMain.init();
|
||||
|
||||
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
|
||||
if (this.sshAgentService == null) {
|
||||
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
|
||||
this.sshAgentService.init();
|
||||
}
|
||||
});
|
||||
|
||||
new EphemeralValueStorageService();
|
||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
||||
}
|
||||
|
@ -69,6 +69,19 @@ export class WindowMain {
|
||||
this.logService.info("Render process reloaded");
|
||||
});
|
||||
|
||||
ipcMain.on("window-focus", () => {
|
||||
if (this.win != null) {
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("window-hide", () => {
|
||||
if (this.win != null) {
|
||||
this.win.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
if (!isMacAppStore() && !isSnapStore()) {
|
||||
|
@ -0,0 +1,17 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="approveSshRequestForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
|
||||
<div bitDialogContent>
|
||||
<b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }}
|
||||
<b>{{params.cipherName}}</b>.
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
<span>{{ "authorize" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
|
||||
{{ "deny" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal file
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
} from "@bitwarden/components";
|
||||
import { DialogService } from "@bitwarden/components/src/dialog";
|
||||
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
|
||||
|
||||
export interface ApproveSshRequestParams {
|
||||
cipherName: string;
|
||||
applicationName: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-approve-ssh-request",
|
||||
templateUrl: "approve-ssh-request.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DialogModule,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
CipherFormGeneratorComponent,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
FormFieldModule,
|
||||
],
|
||||
})
|
||||
export class ApproveSshRequestComponent {
|
||||
approveSshRequestForm = this.formBuilder.group({});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: ApproveSshRequestParams,
|
||||
private dialogRef: DialogRef<boolean>,
|
||||
private formBuilder: FormBuilder,
|
||||
) {}
|
||||
|
||||
static open(dialogService: DialogService, cipherName: string, applicationName: string) {
|
||||
return dialogService.open<boolean, ApproveSshRequestParams>(ApproveSshRequestComponent, {
|
||||
data: {
|
||||
cipherName,
|
||||
applicationName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
}
|
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal file
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { sshagent } from "@bitwarden/desktop-napi";
|
||||
|
||||
class AgentResponse {
|
||||
requestId: number;
|
||||
accepted: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class MainSshAgentService {
|
||||
SIGN_TIMEOUT = 60_000;
|
||||
REQUEST_POLL_INTERVAL = 50;
|
||||
|
||||
private requestResponses: AgentResponse[] = [];
|
||||
private request_id = 0;
|
||||
private agentState: sshagent.SshAgentState;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
// handle sign request passing to UI
|
||||
sshagent
|
||||
.serve(async (err: Error, cipherId: string) => {
|
||||
// clear all old (> SIGN_TIMEOUT) requests
|
||||
this.requestResponses = this.requestResponses.filter(
|
||||
(response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT),
|
||||
);
|
||||
|
||||
this.request_id += 1;
|
||||
const id_for_this_request = this.request_id;
|
||||
this.messagingService.send("sshagent.signrequest", {
|
||||
cipherId,
|
||||
requestId: id_for_this_request,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
race(
|
||||
from([false]).pipe(delay(this.SIGN_TIMEOUT)),
|
||||
|
||||
//poll for response
|
||||
timer(0, this.REQUEST_POLL_INTERVAL).pipe(
|
||||
concatMap(() => from(this.requestResponses)),
|
||||
filter((response) => response.requestId == id_for_this_request),
|
||||
take(1),
|
||||
concatMap(() => from([true])),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = this.requestResponses.find(
|
||||
(response) => response.requestId == id_for_this_request,
|
||||
);
|
||||
|
||||
this.requestResponses = this.requestResponses.filter(
|
||||
(response) => response.requestId != id_for_this_request,
|
||||
);
|
||||
|
||||
return response.accepted;
|
||||
})
|
||||
.then((agentState: sshagent.SshAgentState) => {
|
||||
this.agentState = agentState;
|
||||
this.logService.info("SSH agent started");
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logService.error("SSH agent encountered an error: ", e);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"sshagent.setkeys",
|
||||
async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => {
|
||||
if (this.agentState != null) {
|
||||
sshagent.setKeys(this.agentState, keys);
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.signrequestresponse",
|
||||
async (event: any, { requestId, accepted }: { requestId: number; accepted: boolean }) => {
|
||||
this.requestResponses.push({ requestId, accepted, timestamp: new Date() });
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.generatekey",
|
||||
async (event: any, { keyAlgorithm }: { keyAlgorithm: string }): Promise<sshagent.SshKey> => {
|
||||
return await sshagent.generateKeypair(keyAlgorithm);
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.importkey",
|
||||
async (
|
||||
event: any,
|
||||
{ privateKey, password }: { privateKey: string; password?: string },
|
||||
): Promise<sshagent.SshKeyImportResult> => {
|
||||
return sshagent.importKey(privateKey, password);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("sshagent.lock", async (event: any) => {
|
||||
if (this.agentState != null) {
|
||||
sshagent.lock(this.agentState);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { sshagent as ssh } from "desktop_native/napi";
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
@ -40,6 +41,30 @@ const clipboard = {
|
||||
write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message),
|
||||
};
|
||||
|
||||
const sshAgent = {
|
||||
init: async () => {
|
||||
await ipcRenderer.invoke("sshagent.init");
|
||||
},
|
||||
setKeys: (keys: { name: string; privateKey: string; cipherId: string }[]): Promise<void> =>
|
||||
ipcRenderer.invoke("sshagent.setkeys", keys),
|
||||
signRequestResponse: async (requestId: number, accepted: boolean) => {
|
||||
await ipcRenderer.invoke("sshagent.signrequestresponse", { requestId, accepted });
|
||||
},
|
||||
generateKey: async (keyAlgorithm: string): Promise<ssh.SshKey> => {
|
||||
return await ipcRenderer.invoke("sshagent.generatekey", { keyAlgorithm });
|
||||
},
|
||||
lock: async () => {
|
||||
return await ipcRenderer.invoke("sshagent.lock");
|
||||
},
|
||||
importKey: async (key: string, password: string): Promise<ssh.SshKeyImportResult> => {
|
||||
const res = await ipcRenderer.invoke("sshagent.importkey", {
|
||||
privateKey: key,
|
||||
password: password,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
const powermonitor = {
|
||||
isLockMonitorAvailable: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
|
||||
@ -106,6 +131,8 @@ export default {
|
||||
isSnapStore: isSnapStore(),
|
||||
isAppImage: isAppImage(),
|
||||
reloadProcess: () => ipcRenderer.send("reload-process"),
|
||||
focusWindow: () => ipcRenderer.send("window-focus"),
|
||||
hideWindow: () => ipcRenderer.send("window-hide"),
|
||||
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
|
||||
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
|
||||
|
||||
@ -150,6 +177,7 @@ export default {
|
||||
storage,
|
||||
passwords,
|
||||
clipboard,
|
||||
sshAgent,
|
||||
powermonitor,
|
||||
nativeMessaging,
|
||||
crypto,
|
||||
|
@ -66,6 +66,10 @@ const BROWSER_INTEGRATION_FINGERPRINT_ENABLED = new KeyDefinition<boolean>(
|
||||
},
|
||||
);
|
||||
|
||||
const SSH_AGENT_ENABLED = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "sshAgentEnabled", {
|
||||
deserializer: (b) => b,
|
||||
});
|
||||
|
||||
const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeOnCopy", {
|
||||
deserializer: (b) => b,
|
||||
clearOn: [], // User setting, no need to clear
|
||||
@ -139,6 +143,10 @@ export class DesktopSettingsService {
|
||||
browserIntegrationFingerprintEnabled$ =
|
||||
this.browserIntegrationFingerprintEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
private readonly sshAgentEnabledState = this.stateProvider.getGlobal(SSH_AGENT_ENABLED);
|
||||
|
||||
sshAgentEnabled$ = this.sshAgentEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
private readonly minimizeOnCopyState = this.stateProvider.getActive(MINIMIZE_ON_COPY);
|
||||
|
||||
/**
|
||||
@ -246,6 +254,13 @@ export class DesktopSettingsService {
|
||||
await this.browserIntegrationFingerprintEnabledState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a setting for whether or not the SSH agent is enabled.
|
||||
*/
|
||||
async setSshAgentEnabled(value: boolean) {
|
||||
await this.sshAgentEnabledState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimize on copy value for the current user.
|
||||
* @param value `true` if the application should minimize when a value is copied,
|
||||
|
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal file
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
EMPTY,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timeout,
|
||||
TimeoutError,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ApproveSshRequestComponent } from "../components/approve-ssh-request";
|
||||
|
||||
import { DesktopSettingsService } from "./desktop-settings.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SshAgentService implements OnDestroy {
|
||||
SSH_REFRESH_INTERVAL = 1000;
|
||||
SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 1000 * 60;
|
||||
SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
private messageListener: MessageListener,
|
||||
private authService: AuthService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const isSshAgentFeatureEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||
if (isSshAgentFeatureEnabled) {
|
||||
await ipc.platform.sshAgent.init();
|
||||
|
||||
this.messageListener
|
||||
.messages$(new CommandDefinition("sshagent.signrequest"))
|
||||
.pipe(
|
||||
withLatestFrom(this.authService.activeAccountStatus$),
|
||||
// This switchMap handles unlocking the vault if it is locked:
|
||||
// - If the vault is locked, we will wait for it to be unlocked.
|
||||
// - If the vault is not unlocked within the timeout, we will abort the flow.
|
||||
// - If the vault is unlocked, we will continue with the flow.
|
||||
// switchMap is used here to prevent multiple requests from being processed at the same time,
|
||||
// and will cancel the previous request if a new one is received.
|
||||
switchMap(([message, status]) => {
|
||||
if (status !== AuthenticationStatus.Unlocked) {
|
||||
ipc.platform.focusWindow();
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockRequired"),
|
||||
});
|
||||
return this.authService.activeAccountStatus$.pipe(
|
||||
filter((status) => status === AuthenticationStatus.Unlocked),
|
||||
timeout(this.SSH_VAULT_UNLOCK_REQUEST_TIMEOUT),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof TimeoutError) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockTimeout"),
|
||||
});
|
||||
const requestId = message.requestId as number;
|
||||
// Abort flow by sending a false response.
|
||||
// Returning an empty observable this will prevent the rest of the flow from executing
|
||||
return from(ipc.platform.sshAgent.signRequestResponse(requestId, false)).pipe(
|
||||
map(() => EMPTY),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
map(() => message),
|
||||
);
|
||||
}
|
||||
|
||||
return of(message);
|
||||
}),
|
||||
// This switchMap handles fetching the ciphers from the vault.
|
||||
switchMap((message) =>
|
||||
from(this.cipherService.getAllDecrypted()).pipe(
|
||||
map((ciphers) => [message, ciphers] as const),
|
||||
),
|
||||
),
|
||||
// This concatMap handles showing the dialog to approve the request.
|
||||
concatMap(([message, decryptedCiphers]) => {
|
||||
const cipherId = message.cipherId as string;
|
||||
const requestId = message.requestId as number;
|
||||
|
||||
if (decryptedCiphers === undefined) {
|
||||
return of(false).pipe(
|
||||
switchMap((result) =>
|
||||
ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const cipher = decryptedCiphers.find((cipher) => cipher.id == cipherId);
|
||||
|
||||
ipc.platform.focusWindow();
|
||||
const dialogRef = ApproveSshRequestComponent.open(
|
||||
this.dialogService,
|
||||
cipher.name,
|
||||
this.i18nService.t("unknownApplication"),
|
||||
);
|
||||
|
||||
return dialogRef.closed.pipe(
|
||||
switchMap((result) => {
|
||||
return ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result));
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
combineLatest([
|
||||
timer(0, this.SSH_REFRESH_INTERVAL),
|
||||
this.desktopSettingsService.sshAgentEnabled$,
|
||||
])
|
||||
.pipe(
|
||||
concatMap(async ([, enabled]) => {
|
||||
if (!enabled) {
|
||||
await ipc.platform.sshAgent.setKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
if (ciphers == null) {
|
||||
await ipc.platform.sshAgent.lock();
|
||||
return;
|
||||
}
|
||||
|
||||
const sshCiphers = ciphers.filter(
|
||||
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
|
||||
);
|
||||
const keys = sshCiphers.map((cipher) => {
|
||||
return {
|
||||
name: cipher.name,
|
||||
privateKey: cipher.sshKey.privateKey,
|
||||
cipherId: cipher.id,
|
||||
};
|
||||
});
|
||||
await ipc.platform.sshAgent.setKeys(keys);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
<div class="box-content">
|
||||
<div class="box-content-row" *ngIf="!editMode" appBoxRow>
|
||||
<label for="type">{{ "type" | i18n }}</label>
|
||||
<select id="type" name="Type" [(ngModel)]="cipher.type">
|
||||
<select id="type" name="Type" [(ngModel)]="cipher.type" (change)="typeChange()">
|
||||
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{ o.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -471,6 +471,115 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ssh Key -->
|
||||
<div *ngIf="cipher.type === cipherType.SshKey">
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="sshPrivateKey">{{ "sshPrivateKey" | i18n }}</label>
|
||||
<div
|
||||
*ngIf="!showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.maskedPrivateKey"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.privateKey"
|
||||
></div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'copySshPrivateKey' | i18n }}"
|
||||
(click)="copy(this.cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePrivateKey()"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'regenerateSshKey' | i18n }}"
|
||||
(click)="generateSshKey()"
|
||||
*ngIf="cipher.edit || !editMode"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-generate" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
|
||||
<input
|
||||
id="sshPublicKey"
|
||||
type="text"
|
||||
name="SSHKey.SSHPublicKey"
|
||||
[ngModel]="cipher.sshKey.publicKey"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SSHPublicKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
type="text"
|
||||
name="SSHKey.SSHKeyFingerprint"
|
||||
[ngModel]="cipher.sshKey.keyFingerprint"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SSHFingerprint')"
|
||||
appA11yTitle="{{ 'generateSSHKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
>
|
||||
{{ "importSshKeyFromClipboard" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component, NgZone, OnChanges, OnInit, OnDestroy, ViewChild } from "@angular/core";
|
||||
import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { NgForm } from "@angular/forms";
|
||||
import { sshagent as sshAgent } from "desktop_native/napi";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
|
||||
@ -18,8 +19,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
const BroadcasterSubscriptionId = "AddEditComponent";
|
||||
@ -31,6 +33,7 @@ const BroadcasterSubscriptionId = "AddEditComponent";
|
||||
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@ViewChild("form")
|
||||
private form: NgForm;
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
folderService: FolderService,
|
||||
@ -51,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
dialogService: DialogService,
|
||||
datePipe: DatePipe,
|
||||
configService: ConfigService,
|
||||
private toastService: ToastService,
|
||||
cipherAuthorizationService: CipherAuthorizationService,
|
||||
) {
|
||||
super(
|
||||
@ -140,4 +144,68 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
"https://bitwarden.com/help/managing-items/#protect-individual-items",
|
||||
);
|
||||
}
|
||||
|
||||
async generateSshKey() {
|
||||
const sshKey = await ipc.platform.sshAgent.generateKey("ed25519");
|
||||
this.cipher.sshKey.privateKey = sshKey.privateKey;
|
||||
this.cipher.sshKey.publicKey = sshKey.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = sshKey.keyFingerprint;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyGenerated"),
|
||||
});
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard() {
|
||||
const key = await this.platformUtilsService.readFromClipboard();
|
||||
const parsedKey = await ipc.platform.sshAgent.importKey(key, "");
|
||||
if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("invalidSshKey"),
|
||||
});
|
||||
return;
|
||||
} else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyTypeUnsupported"),
|
||||
});
|
||||
} else if (
|
||||
parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired ||
|
||||
parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyPasswordUnsupported"),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey;
|
||||
this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyPasted"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async typeChange() {
|
||||
if (this.cipher.type === CipherType.SshKey) {
|
||||
await this.generateSshKey();
|
||||
}
|
||||
}
|
||||
|
||||
truncateString(value: string, length: number) {
|
||||
return value.length > length ? value.substring(0, length) + "..." : value;
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
}
|
||||
|
@ -79,4 +79,19 @@
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="filter-option"
|
||||
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
|
||||
>
|
||||
<span class="filter-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-button"
|
||||
(click)="applyFilter(cipherTypeEnum.SshKey)"
|
||||
[attr.aria-pressed]="activeFilter.cipherType === cipherTypeEnum.SshKey"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i> {{ "typeSshKey" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -399,6 +399,105 @@
|
||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ssh Key -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row box-content-row-flex" *ngIf="cipher.sshKey.privateKey">
|
||||
<div class="row-main">
|
||||
<span class="row-label">{{ "sshPrivateKey" | i18n }}</span>
|
||||
<div
|
||||
*ngIf="!showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.maskedPrivateKey"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.privateKey"
|
||||
></div>
|
||||
</div>
|
||||
<div class="action-buttons" *ngIf="cipher.viewPassword">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
[attr.aria-pressed]="showPrivateKey"
|
||||
(click)="togglePrivateKey()"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'copySSHPrivateKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
*ngIf="cipher.sshKey.publicKey"
|
||||
appBoxRow
|
||||
>
|
||||
<div class="row-main">
|
||||
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
|
||||
<input
|
||||
id="sshPublicKey"
|
||||
type="text"
|
||||
name="SshKey.SshPublicKey"
|
||||
[ngModel]="cipher.sshKey.publicKey"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SshPublicKey')"
|
||||
appA11yTitle="{{ 'generateSshKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
*ngIf="cipher.sshKey.keyFingerprint"
|
||||
appBoxRow
|
||||
>
|
||||
<div class="row-main">
|
||||
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
type="text"
|
||||
name="SshKey.SshKeyFingerprint"
|
||||
[ngModel]="cipher.sshKey.keyFingerprint"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SshFingerprint')"
|
||||
appA11yTitle="{{ 'generateSshKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||
|
@ -851,6 +851,99 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Ssh Key -->
|
||||
<ng-container *ngIf="cipher.type === cipherType.SshKey">
|
||||
<div class="row">
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyPrivateKey">{{ "sshKeyPrivateKey" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyPrivateKey"
|
||||
class="form-control"
|
||||
type="{{ showPrivateKey ? 'text' : 'password' }}"
|
||||
name="SSHKey.PrivateKey"
|
||||
[(ngModel)]="cipher.sshKey.privateKey"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePrivateKey()"
|
||||
[disabled]="!cipher.viewPassword"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySSHPrivateKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.privateKey, 'sshKeyPrivateKey', 'PrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyPublicKey">{{ "sshKeyPublicKey" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyPublicKey"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="SshKey.PublicKey"
|
||||
[(ngModel)]="cipher.sshKey.publicKey"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySshPublicKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshKeyPublicKey', 'PublicKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyFingerprint">{{ "sshKeyFingerprint" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="SshKey.Fingerprint"
|
||||
[(ngModel)]="cipher.sshKey.keyFingerprint"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySshFingerprint' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshKeyFingerprint', 'Fingerprint')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="form-group">
|
||||
<label for="notes">{{ "notes" | i18n }}</label>
|
||||
<textarea
|
||||
|
@ -216,6 +216,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
type: CipherType.SecureNote,
|
||||
icon: "bwi-sticky-note",
|
||||
},
|
||||
{
|
||||
id: "sshKey",
|
||||
name: this.i18nService.t("typeSshKey"),
|
||||
type: CipherType.SshKey,
|
||||
icon: "bwi-key",
|
||||
},
|
||||
];
|
||||
|
||||
const typeFilterSection: VaultFilterSection = {
|
||||
|
@ -559,6 +559,9 @@
|
||||
"typeSecureNote": {
|
||||
"message": "Secure note"
|
||||
},
|
||||
"typeSshKey": {
|
||||
"message": "Ssh key"
|
||||
},
|
||||
"typeLoginPlural": {
|
||||
"message": "Logins"
|
||||
},
|
||||
@ -9416,6 +9419,30 @@
|
||||
"additionalStorageGbMessage": {
|
||||
"message": "GB additional storage"
|
||||
},
|
||||
"sshKeyAlgorithm": {
|
||||
"message": "Key algorithm"
|
||||
},
|
||||
"sshKeyFingerprint": {
|
||||
"message": "Fingerprint"
|
||||
},
|
||||
"sshKeyPrivateKey": {
|
||||
"message": "Private key"
|
||||
},
|
||||
"sshKeyPublicKey": {
|
||||
"message": "Public key"
|
||||
},
|
||||
"sshKeyAlgorithmED25519": {
|
||||
"message": "ED25519"
|
||||
},
|
||||
"sshKeyAlgorithmRSA2048": {
|
||||
"message": "RSA 2048-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA3072": {
|
||||
"message": "RSA 3072-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA4096": {
|
||||
"message": "RSA 4096-Bit"
|
||||
},
|
||||
"premiumAccounts": {
|
||||
"message": "6 premium accounts"
|
||||
},
|
||||
|
@ -14,7 +14,8 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ClientType, EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@ -36,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
restorePromise: Promise<any>;
|
||||
checkPasswordPromise: Promise<number>;
|
||||
showPassword = false;
|
||||
showPrivateKey = false;
|
||||
showTotpSeed = false;
|
||||
showCardNumber = false;
|
||||
showCardCode = false;
|
||||
@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
|
||||
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
|
||||
];
|
||||
|
||||
this.cardBrandOptions = [
|
||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||
{ name: "Visa", value: "Visa" },
|
||||
@ -200,6 +204,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.writeableCollections = await this.loadCollections();
|
||||
this.canUseReprompt = await this.passwordRepromptService.enabled();
|
||||
|
||||
const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
|
||||
if (this.platformUtilsService.getClientType() == ClientType.Desktop && sshKeysEnabled) {
|
||||
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
this.cipher.identity = new IdentityView();
|
||||
this.cipher.secureNote = new SecureNoteView();
|
||||
this.cipher.secureNote.type = SecureNoteType.Generic;
|
||||
this.cipher.sshKey = new SshKeyView();
|
||||
this.cipher.reprompt = CipherRepromptType.None;
|
||||
}
|
||||
}
|
||||
@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
|
||||
toggleUriOptions(uri: LoginUriView) {
|
||||
const u = uri as any;
|
||||
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
|
||||
|
@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
showPasswordCount: boolean;
|
||||
showCardNumber: boolean;
|
||||
showCardCode: boolean;
|
||||
showPrivateKey: boolean;
|
||||
canAccessPremium: boolean;
|
||||
showPremiumRequiredTotp: boolean;
|
||||
totpCode: string;
|
||||
@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
|
||||
async checkPassword() {
|
||||
if (
|
||||
this.cipher.login == null ||
|
||||
|
@ -27,6 +27,8 @@ export enum FeatureFlag {
|
||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
SSHKeyVaultItem = "ssh-key-vault-item",
|
||||
SSHAgent = "ssh-agent",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
@ -73,6 +75,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.SSHKeyVaultItem]: FALSE,
|
||||
[FeatureFlag.SSHAgent]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
|
@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export";
|
||||
import { LoginExport } from "./login.export";
|
||||
import { PasswordHistoryExport } from "./password-history.export";
|
||||
import { SecureNoteExport } from "./secure-note.export";
|
||||
import { SshKeyExport } from "./ssh-key.export";
|
||||
import { safeGetString } from "./utils";
|
||||
|
||||
export class CipherExport {
|
||||
@ -27,6 +28,7 @@ export class CipherExport {
|
||||
req.secureNote = null;
|
||||
req.card = null;
|
||||
req.identity = null;
|
||||
req.sshKey = null;
|
||||
req.reprompt = CipherRepromptType.None;
|
||||
req.passwordHistory = [];
|
||||
req.creationDate = null;
|
||||
@ -67,6 +69,8 @@ export class CipherExport {
|
||||
case CipherType.Identity:
|
||||
view.identity = IdentityExport.toView(req.identity);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
view.sshKey = SshKeyExport.toView(req.sshKey);
|
||||
}
|
||||
|
||||
if (req.passwordHistory != null) {
|
||||
@ -108,6 +112,9 @@ export class CipherExport {
|
||||
case CipherType.Identity:
|
||||
domain.identity = IdentityExport.toDomain(req.identity);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
domain.sshKey = SshKeyExport.toDomain(req.sshKey);
|
||||
break;
|
||||
}
|
||||
|
||||
if (req.passwordHistory != null) {
|
||||
@ -132,6 +139,7 @@ export class CipherExport {
|
||||
secureNote: SecureNoteExport;
|
||||
card: CardExport;
|
||||
identity: IdentityExport;
|
||||
sshKey: SshKeyExport;
|
||||
reprompt: CipherRepromptType;
|
||||
passwordHistory: PasswordHistoryExport[] = null;
|
||||
revisionDate: Date = null;
|
||||
@ -171,6 +179,9 @@ export class CipherExport {
|
||||
case CipherType.Identity:
|
||||
this.identity = new IdentityExport(o.identity);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.sshKey = new SshKeyExport(o.sshKey);
|
||||
break;
|
||||
}
|
||||
|
||||
if (o.passwordHistory != null) {
|
||||
|
44
libs/common/src/models/export/ssh-key.export.ts
Normal file
44
libs/common/src/models/export/ssh-key.export.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { SshKeyView as SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
|
||||
|
||||
import { safeGetString } from "./utils";
|
||||
|
||||
export class SshKeyExport {
|
||||
static template(): SshKeyExport {
|
||||
const req = new SshKeyExport();
|
||||
req.privateKey = "";
|
||||
req.publicKey = "";
|
||||
req.keyFingerprint = "";
|
||||
return req;
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
|
||||
domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null;
|
||||
domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null;
|
||||
domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null;
|
||||
return domain;
|
||||
}
|
||||
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
|
||||
constructor(o?: SshKeyView | SshKeyDomain) {
|
||||
if (o == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privateKey = safeGetString(o.privateKey);
|
||||
this.publicKey = safeGetString(o.publicKey);
|
||||
this.keyFingerprint = safeGetString(o.keyFingerprint);
|
||||
}
|
||||
}
|
@ -3,4 +3,5 @@ export enum CipherType {
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SshKey = 5,
|
||||
}
|
||||
|
@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
|
||||
case CipherType.Identity:
|
||||
icon = "bwi-id-card";
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
icon = "bwi-key";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
17
libs/common/src/vault/models/api/ssh-key.api.ts
Normal file
17
libs/common/src/vault/models/api/ssh-key.api.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class SshKeyApi extends BaseResponse {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
this.privateKey = this.getResponseProperty("PrivateKey");
|
||||
this.publicKey = this.getResponseProperty("PublicKey");
|
||||
this.keyFingerprint = this.getResponseProperty("KeyFingerprint");
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data";
|
||||
import { LoginData } from "./login.data";
|
||||
import { PasswordHistoryData } from "./password-history.data";
|
||||
import { SecureNoteData } from "./secure-note.data";
|
||||
import { SshKeyData } from "./ssh-key.data";
|
||||
|
||||
export class CipherData {
|
||||
id: string;
|
||||
@ -28,6 +29,7 @@ export class CipherData {
|
||||
secureNote?: SecureNoteData;
|
||||
card?: CardData;
|
||||
identity?: IdentityData;
|
||||
sshKey?: SshKeyData;
|
||||
fields?: FieldData[];
|
||||
attachments?: AttachmentData[];
|
||||
passwordHistory?: PasswordHistoryData[];
|
||||
@ -72,6 +74,9 @@ export class CipherData {
|
||||
case CipherType.Identity:
|
||||
this.identity = new IdentityData(response.identity);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.sshKey = new SshKeyData(response.sshKey);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
17
libs/common/src/vault/models/data/ssh-key.data.ts
Normal file
17
libs/common/src/vault/models/data/ssh-key.data.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
|
||||
export class SshKeyData {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
|
||||
constructor(data?: SshKeyApi) {
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privateKey = data.privateKey;
|
||||
this.publicKey = data.publicKey;
|
||||
this.keyFingerprint = data.keyFingerprint;
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ import { Identity } from "./identity";
|
||||
import { Login } from "./login";
|
||||
import { Password } from "./password";
|
||||
import { SecureNote } from "./secure-note";
|
||||
import { SshKey } from "./ssh-key";
|
||||
|
||||
export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
readonly initializerKey = InitializerKey.Cipher;
|
||||
@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
identity: Identity;
|
||||
card: Card;
|
||||
secureNote: SecureNote;
|
||||
sshKey: SshKey;
|
||||
attachments: Attachment[];
|
||||
fields: Field[];
|
||||
passwordHistory: Password[];
|
||||
@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
case CipherType.Identity:
|
||||
this.identity = new Identity(obj.identity);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.sshKey = new SshKey(obj.sshKey);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
case CipherType.Identity:
|
||||
model.identity = await this.identity.decrypt(this.organizationId, encKey);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
case CipherType.Identity:
|
||||
c.identity = this.identity.toIdentityData();
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
c.sshKey = this.sshKey.toSshKeyData();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
case CipherType.SecureNote:
|
||||
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
domain.sshKey = SshKey.fromJSON(obj.sshKey);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
67
libs/common/src/vault/models/domain/ssh-key.spec.ts
Normal file
67
libs/common/src/vault/models/domain/ssh-key.spec.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { mockEnc } from "../../../../spec";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
import { SshKeyData } from "../data/ssh-key.data";
|
||||
|
||||
import { SshKey } from "./ssh-key";
|
||||
|
||||
describe("Sshkey", () => {
|
||||
let data: SshKeyData;
|
||||
|
||||
beforeEach(() => {
|
||||
data = new SshKeyData(
|
||||
new SshKeyApi({
|
||||
PrivateKey: "privateKey",
|
||||
PublicKey: "publicKey",
|
||||
KeyFingerprint: "keyFingerprint",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
const sshKey = new SshKey(data);
|
||||
|
||||
expect(sshKey).toEqual({
|
||||
privateKey: { encryptedString: "privateKey", encryptionType: 0 },
|
||||
publicKey: { encryptedString: "publicKey", encryptionType: 0 },
|
||||
keyFingerprint: { encryptedString: "keyFingerprint", encryptionType: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("Convert from empty", () => {
|
||||
const data = new SshKeyData();
|
||||
const sshKey = new SshKey(data);
|
||||
|
||||
expect(sshKey).toEqual({
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("toSshKeyData", () => {
|
||||
const sshKey = new SshKey(data);
|
||||
expect(sshKey.toSshKeyData()).toEqual(data);
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
const sshKey = Object.assign(new SshKey(), {
|
||||
privateKey: mockEnc("privateKey"),
|
||||
publicKey: mockEnc("publicKey"),
|
||||
keyFingerprint: mockEnc("keyFingerprint"),
|
||||
});
|
||||
const expectedView = {
|
||||
privateKey: "privateKey",
|
||||
publicKey: "publicKey",
|
||||
keyFingerprint: "keyFingerprint",
|
||||
};
|
||||
|
||||
const loginView = await sshKey.decrypt(null);
|
||||
expect(loginView).toEqual(expectedView);
|
||||
});
|
||||
|
||||
describe("fromJSON", () => {
|
||||
it("returns null if object is null", () => {
|
||||
expect(SshKey.fromJSON(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
70
libs/common/src/vault/models/domain/ssh-key.ts
Normal file
70
libs/common/src/vault/models/domain/ssh-key.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import Domain from "../../../platform/models/domain/domain-base";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { SshKeyData } from "../data/ssh-key.data";
|
||||
import { SshKeyView } from "../view/ssh-key.view";
|
||||
|
||||
export class SshKey extends Domain {
|
||||
privateKey: EncString;
|
||||
publicKey: EncString;
|
||||
keyFingerprint: EncString;
|
||||
|
||||
constructor(obj?: SshKeyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buildDomainModel(
|
||||
this,
|
||||
obj,
|
||||
{
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SshKeyView> {
|
||||
return this.decryptObj(
|
||||
new SshKeyView(),
|
||||
{
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
orgId,
|
||||
encKey,
|
||||
);
|
||||
}
|
||||
|
||||
toSshKeyData(): SshKeyData {
|
||||
const c = new SshKeyData();
|
||||
this.buildDataModel(this, c, {
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<SshKey>>): SshKey {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKey = EncString.fromJSON(obj.privateKey);
|
||||
const publicKey = EncString.fromJSON(obj.publicKey);
|
||||
const keyFingerprint = EncString.fromJSON(obj.keyFingerprint);
|
||||
return Object.assign(new SshKey(), obj, {
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
});
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
import { LoginApi } from "../api/login.api";
|
||||
import { SecureNoteApi } from "../api/secure-note.api";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
import { Cipher } from "../domain/cipher";
|
||||
|
||||
import { AttachmentRequest } from "./attachment.request";
|
||||
@ -23,6 +24,7 @@ export class CipherRequest {
|
||||
secureNote: SecureNoteApi;
|
||||
card: CardApi;
|
||||
identity: IdentityApi;
|
||||
sshKey: SshKeyApi;
|
||||
fields: FieldApi[];
|
||||
passwordHistory: PasswordHistoryRequest[];
|
||||
// Deprecated, remove at some point and rename attachments2 to attachments
|
||||
@ -93,6 +95,17 @@ export class CipherRequest {
|
||||
this.secureNote = new SecureNoteApi();
|
||||
this.secureNote.type = cipher.secureNote.type;
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.sshKey = new SshKeyApi();
|
||||
this.sshKey.privateKey =
|
||||
cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null;
|
||||
this.sshKey.publicKey =
|
||||
cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null;
|
||||
this.sshKey.keyFingerprint =
|
||||
cipher.sshKey.keyFingerprint != null
|
||||
? cipher.sshKey.keyFingerprint.encryptedString
|
||||
: null;
|
||||
break;
|
||||
case CipherType.Card:
|
||||
this.card = new CardApi();
|
||||
this.card.cardholderName =
|
||||
|
@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api";
|
||||
import { IdentityApi } from "../api/identity.api";
|
||||
import { LoginApi } from "../api/login.api";
|
||||
import { SecureNoteApi } from "../api/secure-note.api";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
|
||||
import { AttachmentResponse } from "./attachment.response";
|
||||
import { PasswordHistoryResponse } from "./password-history.response";
|
||||
@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse {
|
||||
card: CardApi;
|
||||
identity: IdentityApi;
|
||||
secureNote: SecureNoteApi;
|
||||
sshKey: SshKeyApi;
|
||||
favorite: boolean;
|
||||
edit: boolean;
|
||||
viewPassword: boolean;
|
||||
@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse {
|
||||
this.secureNote = new SecureNoteApi(secureNote);
|
||||
}
|
||||
|
||||
const sshKey = this.getResponseProperty("sshKey");
|
||||
if (sshKey != null) {
|
||||
this.sshKey = new SshKeyApi(sshKey);
|
||||
}
|
||||
|
||||
const fields = this.getResponseProperty("Fields");
|
||||
if (fields != null) {
|
||||
this.fields = fields.map((f: any) => new FieldApi(f));
|
||||
|
@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view";
|
||||
import { LoginView } from "./login.view";
|
||||
import { PasswordHistoryView } from "./password-history.view";
|
||||
import { SecureNoteView } from "./secure-note.view";
|
||||
import { SshKeyView } from "./ssh-key.view";
|
||||
|
||||
export class CipherView implements View, InitializerMetadata {
|
||||
readonly initializerKey = InitializerKey.CipherView;
|
||||
@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata {
|
||||
identity = new IdentityView();
|
||||
card = new CardView();
|
||||
secureNote = new SecureNoteView();
|
||||
sshKey = new SshKeyView();
|
||||
attachments: AttachmentView[] = null;
|
||||
fields: FieldView[] = null;
|
||||
passwordHistory: PasswordHistoryView[] = null;
|
||||
@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.card;
|
||||
case CipherType.Identity:
|
||||
return this.identity;
|
||||
case CipherType.SshKey:
|
||||
return this.sshKey;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata {
|
||||
case CipherType.SecureNote:
|
||||
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
view.sshKey = SshKeyView.fromJSON(obj.sshKey);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
41
libs/common/src/vault/models/view/ssh-key.view.ts
Normal file
41
libs/common/src/vault/models/view/ssh-key.view.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SshKey } from "../domain/ssh-key";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
export class SshKeyView extends ItemView {
|
||||
privateKey: string = null;
|
||||
publicKey: string = null;
|
||||
keyFingerprint: string = null;
|
||||
|
||||
constructor(n?: SshKey) {
|
||||
super();
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
get maskedPrivateKey(): string {
|
||||
let lines = this.privateKey.split("\n").filter((l) => l.trim() !== "");
|
||||
lines = lines.map((l, i) => {
|
||||
if (i === 0 || i === lines.length - 1) {
|
||||
return l;
|
||||
}
|
||||
return this.maskLine(l);
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private maskLine(line: string): string {
|
||||
return "•".repeat(32);
|
||||
}
|
||||
|
||||
get subTitle(): string {
|
||||
return this.keyFingerprint;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<SshKeyView>>): SshKeyView {
|
||||
return Object.assign(new SshKeyView(), obj);
|
||||
}
|
||||
}
|
@ -54,6 +54,7 @@ import { LoginUri } from "../models/domain/login-uri";
|
||||
import { Password } from "../models/domain/password";
|
||||
import { SecureNote } from "../models/domain/secure-note";
|
||||
import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache";
|
||||
import { SshKey } from "../models/domain/ssh-key";
|
||||
import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request";
|
||||
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
|
||||
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
|
||||
@ -1570,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
key,
|
||||
);
|
||||
return;
|
||||
case CipherType.SshKey:
|
||||
cipher.sshKey = new SshKey();
|
||||
await this.encryptObjProperty(
|
||||
model.sshKey,
|
||||
cipher.sshKey,
|
||||
{
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyFingerprint: null,
|
||||
},
|
||||
key,
|
||||
);
|
||||
return;
|
||||
default:
|
||||
throw new Error("Unknown cipher type.");
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ export class ImportSuccessDialogComponent implements OnInit {
|
||||
let cards = 0;
|
||||
let identities = 0;
|
||||
let secureNotes = 0;
|
||||
let sshKeys = 0;
|
||||
this.data.ciphers.map((c) => {
|
||||
switch (c.type) {
|
||||
case CipherType.Login:
|
||||
@ -52,6 +53,9 @@ export class ImportSuccessDialogComponent implements OnInit {
|
||||
case CipherType.Identity:
|
||||
identities++;
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
sshKeys++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -70,6 +74,9 @@ export class ImportSuccessDialogComponent implements OnInit {
|
||||
if (secureNotes > 0) {
|
||||
list.push({ icon: "sticky-note", type: "typeSecureNote", count: secureNotes });
|
||||
}
|
||||
if (sshKeys > 0) {
|
||||
list.push({ icon: "key", type: "typeSSHKey", count: sshKeys });
|
||||
}
|
||||
if (this.data.folders.length > 0) {
|
||||
list.push({ icon: "folder", type: "folders", count: this.data.folders.length });
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.
|
||||
import { IdentitySectionComponent } from "./components/identity/identity.component";
|
||||
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
||||
import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component";
|
||||
import { SshKeySectionComponent } from "./components/sshkey-section/sshkey-section.component";
|
||||
|
||||
/**
|
||||
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
|
||||
@ -20,6 +21,7 @@ export type CipherForm = {
|
||||
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
|
||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||
sshKeyDetails?: SshKeySectionComponent["sshKeyForm"];
|
||||
customFields?: CustomFieldsComponent["customFieldsForm"];
|
||||
};
|
||||
|
||||
|
@ -22,6 +22,12 @@
|
||||
[disabled]="config.mode === 'partial-edit'"
|
||||
></vault-card-details-section>
|
||||
|
||||
<vault-sshkey-section
|
||||
*ngIf="config.cipherType === CipherType.SshKey"
|
||||
[disabled]="config.mode === 'partial-edit'"
|
||||
[originalCipherView]="originalCipherView"
|
||||
></vault-sshkey-section>
|
||||
|
||||
<vault-additional-options-section></vault-additional-options-section>
|
||||
|
||||
<!-- Attachments are only available for existing ciphers -->
|
||||
|
@ -42,6 +42,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details
|
||||
import { IdentitySectionComponent } from "./identity/identity.component";
|
||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
|
||||
import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-cipher-form",
|
||||
@ -65,6 +66,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta
|
||||
ItemDetailsSectionComponent,
|
||||
CardDetailsSectionComponent,
|
||||
IdentitySectionComponent,
|
||||
SshKeySectionComponent,
|
||||
NgIf,
|
||||
AdditionalOptionsSectionComponent,
|
||||
LoginDetailsSectionComponent,
|
||||
|
@ -0,0 +1,30 @@
|
||||
<bit-section [formGroup]="sshKeyForm">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">
|
||||
{{ "typeSshKey" | i18n }}
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshPrivateKey" | i18n }}</bit-label>
|
||||
<input id="privateKey" bitInput formControlName="privateKey" type="password" />
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitSuffix
|
||||
data-testid="toggle-privateKey-visibility"
|
||||
bitPasswordInputToggle
|
||||
></button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshPublicKey" | i18n }}</bit-label>
|
||||
<input id="publicKey" bitInput formControlName="publicKey" />
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshFingerprint" | i18n }}</bit-label>
|
||||
<input id="keyFingerprint" bitInput formControlName="keyFingerprint" />
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
</bit-section>
|
@ -0,0 +1,80 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
@Component({
|
||||
selector: "vault-sshkey-section",
|
||||
templateUrl: "./sshkey-section.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
FormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
SelectModule,
|
||||
SectionHeaderComponent,
|
||||
IconButtonModule,
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
],
|
||||
})
|
||||
export class SshKeySectionComponent implements OnInit {
|
||||
/** The original cipher */
|
||||
@Input() originalCipherView: CipherView;
|
||||
|
||||
/** True when all fields should be disabled */
|
||||
@Input() disabled: boolean;
|
||||
|
||||
/**
|
||||
* All form fields associated with the ssh key
|
||||
*
|
||||
* Note: `as` is used to assert the type of the form control,
|
||||
* leaving as just null gets inferred as `unknown`
|
||||
*/
|
||||
sshKeyForm = this.formBuilder.group({
|
||||
privateKey: null as string | null,
|
||||
publicKey: null as string | null,
|
||||
keyFingerprint: null as string | null,
|
||||
});
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.originalCipherView?.card) {
|
||||
this.setInitialValues();
|
||||
}
|
||||
|
||||
this.sshKeyForm.disable();
|
||||
}
|
||||
|
||||
/** Set form initial form values from the current cipher */
|
||||
private setInitialValues() {
|
||||
const { privateKey, publicKey, keyFingerprint } = this.originalCipherView.sshKey;
|
||||
|
||||
this.sshKeyForm.setValue({
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyFingerprint,
|
||||
});
|
||||
}
|
||||
}
|
@ -40,6 +40,9 @@
|
||||
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
|
||||
</app-view-identity-sections>
|
||||
|
||||
<!-- SshKEY SECTIONS -->
|
||||
<app-sshkey-view *ngIf="hasSshKey" [sshKey]="cipher.sshKey"></app-sshkey-view>
|
||||
|
||||
<!-- ADDITIONAL OPTIONS -->
|
||||
<ng-container *ngIf="cipher.notes">
|
||||
<app-additional-options [notes]="cipher.notes"> </app-additional-options>
|
||||
|
@ -21,6 +21,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone
|
||||
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
|
||||
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
|
||||
import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component";
|
||||
import { SshKeyViewComponent } from "./sshkey-sections/sshkey-view.component";
|
||||
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component";
|
||||
|
||||
@Component({
|
||||
@ -38,6 +39,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
||||
ItemHistoryV2Component,
|
||||
CustomFieldV2Component,
|
||||
CardDetailsComponent,
|
||||
SshKeyViewComponent,
|
||||
ViewIdentitySectionsComponent,
|
||||
LoginCredentialsViewComponent,
|
||||
AutofillOptionsViewComponent,
|
||||
@ -95,6 +97,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
||||
return this.cipher.login?.uris.length > 0;
|
||||
}
|
||||
|
||||
get hasSshKey() {
|
||||
return this.cipher.sshKey?.privateKey;
|
||||
}
|
||||
|
||||
async loadCipherData() {
|
||||
// Load collections if not provided and the cipher has collectionIds
|
||||
if (
|
||||
|
@ -0,0 +1,50 @@
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "typeSshKey" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card class="[&_bit-form-field:last-of-type]:tw-mb-0">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshPrivateKey" | i18n }}</bit-label>
|
||||
<input readonly bitInput [value]="sshKey.privateKey" aria-readonly="true" type="password" />
|
||||
<button
|
||||
bitSuffix
|
||||
type="button"
|
||||
bitIconButton
|
||||
bitPasswordInputToggle
|
||||
data-testid="toggle-privateKey"
|
||||
></button>
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
bitSuffix
|
||||
type="button"
|
||||
[appCopyClick]="sshKey.privateKey"
|
||||
showToast
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshPublicKey" | i18n }}</bit-label>
|
||||
<input readonly bitInput [value]="sshKey.publicKey" aria-readonly="true" />
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
bitSuffix
|
||||
type="button"
|
||||
[appCopyClick]="sshKey.publicKey"
|
||||
showToast
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "sshFingerprint" | i18n }}</bit-label>
|
||||
<input readonly bitInput [value]="sshKey.keyFingerprint" aria-readonly="true" />
|
||||
<button
|
||||
bitIconButton="bwi-clone"
|
||||
bitSuffix
|
||||
type="button"
|
||||
[appCopyClick]="sshKey.keyFingerprint"
|
||||
showToast
|
||||
[appA11yTitle]="'copyValue' | i18n"
|
||||
></button>
|
||||
</bit-form-field>
|
||||
</bit-card>
|
||||
</bit-section>
|
@ -0,0 +1,35 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import {
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { OrgIconDirective } from "../../components/org-icon.directive";
|
||||
|
||||
@Component({
|
||||
selector: "app-sshkey-view",
|
||||
templateUrl: "sshkey-view.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
OrgIconDirective,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
],
|
||||
})
|
||||
export class SshKeyViewComponent {
|
||||
@Input() sshKey: SshKeyView;
|
||||
}
|
@ -91,6 +91,12 @@ export class CopyCipherFieldDirective implements OnChanges {
|
||||
return this.cipher.identity?.fullAddressForCopy;
|
||||
case "secureNote":
|
||||
return this.cipher.notes;
|
||||
case "privateKey":
|
||||
return this.cipher.sshKey?.privateKey;
|
||||
case "publicKey":
|
||||
return this.cipher.sshKey?.publicKey;
|
||||
case "keyFingerprint":
|
||||
return this.cipher.sshKey?.keyFingerprint;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -25,7 +25,10 @@ export type CopyAction =
|
||||
| "phone"
|
||||
| "address"
|
||||
| "secureNote"
|
||||
| "hiddenField";
|
||||
| "hiddenField"
|
||||
| "privateKey"
|
||||
| "publicKey"
|
||||
| "keyFingerprint";
|
||||
|
||||
type CopyActionInfo = {
|
||||
/**
|
||||
@ -62,6 +65,9 @@ const CopyActions: Record<CopyAction, CopyActionInfo> = {
|
||||
phone: { typeI18nKey: "phone", protected: true },
|
||||
address: { typeI18nKey: "address", protected: true },
|
||||
secureNote: { typeI18nKey: "note", protected: true },
|
||||
privateKey: { typeI18nKey: "sshPrivateKey", protected: true },
|
||||
publicKey: { typeI18nKey: "sshPublicKey", protected: true },
|
||||
keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true },
|
||||
hiddenField: {
|
||||
typeI18nKey: "value",
|
||||
protected: true,
|
||||
|
Loading…
Reference in New Issue
Block a user