mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +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/desktop/src/locales/en/messages.json
|
||||||
apps/web/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 ##
|
## BRE team owns these workflows ##
|
||||||
.github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre
|
.github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre
|
||||||
.github/workflows/deploy-web.yml @bitwarden/dept-bre
|
.github/workflows/deploy-web.yml @bitwarden/dept-bre
|
||||||
|
@ -152,6 +152,15 @@
|
|||||||
"copyLicenseNumber": {
|
"copyLicenseNumber": {
|
||||||
"message": "Copy license number"
|
"message": "Copy license number"
|
||||||
},
|
},
|
||||||
|
"copyPrivateKey": {
|
||||||
|
"message": "Copy private key"
|
||||||
|
},
|
||||||
|
"copyPublicKey": {
|
||||||
|
"message": "Copy public key"
|
||||||
|
},
|
||||||
|
"copyFingerprint": {
|
||||||
|
"message": "Copy fingerprint"
|
||||||
|
},
|
||||||
"copyCustomField": {
|
"copyCustomField": {
|
||||||
"message": "Copy $FIELD$",
|
"message": "Copy $FIELD$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -1764,6 +1773,9 @@
|
|||||||
"typeIdentity": {
|
"typeIdentity": {
|
||||||
"message": "Identity"
|
"message": "Identity"
|
||||||
},
|
},
|
||||||
|
"typeSshKey": {
|
||||||
|
"message": "SSH key"
|
||||||
|
},
|
||||||
"newItemHeader": {
|
"newItemHeader": {
|
||||||
"message": "New $TYPE$",
|
"message": "New $TYPE$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@ -4593,6 +4605,30 @@
|
|||||||
"enterprisePolicyRequirementsApplied": {
|
"enterprisePolicyRequirementsApplied": {
|
||||||
"message": "Enterprise policy requirements have been applied to this setting"
|
"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": {
|
"retry": {
|
||||||
"message": "Retry"
|
"message": "Retry"
|
||||||
},
|
},
|
||||||
|
@ -331,6 +331,8 @@ export class AddEditV2Component implements OnInit {
|
|||||||
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLocaleLowerCase());
|
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLocaleLowerCase());
|
||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
return this.i18nService.t(partOne, this.i18nService.t("note").toLocaleLowerCase());
|
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"
|
[cipher]="cipher"
|
||||||
></button>
|
></button>
|
||||||
</bit-item-action>
|
</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;
|
return !!this.cipher.notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasSshKeyValues() {
|
||||||
|
return (
|
||||||
|
!!this.cipher.sshKey.privateKey ||
|
||||||
|
!!this.cipher.sshKey.publicKey ||
|
||||||
|
!!this.cipher.sshKey.keyFingerprint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
}
|
}
|
||||||
|
@ -131,6 +131,8 @@ export class ViewV2Component {
|
|||||||
);
|
);
|
||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
return this.i18nService.t("viewItemHeader", this.i18nService.t("note").toLowerCase());
|
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>
|
||||||
</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>
|
</div>
|
||||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||||
|
@ -114,6 +114,19 @@
|
|||||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.SecureNote) || 0 }}</span>
|
<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>
|
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div class="box list" *ngIf="nestedFolders?.length">
|
<div class="box list" *ngIf="nestedFolders?.length">
|
||||||
|
@ -106,6 +106,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
|
|||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
this.groupingTitle = this.i18nService.t("secureNotes");
|
this.groupingTitle = this.i18nService.t("secureNotes");
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
this.groupingTitle = this.i18nService.t("sshKeys");
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -429,6 +429,39 @@
|
|||||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||||
|
@ -202,6 +202,7 @@ describe("VaultPopupItemsService", () => {
|
|||||||
[CipherType.Card]: 2,
|
[CipherType.Card]: 2,
|
||||||
[CipherType.Identity]: 3,
|
[CipherType.Identity]: 3,
|
||||||
[CipherType.SecureNote]: 4,
|
[CipherType.SecureNote]: 4,
|
||||||
|
[CipherType.SshKey]: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Assume all ciphers are autofill ciphers to test sorting
|
// Assume all ciphers are autofill ciphers to test sorting
|
||||||
|
@ -277,6 +277,7 @@ export class VaultPopupItemsService {
|
|||||||
[CipherType.Card]: 2,
|
[CipherType.Card]: 2,
|
||||||
[CipherType.Identity]: 3,
|
[CipherType.Identity]: 3,
|
||||||
[CipherType.SecureNote]: 4,
|
[CipherType.SecureNote]: 4,
|
||||||
|
[CipherType.SshKey]: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compare types first
|
// Compare types first
|
||||||
|
@ -97,6 +97,7 @@ describe("VaultPopupListFiltersService", () => {
|
|||||||
CipherType.Card,
|
CipherType.Card,
|
||||||
CipherType.Identity,
|
CipherType.Identity,
|
||||||
CipherType.SecureNote,
|
CipherType.SecureNote,
|
||||||
|
CipherType.SshKey,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -163,6 +163,11 @@ export class VaultPopupListFiltersService {
|
|||||||
label: this.i18nService.t("note"),
|
label: this.i18nService.t("note"),
|
||||||
icon: "bwi-sticky-note",
|
icon: "bwi-sticky-note",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: CipherType.SshKey,
|
||||||
|
label: this.i18nService.t("typeSshKey"),
|
||||||
|
icon: "bwi-key",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Resets `filterForm` to the original state */
|
/** 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 = [
|
arboard = { version = "=3.4.1", default-features = false, features = [
|
||||||
"wayland-data-control",
|
"wayland-data-control",
|
||||||
] }
|
] }
|
||||||
|
async-stream = "0.3.5"
|
||||||
base64 = "=0.22.1"
|
base64 = "=0.22.1"
|
||||||
|
byteorder = "1.5.0"
|
||||||
cbc = { version = "=0.1.2", features = ["alloc"] }
|
cbc = { version = "=0.1.2", features = ["alloc"] }
|
||||||
|
homedir = "0.3.3"
|
||||||
|
libc = "=0.2.159"
|
||||||
|
pin-project = "1.1.5"
|
||||||
dirs = "=5.0.1"
|
dirs = "=5.0.1"
|
||||||
futures = "=0.3.31"
|
futures = "=0.3.31"
|
||||||
interprocess = { version = "=2.2.1", features = ["tokio"] }
|
interprocess = { version = "=2.2.1", features = ["tokio"] }
|
||||||
libc = "=0.2.159"
|
|
||||||
log = "=0.4.22"
|
log = "=0.4.22"
|
||||||
rand = "=0.8.5"
|
rand = "=0.8.5"
|
||||||
retry = "=2.0.0"
|
retry = "=2.0.0"
|
||||||
|
russh-cryptovec = "0.7.3"
|
||||||
scopeguard = "=1.2.0"
|
scopeguard = "=1.2.0"
|
||||||
sha2 = "=0.10.8"
|
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"
|
thiserror = "=1.0.68"
|
||||||
tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] }
|
|
||||||
tokio-util = "=0.7.12"
|
|
||||||
typenum = "=1.17.0"
|
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]
|
[target.'cfg(windows)'.dependencies]
|
||||||
widestring = { version = "=1.1.0", optional = true }
|
widestring = { version = "=1.1.0", optional = true }
|
||||||
|
@ -6,8 +6,8 @@ use anyhow::{anyhow, Result};
|
|||||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||||
mod biometric;
|
mod biometric;
|
||||||
|
|
||||||
pub use biometric::Biometric;
|
|
||||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||||
|
pub use biometric::Biometric;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::crypto::{self, CipherString};
|
use crate::crypto::{self, CipherString};
|
||||||
@ -42,7 +42,6 @@ pub trait BiometricTrait {
|
|||||||
) -> Result<String>;
|
) -> Result<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
||||||
let iv = base64_engine
|
let iv = base64_engine
|
||||||
.decode(iv_b64)?
|
.decode(iv_b64)?
|
||||||
@ -77,4 +76,4 @@ impl KeyMaterial {
|
|||||||
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
|
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
|
||||||
Ok(Sha256::digest(self.digest_material()))
|
Ok(Sha256::digest(self.digest_material()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,13 @@ use base64::Engine;
|
|||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine};
|
use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey};
|
||||||
use zbus::Connection;
|
use zbus::Connection;
|
||||||
use zbus_polkit::policykit1::*;
|
use zbus_polkit::policykit1::*;
|
||||||
|
|
||||||
use super::{decrypt, encrypt};
|
use super::{decrypt, encrypt};
|
||||||
use anyhow::anyhow;
|
|
||||||
use crate::crypto::CipherString;
|
use crate::crypto::CipherString;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
|
||||||
/// The Unix implementation of the biometric trait.
|
/// The Unix implementation of the biometric trait.
|
||||||
pub struct Biometric {}
|
pub struct Biometric {}
|
||||||
@ -22,13 +22,15 @@ impl super::BiometricTrait for Biometric {
|
|||||||
let proxy = AuthorityProxy::new(&connection).await?;
|
let proxy = AuthorityProxy::new(&connection).await?;
|
||||||
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
|
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
|
||||||
let details = std::collections::HashMap::new();
|
let details = std::collections::HashMap::new();
|
||||||
let result = proxy.check_authorization(
|
let result = proxy
|
||||||
&subject,
|
.check_authorization(
|
||||||
"com.bitwarden.Bitwarden.unlock",
|
&subject,
|
||||||
&details,
|
"com.bitwarden.Bitwarden.unlock",
|
||||||
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
&details,
|
||||||
"",
|
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
||||||
).await;
|
"",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
@ -106,4 +108,4 @@ fn random_challenge() -> [u8; 16] {
|
|||||||
let mut challenge = [0u8; 16];
|
let mut challenge = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut challenge);
|
rand::thread_rng().fill_bytes(&mut challenge);
|
||||||
challenge
|
challenge
|
||||||
}
|
}
|
||||||
|
@ -160,7 +160,6 @@ impl super::BiometricTrait for Biometric {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn random_challenge() -> [u8; 16] {
|
fn random_challenge() -> [u8; 16] {
|
||||||
let mut challenge = [0u8; 16];
|
let mut challenge = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut challenge);
|
rand::thread_rng().fill_bytes(&mut challenge);
|
||||||
|
@ -11,3 +11,6 @@ pub mod password;
|
|||||||
pub mod process_isolation;
|
pub mod process_isolation;
|
||||||
#[cfg(feature = "sys")]
|
#[cfg(feature = "sys")]
|
||||||
pub mod powermonitor;
|
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> {
|
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 {
|
match result {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use zbus::{Connection, MatchRule, export::futures_util::TryStreamExt};
|
use zbus::{export::futures_util::TryStreamExt, Connection, MatchRule};
|
||||||
struct ScreenLock {
|
struct ScreenLock {
|
||||||
interface: Cow<'static, str>,
|
interface: Cow<'static, str>,
|
||||||
path: 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 {
|
pub async fn is_lock_monitor_available() -> bool {
|
||||||
let connection = Connection::session().await.unwrap();
|
let connection = Connection::session().await.unwrap();
|
||||||
for monitor in SCREEN_LOCK_MONITORS {
|
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() {
|
if res.is_ok() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use libc::{c_int, self};
|
|
||||||
#[cfg(target_env = "gnu")]
|
#[cfg(target_env = "gnu")]
|
||||||
use libc::c_uint;
|
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
|
// 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
|
// 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 {
|
if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 {
|
||||||
let e = std::io::Error::last_os_error();
|
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(())
|
Ok(())
|
||||||
@ -35,7 +38,7 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
|
|||||||
};
|
};
|
||||||
if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 {
|
if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 {
|
||||||
let e = std::io::Error::last_os_error();
|
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)
|
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<()> {
|
pub fn disable_memory_access() -> Result<()> {
|
||||||
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
|
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
|
||||||
let e = std::io::Error::last_os_error();
|
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(())
|
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-----
|
||||||
|
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
|
||||||
|
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 = []
|
manual_test = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "=0.22.1"
|
||||||
|
hex = "=0.4.3"
|
||||||
anyhow = "=1.0.93"
|
anyhow = "=1.0.93"
|
||||||
desktop_core = { path = "../core" }
|
desktop_core = { path = "../core" }
|
||||||
napi = { version = "=2.16.13", features = ["async"] }
|
napi = { version = "=2.16.13", features = ["async"] }
|
||||||
napi-derive = "=2.16.12"
|
napi-derive = "=2.16.12"
|
||||||
tokio = { version = "1.38.0" }
|
tokio = { version = "=1.40.0" }
|
||||||
tokio-util = "0.7.11"
|
tokio-util = "=0.7.11"
|
||||||
|
tokio-stream = "=0.1.15"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-registry = "=0.3.0"
|
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 read(): Promise<string>
|
||||||
export function write(text: string, password: boolean): Promise<void>
|
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 declare namespace processisolations {
|
||||||
export function disableCoredumps(): Promise<void>
|
export function disableCoredumps(): Promise<void>
|
||||||
export function isCoreDumpingDisabled(): Promise<boolean>
|
export function isCoreDumpingDisabled(): Promise<boolean>
|
||||||
|
@ -54,12 +54,16 @@ pub mod biometrics {
|
|||||||
hwnd: napi::bindgen_prelude::Buffer,
|
hwnd: napi::bindgen_prelude::Buffer,
|
||||||
message: String,
|
message: String,
|
||||||
) -> napi::Result<bool> {
|
) -> 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]
|
#[napi]
|
||||||
pub async fn available() -> napi::Result<bool> {
|
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]
|
#[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]
|
#[napi]
|
||||||
pub mod processisolations {
|
pub mod processisolations {
|
||||||
#[napi]
|
#[napi]
|
||||||
@ -172,12 +369,19 @@ pub mod processisolations {
|
|||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub mod powermonitors {
|
pub mod powermonitors {
|
||||||
use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio};
|
use napi::{
|
||||||
|
threadsafe_function::{
|
||||||
|
ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode,
|
||||||
|
},
|
||||||
|
tokio,
|
||||||
|
};
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
|
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
|
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 {
|
tokio::spawn(async move {
|
||||||
while let Some(message) = rx.recv().await {
|
while let Some(message) = rx.recv().await {
|
||||||
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
|
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||||
@ -190,7 +394,6 @@ pub mod powermonitors {
|
|||||||
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
|
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
|
||||||
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
|
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[napi]
|
#[napi]
|
||||||
|
@ -419,6 +419,23 @@
|
|||||||
"enableHardwareAccelerationDesc" | i18n
|
"enableHardwareAccelerationDesc" | i18n
|
||||||
}}</small>
|
}}</small>
|
||||||
</div>
|
</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="form-group" *ngIf="showDuckDuckGoIntegrationOption">
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label for="enableDuckDuckGoBrowserIntegration">
|
<label for="enableDuckDuckGoBrowserIntegration">
|
||||||
|
@ -12,7 +12,9 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
|||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||||
import { DeviceType } from "@bitwarden/common/enums";
|
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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
@ -53,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
showAlwaysShowDock = false;
|
showAlwaysShowDock = false;
|
||||||
requireEnableTray = false;
|
requireEnableTray = false;
|
||||||
showDuckDuckGoIntegrationOption = false;
|
showDuckDuckGoIntegrationOption = false;
|
||||||
|
showSshAgentOption = false;
|
||||||
isWindows: boolean;
|
isWindows: boolean;
|
||||||
isLinux: boolean;
|
isLinux: boolean;
|
||||||
|
|
||||||
@ -107,6 +110,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
disabled: true,
|
disabled: true,
|
||||||
}),
|
}),
|
||||||
enableHardwareAcceleration: true,
|
enableHardwareAcceleration: true,
|
||||||
|
enableSshAgent: false,
|
||||||
enableDuckDuckGoBrowserIntegration: false,
|
enableDuckDuckGoBrowserIntegration: false,
|
||||||
theme: [null as ThemeType | null],
|
theme: [null as ThemeType | null],
|
||||||
locale: [null as string | null],
|
locale: [null as string | null],
|
||||||
@ -137,6 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
private pinService: PinServiceAbstraction,
|
private pinService: PinServiceAbstraction,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private nativeMessagingManifestService: NativeMessagingManifestService,
|
private nativeMessagingManifestService: NativeMessagingManifestService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||||
|
|
||||||
@ -200,6 +205,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
if (activeAccount == null || activeAccount.id == null) {
|
if (activeAccount == null || activeAccount.id == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||||
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||||
|
|
||||||
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
|
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
|
||||||
@ -272,6 +279,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
enableHardwareAcceleration: await firstValueFrom(
|
enableHardwareAcceleration: await firstValueFrom(
|
||||||
this.desktopSettingsService.hardwareAcceleration$,
|
this.desktopSettingsService.hardwareAcceleration$,
|
||||||
),
|
),
|
||||||
|
enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$),
|
||||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||||
locale: await firstValueFrom(this.i18nService.userSetLocale$),
|
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[]> {
|
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
|
||||||
let vaultTimeoutOptions: VaultTimeoutOption[] = [
|
let vaultTimeoutOptions: VaultTimeoutOption[] = [
|
||||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
{ 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 { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
|
||||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||||
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.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 { PremiumComponent } from "../vault/app/accounts/premium.component";
|
||||||
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
|
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
|
||||||
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
|
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
|
||||||
@ -100,6 +101,7 @@ import { SendComponent } from "./tools/send/send.component";
|
|||||||
ViewComponent,
|
ViewComponent,
|
||||||
ViewCustomFieldsComponent,
|
ViewCustomFieldsComponent,
|
||||||
],
|
],
|
||||||
|
providers: [SshAgentService],
|
||||||
bootstrap: [AppComponent],
|
bootstrap: [AppComponent],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -21,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
|||||||
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
|
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
|
||||||
|
|
||||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||||
|
import { SshAgentService } from "../../platform/services/ssh-agent.service";
|
||||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -41,11 +42,13 @@ export class InitService {
|
|||||||
private encryptService: EncryptService,
|
private encryptService: EncryptService,
|
||||||
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private sshAgentService: SshAgentService,
|
||||||
@Inject(DOCUMENT) private document: Document,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
return async () => {
|
return async () => {
|
||||||
|
await this.sshAgentService.init();
|
||||||
this.nativeMessagingService.init();
|
this.nativeMessagingService.init();
|
||||||
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
||||||
|
|
||||||
|
@ -26,6 +26,9 @@
|
|||||||
"typeSecureNote": {
|
"typeSecureNote": {
|
||||||
"message": "Secure note"
|
"message": "Secure note"
|
||||||
},
|
},
|
||||||
|
"typeSshKey": {
|
||||||
|
"message": "SSH key"
|
||||||
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"message": "Folders"
|
"message": "Folders"
|
||||||
},
|
},
|
||||||
@ -177,6 +180,48 @@
|
|||||||
"address": {
|
"address": {
|
||||||
"message": "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": {
|
"premiumRequired": {
|
||||||
"message": "Premium required"
|
"message": "Premium required"
|
||||||
},
|
},
|
||||||
@ -400,6 +445,12 @@
|
|||||||
"copyPassword": {
|
"copyPassword": {
|
||||||
"message": "Copy password"
|
"message": "Copy password"
|
||||||
},
|
},
|
||||||
|
"regenerateSshKey": {
|
||||||
|
"message": "Regenerate SSH key"
|
||||||
|
},
|
||||||
|
"copySshPrivateKey": {
|
||||||
|
"message": "Copy SSH private key"
|
||||||
|
},
|
||||||
"copyPassphrase": {
|
"copyPassphrase": {
|
||||||
"message": "Copy passphrase",
|
"message": "Copy passphrase",
|
||||||
"description": "Copy passphrase to clipboard"
|
"description": "Copy passphrase to clipboard"
|
||||||
@ -3225,6 +3276,36 @@
|
|||||||
"ssoError": {
|
"ssoError": {
|
||||||
"message": "No free ports could be found for the sso login."
|
"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": {
|
"fileSavedToDevice": {
|
||||||
"message": "File saved to device. Manage from your device downloads."
|
"message": "File saved to device. Manage from your device downloads."
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { app } from "electron";
|
import { app, ipcMain } from "electron";
|
||||||
import { Subject, firstValueFrom } from "rxjs";
|
import { Subject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
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 { ClipboardMain } from "./platform/main/clipboard.main";
|
||||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||||
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
|
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 { DesktopSettingsService } from "./platform/services/desktop-settings.service";
|
||||||
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
|
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
|
||||||
import { ElectronStorageService } from "./platform/services/electron-storage.service";
|
import { ElectronStorageService } from "./platform/services/electron-storage.service";
|
||||||
@ -71,6 +72,7 @@ export class Main {
|
|||||||
nativeMessagingMain: NativeMessagingMain;
|
nativeMessagingMain: NativeMessagingMain;
|
||||||
clipboardMain: ClipboardMain;
|
clipboardMain: ClipboardMain;
|
||||||
desktopAutofillSettingsService: DesktopAutofillSettingsService;
|
desktopAutofillSettingsService: DesktopAutofillSettingsService;
|
||||||
|
sshAgentService: MainSshAgentService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Set paths for portable builds
|
// Set paths for portable builds
|
||||||
@ -240,6 +242,13 @@ export class Main {
|
|||||||
this.clipboardMain = new ClipboardMain();
|
this.clipboardMain = new ClipboardMain();
|
||||||
this.clipboardMain.init();
|
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 EphemeralValueStorageService();
|
||||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
||||||
}
|
}
|
||||||
|
@ -69,6 +69,19 @@ export class WindowMain {
|
|||||||
this.logService.info("Render process reloaded");
|
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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
if (!isMacAppStore() && !isSnapStore()) {
|
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 { ipcRenderer } from "electron";
|
||||||
|
|
||||||
import { DeviceType } from "@bitwarden/common/enums";
|
import { DeviceType } from "@bitwarden/common/enums";
|
||||||
@ -40,6 +41,30 @@ const clipboard = {
|
|||||||
write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message),
|
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 = {
|
const powermonitor = {
|
||||||
isLockMonitorAvailable: (): Promise<boolean> =>
|
isLockMonitorAvailable: (): Promise<boolean> =>
|
||||||
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
|
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
|
||||||
@ -106,6 +131,8 @@ export default {
|
|||||||
isSnapStore: isSnapStore(),
|
isSnapStore: isSnapStore(),
|
||||||
isAppImage: isAppImage(),
|
isAppImage: isAppImage(),
|
||||||
reloadProcess: () => ipcRenderer.send("reload-process"),
|
reloadProcess: () => ipcRenderer.send("reload-process"),
|
||||||
|
focusWindow: () => ipcRenderer.send("window-focus"),
|
||||||
|
hideWindow: () => ipcRenderer.send("window-hide"),
|
||||||
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
|
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
|
||||||
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
|
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
|
||||||
|
|
||||||
@ -150,6 +177,7 @@ export default {
|
|||||||
storage,
|
storage,
|
||||||
passwords,
|
passwords,
|
||||||
clipboard,
|
clipboard,
|
||||||
|
sshAgent,
|
||||||
powermonitor,
|
powermonitor,
|
||||||
nativeMessaging,
|
nativeMessaging,
|
||||||
crypto,
|
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", {
|
const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeOnCopy", {
|
||||||
deserializer: (b) => b,
|
deserializer: (b) => b,
|
||||||
clearOn: [], // User setting, no need to clear
|
clearOn: [], // User setting, no need to clear
|
||||||
@ -139,6 +143,10 @@ export class DesktopSettingsService {
|
|||||||
browserIntegrationFingerprintEnabled$ =
|
browserIntegrationFingerprintEnabled$ =
|
||||||
this.browserIntegrationFingerprintEnabledState.state$.pipe(map(Boolean));
|
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);
|
private readonly minimizeOnCopyState = this.stateProvider.getActive(MINIMIZE_ON_COPY);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -246,6 +254,13 @@ export class DesktopSettingsService {
|
|||||||
await this.browserIntegrationFingerprintEnabledState.update(() => value);
|
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.
|
* Sets the minimize on copy value for the current user.
|
||||||
* @param value `true` if the application should minimize when a value is copied,
|
* @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">
|
||||||
<div class="box-content-row" *ngIf="!editMode" appBoxRow>
|
<div class="box-content-row" *ngIf="!editMode" appBoxRow>
|
||||||
<label for="type">{{ "type" | i18n }}</label>
|
<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>
|
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{ o.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -471,6 +471,115 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DatePipe } from "@angular/common";
|
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 { NgForm } from "@angular/forms";
|
||||||
|
import { sshagent as sshAgent } from "desktop_native/napi";
|
||||||
|
|
||||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
|
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 { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
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 { 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";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "AddEditComponent";
|
const BroadcasterSubscriptionId = "AddEditComponent";
|
||||||
@ -31,6 +33,7 @@ const BroadcasterSubscriptionId = "AddEditComponent";
|
|||||||
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
|
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
@ViewChild("form")
|
@ViewChild("form")
|
||||||
private form: NgForm;
|
private form: NgForm;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
cipherService: CipherService,
|
cipherService: CipherService,
|
||||||
folderService: FolderService,
|
folderService: FolderService,
|
||||||
@ -51,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
|||||||
dialogService: DialogService,
|
dialogService: DialogService,
|
||||||
datePipe: DatePipe,
|
datePipe: DatePipe,
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
|
private toastService: ToastService,
|
||||||
cipherAuthorizationService: CipherAuthorizationService,
|
cipherAuthorizationService: CipherAuthorizationService,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
@ -140,4 +144,68 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
|||||||
"https://bitwarden.com/help/managing-items/#protect-individual-items",
|
"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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
@ -399,6 +399,105 @@
|
|||||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||||
|
@ -851,6 +851,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</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">
|
<div class="form-group">
|
||||||
<label for="notes">{{ "notes" | i18n }}</label>
|
<label for="notes">{{ "notes" | i18n }}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
@ -216,6 +216,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
|||||||
type: CipherType.SecureNote,
|
type: CipherType.SecureNote,
|
||||||
icon: "bwi-sticky-note",
|
icon: "bwi-sticky-note",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sshKey",
|
||||||
|
name: this.i18nService.t("typeSshKey"),
|
||||||
|
type: CipherType.SshKey,
|
||||||
|
icon: "bwi-key",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const typeFilterSection: VaultFilterSection = {
|
const typeFilterSection: VaultFilterSection = {
|
||||||
|
@ -559,6 +559,9 @@
|
|||||||
"typeSecureNote": {
|
"typeSecureNote": {
|
||||||
"message": "Secure note"
|
"message": "Secure note"
|
||||||
},
|
},
|
||||||
|
"typeSshKey": {
|
||||||
|
"message": "Ssh key"
|
||||||
|
},
|
||||||
"typeLoginPlural": {
|
"typeLoginPlural": {
|
||||||
"message": "Logins"
|
"message": "Logins"
|
||||||
},
|
},
|
||||||
@ -9416,6 +9419,30 @@
|
|||||||
"additionalStorageGbMessage": {
|
"additionalStorageGbMessage": {
|
||||||
"message": "GB additional storage"
|
"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": {
|
"premiumAccounts": {
|
||||||
"message": "6 premium accounts"
|
"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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
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 { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.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 { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
restorePromise: Promise<any>;
|
restorePromise: Promise<any>;
|
||||||
checkPasswordPromise: Promise<number>;
|
checkPasswordPromise: Promise<number>;
|
||||||
showPassword = false;
|
showPassword = false;
|
||||||
|
showPrivateKey = false;
|
||||||
showTotpSeed = false;
|
showTotpSeed = false;
|
||||||
showCardNumber = false;
|
showCardNumber = false;
|
||||||
showCardCode = false;
|
showCardCode = false;
|
||||||
@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
|
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
|
||||||
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
|
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
|
||||||
];
|
];
|
||||||
|
|
||||||
this.cardBrandOptions = [
|
this.cardBrandOptions = [
|
||||||
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
{ name: "-- " + i18nService.t("select") + " --", value: null },
|
||||||
{ name: "Visa", value: "Visa" },
|
{ name: "Visa", value: "Visa" },
|
||||||
@ -200,6 +204,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.writeableCollections = await this.loadCollections();
|
this.writeableCollections = await this.loadCollections();
|
||||||
this.canUseReprompt = await this.passwordRepromptService.enabled();
|
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() {
|
ngOnDestroy() {
|
||||||
@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
this.cipher.identity = new IdentityView();
|
this.cipher.identity = new IdentityView();
|
||||||
this.cipher.secureNote = new SecureNoteView();
|
this.cipher.secureNote = new SecureNoteView();
|
||||||
this.cipher.secureNote.type = SecureNoteType.Generic;
|
this.cipher.secureNote.type = SecureNoteType.Generic;
|
||||||
|
this.cipher.sshKey = new SshKeyView();
|
||||||
this.cipher.reprompt = CipherRepromptType.None;
|
this.cipher.reprompt = CipherRepromptType.None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePrivateKey() {
|
||||||
|
this.showPrivateKey = !this.showPrivateKey;
|
||||||
|
}
|
||||||
|
|
||||||
toggleUriOptions(uri: LoginUriView) {
|
toggleUriOptions(uri: LoginUriView) {
|
||||||
const u = uri as any;
|
const u = uri as any;
|
||||||
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
|
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
|
||||||
|
@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
|||||||
showPasswordCount: boolean;
|
showPasswordCount: boolean;
|
||||||
showCardNumber: boolean;
|
showCardNumber: boolean;
|
||||||
showCardCode: boolean;
|
showCardCode: boolean;
|
||||||
|
showPrivateKey: boolean;
|
||||||
canAccessPremium: boolean;
|
canAccessPremium: boolean;
|
||||||
showPremiumRequiredTotp: boolean;
|
showPremiumRequiredTotp: boolean;
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePrivateKey() {
|
||||||
|
this.showPrivateKey = !this.showPrivateKey;
|
||||||
|
}
|
||||||
|
|
||||||
async checkPassword() {
|
async checkPassword() {
|
||||||
if (
|
if (
|
||||||
this.cipher.login == null ||
|
this.cipher.login == null ||
|
||||||
|
@ -27,6 +27,8 @@ export enum FeatureFlag {
|
|||||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||||
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
||||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||||
|
SSHKeyVaultItem = "ssh-key-vault-item",
|
||||||
|
SSHAgent = "ssh-agent",
|
||||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||||
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
|
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
|
||||||
CipherKeyEncryption = "cipher-key-encryption",
|
CipherKeyEncryption = "cipher-key-encryption",
|
||||||
@ -73,6 +75,8 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||||
|
[FeatureFlag.SSHKeyVaultItem]: FALSE,
|
||||||
|
[FeatureFlag.SSHAgent]: FALSE,
|
||||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||||
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
|
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
|
||||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||||
|
@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export";
|
|||||||
import { LoginExport } from "./login.export";
|
import { LoginExport } from "./login.export";
|
||||||
import { PasswordHistoryExport } from "./password-history.export";
|
import { PasswordHistoryExport } from "./password-history.export";
|
||||||
import { SecureNoteExport } from "./secure-note.export";
|
import { SecureNoteExport } from "./secure-note.export";
|
||||||
|
import { SshKeyExport } from "./ssh-key.export";
|
||||||
import { safeGetString } from "./utils";
|
import { safeGetString } from "./utils";
|
||||||
|
|
||||||
export class CipherExport {
|
export class CipherExport {
|
||||||
@ -27,6 +28,7 @@ export class CipherExport {
|
|||||||
req.secureNote = null;
|
req.secureNote = null;
|
||||||
req.card = null;
|
req.card = null;
|
||||||
req.identity = null;
|
req.identity = null;
|
||||||
|
req.sshKey = null;
|
||||||
req.reprompt = CipherRepromptType.None;
|
req.reprompt = CipherRepromptType.None;
|
||||||
req.passwordHistory = [];
|
req.passwordHistory = [];
|
||||||
req.creationDate = null;
|
req.creationDate = null;
|
||||||
@ -67,6 +69,8 @@ export class CipherExport {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
view.identity = IdentityExport.toView(req.identity);
|
view.identity = IdentityExport.toView(req.identity);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
view.sshKey = SshKeyExport.toView(req.sshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.passwordHistory != null) {
|
if (req.passwordHistory != null) {
|
||||||
@ -108,6 +112,9 @@ export class CipherExport {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
domain.identity = IdentityExport.toDomain(req.identity);
|
domain.identity = IdentityExport.toDomain(req.identity);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
domain.sshKey = SshKeyExport.toDomain(req.sshKey);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.passwordHistory != null) {
|
if (req.passwordHistory != null) {
|
||||||
@ -132,6 +139,7 @@ export class CipherExport {
|
|||||||
secureNote: SecureNoteExport;
|
secureNote: SecureNoteExport;
|
||||||
card: CardExport;
|
card: CardExport;
|
||||||
identity: IdentityExport;
|
identity: IdentityExport;
|
||||||
|
sshKey: SshKeyExport;
|
||||||
reprompt: CipherRepromptType;
|
reprompt: CipherRepromptType;
|
||||||
passwordHistory: PasswordHistoryExport[] = null;
|
passwordHistory: PasswordHistoryExport[] = null;
|
||||||
revisionDate: Date = null;
|
revisionDate: Date = null;
|
||||||
@ -171,6 +179,9 @@ export class CipherExport {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
this.identity = new IdentityExport(o.identity);
|
this.identity = new IdentityExport(o.identity);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
this.sshKey = new SshKeyExport(o.sshKey);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (o.passwordHistory != null) {
|
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,
|
SecureNote = 2,
|
||||||
Card = 3,
|
Card = 3,
|
||||||
Identity = 4,
|
Identity = 4,
|
||||||
|
SshKey = 5,
|
||||||
}
|
}
|
||||||
|
@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
icon = "bwi-id-card";
|
icon = "bwi-id-card";
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
icon = "bwi-key";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 { LoginData } from "./login.data";
|
||||||
import { PasswordHistoryData } from "./password-history.data";
|
import { PasswordHistoryData } from "./password-history.data";
|
||||||
import { SecureNoteData } from "./secure-note.data";
|
import { SecureNoteData } from "./secure-note.data";
|
||||||
|
import { SshKeyData } from "./ssh-key.data";
|
||||||
|
|
||||||
export class CipherData {
|
export class CipherData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -28,6 +29,7 @@ export class CipherData {
|
|||||||
secureNote?: SecureNoteData;
|
secureNote?: SecureNoteData;
|
||||||
card?: CardData;
|
card?: CardData;
|
||||||
identity?: IdentityData;
|
identity?: IdentityData;
|
||||||
|
sshKey?: SshKeyData;
|
||||||
fields?: FieldData[];
|
fields?: FieldData[];
|
||||||
attachments?: AttachmentData[];
|
attachments?: AttachmentData[];
|
||||||
passwordHistory?: PasswordHistoryData[];
|
passwordHistory?: PasswordHistoryData[];
|
||||||
@ -72,6 +74,9 @@ export class CipherData {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
this.identity = new IdentityData(response.identity);
|
this.identity = new IdentityData(response.identity);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
this.sshKey = new SshKeyData(response.sshKey);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 { Login } from "./login";
|
||||||
import { Password } from "./password";
|
import { Password } from "./password";
|
||||||
import { SecureNote } from "./secure-note";
|
import { SecureNote } from "./secure-note";
|
||||||
|
import { SshKey } from "./ssh-key";
|
||||||
|
|
||||||
export class Cipher extends Domain implements Decryptable<CipherView> {
|
export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||||
readonly initializerKey = InitializerKey.Cipher;
|
readonly initializerKey = InitializerKey.Cipher;
|
||||||
@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
|||||||
identity: Identity;
|
identity: Identity;
|
||||||
card: Card;
|
card: Card;
|
||||||
secureNote: SecureNote;
|
secureNote: SecureNote;
|
||||||
|
sshKey: SshKey;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
passwordHistory: Password[];
|
passwordHistory: Password[];
|
||||||
@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
this.identity = new Identity(obj.identity);
|
this.identity = new Identity(obj.identity);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
this.sshKey = new SshKey(obj.sshKey);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
model.identity = await this.identity.decrypt(this.organizationId, encKey);
|
model.identity = await this.identity.decrypt(this.organizationId, encKey);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
c.identity = this.identity.toIdentityData();
|
c.identity = this.identity.toIdentityData();
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
c.sshKey = this.sshKey.toSshKeyData();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
|||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
|
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
domain.sshKey = SshKey.fromJSON(obj.sshKey);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 { LoginUriApi } from "../api/login-uri.api";
|
||||||
import { LoginApi } from "../api/login.api";
|
import { LoginApi } from "../api/login.api";
|
||||||
import { SecureNoteApi } from "../api/secure-note.api";
|
import { SecureNoteApi } from "../api/secure-note.api";
|
||||||
|
import { SshKeyApi } from "../api/ssh-key.api";
|
||||||
import { Cipher } from "../domain/cipher";
|
import { Cipher } from "../domain/cipher";
|
||||||
|
|
||||||
import { AttachmentRequest } from "./attachment.request";
|
import { AttachmentRequest } from "./attachment.request";
|
||||||
@ -23,6 +24,7 @@ export class CipherRequest {
|
|||||||
secureNote: SecureNoteApi;
|
secureNote: SecureNoteApi;
|
||||||
card: CardApi;
|
card: CardApi;
|
||||||
identity: IdentityApi;
|
identity: IdentityApi;
|
||||||
|
sshKey: SshKeyApi;
|
||||||
fields: FieldApi[];
|
fields: FieldApi[];
|
||||||
passwordHistory: PasswordHistoryRequest[];
|
passwordHistory: PasswordHistoryRequest[];
|
||||||
// Deprecated, remove at some point and rename attachments2 to attachments
|
// Deprecated, remove at some point and rename attachments2 to attachments
|
||||||
@ -93,6 +95,17 @@ export class CipherRequest {
|
|||||||
this.secureNote = new SecureNoteApi();
|
this.secureNote = new SecureNoteApi();
|
||||||
this.secureNote.type = cipher.secureNote.type;
|
this.secureNote.type = cipher.secureNote.type;
|
||||||
break;
|
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:
|
case CipherType.Card:
|
||||||
this.card = new CardApi();
|
this.card = new CardApi();
|
||||||
this.card.cardholderName =
|
this.card.cardholderName =
|
||||||
|
@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api";
|
|||||||
import { IdentityApi } from "../api/identity.api";
|
import { IdentityApi } from "../api/identity.api";
|
||||||
import { LoginApi } from "../api/login.api";
|
import { LoginApi } from "../api/login.api";
|
||||||
import { SecureNoteApi } from "../api/secure-note.api";
|
import { SecureNoteApi } from "../api/secure-note.api";
|
||||||
|
import { SshKeyApi } from "../api/ssh-key.api";
|
||||||
|
|
||||||
import { AttachmentResponse } from "./attachment.response";
|
import { AttachmentResponse } from "./attachment.response";
|
||||||
import { PasswordHistoryResponse } from "./password-history.response";
|
import { PasswordHistoryResponse } from "./password-history.response";
|
||||||
@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse {
|
|||||||
card: CardApi;
|
card: CardApi;
|
||||||
identity: IdentityApi;
|
identity: IdentityApi;
|
||||||
secureNote: SecureNoteApi;
|
secureNote: SecureNoteApi;
|
||||||
|
sshKey: SshKeyApi;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
edit: boolean;
|
edit: boolean;
|
||||||
viewPassword: boolean;
|
viewPassword: boolean;
|
||||||
@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse {
|
|||||||
this.secureNote = new SecureNoteApi(secureNote);
|
this.secureNote = new SecureNoteApi(secureNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sshKey = this.getResponseProperty("sshKey");
|
||||||
|
if (sshKey != null) {
|
||||||
|
this.sshKey = new SshKeyApi(sshKey);
|
||||||
|
}
|
||||||
|
|
||||||
const fields = this.getResponseProperty("Fields");
|
const fields = this.getResponseProperty("Fields");
|
||||||
if (fields != null) {
|
if (fields != null) {
|
||||||
this.fields = fields.map((f: any) => new FieldApi(f));
|
this.fields = fields.map((f: any) => new FieldApi(f));
|
||||||
|
@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view";
|
|||||||
import { LoginView } from "./login.view";
|
import { LoginView } from "./login.view";
|
||||||
import { PasswordHistoryView } from "./password-history.view";
|
import { PasswordHistoryView } from "./password-history.view";
|
||||||
import { SecureNoteView } from "./secure-note.view";
|
import { SecureNoteView } from "./secure-note.view";
|
||||||
|
import { SshKeyView } from "./ssh-key.view";
|
||||||
|
|
||||||
export class CipherView implements View, InitializerMetadata {
|
export class CipherView implements View, InitializerMetadata {
|
||||||
readonly initializerKey = InitializerKey.CipherView;
|
readonly initializerKey = InitializerKey.CipherView;
|
||||||
@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata {
|
|||||||
identity = new IdentityView();
|
identity = new IdentityView();
|
||||||
card = new CardView();
|
card = new CardView();
|
||||||
secureNote = new SecureNoteView();
|
secureNote = new SecureNoteView();
|
||||||
|
sshKey = new SshKeyView();
|
||||||
attachments: AttachmentView[] = null;
|
attachments: AttachmentView[] = null;
|
||||||
fields: FieldView[] = null;
|
fields: FieldView[] = null;
|
||||||
passwordHistory: PasswordHistoryView[] = null;
|
passwordHistory: PasswordHistoryView[] = null;
|
||||||
@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata {
|
|||||||
return this.card;
|
return this.card;
|
||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
return this.identity;
|
return this.identity;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
return this.sshKey;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata {
|
|||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
|
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
view.sshKey = SshKeyView.fromJSON(obj.sshKey);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
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 { Password } from "../models/domain/password";
|
||||||
import { SecureNote } from "../models/domain/secure-note";
|
import { SecureNote } from "../models/domain/secure-note";
|
||||||
import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache";
|
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 { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request";
|
||||||
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
|
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
|
||||||
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
|
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
|
||||||
@ -1570,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction {
|
|||||||
key,
|
key,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
cipher.sshKey = new SshKey();
|
||||||
|
await this.encryptObjProperty(
|
||||||
|
model.sshKey,
|
||||||
|
cipher.sshKey,
|
||||||
|
{
|
||||||
|
privateKey: null,
|
||||||
|
publicKey: null,
|
||||||
|
keyFingerprint: null,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
throw new Error("Unknown cipher type.");
|
throw new Error("Unknown cipher type.");
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ export class ImportSuccessDialogComponent implements OnInit {
|
|||||||
let cards = 0;
|
let cards = 0;
|
||||||
let identities = 0;
|
let identities = 0;
|
||||||
let secureNotes = 0;
|
let secureNotes = 0;
|
||||||
|
let sshKeys = 0;
|
||||||
this.data.ciphers.map((c) => {
|
this.data.ciphers.map((c) => {
|
||||||
switch (c.type) {
|
switch (c.type) {
|
||||||
case CipherType.Login:
|
case CipherType.Login:
|
||||||
@ -52,6 +53,9 @@ export class ImportSuccessDialogComponent implements OnInit {
|
|||||||
case CipherType.Identity:
|
case CipherType.Identity:
|
||||||
identities++;
|
identities++;
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SshKey:
|
||||||
|
sshKeys++;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -70,6 +74,9 @@ export class ImportSuccessDialogComponent implements OnInit {
|
|||||||
if (secureNotes > 0) {
|
if (secureNotes > 0) {
|
||||||
list.push({ icon: "sticky-note", type: "typeSecureNote", count: secureNotes });
|
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) {
|
if (this.data.folders.length > 0) {
|
||||||
list.push({ icon: "folder", type: "folders", count: this.data.folders.length });
|
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 { IdentitySectionComponent } from "./components/identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
|
||||||
import { LoginDetailsSectionComponent } from "./components/login-details-section/login-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.
|
* 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"];
|
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
|
||||||
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
|
||||||
identityDetails?: IdentitySectionComponent["identityForm"];
|
identityDetails?: IdentitySectionComponent["identityForm"];
|
||||||
|
sshKeyDetails?: SshKeySectionComponent["sshKeyForm"];
|
||||||
customFields?: CustomFieldsComponent["customFieldsForm"];
|
customFields?: CustomFieldsComponent["customFieldsForm"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -22,6 +22,12 @@
|
|||||||
[disabled]="config.mode === 'partial-edit'"
|
[disabled]="config.mode === 'partial-edit'"
|
||||||
></vault-card-details-section>
|
></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>
|
<vault-additional-options-section></vault-additional-options-section>
|
||||||
|
|
||||||
<!-- Attachments are only available for existing ciphers -->
|
<!-- 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 { IdentitySectionComponent } from "./identity/identity.component";
|
||||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||||
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
|
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
|
||||||
|
import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "vault-cipher-form",
|
selector: "vault-cipher-form",
|
||||||
@ -65,6 +66,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta
|
|||||||
ItemDetailsSectionComponent,
|
ItemDetailsSectionComponent,
|
||||||
CardDetailsSectionComponent,
|
CardDetailsSectionComponent,
|
||||||
IdentitySectionComponent,
|
IdentitySectionComponent,
|
||||||
|
SshKeySectionComponent,
|
||||||
NgIf,
|
NgIf,
|
||||||
AdditionalOptionsSectionComponent,
|
AdditionalOptionsSectionComponent,
|
||||||
LoginDetailsSectionComponent,
|
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 *ngIf="cipher.identity" [cipher]="cipher">
|
||||||
</app-view-identity-sections>
|
</app-view-identity-sections>
|
||||||
|
|
||||||
|
<!-- SshKEY SECTIONS -->
|
||||||
|
<app-sshkey-view *ngIf="hasSshKey" [sshKey]="cipher.sshKey"></app-sshkey-view>
|
||||||
|
|
||||||
<!-- ADDITIONAL OPTIONS -->
|
<!-- ADDITIONAL OPTIONS -->
|
||||||
<ng-container *ngIf="cipher.notes">
|
<ng-container *ngIf="cipher.notes">
|
||||||
<app-additional-options [notes]="cipher.notes"> </app-additional-options>
|
<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 { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
|
||||||
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
|
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
|
||||||
import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.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";
|
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -38,6 +39,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
|
|||||||
ItemHistoryV2Component,
|
ItemHistoryV2Component,
|
||||||
CustomFieldV2Component,
|
CustomFieldV2Component,
|
||||||
CardDetailsComponent,
|
CardDetailsComponent,
|
||||||
|
SshKeyViewComponent,
|
||||||
ViewIdentitySectionsComponent,
|
ViewIdentitySectionsComponent,
|
||||||
LoginCredentialsViewComponent,
|
LoginCredentialsViewComponent,
|
||||||
AutofillOptionsViewComponent,
|
AutofillOptionsViewComponent,
|
||||||
@ -95,6 +97,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
|
|||||||
return this.cipher.login?.uris.length > 0;
|
return this.cipher.login?.uris.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasSshKey() {
|
||||||
|
return this.cipher.sshKey?.privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
async loadCipherData() {
|
async loadCipherData() {
|
||||||
// Load collections if not provided and the cipher has collectionIds
|
// Load collections if not provided and the cipher has collectionIds
|
||||||
if (
|
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;
|
return this.cipher.identity?.fullAddressForCopy;
|
||||||
case "secureNote":
|
case "secureNote":
|
||||||
return this.cipher.notes;
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,10 @@ export type CopyAction =
|
|||||||
| "phone"
|
| "phone"
|
||||||
| "address"
|
| "address"
|
||||||
| "secureNote"
|
| "secureNote"
|
||||||
| "hiddenField";
|
| "hiddenField"
|
||||||
|
| "privateKey"
|
||||||
|
| "publicKey"
|
||||||
|
| "keyFingerprint";
|
||||||
|
|
||||||
type CopyActionInfo = {
|
type CopyActionInfo = {
|
||||||
/**
|
/**
|
||||||
@ -62,6 +65,9 @@ const CopyActions: Record<CopyAction, CopyActionInfo> = {
|
|||||||
phone: { typeI18nKey: "phone", protected: true },
|
phone: { typeI18nKey: "phone", protected: true },
|
||||||
address: { typeI18nKey: "address", protected: true },
|
address: { typeI18nKey: "address", protected: true },
|
||||||
secureNote: { typeI18nKey: "note", protected: true },
|
secureNote: { typeI18nKey: "note", protected: true },
|
||||||
|
privateKey: { typeI18nKey: "sshPrivateKey", protected: true },
|
||||||
|
publicKey: { typeI18nKey: "sshPublicKey", protected: true },
|
||||||
|
keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true },
|
||||||
hiddenField: {
|
hiddenField: {
|
||||||
typeI18nKey: "value",
|
typeI18nKey: "value",
|
||||||
protected: true,
|
protected: true,
|
||||||
|
Loading…
Reference in New Issue
Block a user