1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 23:13:36 +00:00

[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
This commit is contained in:
Bernd Schoolmann
2024-08-30 16:16:24 +02:00
committed by GitHub
parent cfdc52ee84
commit b18fa68acc
46 changed files with 800 additions and 3 deletions

View File

@@ -1593,6 +1593,9 @@
"typeIdentity": {
"message": "Identity"
},
"typeSSHKey": {
"message": "SSH key"
},
"newItemHeader": {
"message": "New $TYPE$",
"placeholders": {
@@ -4210,5 +4213,29 @@
},
"enterprisePolicyRequirementsApplied": {
"message": "Enterprise policy requirements have been applied to this setting"
},
"sshPrivateKey": {
"message": "Private key"
},
"sshPublicKey": {
"message": "Public key"
},
"sshFingerprint": {
"message": "Fingerprint"
},
"sshKeyAlgorithm": {
"message": "Key type"
},
"sshKeyAlgorithmED25519": {
"message": "ED25519"
},
"sshKeyAlgorithmRSA2048": {
"message": "RSA 2048-Bit"
},
"sshKeyAlgorithmRSA3072": {
"message": "RSA 3072-Bit"
},
"sshKeyAlgorithmRSA4096": {
"message": "RSA 4096-Bit"
}
}

View File

@@ -300,6 +300,8 @@ export class AddEditV2Component implements OnInit {
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity"));
case CipherType.SecureNote:
return this.i18nService.t(partOne, this.i18nService.t("note"));
case CipherType.SSHKey:
return this.i18nService.t(partOne, this.i18nService.t("typeSSHKey"));
}
}
}

View File

@@ -82,3 +82,27 @@
[cipher]="cipher"
></button>
</bit-item-action>
<bit-item-action *ngIf="cipher.type === CipherType.SSHKey">
<button
type="button"
bitIconButton="bwi-clone"
size="small"
[appA11yTitle]="
hasIdentityValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
"
[disabled]="!hasIdentityValues"
[bitMenuTriggerFor]="identityOptions"
></button>
<bit-menu #identityOptions>
<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>

View File

@@ -48,5 +48,13 @@ export class ItemCopyActionsComponent {
return !!this.cipher.notes;
}
get hasSSHKeyValues() {
return (
!!this.cipher.sshKey.privateKey ||
!!this.cipher.sshKey.publicKey ||
!!this.cipher.sshKey.keyFingerprint
);
}
constructor() {}
}

View File

@@ -100,6 +100,8 @@ export class ViewV2Component {
);
case CipherType.SecureNote:
return this.i18nService.t("viewItemHeader", this.i18nService.t("note").toLowerCase());
case CipherType.SSHKey:
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeSSHkey").toLowerCase());
}
}

View File

@@ -529,6 +529,14 @@
/>
</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>
</div>
</div>
<div class="box" *ngIf="cipher.type === cipherType.Login">

View File

@@ -114,6 +114,19 @@
<span class="row-sub-label">{{ typeCounts.get(cipherType.SecureNote) || 0 }}</span>
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
</button>
<button
type="button"
class="box-content-row"
appStopClick
(click)="selectType(cipherType.SSHKey)"
>
<div class="row-main">
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-key"></i></div>
<span class="text">{{ "typeSSHKey" | i18n }}</span>
</div>
<span class="row-sub-label">{{ typeCounts.get(cipherType.SSHKey) || 0 }}</span>
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
</button>
</div>
</div>
<div class="box list" *ngIf="nestedFolders?.length">

View File

@@ -107,6 +107,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
case CipherType.SecureNote:
this.groupingTitle = this.i18nService.t("secureNotes");
break;
case CipherType.SSHKey:
this.groupingTitle = this.i18nService.t("sshKeys");
break;
default:
break;
}

View File

@@ -429,6 +429,29 @@
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
</div>
</div>
<!-- SSHKey -->
<div *ngIf="cipher.sshKey">
<div class="box-content-row" *ngIf="cipher.sshKey.publicKey" style="overflow: hidden">
<span
class="row-label draggable"
draggable="true"
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.publicKey)"
>
{{ "sshPublicKey" | i18n }}</span
>
{{ cipher.sshKey.publicKey }}
</div>
<div class="box-content-row" *ngIf="cipher.sshKey.keyFingerprint" style="overflow: hidden">
<span
class="row-label draggable"
draggable="true"
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.keyFingerprint)"
>
{{ "sshFingerprint" | i18n }}</span
>
{{ cipher.sshKey.keyFingerprint }}
</div>
</div>
</div>
</div>
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">

