1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into vault/PM-12049

This commit is contained in:
Matt Bishop
2024-11-11 17:43:44 -05:00
committed by GitHub
254 changed files with 7131 additions and 795 deletions

View File

@@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
getLicense: (id: string, installationId: string) => Promise<unknown>;
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
createWithoutPayment: (
request: OrganizationNoPaymentMethodCreateRequest,
) => Promise<OrganizationResponse>;
createLicense: (data: FormData) => Promise<OrganizationResponse>;
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;

View File

@@ -1,4 +1,5 @@
export enum ProviderType {
Msp = 0,
Reseller = 1,
MultiOrganizationEnterprise = 2,
}

View File

@@ -1,32 +1,7 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PaymentMethodType } from "../../../billing/enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationKeysRequest } from "./organization-keys.request";
export class OrganizationCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
paymentMethodType: PaymentMethodType;
paymentToken: string;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new OrganizationResponse(r);
}
async createWithoutPayment(
request: OrganizationNoPaymentMethodCreateRequest,
): Promise<OrganizationResponse> {
const r = await this.apiService.send(
"POST",
"/organizations/create-without-payment",
request,
true,
true,
);
// Forcing a sync will notify organization service that they need to repull
await this.syncService.fullSync(true);
return new OrganizationResponse(r);
}
async createLicense(data: FormData): Promise<OrganizationResponse> {
const r = await this.apiService.send(
"POST",

View File

@@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
purchaseSubscriptionNoPaymentMethod: (
subscription: SubscriptionInformation,
) => Promise<OrganizationResponse>;
}

View File

@@ -0,0 +1,29 @@
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PlanType } from "../../enums";
export class OrganizationNoPaymentMethodCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
isEligibleForSelfHost: boolean;
isManaged: boolean;
isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
constructor(response: any) {
super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
}
}

View File

