Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored Onboarding #223

Merged
merged 21 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
94102c1
pre-fill browser name
overheadhunter Aug 21, 2023
4136a76
renamed `SetupUserKeys.vue` → `InitialSetup.vue`
overheadhunter Aug 21, 2023
e5812bc
refactor onboarding
overheadhunter Aug 21, 2023
36fcf97
`BrowserKeys.load` can now return `BrowserKeys | undefined`
overheadhunter Aug 23, 2023
b980a52
handle "wrong account key"
overheadhunter Aug 23, 2023
de412b6
Merge branch 'develop' into feature/onboarding
overheadhunter Aug 30, 2023
d5756a6
fine-tuned labels
overheadhunter Aug 30, 2023
dfc957c
add logout link to onboarding wizard
overheadhunter Aug 30, 2023
1c9449c
added simple navigation bar to initial setup, improved browser name e…
tobihagemann Sep 4, 2023
c6c9a82
fixed some edge cases (invalid states) when editing browser name
tobihagemann Sep 4, 2023
d7f6da8
localization
tobihagemann Sep 5, 2023
e3cbc0f
restructered layout
tobihagemann Sep 5, 2023
8e4d172
renamed localization keys
tobihagemann Sep 5, 2023
e2f5b34
renamed other instances of setup code to account key
tobihagemann Sep 5, 2023
20f72bc
Merge branch 'develop' into feature/onboarding
tobihagemann Sep 8, 2023
dd90976
removed unique device name per owner constraint
tobihagemann Sep 8, 2023
bf29d70
Merge branch 'develop' into feature/onboarding
overheadhunter Oct 25, 2023
7959d67
added SetupAlreadyCompleted state in InitialSetup
tobihagemann Oct 30, 2023
b19e2d2
Recover user key when the device is absend in Hub
SailReal Oct 30, 2023
6849858
Wrap long one word device name in initial setup
SailReal Oct 30, 2023
3f5bb7a
Exclude looking for nullable device keys while recovering user key
SailReal Oct 31, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
Expand All @@ -32,7 +31,6 @@
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.hibernate.exception.ConstraintViolationException;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;

Expand Down Expand Up @@ -68,7 +66,6 @@ public List<DeviceDto> getSome(@QueryParam("ids") List<String> deviceIds) {
@Transactional
@Operation(summary = "creates or updates a device", description = "the device will be owned by the currently logged-in user")
@APIResponse(responseCode = "201", description = "Device created or updated")
@APIResponse(responseCode = "409", description = "Conflicting device name")
public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("deviceId") @ValidId String deviceId) {
Device device;
try {
Expand All @@ -88,13 +85,9 @@ public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("device
device.name = dto.name;
device.publickey = dto.publicKey;
device.userPrivateKey = dto.userPrivateKey;
try {
device.persistAndFlush();
AuditEventDeviceRegister.log(jwt.getSubject(), deviceId, device.name, device.type);
return Response.created(URI.create(".")).build();
} catch (ConstraintViolationException e) {
throw new ClientErrorException(Response.Status.CONFLICT, e);
}
device.persistAndFlush();
AuditEventDeviceRegister.log(jwt.getSubject(), deviceId, device.name, device.type);
return Response.created(URI.create(".")).build();
}

@GET
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ CREATE TABLE "device"
"user_privatekey" VARCHAR(2000) NOT NULL UNIQUE, -- private key, encrypted using device's public key (JWE ECDH-ES)
"creation_time" TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT "DEVICE_PK" PRIMARY KEY ("id"),
CONSTRAINT "DEVICE_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "user_details" ("id") ON DELETE CASCADE,
CONSTRAINT "DEVICE_UNIQUE_NAME_PER_OWNER" UNIQUE ("owner_id", "name")
CONSTRAINT "DEVICE_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "user_details" ("id") ON DELETE CASCADE
);

-- new access tokens will be issued for users (not devices):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,6 @@ public void testCreateNoDeviceId() {
.then().statusCode(400);
}

@Test
@Order(1)
@DisplayName("PUT /devices/deviceX returns 409 due to non-unique name")
public void testCreateX() {
var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z"));

given().contentType(ContentType.JSON).body(deviceDto)
.when().put("/devices/{deviceId}", "deviceX")
.then().statusCode(409);
}

@Test
@Order(1)
@DisplayName("GET /devices/device1 returns 200")
Expand Down Expand Up @@ -131,6 +120,17 @@ public void testCreate999() throws SQLException {
}
}

@Test
@Order(2)
@DisplayName("PUT /devices/deviceX returns 201 (creating new device with same name as device1)")
public void testCreateX() {
var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z"));

given().contentType(ContentType.JSON).body(deviceDto)
.when().put("/devices/{deviceId}", "deviceX")
.then().statusCode(201);
}

