From f62a43a8af799fbe9225185d4e4ec5255d6c3da5 Mon Sep 17 00:00:00 2001 From: chenkins Date: Wed, 28 Feb 2024 16:57:34 +0100 Subject: [PATCH] uvf metadata (WiP). --- .../cryptomator/hub/api/VaultResource.java | 5 +- .../org/cryptomator/hub/entities/Vault.java | 3 + .../hub/flyway/V15__Vault_Metadata.sql | 1 + .../hub/api/VaultResourceTest.java | 24 ++- .../hub/flyway/V9999__Test_Data.sql | 11 +- frontend/src/common/backend.ts | 36 +++- frontend/src/common/crypto.ts | 175 ++++++++++++++++-- frontend/src/common/vaultconfig.ts | 12 +- .../src/components/ArchiveVaultDialog.vue | 2 +- frontend/src/components/CreateVault.vue | 11 +- .../DownloadVaultTemplateDialog.vue | 2 +- .../components/EditVaultMetadataDialog.vue | 2 +- .../src/components/ReactivateVaultDialog.vue | 2 +- frontend/test/common/crypto.spec.ts | 40 +++- 14 files changed, 272 insertions(+), 54 deletions(-) create mode 100644 backend/src/main/resources/org/cryptomator/hub/flyway/V15__Vault_Metadata.sql diff --git a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java index 76d1e0c69..3ecb60d02 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -421,6 +421,8 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu vault.description = vaultDto.description; vault.archived = existingVault.isEmpty() ? false : vaultDto.archived; + vault.metadata = vaultDto.metadata; + vault.persistAndFlush(); // trigger PersistenceException before we continue with if (existingVault.isEmpty()) { var access = new VaultAccess(); @@ -504,10 +506,11 @@ public record VaultDto(@JsonProperty("id") UUID id, @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") Integer iterations, @JsonProperty("salt") @OnlyBase64Chars String salt, @JsonProperty("authPublicKey") @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars String authPrivateKey + ,@JsonProperty("metadata") @NotNull String metadata ) { public static VaultDto fromEntity(Vault entity) { - return new VaultDto(entity.id, entity.name, entity.description, entity.archived, entity.creationTime.truncatedTo(ChronoUnit.MILLIS), entity.masterkey, entity.iterations, entity.salt, entity.authenticationPublicKey, entity.authenticationPrivateKey); + return new VaultDto(entity.id, entity.name, entity.description, entity.archived, entity.creationTime.truncatedTo(ChronoUnit.MILLIS), entity.masterkey, entity.iterations, entity.salt, entity.authenticationPublicKey, entity.authenticationPrivateKey, entity.metadata); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java index 682b17e10..e08d96be6 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java @@ -104,6 +104,9 @@ public class Vault extends PanacheEntityBase { @Column(name = "archived", nullable = false) public boolean archived; + @Column(name = "metadata", nullable = false) + public String metadata; + public Optional getAuthenticationPublicKey() { if (authenticationPublicKey == null) { return Optional.empty(); diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/V15__Vault_Metadata.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V15__Vault_Metadata.sql new file mode 100644 index 000000000..96cdc6b4f --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V15__Vault_Metadata.sql @@ -0,0 +1 @@ +ALTER TABLE vault ADD metadata VARCHAR UNIQUE; -- encrypted using uvf vault masterkey A256KW diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java index abcc9fa25..50a2060c6 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java @@ -48,9 +48,8 @@ import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.Matchers.comparesEqualTo; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.*; import static org.hamcrest.text.IsEqualIgnoringCase.equalToIgnoringCase; @QuarkusTest @@ -82,10 +81,13 @@ public class TestVaultDtoValidation { private static final String VALID_SALT = "base64"; private static final String VALID_AUTH_PUB = "base64"; private static final String VALID_AUTH_PRI = "base64"; + private static final String VALID_METADATA = "base64"; @Test public void testValidDto() { - var dto = new VaultResource.VaultDto(VALID_ID, VALID_NAME, "foobarbaz", false, Instant.parse("2020-02-20T20:20:20Z"), VALID_MASTERKEY, 8, VALID_SALT, VALID_AUTH_PUB, VALID_AUTH_PRI); + var dto = new VaultResource.VaultDto(VALID_ID, VALID_NAME, "foobarbaz", false, Instant.parse("2020-02-20T20:20:20Z"), VALID_MASTERKEY, 8, VALID_SALT, VALID_AUTH_PUB + , VALID_AUTH_PRI, VALID_METADATA + ); var violations = validator.validate(dto); MatcherAssert.assertThat(violations, Matchers.empty()); } @@ -193,7 +195,8 @@ public void testUnlock2() { } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 403") // legacy unlock must not encourage to register a legacy device by responding with 404 here + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 403") + // legacy unlock must not encourage to register a legacy device by responding with 404 here public void testUnlock3() { when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "noSuchDevice") .then().statusCode(403); @@ -247,7 +250,7 @@ public class CreateVaults { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 201") public void testCreateVault1() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3", "metadata1"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -272,7 +275,7 @@ public void testCreateVault2() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100004444 returns 201 ignoring archived flag") public void testCreateVault3() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100004444"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", true, Instant.parse("2112-12-21T21:12:21Z"), "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", true, Instant.parse("2112-12-21T21:12:21Z"), "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4", "metadata3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100004444") @@ -288,7 +291,7 @@ public void testCreateVault3() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 200, updating only name, description and archive flag") public void testUpdateVault() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate", "metadata4"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -750,7 +753,7 @@ public void testCreateVaultExceedingSeats() { assert EffectiveVaultAccess.countSeatOccupyingUsers() == 5; var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3", "metadata5"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") .then().statusCode(402); @@ -763,7 +766,7 @@ public void testCreateVaultNotExceedingSeats() { assert EffectiveVaultAccess.countSeatOccupyingUsers() == 5; var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3", "metadata6"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") .then().statusCode(201) @@ -780,7 +783,7 @@ public void testUpdateVaultDespiteLicenseExceeded() { assert EffectiveVaultAccess.countSeatOccupyingUsers() == 5; var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate", "metadata7"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") @@ -871,6 +874,7 @@ public static void setup() throws GeneralSecurityException { v.name = "ownership-test-vault"; v.creationTime = Instant.now(); v.authenticationPublicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + v.metadata = UUID.randomUUID().toString(); v.persist(); } diff --git a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql index 3ac426e73..b42ebdcd7 100644 --- a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql +++ b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql @@ -29,20 +29,23 @@ VALUES ('group1', 'user1'), ('group2', 'user2'); -INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey", "archived") +INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey", "archived" , "metadata" ) VALUES ('7E57C0DE-0000-4000-8000-000100001111', 'Vault 1', 'This is a testvault.', '2020-02-20 20:20:20', 'salt1', 42, 'masterkey1', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAElS+JW3VaBvVr9GKZGn1399WDTd61Q9fwQMmZuBGAYPdl/rWk705QY6WhlmbokmEVva/mEHSoNQ98wFm9FBCqzh45IGd/DGwZ04Xhi5ah+1bKbkVhtds8nZtHRdSJokYp', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAa57e0Q/KAqmIVOVcWX7b+Sm5YVNRUx8W7nc4wk1IBj2QJmsj+MeShQRHG4ozTE9KhZANiAASVL4lbdVoG9Wv0YpkafXf31YNN3rVD1/BAyZm4EYBg92X+taTvTlBjpaGWZuiSYRW9r+YQdKg1D3zAWb0UEKrOHjkgZ38MbBnTheGLlqH7VspuRWG12zydm0dF1ImiRik=', - FALSE), + FALSE, 'm1' + ), ('7E57C0DE-0000-4000-8000-000100002222', 'Vault 2', 'This is a testvault.', '2020-02-20 20:20:20', 'salt2', 42, 'masterkey2', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', - FALSE), + FALSE, 'm2' + ), ('7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault Archived', 'This is a archived vault.', '2020-02-20 20:20:20', 'salt3', 42, 'masterkey3', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', - TRUE); + TRUE, 'm3' + ); INSERT INTO "vault_access" ("vault_id", "authority_id", "role") VALUES diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 0196012cc..969738c7d 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -46,6 +46,7 @@ export type VaultDto = { salt?: string; authPublicKey?: string; authPrivateKey?: string; + metadata: string; }; export type DeviceDto = { @@ -195,6 +196,18 @@ export type VersionDto = { keycloakVersion: string; } +export type ConfigDto = { + keycloakUrl: string; + keycloakRealm: string; + keycloakClientIdHub: string; + keycloakClientIdCryptomator: string; + keycloakAuthEndpoint: string; + keycloakTokenEndpoint: string; + serverTime: string; + apiLevel: number; + uuid: string; +} + /* Services */ export interface VaultIdHeader extends JWTHeader { @@ -250,8 +263,12 @@ class VaultService { .catch(err => rethrowAndConvertIfExpected(err, 403)); } - public async createOrUpdateVault(vaultId: string, name: string, archived: boolean, description?: string): Promise { - const body: VaultDto = { id: vaultId, name: name, description: description, archived: archived, creationTime: new Date() }; + public async createOrUpdateVault(vaultId: string, name: string, archived: boolean + , metadata: string + , description?: string): Promise { + const body: VaultDto = { id: vaultId, name: name, description: description, archived: archived, creationTime: new Date() + , metadata: metadata + }; return axiosAuth.put(`/vaults/${vaultId}`, body) .then(response => response.data) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404)); @@ -439,3 +456,18 @@ export class ConflictError extends BackendError { super('Resource already exists'); } } + +export type VaultMetadataJWEAutomaticAccessGrantDto = { + enabled: boolean, + maxWotDepth: number +} + +export type VaultMetadataJWEDto = { + fileFormat: string; + nameFormat: string; + keys: Record; + latestFileKey: string; + nameKey: string; + kdf: string; + automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto; +} \ No newline at end of file diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 9895b0b08..934145de5 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -2,6 +2,9 @@ import * as miscreant from 'miscreant'; import { base16, base32, base64, base64url } from 'rfc4648'; import { JWEBuilder, JWEParser } from './jwe'; import { CRC32, DB, wordEncoder } from './util'; + +import { VaultMetadataJWEAutomaticAccessGrantDto } from './backend'; + export class UnwrapKeyError extends Error { readonly actualError: any; @@ -29,8 +32,13 @@ export interface VaultConfigHeaderHub { devicesResourceUrl: string } -interface JWEPayload { +interface UserKeysJWEPayload { + key: string +} + +interface VaultKeysWEPayload { key: string + uvfKey: string } const GCM_NONCE_LEN = 12; @@ -46,14 +54,19 @@ export class VaultKeys { length: 512 }; + // in uvf setting, the vault masterKey is used to encrypt the vault metadata JWE using A256KW + private static readonly UVF_MASTERKEY_KEY_DESIGNATION = { name: 'AES-KW', length: 256 }; + readonly masterKey: CryptoKey; + readonly uvfMasterKey: CryptoKey; - protected constructor(masterkey: CryptoKey) { - this.masterKey = masterkey; + protected constructor(masterKey: CryptoKey, uvfMasterKey: CryptoKey) { + this.masterKey = masterKey; + this.uvfMasterKey = uvfMasterKey; } /** - * Creates a new masterkey + * Creates a new masterkey (vault8 and uvf) * @returns A new masterkey */ public static async create(): Promise { @@ -62,22 +75,31 @@ export class VaultKeys { true, ['sign'] ); - return new VaultKeys(await key); + const uvfKey = crypto.subtle.generateKey( + VaultKeys.UVF_MASTERKEY_KEY_DESIGNATION, + true, + ['wrapKey', 'unwrapKey'] + ); + return new VaultKeys(await key, await uvfKey); } + /** - * Decrypts the vault's masterkey using the user's private key + * Decrypts the vault's masterkey (vault8 and uvf) using the user's private key * @param jwe JWE containing the vault key * @param userPrivateKey The user's private key * @returns The masterkey */ public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { let rawKey = new Uint8Array(); + let rawUvfKey = new Uint8Array(); try { - const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); + const payload: VaultKeysWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); rawKey = base64.parse(payload.key); - const masterkey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); - return new VaultKeys(await masterkey); + rawUvfKey = base64.parse(payload.uvfKey); + const masterKey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); + const uvfMasterKey = crypto.subtle.importKey('raw', rawUvfKey, VaultKeys.UVF_MASTERKEY_KEY_DESIGNATION, true, ['wrapKey', 'unwrapKey']); + return new VaultKeys(await masterKey, await uvfMasterKey); } finally { rawKey.fill(0x00); } @@ -141,7 +163,8 @@ export class VaultKeys { true, ['verify'] ); - return [new VaultKeys(await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 upstream legacy integration for uvf? + return [new VaultKeys(await masterkey, await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; } catch (error) { throw new UnwrapKeyError(error); } @@ -174,7 +197,8 @@ export class VaultKeys { true, ['sign'] ); - return new VaultKeys(await key); + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 upstream legacy integration for uvf? + return new VaultKeys(await key, await key); } public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { @@ -215,16 +239,18 @@ export class VaultKeys { } /** - * Encrypts this masterkey using the given public key + * Encrypts this masterkey (vault8 and uvf) using the given public key * @param userPublicKey The recipient's public key (DER-encoded) * @returns a JWE containing this Masterkey */ public async encryptForUser(userPublicKey: Uint8Array): Promise { const publicKey = await crypto.subtle.importKey('spki', userPublicKey, UserKeys.KEY_DESIGNATION, false, []); const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); + const rawUvfKey = new Uint8Array(await crypto.subtle.exportKey('raw', this.uvfMasterKey)); try { - const payload: JWEPayload = { - key: base64.stringify(rawkey) + const payload: VaultKeysWEPayload = { + key: base64.stringify(rawkey), + uvfKey: base64.stringify(rawUvfKey) }; return JWEBuilder.ecdhEs(publicKey).encrypt(payload); } finally { @@ -282,7 +308,7 @@ export class 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 { - const jwe: JWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode); + const jwe: UserKeysJWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode); const decodedPublicKey = base64.parse(encodedPublicKey, { loose: true }); const decodedPrivateKey = base64.parse(jwe.key, { loose: true }); const privateKey = crypto.subtle.importKey('pkcs8', decodedPrivateKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); @@ -308,7 +334,7 @@ export class UserKeys { public async encryptedPrivateKey(setupCode: string): Promise { const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); try { - const payload: JWEPayload = { + const payload: UserKeysJWEPayload = { key: base64.stringify(rawkey) }; return await JWEBuilder.pbes2(setupCode).encrypt(payload); @@ -327,7 +353,7 @@ export class UserKeys { const publicKey = await UserKeys.publicKey(devicePublicKey); const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); try { - const payload: JWEPayload = { + const payload: UserKeysJWEPayload = { key: base64.stringify(rawkey) }; return JWEBuilder.ecdhEs(publicKey).encrypt(payload); @@ -347,7 +373,7 @@ export class UserKeys { const publicKey = await UserKeys.publicKey(userPublicKey); let rawKey = new Uint8Array(); try { - const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); + const payload: UserKeysJWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); rawKey = base64.parse(payload.key); const privateKey = await crypto.subtle.importKey('pkcs8', rawKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); return new UserKeys({ publicKey: publicKey, privateKey: privateKey }); @@ -449,3 +475,116 @@ export async function getFingerprint(key: string | undefined) { return hashHex; } } + +export class VaultMetadata { + // a 256 bit = 32 byte file key for data encryption + private static readonly RAWKEY_KEY_DESIGNATION: HmacImportParams | HmacKeyGenParams = { + name: 'HMAC', + hash: 'SHA-256', + length: 256 + }; + + readonly automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto; + readonly keys: Record; + readonly latestFileKey: string; + readonly nameKey: string; + + protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, keys: Record, latestFileKey: string, nameKey: string) { + this.automaticAccessGrant = automaticAccessGrant; + this.keys = keys; + this.latestFileKey = latestFileKey; + this.nameKey = nameKey; + } + + /** + * Creates new vault metadata with a new file key and name key + * @returns new vault metadata + */ + public static async create(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto): Promise { + const fileKey = crypto.subtle.generateKey( + VaultMetadata.RAWKEY_KEY_DESIGNATION, + true, + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 is this correct? + ['sign'] + ); + const nameKey = crypto.subtle.generateKey( + VaultMetadata.RAWKEY_KEY_DESIGNATION, + true, + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 is this correct? + ['sign'] + ); + const fileKeyId = Array(4).fill(null).map(()=>"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".charAt(Math.random()*62)).join("") + const nameKeyId = Array(4).fill(null).map(()=>"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".charAt(Math.random()*62)).join("") + const keys: Record = {}; + keys[fileKeyId] = await fileKey; + keys[nameKeyId] = await nameKey; + return new VaultMetadata(automaticAccessGrant, keys, fileKeyId, nameKeyId); + } + + /** + * Decrypts the vault metadata using the vault masterkey + * @param jwe JWE containing the vault key + * @param masterKey the vault masterKey + * @returns vault metadata + */ + public static async decryptWithMasterKey(jwe: string, masterKey: CryptoKey): Promise { + const payload = await JWEParser.parse(jwe).decryptA256kw(masterKey); + const keys: Record = payload['keys']; + const keysImported: Record = payload['keys']; + for (const k in keys) { + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 is this correct? + keysImported[k] = await crypto.subtle.importKey('raw', base64.parse(keys[k]), VaultMetadata.RAWKEY_KEY_DESIGNATION, true, ['sign']); + } + const latestFileKey = payload['latestFileKey'] + const nameKey = payload['nameKey'] + return new VaultMetadata( + payload['org.cryptomator.automaticAccessGrant'], + keysImported, + latestFileKey, + nameKey + ); + } + + /** + * Encrypts the vault metadata using the given vault masterKey + * @param userPublicKey The recipient's public key (DER-encoded) + * @returns a JWE containing this Masterkey + */ + public async encryptWithMasterKey(masterKey: CryptoKey): Promise { + const keysExported: Record = {}; + for (const k in this.keys) { + keysExported[k] = base64.stringify(new Uint8Array(await crypto.subtle.exportKey('raw', this.keys[k]))); + } + const payload = { + fileFormat: "AES-256-GCM-32k", + nameFormat: "AES-256-SIV", + keys: keysExported, + latestFileKey: this.latestFileKey, + nameKey: this.nameKey, + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/21 finalize kdf + kdf: "1STEP-HMAC-SHA512", + 'org.cryptomator.automaticAccessGrant': this.automaticAccessGrant + } + return JWEBuilder.a256kw(masterKey).encrypt(payload); + } + + public async hashDirectoryId(cleartextDirectoryId: string): Promise { + const dirHash = new TextEncoder().encode(cleartextDirectoryId); + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! MUST NEVER BE RELEASED LIKE THIS + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 use rawFileKey,rawNameKey for rootDirHash for now - should depend on nameKey only!! + const rawkey = new Uint8Array([...new Uint8Array(await crypto.subtle.exportKey('raw', this.keys[this.latestFileKey])),...new Uint8Array(await crypto.subtle.exportKey('raw', this.keys[this.nameKey]))]); + try { + // miscreant lib requires mac key first and then the enc key + const encKey = rawkey.subarray(0, rawkey.length / 2 | 0); + const macKey = rawkey.subarray(rawkey.length / 2 | 0); + const shiftedRawKey = new Uint8Array([...macKey, ...encKey]); + const key = await miscreant.SIV.importKey(shiftedRawKey, 'AES-SIV'); + const ciphertext = await key.seal(dirHash, []); + // hash is only used as deterministic scheme for the root dir + const hash = await crypto.subtle.digest('SHA-1', ciphertext); + return base32.stringify(new Uint8Array(hash)); + } finally { + rawkey.fill(0x00); + } + } +} diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts index ba6b380f5..7ba183c04 100644 --- a/frontend/src/common/vaultconfig.ts +++ b/frontend/src/common/vaultconfig.ts @@ -4,14 +4,16 @@ import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/c export class VaultConfig { readonly vaultConfigToken: string; - private readonly rootDirHash: string; + readonly rootDirHash: string; + readonly vaultUvf: string; - private constructor(vaultConfigToken: string, rootDirHash: string) { + private constructor(vaultConfigToken: string, vaultUvf: string, rootDirHash: string) { this.vaultConfigToken = vaultConfigToken; + this.vaultUvf = vaultUvf; this.rootDirHash = rootDirHash; } - public static async create(vaultId: string, vaultKeys: VaultKeys): Promise { + public static async create(vaultId: string, vaultKeys: VaultKeys, vaultUvf: string): Promise { const cfg = config.get(); const kid = `hub+${absBackendBaseURL}vaults/${vaultId}`; @@ -35,12 +37,14 @@ export class VaultConfig { const vaultConfigToken = await vaultKeys.createVaultConfig(kid, hubConfig, jwtPayload); const rootDirHash = await vaultKeys.hashDirectoryId(''); - return new VaultConfig(vaultConfigToken, rootDirHash); + return new VaultConfig(vaultConfigToken, vaultUvf, rootDirHash); } public async exportTemplate(): Promise { const zip = new JSZip(); + // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 what about vault.uvf? Not in template but only in vault dto? zip.file('vault.cryptomator', this.vaultConfigToken); + zip.file('vault.uvf', this.vaultUvf); zip.folder('d')?.folder(this.rootDirHash.substring(0, 2))?.folder(this.rootDirHash.substring(2)); return zip.generateAsync({ type: 'blob' }); } diff --git a/frontend/src/components/ArchiveVaultDialog.vue b/frontend/src/components/ArchiveVaultDialog.vue index 1e1a846e0..880d82c9c 100644 --- a/frontend/src/components/ArchiveVaultDialog.vue +++ b/frontend/src/components/ArchiveVaultDialog.vue @@ -81,7 +81,7 @@ async function archiveVault() { onArchiveVaultError.value = null; const v = props.vault; try { - const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, true, v.description); + const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, true, v.metadata, v.description); emit('archived', vaultDto); open.value = false; } catch (error) { diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index 02cca908b..93181b03b 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -185,7 +185,7 @@ import { base64 } from 'rfc4648'; import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { PaymentRequiredError } from '../common/backend'; -import { VaultKeys } from '../common/crypto'; +import { VaultKeys, VaultMetadata } from '../common/crypto'; import { debounce } from '../common/util'; import { VaultConfig } from '../common/vaultconfig'; @@ -292,9 +292,14 @@ async function createVault() { throw new Error('Invalid state'); } const vaultId = crypto.randomUUID(); - vaultConfig.value = await VaultConfig.create(vaultId, vaultKeys.value); + const vaultMetadata: VaultMetadata = await VaultMetadata.create({ + enabled: true, + maxWotDepth: -1 + }); + const vaultMetadataEncrypted = await vaultMetadata.encryptWithMasterKey(vaultKeys.value.uvfMasterKey); + vaultConfig.value = await VaultConfig.create(vaultId, vaultKeys.value, vaultMetadataEncrypted); const ownerJwe = await vaultKeys.value.encryptForUser(base64.parse(owner.publicKey)); - await backend.vaults.createOrUpdateVault(vaultId, vaultName.value, false, vaultDescription.value); + await backend.vaults.createOrUpdateVault(vaultId, vaultName.value, false, vaultMetadataEncrypted, vaultDescription.value); await backend.vaults.grantAccess(vaultId, { userId: owner.id, token: ownerJwe }); state.value = State.Finished; } catch (error) { diff --git a/frontend/src/components/DownloadVaultTemplateDialog.vue b/frontend/src/components/DownloadVaultTemplateDialog.vue index 1a067ec4b..b12400ac6 100644 --- a/frontend/src/components/DownloadVaultTemplateDialog.vue +++ b/frontend/src/components/DownloadVaultTemplateDialog.vue @@ -93,7 +93,7 @@ async function downloadVault() { } async function generateVaultZip(): Promise { - const config = await VaultConfig.create(props.vault.id, props.vaultKeys); + const config = await VaultConfig.create(props.vault.id, props.vaultKeys, props.vault.metadata); return await config.exportTemplate(); } diff --git a/frontend/src/components/EditVaultMetadataDialog.vue b/frontend/src/components/EditVaultMetadataDialog.vue index 2739c2abe..5c7f8642c 100644 --- a/frontend/src/components/EditVaultMetadataDialog.vue +++ b/frontend/src/components/EditVaultMetadataDialog.vue @@ -112,7 +112,7 @@ async function updateVaultMetadata() { throw new FormValidationFailedError(); } const vault = props.vault; - const updatedVault = await backend.vaults.createOrUpdateVault(vault.id, vaultName.value, vault.archived, vaultDescription.value); + const updatedVault = await backend.vaults.createOrUpdateVault(vault.id, vaultName.value, vault.archived, vault.metadata, vaultDescription.value); emit('updated', updatedVault); open.value = false; } catch (error) { diff --git a/frontend/src/components/ReactivateVaultDialog.vue b/frontend/src/components/ReactivateVaultDialog.vue index 6bcf75de5..98fa6d5ca 100644 --- a/frontend/src/components/ReactivateVaultDialog.vue +++ b/frontend/src/components/ReactivateVaultDialog.vue @@ -81,7 +81,7 @@ async function reactivateVault() { onReactivateVaultError.value = null; const v = props.vault; try { - const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, false, v.description); + const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, false, v.metadata, v.description); emit('reactivated', vaultDto); open.value = false; } catch (error) { diff --git a/frontend/test/common/crypto.spec.ts b/frontend/test/common/crypto.spec.ts index 8b7bd397f..557f450d5 100644 --- a/frontend/test/common/crypto.spec.ts +++ b/frontend/test/common/crypto.spec.ts @@ -3,6 +3,9 @@ import chaiAsPromised from 'chai-as-promised'; import { before, describe } from 'mocha'; import { base64 } from 'rfc4648'; import { UnwrapKeyError, UserKeys, VaultKeys } from '../../src/common/crypto'; +import { VaultMetadata } from '../../src/common/crypto'; +import { VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/backend'; +import { JWEParser } from '../../src/common/jwe'; chaiUse(chaiAsPromised); @@ -58,8 +61,8 @@ describe('crypto', () => { it('recover() succeeds for valid key', async () => { let recoveryKey = ` - pathway lift abuse plenty export texture gentleman landscape beyond ceiling around leaf cafe charity - border breakdown victory surely computer cat linger restrict infer crowd live computer true written amazed + pathway lift abuse plenty export texture gentleman landscape beyond ceiling around leaf cafe charity + border breakdown victory surely computer cat linger restrict infer crowd live computer true written amazed investor boot depth left theory snow whereby terminal weekly reject happiness circuit partial cup ad `; @@ -101,7 +104,6 @@ describe('crypto', () => { it('decryptWithAdminPassword() with wrong pw', () => { return expect(VaultKeys.decryptWithAdminPassword('wrong', wrapped.wrappedMasterkey, wrapped.wrappedOwnerPrivateKey, wrapped.ownerPublicKey, wrapped.salt, wrapped.iterations)).to.eventually.be.rejectedWith(UnwrapKeyError); }); - it('decryptWithAdminPassword() with correct pw', () => { return expect(VaultKeys.decryptWithAdminPassword('pass', wrapped.wrappedMasterkey, wrapped.wrappedOwnerPrivateKey, wrapped.ownerPublicKey, wrapped.salt, wrapped.iterations)).to.eventually.be.fulfilled; }); @@ -133,7 +135,6 @@ describe('crypto', () => { beforeEach(async () => { recoveryKey = await vaultKeys.createRecoveryKey(); }); - it('recover() imports original key', async () => { const recovered = await VaultKeys.recover(recoveryKey); @@ -146,7 +147,6 @@ describe('crypto', () => { }); }); }); - describe('UserKeys', () => { it('create()', async () => { const orig = await UserKeys.create(); @@ -170,6 +170,30 @@ describe('crypto', () => { }); }); + describe('VaultMetadata', () => { + // TODO review @sebi what else should we test? + it('encryptWithMasterKey() and decryptWithMasterKey()', async () => { + const vaultKeys = await VaultKeys.create(); + const uvfMasterKey: CryptoKey = vaultKeys.uvfMasterKey; + const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto ={ + "enabled": true, + "maxWotDepth": -1 + } + const orig = await VaultMetadata.create(automaticAccessGrant); + expect(orig).to.be.not.null; + const jwe: string = await orig.encryptWithMasterKey(uvfMasterKey); + expect(jwe).to.be.not.null; + const decrypted: VaultMetadata = await VaultMetadata.decryptWithMasterKey(jwe,uvfMasterKey); + expect(JSON.stringify(decrypted.automaticAccessGrant)).to.eq(JSON.stringify(automaticAccessGrant)); + const decryptedRaw: any = await JWEParser.parse(jwe).decryptA256kw(uvfMasterKey); + expect(decryptedRaw.fileFormat).to.eq("AES-256-GCM-32k"); + expect(decryptedRaw.latestFileKey).to.eq(orig.latestFileKey); + expect(decryptedRaw.nameKey).to.eq(orig.nameKey); + expect(decryptedRaw.kdf).to.eq("1STEP-HMAC-SHA512"); + expect(decryptedRaw['org.cryptomator.automaticAccessGrant']).to.deep.eq(automaticAccessGrant); + }); + }); + // base64-encoded test key pairs for use in other implementations (Java, Swift, ...) describe('Test Key Pairs', () => { it('alice private key (PKCS8)', async () => { @@ -215,8 +239,8 @@ describe('crypto', () => { /* ---------- MOCKS ---------- */ class TestVaultKeys extends VaultKeys { - constructor(key: CryptoKey) { - super(key); + constructor(masterKey: CryptoKey, uvfMasterKey: CryptoKey) { + super(masterKey, uvfMasterKey); } static async create() { @@ -234,7 +258,7 @@ class TestVaultKeys extends VaultKeys { true, ['sign'] ); - return new TestVaultKeys(key); + return new TestVaultKeys(key, key); } }