View File

@@ -201,6 +201,7 @@ describe("VaultPopupItemsService", () => {
[CipherType.Card]: 2,
[CipherType.Identity]: 3,
[CipherType.SecureNote]: 4,
[CipherType.SSHKey]: 5,
};
// Assume all ciphers are autofill ciphers to test sorting

View File

@@ -257,6 +257,7 @@ export class VaultPopupItemsService {
[CipherType.Card]: 2,
[CipherType.Identity]: 3,
[CipherType.SecureNote]: 4,
[CipherType.SSHKey]: 5,
};
// Compare types first

View File

@@ -99,6 +99,7 @@ describe("VaultPopupListFiltersService", () => {
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SSHKey,
]);
});
});

View File

@@ -165,6 +165,11 @@ export class VaultPopupListFiltersService {
label: this.i18nService.t("note"),
icon: "bwi-sticky-note",
},
{
value: CipherType.SSHKey,
label: this.i18nService.t("typeSSHKey"),
icon: "bwi-key",
},
];
/** Resets `filterForm` to the original state */

View File

@@ -26,6 +26,9 @@
"typeSecureNote": {
"message": "Secure note"
},
"typeSSHKey": {
"message": "SSH key"
},
"folders": {
"message": "Folders"
},
@@ -174,6 +177,30 @@
"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"
},
"premiumRequired": {
"message": "Premium required"
},

View File

@@ -471,6 +471,44 @@
/>
</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>
<input
id="sshPublicKey"
type="{{ showPrivateKey ? 'text' : 'password' }}"
name="SSHKey.SSHPrivateKey"
[ngModel]="cipher.sshKey.privateKey"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(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>
</div>
</div>
</div>
</div>
</div>
<div class="box" *ngIf="cipher.type === cipherType.Login">

View File

@@ -30,6 +30,8 @@ const BroadcasterSubscriptionId = "AddEditComponent";
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild("form")
private form: NgForm;
showPrivateKey = false;
constructor(
cipherService: CipherService,
folderService: FolderService,
@@ -137,4 +139,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
"https://bitwarden.com/help/managing-items/#protect-individual-items",
);
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
}

View File

@@ -79,4 +79,19 @@
</button>
</span>
</li>
<li
class="filter-option"
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SSHKey }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter(cipherTypeEnum.SSHKey)"
[attr.aria-pressed]="activeFilter.cipherType === cipherTypeEnum.SSHKey"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>&nbsp;{{ "typeSSHKey" | i18n }}
</button>
</span>
</li>
</ul>

View File

@@ -399,6 +399,55 @@
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
</div>
</div>
<!-- SSH Key -->
<div *ngIf="cipher.sshKey">
<div class="box-content-row box-content-row-flex" 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" appBoxRow>
<div class="row-main">
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
<input
id="sshKeyFingerprint"
type="text"
name="SSHKey.SSHKeyFingerprint"
[ngModel]="cipher.sshKey.keyFingerprint"
readonly
/>
</div>
<div class="action-buttons">
<button
type="button"
class="row-btn"
appStopClick
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SSHFingerprint')"
appA11yTitle="{{ 'generateSSHKey' | i18n }}"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">

View File

@@ -847,6 +847,61 @@
</div>
</div>
</ng-container>
<!-- SSH Key -->
<ng-container *ngIf="cipher.type === cipherType.SSHKey">
<div class="row">
<div class="col-12 form-group">
<label for="sshKeyPublicKey">{{ "sshKeyPublicKey" | i18n }}</label>
<div class="input-group">
<input
id="sshKeyPublicKey"
class="form-control"
type="text"
name="SSHKey.PublicKey"
[(ngModel)]="cipher.sshKey.publicKey"
appInputVerbatim
disabled
readonly
/>
<div class="input-group-append" *ngIf="!cipher.isDeleted">
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'copySSHPublicKey' | i18n }}"
(click)="copy(cipher.sshKey.publicKey, 'sshKeyPublicKey', 'PublicKey')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="col-12 form-group">
<label for="sshKeyFingerprint">{{ "sshKeyFingerprint" | i18n }}</label>
<div class="input-group">
<input
id="sshKeyFingerprint"
class="form-control"
type="text"
name="SSHKey.Fingerprint"
[(ngModel)]="cipher.sshKey.keyFingerprint"
appInputVerbatim
disabled
readonly
/>
<div class="input-group-append" *ngIf="!cipher.isDeleted">
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'copySSHFingerprint' | i18n }}"
(click)="copy(cipher.sshKey.keyFingerprint, 'sshKeyFingerprint', 'Fingerprint')"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</ng-container>
<div class="form-group">
<label for="notes">{{ "notes" | i18n }}</label>
<textarea