@Test
@Order(3)
@DisplayName("GET /devices/device999 returns 200")
Expand Down
63 changes: 17 additions & 46 deletions frontend/src/common/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as miscreant from 'miscreant';
import { base16, base32, base64, base64url } from 'rfc4648';
import { JWEBuilder, JWEParser } from './jwe';
import { CRC32, wordEncoder } from './util';
import { CRC32, DB, wordEncoder } from './util';
export class UnwrapKeyError extends Error {
readonly actualError: any;

Expand Down Expand Up @@ -278,7 +278,8 @@ export class UserKeys {
* @param encodedPublicKey The public key (base64-encoded SPKI)
* @param encryptedPrivateKey The JWE holding the encrypted private key
* @param setupCode The password used to protect the private key
* @returns
* @returns Decrypted UserKeys
* @throws {UnwrapKeyError} when attempting to decrypt the private key using an incorrect setupCode
*/
public static async recover(encodedPublicKey: string, encryptedPrivateKey: string, setupCode: string): Promise<UserKeys> {
const jwe: JWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode);
Expand Down Expand Up @@ -391,45 +392,26 @@ export class BrowserKeys {
* Attempts to load previously stored key pair from the browser's IndexedDB.
* @returns a promise resolving to the loaded browser key pair
*/
public static async load(userId: string): Promise<BrowserKeys> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('hub');
req.onsuccess = evt => { resolve(req.result); };
req.onerror = evt => { reject(req.error); };
req.onupgradeneeded = evt => { req.result.createObjectStore('keys'); };
public static async load(userId: string): Promise<BrowserKeys | undefined> {
const keyPair: CryptoKeyPair = await DB.transaction('keys', 'readonly', tx => {
const keyStore = tx.objectStore('keys');
return keyStore.get(userId);
});
return new Promise<CryptoKeyPair>((resolve, reject) => {
const transaction = db.transaction('keys', 'readonly');
const keyStore = transaction.objectStore('keys');
const query = keyStore.get(userId);
query.onsuccess = evt => { resolve(query.result); };
query.onerror = evt => { reject(query.error); };
}).then((keyPair) => {
if (keyPair) {
return new BrowserKeys(keyPair);
}).finally(() => {
db.close();
});
} else {
return undefined;
}
}

/**
* Deletes the key pair for the given user.
* @returns a promise resolving on success
*/
public static async delete(userId: string): Promise<void> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('hub');
req.onsuccess = evt => { resolve(req.result); };
req.onerror = evt => { reject(req.error); };
req.onupgradeneeded = evt => { req.result.createObjectStore('keys'); };
});
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('keys', 'readwrite');
const keyStore = transaction.objectStore('keys');
const query = keyStore.delete(userId);
query.onsuccess = evt => { resolve(); };
query.onerror = evt => { reject(query.error); };
}).finally(() => {
db.close();
await DB.transaction('keys', 'readwrite', tx => {
const keyStore = tx.objectStore('keys');
return keyStore.delete(userId);
});
}

Expand All @@ -438,20 +420,9 @@ export class BrowserKeys {
* @returns a promise that will resolve if the key pair has been saved
*/
public async store(userId: string): Promise<void> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open('hub');
req.onsuccess = evt => { resolve(req.result); };
req.onerror = evt => { reject(req.error); };
req.onupgradeneeded = evt => { req.result.createObjectStore('keys'); };
});
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('keys', 'readwrite');
const keyStore = transaction.objectStore('keys');
const query = keyStore.put(this.keyPair, userId);
query.onsuccess = evt => { transaction.commit(); resolve(); };
query.onerror = evt => { reject(query.error); };
}).finally(() => {
db.close();
await DB.transaction('keys', 'readwrite', tx => {
const keyStore = tx.objectStore('keys');
return keyStore.put(this.keyPair, userId);
});
}

Expand Down
10 changes: 8 additions & 2 deletions frontend/src/common/jwe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { base64url } from 'rfc4648';
import { UnwrapKeyError } from './crypto';

// visible for testing
export class ConcatKDF {
Expand Down Expand Up @@ -93,15 +94,20 @@ export class JWEParser {
* Decrypts the JWE, assuming alg == PBES2-HS512+A256KW and enc == A256GCM.
* @param password The password to feed into the KDF
* @returns Decrypted payload
* @throws {UnwrapKeyError} if decryption failed (wrong password?)
*/
public async decryptPbes2(password: string): Promise<any> {
if (this.header.alg != 'PBES2-HS512+A256KW' || /* this.header.enc != 'A256GCM' || */ !this.header.p2s || !this.header.p2c) {
throw new Error('unsupported alg or enc');
}
const saltInput = base64url.parse(this.header.p2s, { loose: true });
const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c);
const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
return this.decrypt(await cek);
try {
const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
return this.decrypt(await cek);
} catch (error) {
throw new UnwrapKeyError(error);
}
}

private async decrypt(cek: CryptoKey): Promise<any> {
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/common/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { dictionary } from './4096words_en';

export class DB {
private static readonly NAME = 'hub';

public static async transaction<T = any>(objectStore: string, mode: IDBTransactionMode, query: (transaction: IDBTransaction) => IDBRequest<T>): Promise<T> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB.NAME);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => req.result.createObjectStore(objectStore);
});
const transaction = db.transaction(objectStore, mode);
return new Promise<T>((resolve, reject) => {
const req = query(transaction);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}).finally(() => {
db.close();
});
}
}

export class Deferred<T> {
public promise: Promise<T>;
public reject: (reason?: any) => void;
Expand Down
Loading
Loading