@@ -1,3 +1,5 @@
import { ProviderType } from "@bitwarden/common/admin-console/enums";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response";
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
@@ -13,6 +15,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
taxInformation?: TaxInfoResponse;
cancelAt?: string;
suspension?: SubscriptionSuspensionResponse;
providerType: ProviderType;
constructor(response: any) {
super(response);
@@ -34,6 +37,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
if (suspension != null) {
this.suspension = new SubscriptionSuspensionResponse(suspension);
}
this.providerType = this.getResponseProperty("providerType");
}
}
@@ -44,6 +48,8 @@ export class ProviderPlanResponse extends BaseResponse {
purchasedSeats: number;
cost: number;
cadence: string;
type: PlanType;
productTier: ProductTierType;
constructor(response: any) {
super(response);
@@ -53,5 +59,7 @@ export class ProviderPlanResponse extends BaseResponse {
this.purchasedSeats = this.getResponseProperty("PurchasedSeats");
this.cost = this.getResponseProperty("Cost");
this.cadence = this.getResponseProperty("Cadence");
this.type = this.getResponseProperty("Type");
this.productTier = this.getResponseProperty("ProductTier");
}
}

View File

@@ -17,6 +17,7 @@ import {
SubscriptionInformation,
} from "../abstractions/organization-billing.service";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
interface OrganizationKeys {
encryptedKey: EncString;
@@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
return response;
}
async purchaseSubscriptionNoPaymentMethod(
subscription: SubscriptionInformation,
): Promise<OrganizationResponse> {
const request = new OrganizationNoPaymentMethodCreateRequest();
const organizationKeys = await this.makeOrganizationKeys();
this.setOrganizationKeys(request, organizationKeys);
this.setOrganizationInformation(request, subscription.organization);
this.setPlanInformation(request, subscription.plan);
const response = await this.organizationApiService.createWithoutPayment(request);
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
return response;
}
private async makeOrganizationKeys(): Promise<OrganizationKeys> {
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
@@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: OrganizationInformation,
): void {
request.name = information.name;
@@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
request.initiationPath = information.initiationPath;
}
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
private setOrganizationKeys(
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
keys: OrganizationKeys,
): void {
request.key = keys.encryptedKey.encryptedString;
request.keys = new OrganizationKeysRequest(
keys.publicKey,
@@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setPlanInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: PlanInformation,
): void {
request.planType = information.type;

View File

@@ -56,6 +56,8 @@ export enum EventType {
OrganizationUser_Restored = 1512,
OrganizationUser_ApprovedAuthRequest = 1513,
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515,
OrganizationUser_Left = 1516,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

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",
@@ -35,6 +37,9 @@ export enum FeatureFlag {
AccessIntelligence = "pm-13227-access-intelligence",
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
CriticalApps = "pm-14466-risk-insights-critical-application",
TrialPaymentOptional = "PM-8163-trial-payment",
SecurityTasks = "security-tasks",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -72,6 +77,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,
@@ -80,6 +87,9 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccessIntelligence]: FALSE,
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

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

@@ -4,6 +4,36 @@ describe("Fido2 Utils", () => {
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
describe("bufferSourceToUint8Array(..)", () => {
it("should convert an ArrayBuffer", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
});
it("should convert an ArrayBuffer slice", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8);
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards
});
it("should pass through an Uint8Array", () => {
const typedArray = new Uint8Array(asciiHelloWorldArray);
const out = Fido2Utils.bufferSourceToUint8Array(typedArray);
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
});
it("should preserve the view of TypedArray", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const input = new Uint8Array(buffer, 8, 1);
const out = Fido2Utils.bufferSourceToUint8Array(input);
expect(out).toEqual(new Uint8Array([114]));
});
it("should convert different TypedArrays", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const input = new Uint16Array(buffer, 8, 1);
const out = Fido2Utils.bufferSourceToUint8Array(input);
expect(out).toEqual(new Uint8Array([114, 108]));
});
});
describe("fromBufferToB64(...)", () => {
it("should convert an ArrayBuffer to a b64 string", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;

View File

@@ -1,13 +1,6 @@
export class Fido2Utils {
static bufferToString(bufferSource: BufferSource): string {
let buffer: Uint8Array;
if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) {
buffer = new Uint8Array(bufferSource as ArrayBuffer);
} else {
buffer = new Uint8Array(bufferSource.buffer);
}
return Fido2Utils.fromBufferToB64(buffer)
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
@@ -18,12 +11,10 @@ export class Fido2Utils {
}
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
if (bufferSource instanceof Uint8Array) {
return bufferSource;
} else if (Fido2Utils.isArrayBuffer(bufferSource)) {
if (Fido2Utils.isArrayBuffer(bufferSource)) {
return new Uint8Array(bufferSource);
} else {
return new Uint8Array(bufferSource.buffer);
return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength);
}
}

View File

@@ -1886,7 +1886,7 @@ export class ApiService implements ApiServiceAbstraction {
});
if (flagEnabled("prereleaseBuild")) {
headers.set("Is-Prerelease", "true");
headers.set("Is-Prerelease", "1");
}
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);

View File

@@ -373,7 +373,11 @@ describe("UserStateSubject", () => {
singleUserId$.next(SomeUser);
await awaitAsync();
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
expect(state.nextMock).toHaveBeenCalledWith({
foo: "next",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
@@ -394,7 +398,13 @@ describe("UserStateSubject", () => {
await awaitAsync();
const encrypted = { foo: "encrypt(next)" };
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
expect(state.nextMock).toHaveBeenCalledWith({
id: null,
secret: encrypted,
disclosed: null,
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("applies dynamic constraints", async () => {

View File

@@ -43,6 +43,23 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
// FIXME: The subject should always repeat the value when it's own `next` method is called.
//
// Chrome StateService only calls `next` when the underlying values changes. When enforcing,
// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents
// invalid data received in sequence from calling `next` because the state provider doesn't
// emit.
//
// The hack is pretty simple. Insert arbitrary data into the saved data to ensure
// that it *always* changes.
//
// Any real fix will be fairly complex because it needs to recognize *fast* when it
// is waiting. Alternatively, the kludge could become a format properly fed by random noise.
//
// NOTE: this only matters for plaintext objects; encrypted fields change with every
// update b/c their IVs change.
const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$";
/**
* Adapt a state provider to an rxjs subject.
*
@@ -420,8 +437,25 @@ export class UserStateSubject<
private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable;
private counter = 0;
private onNext(value: unknown) {
this.state.update(() => value).catch((e: any) => this.onError(e));
this.state
.update(() => {
if (typeof value === "object") {
// related: ALWAYS_UPDATE_KLUDGE FIXME
const counter = this.counter++;
if (counter > Number.MAX_SAFE_INTEGER) {
this.counter = 0;
}
const kludge = value as any;
kludge[ALWAYS_UPDATE_KLUDGE] = counter;
}
return value;
})
.catch((e: any) => this.onError(e));
}
private onError(value: any) {

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