1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00: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:
Bernd Schoolmann
2024-11-08 11:01:31 +01:00
committed by GitHub
parent 2c914def29
commit 081fe83d83
98 changed files with 3572 additions and 60 deletions

View File

@@ -27,6 +27,8 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
@@ -73,6 +75,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,

View File

@@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export";
import { LoginExport } from "./login.export";
import { PasswordHistoryExport } from "./password-history.export";
import { SecureNoteExport } from "./secure-note.export";
import { SshKeyExport } from "./ssh-key.export";
import { safeGetString } from "./utils";
export class CipherExport {
@@ -27,6 +28,7 @@ export class CipherExport {
req.secureNote = null;
req.card = null;
req.identity = null;
req.sshKey = null;
req.reprompt = CipherRepromptType.None;
req.passwordHistory = [];
req.creationDate = null;
@@ -67,6 +69,8 @@ export class CipherExport {
case CipherType.Identity:
view.identity = IdentityExport.toView(req.identity);
break;
case CipherType.SshKey:
view.sshKey = SshKeyExport.toView(req.sshKey);
}
if (req.passwordHistory != null) {
@@ -108,6 +112,9 @@ export class CipherExport {
case CipherType.Identity:
domain.identity = IdentityExport.toDomain(req.identity);
break;
case CipherType.SshKey:
domain.sshKey = SshKeyExport.toDomain(req.sshKey);
break;
}
if (req.passwordHistory != null) {
@@ -132,6 +139,7 @@ export class CipherExport {
secureNote: SecureNoteExport;
card: CardExport;
identity: IdentityExport;
sshKey: SshKeyExport;
reprompt: CipherRepromptType;
passwordHistory: PasswordHistoryExport[] = null;
revisionDate: Date = null;
@@ -171,6 +179,9 @@ export class CipherExport {
case CipherType.Identity:
this.identity = new IdentityExport(o.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyExport(o.sshKey);
break;
}
if (o.passwordHistory != null) {

View 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);
}
}

View File

@@ -3,4 +3,5 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5,
}

View File

@@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
case CipherType.Identity:
icon = "bwi-id-card";
break;
case CipherType.SshKey:
icon = "bwi-key";
break;
default:
break;
}

View 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");
}
}

View File

@@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data";
import { LoginData } from "./login.data";
import { PasswordHistoryData } from "./password-history.data";
import { SecureNoteData } from "./secure-note.data";
import { SshKeyData } from "./ssh-key.data";
export class CipherData {
id: string;
@@ -28,6 +29,7 @@ export class CipherData {
secureNote?: SecureNoteData;
card?: CardData;
identity?: IdentityData;
sshKey?: SshKeyData;
fields?: FieldData[];
attachments?: AttachmentData[];
passwordHistory?: PasswordHistoryData[];
@@ -72,6 +74,9 @@ export class CipherData {
case CipherType.Identity:
this.identity = new IdentityData(response.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyData(response.sshKey);
break;
default:
break;
}

View 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;
}
}

View File

@@ -19,6 +19,7 @@ import { Identity } from "./identity";
import { Login } from "./login";
import { Password } from "./password";
import { SecureNote } from "./secure-note";
import { SshKey } from "./ssh-key";
export class Cipher extends Domain implements Decryptable<CipherView> {
readonly initializerKey = InitializerKey.Cipher;
@@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
identity: Identity;
card: Card;
secureNote: SecureNote;
sshKey: SshKey;
attachments: Attachment[];
fields: Field[];
passwordHistory: Password[];
@@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
this.identity = new Identity(obj.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKey(obj.sshKey);
break;
default:
break;
}
@@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
model.identity = await this.identity.decrypt(this.organizationId, encKey);
break;
case CipherType.SshKey:
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
break;
default:
break;
}
@@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
c.identity = this.identity.toIdentityData();
break;
case CipherType.SshKey:
c.sshKey = this.sshKey.toSshKeyData();
break;
default:
break;
}
@@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.SecureNote:
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
domain.sshKey = SshKey.fromJSON(obj.sshKey);
break;
default:
break;
}

View 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();
});
});
});

View 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,
});
}
}

View File

@@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api";
import { LoginUriApi } from "../api/login-uri.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { Cipher } from "../domain/cipher";
import { AttachmentRequest } from "./attachment.request";
@@ -23,6 +24,7 @@ export class CipherRequest {
secureNote: SecureNoteApi;
card: CardApi;
identity: IdentityApi;
sshKey: SshKeyApi;
fields: FieldApi[];
passwordHistory: PasswordHistoryRequest[];
// Deprecated, remove at some point and rename attachments2 to attachments
@@ -93,6 +95,17 @@ export class CipherRequest {
this.secureNote = new SecureNoteApi();
this.secureNote.type = cipher.secureNote.type;
break;
case CipherType.SshKey:
this.sshKey = new SshKeyApi();
this.sshKey.privateKey =
cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null;
this.sshKey.publicKey =
cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null;
this.sshKey.keyFingerprint =
cipher.sshKey.keyFingerprint != null
? cipher.sshKey.keyFingerprint.encryptedString
: null;
break;
case CipherType.Card:
this.card = new CardApi();
this.card.cardholderName =

View File

@@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api";
import { IdentityApi } from "../api/identity.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { AttachmentResponse } from "./attachment.response";
import { PasswordHistoryResponse } from "./password-history.response";
@@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse {
card: CardApi;
identity: IdentityApi;
secureNote: SecureNoteApi;
sshKey: SshKeyApi;
favorite: boolean;
edit: boolean;
viewPassword: boolean;
@@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse {
this.secureNote = new SecureNoteApi(secureNote);
}
const sshKey = this.getResponseProperty("sshKey");
if (sshKey != null) {
this.sshKey = new SshKeyApi(sshKey);
}
const fields = this.getResponseProperty("Fields");
if (fields != null) {
this.fields = fields.map((f: any) => new FieldApi(f));

View File

@@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view";
import { LoginView } from "./login.view";
import { PasswordHistoryView } from "./password-history.view";
import { SecureNoteView } from "./secure-note.view";
import { SshKeyView } from "./ssh-key.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
@@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata {
identity = new IdentityView();
card = new CardView();
secureNote = new SecureNoteView();
sshKey = new SshKeyView();
attachments: AttachmentView[] = null;
fields: FieldView[] = null;
passwordHistory: PasswordHistoryView[] = null;
@@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata {
return this.card;
case CipherType.Identity:
return this.identity;
case CipherType.SshKey:
return this.sshKey;
default:
break;
}
@@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata {
case CipherType.SecureNote:
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
view.sshKey = SshKeyView.fromJSON(obj.sshKey);
break;
default:
break;
}

View 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);
}
}

View File

@@ -54,6 +54,7 @@ import { LoginUri } from "../models/domain/login-uri";
import { Password } from "../models/domain/password";
import { SecureNote } from "../models/domain/secure-note";
import { SortedCiphersCache } from "../models/domain/sorted-ciphers-cache";
import { SshKey } from "../models/domain/ssh-key";
import { CipherBulkDeleteRequest } from "../models/request/cipher-bulk-delete.request";
import { CipherBulkMoveRequest } from "../models/request/cipher-bulk-move.request";
import { CipherBulkRestoreRequest } from "../models/request/cipher-bulk-restore.request";
@@ -1570,6 +1571,19 @@ export class CipherService implements CipherServiceAbstraction {
key,
);
return;
case CipherType.SshKey:
cipher.sshKey = new SshKey();
await this.encryptObjProperty(
model.sshKey,
cipher.sshKey,
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
key,
);
return;
default:
throw new Error("Unknown cipher type.");
}