View File

@@ -216,6 +216,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
type: CipherType.SecureNote,
icon: "bwi-sticky-note",
},
{
id: "sshKey",
name: this.i18nService.t("typeSSHKey"),
type: CipherType.SSHKey,
icon: "bwi-key",
},
];
const typeFilterSection: VaultFilterSection = {

View File

@@ -403,6 +403,9 @@
"typeSecureNote": {
"message": "Secure note"
},
"typeSSHKey": {
"message": "SSH key"
},
"typeLoginPlural": {
"message": "Logins"
},
@@ -8428,7 +8431,7 @@
"deleteProviderRecoverConfirmDesc": {
"message": "You have requested to delete this Provider. Use the button below to confirm."
},
"deleteProviderWarning": {
"deleteProviderWarning": {
"message": "Deleting your provider is permanent. It cannot be undone."
},
"errorAssigningTargetCollection": {
@@ -8512,7 +8515,7 @@
},
"createdNewClient": {
"message": "Successfully created new client"
},
},
"noAccess": {
"message": "No access"
},
@@ -8948,5 +8951,26 @@
},
"additionalStorageGbMessage": {
"message": "GB additional storage"
},
"sshKeyAlgorithm": {
"message": "Key algorithm"
},
"sshKeyFingerprint": {
"message": "Fingerprint"
},
"sshKeyPublicKey": {
"message": "Public key"
},
"sshKeyAlgorithmED25519": {
"message": "ED25519"
},
"sshKeyAlgorithmRSA2048": {
"message": "RSA 2048-Bit"
},
"sshKeyAlgorithmRSA3072": {
"message": "RSA 3072-Bit"
},
"sshKeyAlgorithmRSA4096": {
"message": "RSA 4096-Bit"
}
}

View File

@@ -37,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SSHKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -128,6 +129,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
];
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
@@ -277,6 +279,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.identity = new IdentityView();
this.cipher.secureNote = new SecureNoteView();
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.sshKey = new SSHKeyView();
this.cipher.reprompt = CipherRepromptType.None;
}
}

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

@@ -15,6 +15,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;
@@ -34,6 +35,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;
@@ -75,6 +77,8 @@ export class CipherView implements View, InitializerMetadata {
return this.card;
case CipherType.Identity:
return this.identity;
case CipherType.SSHKey:
return this.sshKey;
default:
break;
}
@@ -184,6 +188,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,26 @@
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 subTitle(): string {
return null;
}
static fromJSON(obj: Partial<Jsonify<SSHKeyView>>): SSHKeyView {
return Object.assign(new SSHKeyView(), obj);
}
}

View File

@@ -50,6 +50,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";
@@ -1542,6 +1543,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.");
}

View File

@@ -8,6 +8,7 @@ import { CustomFieldsComponent } from "./components/custom-fields/custom-fields.
import { IdentitySectionComponent } from "./components/identity/identity.component";
import { ItemDetailsSectionComponent } from "./components/item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./components/login-details-section/login-details-section.component";
import { SSHKeySectionComponent } from "./components/sshkey-section/sshkey-section.component";
/**
* The complete form for a cipher. Includes all the sub-forms from their respective section components.
@@ -20,6 +21,7 @@ export type CipherForm = {
autoFillOptions?: AutofillOptionsComponent["autofillOptionsForm"];
cardDetails?: CardDetailsSectionComponent["cardDetailsForm"];
identityDetails?: IdentitySectionComponent["identityForm"];
sshKeyDetails?: SSHKeySectionComponent["sshKeyForm"];
customFields?: CustomFieldsComponent["customFieldsForm"];
};

View File

@@ -22,6 +22,12 @@
[disabled]="config.mode === 'partial-edit'"
></vault-card-details-section>
<vault-sshkey-section
*ngIf="config.cipherType === CipherType.SSHKey"
[disabled]="config.mode === 'partial-edit'"
[originalCipherView]="originalCipherView"
></vault-sshkey-section>
<vault-additional-options-section></vault-additional-options-section>
<!-- Attachments are only available for existing ciphers -->

View File

@@ -40,6 +40,7 @@ import { CardDetailsSectionComponent } from "./card-details-section/card-details
import { IdentitySectionComponent } from "./identity/identity.component";
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
import { LoginDetailsSectionComponent } from "./login-details-section/login-details-section.component";
import { SSHKeySectionComponent } from "./sshkey-section/sshkey-section.component";
@Component({
selector: "vault-cipher-form",
@@ -63,6 +64,7 @@ import { LoginDetailsSectionComponent } from "./login-details-section/login-deta
ItemDetailsSectionComponent,
CardDetailsSectionComponent,
IdentitySectionComponent,
SSHKeySectionComponent,
NgIf,
AdditionalOptionsSectionComponent,
LoginDetailsSectionComponent,

View File

@@ -0,0 +1,18 @@
<bit-section [formGroup]="sshKeyForm">
<bit-section-header>
<h2 bitTypography="h6">
{{ "typeSSHKey" | i18n }}
</h2>
</bit-section-header>
<bit-card>
<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>

View File

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

View File

@@ -22,6 +22,9 @@
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
</app-view-identity-sections>
<!-- SSHKEY SECTIONS -->
<app-sshkey-view *ngIf="hasSshKey" [sshKey]="cipher.sshKey"></app-sshkey-view>
<!-- ADDITIONAL OPTIONS -->
<ng-container *ngIf="cipher.notes">
<app-additional-options [notes]="cipher.notes"> </app-additional-options>

View File

@@ -21,6 +21,7 @@ import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.compone
import { ItemDetailsV2Component } from "./item-details/item-details-v2.component";
import { ItemHistoryV2Component } from "./item-history/item-history-v2.component";
import { LoginCredentialsViewComponent } from "./login-credentials/login-credentials-view.component";
import { SSHKeyViewComponent } from "./sshkey-sections/sshkey-view.component";
import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component";
@Component({
@@ -37,6 +38,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
ItemHistoryV2Component,
CustomFieldV2Component,
CardDetailsComponent,
SSHKeyViewComponent,
ViewIdentitySectionsComponent,
LoginCredentialsViewComponent,
AutofillOptionsViewComponent,
@@ -78,6 +80,10 @@ export class CipherViewComponent implements OnInit, OnDestroy {
return this.cipher.login?.uris.length > 0;
}
get hasSshKey() {
return this.cipher.sshKey?.privateKey;
}
async loadCipherData() {
if (this.cipher.collectionIds.length > 0) {
this.collections$ = this.collectionService

View File

@@ -0,0 +1,31 @@
<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>{{ "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>

View File

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

View File

@@ -91,6 +91,12 @@ export class CopyCipherFieldDirective implements OnChanges {
return this.cipher.identity?.fullAddressForCopy;
case "secureNote":
return this.cipher.notes;
case "privateKey":
return this.cipher.sshKey?.privateKey;
case "publicKey":
return this.cipher.sshKey?.publicKey;
case "keyFingerprint":
return this.cipher.sshKey?.keyFingerprint;
default:
return null;
}

View File

@@ -25,7 +25,10 @@ export type CopyAction =
| "phone"
| "address"
| "secureNote"
| "hiddenField";
| "hiddenField"
| "privateKey"
| "publicKey"
| "keyFingerprint";
type CopyActionInfo = {
/**
@@ -62,6 +65,9 @@ const CopyActions: Record<CopyAction, CopyActionInfo> = {
phone: { typeI18nKey: "phone", protected: true },
address: { typeI18nKey: "address", protected: true },
secureNote: { typeI18nKey: "note", protected: true },
privateKey: { typeI18nKey: "sshPrivateKey", protected: true },
publicKey: { typeI18nKey: "sshPublicKey", protected: true },
keyFingerprint: { typeI18nKey: "sshFingerprint", protected: true },
hiddenField: {
typeI18nKey: "value",
protected: true,