From 2d080ea2aaab9c4280b386719be042e0d8a38e0c Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 2 Mar 2024 12:10:28 +0100 Subject: [PATCH 01/94] add `"alg": "A256KW"` support for JWEs --- frontend/src/common/jwe.ts | 40 +++++++++++++++++++++++++++++--- frontend/test/common/jwe.spec.ts | 23 ++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 6ea42ade3..ec0c33cdf 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -36,8 +36,8 @@ export class ConcatKDF { } export type JWEHeader = { - readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW', - readonly enc: 'A256GCM' | 'A128GCM', + readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW' | 'A256KW', + readonly enc: 'A256GCM' | 'A128GCM', // A128GCM for testing only, as we use test vectors with 128 bit keys readonly apu?: string, readonly apv?: string, readonly epk?: JsonWebKey, @@ -97,7 +97,7 @@ export class JWEParser { * @throws {UnwrapKeyError} if decryption failed (wrong password?) */ public async decryptPbes2(password: string): Promise { - if (this.header.alg != 'PBES2-HS512+A256KW' || /* this.header.enc != 'A256GCM' || */ !this.header.p2s || !this.header.p2c) { + 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 }); @@ -110,6 +110,24 @@ export class JWEParser { } } + /** + * Decrypts the JWE, assuming alg == A256KW and enc == A256GCM. + * @param kek The key used to wrap the CEK + * @returns Decrypted payload + * @throws {UnwrapKeyError} if decryption failed (wrong kek?) + */ + public async decryptA256kw(kek: CryptoKey): Promise { + if (this.header.alg != 'A256KW' || this.header.enc != 'A256GCM') { + throw new Error('unsupported alg or enc'); + } + try { + const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, kek, '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 { const utf8enc = new TextEncoder(); const m = new Uint8Array(this.ciphertext.length + this.tag.length); @@ -180,6 +198,22 @@ export class JWEBuilder { return new JWEBuilder(header, encryptedKey, cek); } + /** + * Prepares a new JWE using alg: A256KW and enc: A256GCM. + * + * @param kek The key used to wrap the CEK + * @returns A new JWEBuilder ready to encrypt the payload + */ + public static a256kw(kek: CryptoKey): JWEBuilder { + const header = (async () => { + alg: 'A256KW', + enc: 'A256GCM' + })(); + const cek = crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + const encryptedKey = (async () => new Uint8Array(await crypto.subtle.wrapKey('raw', await cek, kek, 'AES-KW')))(); + return new JWEBuilder(header, encryptedKey, cek); + } + /** * Builds the JWE. * @param payload Payload to be encrypted diff --git a/frontend/test/common/jwe.spec.ts b/frontend/test/common/jwe.spec.ts index 62c65586e..0d0b73e1b 100644 --- a/frontend/test/common/jwe.spec.ts +++ b/frontend/test/common/jwe.spec.ts @@ -74,6 +74,29 @@ describe('JWE', () => { // TODO: add some more decrypt-only tests with JWE from 3rd party }); + describe('JWE using alg: A256KW', () => { + it('x = decrypt(encrypt(x, kek), kek)', async () => { + const kek = await crypto.subtle.generateKey({ name: 'AES-KW', length: 256 }, false, ['wrapKey', 'unwrapKey']); + const orig = { hello: 'world' }; + + const jwe = await JWEBuilder.a256kw(kek).encrypt(orig); + + const decrypted = await JWEParser.parse(jwe).decryptA256kw(kek); + expect(decrypted).to.deep.eq(orig); + }); + + it('decrypt', async () => { + // JWE generated by https://dinochiesa.github.io/jwt/ + const jwe = 'eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIn0.JTSrGbw4XEKXYFTC7siTT7DIZUX2SogThcLKXgxe0FPK3Fi8ckjr9A.zQx0t4qoTVIc-h5f.cmqzZ-md3cvdTNH9FWbKOsw.DCdGhmdwjoYKIuNC5zgQJQ'; + const rawKek = base64url.parse('y_uxz8iAtcOXlqMYpm2jASvDWokpCYMtwkthFSK6IF0', { loose: true }); + const kek = await crypto.subtle.importKey('raw', rawKek, 'AES-KW', false, ['unwrapKey']); + const orig = { hello: 'world' }; + + const decrypted = await JWEParser.parse(jwe).decryptA256kw(kek); + expect(decrypted).to.deep.eq(orig); + }); + }); + describe('PBES2', () => { /** * Test vectors from https://www.rfc-editor.org/rfc/rfc7517#appendix-C.4 From d109cffef162ea9e848eeb21c4d1d408bb30ee83 Mon Sep 17 00:00:00 2001 From: chenkins Date: Wed, 28 Feb 2024 16:57:34 +0100 Subject: [PATCH 02/94] 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); } } From 55633f19a8f72321fc7c6e7c164435371f9838e8 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 13 Apr 2024 12:37:41 +0200 Subject: [PATCH 03/94] remove dead code from `backend.ts` for now --- frontend/src/common/backend.ts | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 969738c7d..6fa61ca3e 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -196,18 +196,6 @@ 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 { @@ -263,12 +251,8 @@ class VaultService { .catch(err => rethrowAndConvertIfExpected(err, 403)); } - 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 - }; + 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)); @@ -461,13 +445,3 @@ 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 From fe7ff3dabd5c3ac114f57d8a7672dfc57dff4912 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 13 Apr 2024 13:37:14 +0200 Subject: [PATCH 04/94] disentangle v8 and uvf based vaults --- frontend/src/common/crypto.ts | 32 ++++--------------- frontend/src/common/vaultconfig.ts | 10 ++---- frontend/src/components/CreateVault.vue | 7 ++-- .../DownloadVaultTemplateDialog.vue | 2 +- frontend/test/common/crypto.spec.ts | 17 ++++++---- 5 files changed, 26 insertions(+), 42 deletions(-) diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 934145de5..a4c2e8f84 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -38,7 +38,6 @@ interface UserKeysJWEPayload { interface VaultKeysWEPayload { key: string - uvfKey: string } const GCM_NONCE_LEN = 12; @@ -54,19 +53,14 @@ 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, uvfMasterKey: CryptoKey) { + protected constructor(masterKey: CryptoKey) { this.masterKey = masterKey; - this.uvfMasterKey = uvfMasterKey; } /** - * Creates a new masterkey (vault8 and uvf) + * Creates a new masterkey * @returns A new masterkey */ public static async create(): Promise { @@ -75,12 +69,7 @@ export class VaultKeys { true, ['sign'] ); - const uvfKey = crypto.subtle.generateKey( - VaultKeys.UVF_MASTERKEY_KEY_DESIGNATION, - true, - ['wrapKey', 'unwrapKey'] - ); - return new VaultKeys(await key, await uvfKey); + return new VaultKeys(await key); } @@ -92,14 +81,11 @@ export class VaultKeys { */ public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { let rawKey = new Uint8Array(); - let rawUvfKey = new Uint8Array(); try { const payload: VaultKeysWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); rawKey = base64.parse(payload.key); - 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); + return new VaultKeys(await masterKey); } finally { rawKey.fill(0x00); } @@ -163,8 +149,7 @@ export class VaultKeys { true, ['verify'] ); - // 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 }]; + return [new VaultKeys(await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; } catch (error) { throw new UnwrapKeyError(error); } @@ -197,8 +182,7 @@ export class VaultKeys { true, ['sign'] ); - // TODO https://github.com/encryption-alliance/unified-vault-format/pull/19 upstream legacy integration for uvf? - return new VaultKeys(await key, await key); + return new VaultKeys(await key); } public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { @@ -239,18 +223,16 @@ export class VaultKeys { } /** - * Encrypts this masterkey (vault8 and uvf) using the given public key + * Encrypts this masterkey 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: VaultKeysWEPayload = { key: base64.stringify(rawkey), - uvfKey: base64.stringify(rawUvfKey) }; return JWEBuilder.ecdhEs(publicKey).encrypt(payload); } finally { diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts index 7ba183c04..37b2df980 100644 --- a/frontend/src/common/vaultconfig.ts +++ b/frontend/src/common/vaultconfig.ts @@ -5,15 +5,13 @@ import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/c export class VaultConfig { readonly vaultConfigToken: string; readonly rootDirHash: string; - readonly vaultUvf: string; - private constructor(vaultConfigToken: string, vaultUvf: string, rootDirHash: string) { + private constructor(vaultConfigToken: string, rootDirHash: string) { this.vaultConfigToken = vaultConfigToken; - this.vaultUvf = vaultUvf; this.rootDirHash = rootDirHash; } - public static async create(vaultId: string, vaultKeys: VaultKeys, vaultUvf: string): Promise { + public static async create(vaultId: string, vaultKeys: VaultKeys): Promise { const cfg = config.get(); const kid = `hub+${absBackendBaseURL}vaults/${vaultId}`; @@ -37,14 +35,12 @@ export class VaultConfig { const vaultConfigToken = await vaultKeys.createVaultConfig(kid, hubConfig, jwtPayload); const rootDirHash = await vaultKeys.hashDirectoryId(''); - return new VaultConfig(vaultConfigToken, vaultUvf, rootDirHash); + return new VaultConfig(vaultConfigToken, 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/CreateVault.vue b/frontend/src/components/CreateVault.vue index 93181b03b..884886e03 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -292,14 +292,17 @@ async function createVault() { throw new Error('Invalid state'); } const vaultId = crypto.randomUUID(); + /* + FIXME create UVF vault 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); + */ + vaultConfig.value = await VaultConfig.create(vaultId, vaultKeys.value); const ownerJwe = await vaultKeys.value.encryptForUser(base64.parse(owner.publicKey)); - await backend.vaults.createOrUpdateVault(vaultId, vaultName.value, false, vaultMetadataEncrypted, vaultDescription.value); + await backend.vaults.createOrUpdateVault(vaultId, vaultName.value, false, '', 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 b12400ac6..1a067ec4b 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, props.vault.metadata); + const config = await VaultConfig.create(props.vault.id, props.vaultKeys); return await config.exportTemplate(); } diff --git a/frontend/test/common/crypto.spec.ts b/frontend/test/common/crypto.spec.ts index 557f450d5..57f7054e8 100644 --- a/frontend/test/common/crypto.spec.ts +++ b/frontend/test/common/crypto.spec.ts @@ -61,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 `; @@ -173,8 +173,11 @@ 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 uvfMasterKey = await crypto.subtle.generateKey( + { name: 'AES-KW', length: 256 }, + true, + ['wrapKey', 'unwrapKey'] + ); const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto ={ "enabled": true, "maxWotDepth": -1 @@ -239,8 +242,8 @@ describe('crypto', () => { /* ---------- MOCKS ---------- */ class TestVaultKeys extends VaultKeys { - constructor(masterKey: CryptoKey, uvfMasterKey: CryptoKey) { - super(masterKey, uvfMasterKey); + constructor(masterKey: CryptoKey) { + super(masterKey); } static async create() { @@ -258,7 +261,7 @@ class TestVaultKeys extends VaultKeys { true, ['sign'] ); - return new TestVaultKeys(key, key); + return new TestVaultKeys(key); } } From be8803c6a1d6a52deafe7528104e3690f797c0c2 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 13 Apr 2024 13:53:53 +0200 Subject: [PATCH 05/94] move vault format 8 code to separate file --- frontend/src/common/crypto.ts | 304 ++---------------- frontend/src/common/vaultconfig.ts | 2 +- frontend/src/common/vaultv8.ts | 244 ++++++++++++++ .../components/ClaimVaultOwnershipDialog.vue | 3 +- frontend/src/components/CreateVault.vue | 2 +- .../DownloadVaultTemplateDialog.vue | 2 +- .../src/components/GrantPermissionDialog.vue | 3 +- .../src/components/RecoverVaultDialog.vue | 3 +- frontend/src/components/RecoveryKeyDialog.vue | 2 +- frontend/src/components/VaultDetails.vue | 3 +- frontend/test/common/crypto.spec.ts | 140 +------- frontend/test/common/vaultv8.spec.ts | 154 +++++++++ 12 files changed, 444 insertions(+), 418 deletions(-) create mode 100644 frontend/src/common/vaultv8.ts create mode 100644 frontend/test/common/vaultv8.spec.ts diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index a4c2e8f84..6747a980c 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,7 +1,7 @@ import * as miscreant from 'miscreant'; -import { base16, base32, base64, base64url } from 'rfc4648'; +import { base16, base32, base64 } from 'rfc4648'; import { JWEBuilder, JWEParser } from './jwe'; -import { CRC32, DB, wordEncoder } from './util'; +import { DB } from './util'; import { VaultMetadataJWEAutomaticAccessGrantDto } from './backend'; @@ -14,249 +14,11 @@ export class UnwrapKeyError extends Error { } } -export interface VaultConfigPayload { - jti: string - format: number - cipherCombo: string - shorteningThreshold: number -} - -export interface VaultConfigHeaderHub { - clientId: string - authEndpoint: string - tokenEndpoint: string - authSuccessUrl: string - authErrorUrl: string - apiBaseUrl: string - // deprecated: - devicesResourceUrl: string -} - interface UserKeysJWEPayload { key: string } -interface VaultKeysWEPayload { - key: string -} - -const GCM_NONCE_LEN = 12; - -export class VaultKeys { - // in this browser application, this 512 bit key is used - // as a hmac key to sign the vault config. - // however when used by cryptomator, it gets split into - // a 256 bit encryption key and a 256 bit mac key - private static readonly MASTERKEY_KEY_DESIGNATION: HmacImportParams | HmacKeyGenParams = { - name: 'HMAC', - hash: 'SHA-256', - length: 512 - }; - - readonly masterKey: CryptoKey; - - protected constructor(masterKey: CryptoKey) { - this.masterKey = masterKey; - } - - /** - * Creates a new masterkey - * @returns A new masterkey - */ - public static async create(): Promise { - const key = crypto.subtle.generateKey( - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - return new VaultKeys(await 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(); - try { - 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); - } finally { - rawKey.fill(0x00); - } - } - - /** - * Unwraps keys protected by the legacy "Vault Admin Password". - * @param vaultAdminPassword Vault Admin Password - * @param wrappedMasterkey The wrapped masterkey - * @param wrappedOwnerPrivateKey The wrapped owner private key - * @param ownerPublicKey The owner public key - * @param salt PBKDF2 Salt - * @param iterations PBKDF2 Iterations - * @returns The unwrapped key material. - * @throws WrongPasswordError, if the wrong password is used - * @deprecated Only used during "claim vault ownership" workflow for legacy vaults - */ - public static async decryptWithAdminPassword(vaultAdminPassword: string, wrappedMasterkey: string, wrappedOwnerPrivateKey: string, ownerPublicKey: string, salt: string, iterations: number): Promise<[VaultKeys, CryptoKeyPair]> { - // pbkdf2: - const encodedPw = new TextEncoder().encode(vaultAdminPassword); - const pwKey = crypto.subtle.importKey('raw', encodedPw, 'PBKDF2', false, ['deriveKey']); - const kek = crypto.subtle.deriveKey( - { - name: 'PBKDF2', - hash: 'SHA-256', - salt: base64.parse(salt, { loose: true }), - iterations: iterations - }, - await pwKey, - { name: 'AES-GCM', length: 256 }, - false, - ['unwrapKey'] - ); - // unwrapping - const decodedMasterKey = base64.parse(wrappedMasterkey, { loose: true }); - const decodedPrivateKey = base64.parse(wrappedOwnerPrivateKey, { loose: true }); - const decodedPublicKey = base64.parse(ownerPublicKey, { loose: true }); - try { - const masterkey = crypto.subtle.unwrapKey( - 'raw', - decodedMasterKey.slice(GCM_NONCE_LEN), - await kek, - { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - const privKey = crypto.subtle.unwrapKey( - 'pkcs8', - decodedPrivateKey.slice(GCM_NONCE_LEN), - await kek, - { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, - { name: 'ECDSA', namedCurve: 'P-384' }, - false, - ['sign'] - ); - const pubKey = crypto.subtle.importKey( - 'spki', - decodedPublicKey, - { name: 'ECDSA', namedCurve: 'P-384' }, - true, - ['verify'] - ); - return [new VaultKeys(await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; - } catch (error) { - throw new UnwrapKeyError(error); - } - } - - /** - * Restore the master key from a given recovery key, create a new admin signature key pair. - * @param recoveryKey The recovery key - * @returns The recovered master key - * @throws Error, if passing a malformed recovery key - */ - public static async recover(recoveryKey: string): Promise { - // decode and check recovery key: - const decoded = wordEncoder.decode(recoveryKey); - if (decoded.length !== 66) { - throw new Error('Invalid recovery key length.'); - } - const decodedKey = decoded.subarray(0, 64); - const crc32 = CRC32.compute(decodedKey); - if (decoded[64] !== (crc32 & 0xFF) - || decoded[65] !== (crc32 >> 8 & 0xFF)) { - throw new Error('Invalid recovery key checksum.'); - } - - // construct new VaultKeys from recovered key - const key = crypto.subtle.importKey( - 'raw', - decodedKey, - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - return new VaultKeys(await key); - } - - public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { - const header = JSON.stringify({ - kid: kid, - typ: 'jwt', - alg: 'HS256', - hub: hubConfig - }); - const payloadJson = JSON.stringify(payload); - const encoder = new TextEncoder(); - const unsignedToken = base64url.stringify(encoder.encode(header), { pad: false }) + '.' + base64url.stringify(encoder.encode(payloadJson), { pad: false }); - const encodedUnsignedToken = new TextEncoder().encode(unsignedToken); - const signature = await crypto.subtle.sign( - 'HMAC', - this.masterKey, - encodedUnsignedToken - ); - return unsignedToken + '.' + base64url.stringify(new Uint8Array(signature), { pad: false }); - } - - public async hashDirectoryId(cleartextDirectoryId: string): Promise { - const dirHash = new TextEncoder().encode(cleartextDirectoryId); - const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); - 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); - } - } - - /** - * Encrypts this masterkey 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)); - try { - const payload: VaultKeysWEPayload = { - key: base64.stringify(rawkey), - }; - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); - } finally { - rawkey.fill(0x00); - } - } - - /** - * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes - */ - public async createRecoveryKey(): Promise { - const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); - - // add 16 bit checksum: - const crc32 = CRC32.compute(rawkey); - const checksum = new Uint8Array(2); - checksum[0] = crc32 & 0xff; // append the least significant byte of the crc - checksum[1] = crc32 >> 8 & 0xff; // followed by the second-least significant byte - const combined = new Uint8Array([...rawkey, ...checksum]); - - // encode using human-readable words: - return wordEncoder.encodePadded(combined); - } -} +export const GCM_NONCE_LEN = 12; export class UserKeys { public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; @@ -467,11 +229,11 @@ export class VaultMetadata { }; readonly automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto; - readonly keys: Record; + readonly keys: Record; readonly latestFileKey: string; readonly nameKey: string; - protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, keys: Record, latestFileKey: string, nameKey: string) { + protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, keys: Record, latestFileKey: string, nameKey: string) { this.automaticAccessGrant = automaticAccessGrant; this.keys = keys; this.latestFileKey = latestFileKey; @@ -495,9 +257,9 @@ export class VaultMetadata { // 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 = {}; + 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); @@ -510,21 +272,21 @@ export class VaultMetadata { * @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 - ); + 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 + ); } /** @@ -533,19 +295,19 @@ export class VaultMetadata { * @returns a JWE containing this Masterkey */ public async encryptWithMasterKey(masterKey: CryptoKey): Promise { - const keysExported: Record = {}; + 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 + 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); } @@ -554,7 +316,7 @@ export class VaultMetadata { 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]))]); + 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); diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts index 37b2df980..51b66c542 100644 --- a/frontend/src/common/vaultconfig.ts +++ b/frontend/src/common/vaultconfig.ts @@ -1,6 +1,6 @@ import JSZip from 'jszip'; import config, { absBackendBaseURL, absFrontendBaseURL } from '../common/config'; -import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/crypto'; +import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/vaultv8'; export class VaultConfig { readonly vaultConfigToken: string; diff --git a/frontend/src/common/vaultv8.ts b/frontend/src/common/vaultv8.ts new file mode 100644 index 000000000..79b25672a --- /dev/null +++ b/frontend/src/common/vaultv8.ts @@ -0,0 +1,244 @@ +import * as miscreant from 'miscreant'; +import { base32, base64, base64url } from 'rfc4648'; +import { GCM_NONCE_LEN, UnwrapKeyError, UserKeys } from './crypto'; +import { JWEBuilder, JWEParser } from './jwe'; +import { CRC32, wordEncoder } from './util'; + +interface JWEPayload { + key: string +} + +export interface VaultConfigPayload { + jti: string + format: number + cipherCombo: string + shorteningThreshold: number +} + +export interface VaultConfigHeaderHub { + clientId: string + authEndpoint: string + tokenEndpoint: string + authSuccessUrl: string + authErrorUrl: string + apiBaseUrl: string + // deprecated: + devicesResourceUrl: string +} + + +export class VaultKeys { + // in this browser application, this 512 bit key is used + // as a hmac key to sign the vault config. + // however when used by cryptomator, it gets split into + // a 256 bit encryption key and a 256 bit mac key + private static readonly MASTERKEY_KEY_DESIGNATION: HmacImportParams | HmacKeyGenParams = { + name: 'HMAC', + hash: 'SHA-256', + length: 512 + }; + + readonly masterKey: CryptoKey; + + protected constructor(masterKey: CryptoKey) { + this.masterKey = masterKey; + } + + /** + * Creates a new masterkey + * @returns A new masterkey + */ + public static async create(): Promise { + const key = crypto.subtle.generateKey( + VaultKeys.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultKeys(await 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(); + try { + const payload: JWEPayload = 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); + } finally { + rawKey.fill(0x00); + } + } + + /** + * Unwraps keys protected by the legacy "Vault Admin Password". + * @param vaultAdminPassword Vault Admin Password + * @param wrappedMasterkey The wrapped masterkey + * @param wrappedOwnerPrivateKey The wrapped owner private key + * @param ownerPublicKey The owner public key + * @param salt PBKDF2 Salt + * @param iterations PBKDF2 Iterations + * @returns The unwrapped key material. + * @throws WrongPasswordError, if the wrong password is used + * @deprecated Only used during "claim vault ownership" workflow for legacy vaults + */ + public static async decryptWithAdminPassword(vaultAdminPassword: string, wrappedMasterkey: string, wrappedOwnerPrivateKey: string, ownerPublicKey: string, salt: string, iterations: number): Promise<[VaultKeys, CryptoKeyPair]> { + // pbkdf2: + const encodedPw = new TextEncoder().encode(vaultAdminPassword); + const pwKey = crypto.subtle.importKey('raw', encodedPw, 'PBKDF2', false, ['deriveKey']); + const kek = crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: base64.parse(salt, { loose: true }), + iterations: iterations + }, + await pwKey, + { name: 'AES-GCM', length: 256 }, + false, + ['unwrapKey'] + ); + // unwrapping + const decodedMasterKey = base64.parse(wrappedMasterkey, { loose: true }); + const decodedPrivateKey = base64.parse(wrappedOwnerPrivateKey, { loose: true }); + const decodedPublicKey = base64.parse(ownerPublicKey, { loose: true }); + try { + const masterkey = crypto.subtle.unwrapKey( + 'raw', + decodedMasterKey.slice(GCM_NONCE_LEN), + await kek, + { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, + VaultKeys.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + const privKey = crypto.subtle.unwrapKey( + 'pkcs8', + decodedPrivateKey.slice(GCM_NONCE_LEN), + await kek, + { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, + { name: 'ECDSA', namedCurve: 'P-384' }, + false, + ['sign'] + ); + const pubKey = crypto.subtle.importKey( + 'spki', + decodedPublicKey, + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['verify'] + ); + return [new VaultKeys(await masterkey), { privateKey: await privKey, publicKey: await pubKey }]; + } catch (error) { + throw new UnwrapKeyError(error); + } + } + + /** + * Restore the master key from a given recovery key, create a new admin signature key pair. + * @param recoveryKey The recovery key + * @returns The recovered master key + * @throws Error, if passing a malformed recovery key + */ + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + const decoded = wordEncoder.decode(recoveryKey); + if (decoded.length !== 66) { + throw new Error('Invalid recovery key length.'); + } + const decodedKey = decoded.subarray(0, 64); + const crc32 = CRC32.compute(decodedKey); + if (decoded[64] !== (crc32 & 0xFF) + || decoded[65] !== (crc32 >> 8 & 0xFF)) { + throw new Error('Invalid recovery key checksum.'); + } + + // construct new VaultKeys from recovered key + const key = crypto.subtle.importKey( + 'raw', + decodedKey, + VaultKeys.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultKeys(await key); + } + + public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { + const header = JSON.stringify({ + kid: kid, + typ: 'jwt', + alg: 'HS256', + hub: hubConfig + }); + const payloadJson = JSON.stringify(payload); + const encoder = new TextEncoder(); + const unsignedToken = base64url.stringify(encoder.encode(header), { pad: false }) + '.' + base64url.stringify(encoder.encode(payloadJson), { pad: false }); + const encodedUnsignedToken = new TextEncoder().encode(unsignedToken); + const signature = await crypto.subtle.sign( + 'HMAC', + this.masterKey, + encodedUnsignedToken + ); + return unsignedToken + '.' + base64url.stringify(new Uint8Array(signature), { pad: false }); + } + + public async hashDirectoryId(cleartextDirectoryId: string): Promise { + const dirHash = new TextEncoder().encode(cleartextDirectoryId); + const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); + 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); + } + } + + /** + * Encrypts this masterkey 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)); + try { + const payload: JWEPayload = { + key: base64.stringify(rawkey), + }; + return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + } finally { + rawkey.fill(0x00); + } + } + + /** + * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes + */ + public async createRecoveryKey(): Promise { + const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); + + // add 16 bit checksum: + const crc32 = CRC32.compute(rawkey); + const checksum = new Uint8Array(2); + checksum[0] = crc32 & 0xff; // append the least significant byte of the crc + checksum[1] = crc32 >> 8 & 0xff; // followed by the second-least significant byte + const combined = new Uint8Array([...rawkey, ...checksum]); + + // encode using human-readable words: + return wordEncoder.encodePadded(combined); + } +} diff --git a/frontend/src/components/ClaimVaultOwnershipDialog.vue b/frontend/src/components/ClaimVaultOwnershipDialog.vue index da081453b..2635e04e6 100644 --- a/frontend/src/components/ClaimVaultOwnershipDialog.vue +++ b/frontend/src/components/ClaimVaultOwnershipDialog.vue @@ -62,7 +62,8 @@ import { KeyIcon } from '@heroicons/vue/24/outline'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { VaultDto } from '../common/backend'; -import { UnwrapKeyError, VaultKeys } from '../common/crypto'; +import { UnwrapKeyError } from '../common/crypto'; +import { VaultKeys } from '../common/vaultv8'; class FormValidationFailedError extends Error { constructor() { diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index 884886e03..3a8dc6b0b 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -185,9 +185,9 @@ import { base64 } from 'rfc4648'; import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { PaymentRequiredError } from '../common/backend'; -import { VaultKeys, VaultMetadata } from '../common/crypto'; import { debounce } from '../common/util'; import { VaultConfig } from '../common/vaultconfig'; +import { VaultKeys } from '../common/vaultv8'; enum State { Initial, diff --git a/frontend/src/components/DownloadVaultTemplateDialog.vue b/frontend/src/components/DownloadVaultTemplateDialog.vue index 1a067ec4b..90262ef18 100644 --- a/frontend/src/components/DownloadVaultTemplateDialog.vue +++ b/frontend/src/components/DownloadVaultTemplateDialog.vue @@ -54,8 +54,8 @@ import { saveAs } from 'file-saver'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { VaultDto } from '../common/backend'; -import { VaultKeys } from '../common/crypto'; import { VaultConfig } from '../common/vaultconfig'; +import { VaultKeys } from '../common/vaultv8'; const { t } = useI18n({ useScope: 'global' }); diff --git a/frontend/src/components/GrantPermissionDialog.vue b/frontend/src/components/GrantPermissionDialog.vue index ce21138e5..e315ccde3 100644 --- a/frontend/src/components/GrantPermissionDialog.vue +++ b/frontend/src/components/GrantPermissionDialog.vue @@ -73,8 +73,7 @@ import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { AccessGrant, ConflictError, NotFoundError, UserDto, VaultDto } from '../common/backend'; import { getFingerprint } from '../common/crypto'; - -import { VaultKeys } from '../common/crypto'; +import { VaultKeys } from '../common/vaultv8'; const { t } = useI18n({ useScope: 'global' }); diff --git a/frontend/src/components/RecoverVaultDialog.vue b/frontend/src/components/RecoverVaultDialog.vue index 280bb9bb8..f4d201917 100644 --- a/frontend/src/components/RecoverVaultDialog.vue +++ b/frontend/src/components/RecoverVaultDialog.vue @@ -56,8 +56,7 @@ import { base64 } from 'rfc4648'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { UserDto, VaultDto } from '../common/backend'; - -import { VaultKeys } from '../common/crypto'; +import { VaultKeys } from '../common/vaultv8'; class FormValidationFailedError extends Error { constructor() { diff --git a/frontend/src/components/RecoveryKeyDialog.vue b/frontend/src/components/RecoveryKeyDialog.vue index 58f9a11cd..401398b0f 100644 --- a/frontend/src/components/RecoveryKeyDialog.vue +++ b/frontend/src/components/RecoveryKeyDialog.vue @@ -69,8 +69,8 @@ import { KeyIcon } from '@heroicons/vue/24/outline'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { VaultDto } from '../common/backend'; -import { VaultKeys } from '../common/crypto'; import { debounce } from '../common/util'; +import { VaultKeys } from '../common/vaultv8'; const { t } = useI18n({ useScope: 'global' }); diff --git a/frontend/src/components/VaultDetails.vue b/frontend/src/components/VaultDetails.vue index 6967ec968..f46d283ef 100644 --- a/frontend/src/components/VaultDetails.vue +++ b/frontend/src/components/VaultDetails.vue @@ -192,8 +192,9 @@ import { base64 } from 'rfc4648'; import { computed, nextTick, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { AuthorityDto, ConflictError, ForbiddenError, MemberDto, NotFoundError, PaymentRequiredError, UserDto, VaultDto, VaultRole } from '../common/backend'; -import { BrowserKeys, UserKeys, VaultKeys } from '../common/crypto'; +import { BrowserKeys, UserKeys } from '../common/crypto'; import { JWT, JWTHeader } from '../common/jwt'; +import { VaultKeys } from '../common/vaultv8'; import ArchiveVaultDialog from './ArchiveVaultDialog.vue'; import ClaimVaultOwnershipDialog from './ClaimVaultOwnershipDialog.vue'; import DownloadVaultTemplateDialog from './DownloadVaultTemplateDialog.vue'; diff --git a/frontend/test/common/crypto.spec.ts b/frontend/test/common/crypto.spec.ts index 57f7054e8..6c9d1e608 100644 --- a/frontend/test/common/crypto.spec.ts +++ b/frontend/test/common/crypto.spec.ts @@ -2,9 +2,8 @@ import { use as chaiUse, expect } from 'chai'; 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 { UserKeys, VaultMetadata } from '../../src/common/crypto'; import { JWEParser } from '../../src/common/jwe'; chaiUse(chaiAsPromised); @@ -52,101 +51,6 @@ describe('crypto', () => { bob = { privateKey: await bobPrv, publicKey: await bobPub }; }); - describe('VaultKeys', () => { - it('create()', async () => { - const orig = await VaultKeys.create(); - - expect(orig).to.be.not.null; - }); - - 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 - investor boot depth left theory snow whereby terminal weekly reject happiness circuit partial cup ad - `; - - const recovered = await VaultKeys.recover(recoveryKey); - - const newMasterKey = await crypto.subtle.exportKey('jwk', recovered.masterKey); - expect(newMasterKey).to.deep.include({ - 'k': 'uwHiVreDbmv47K7oZzlwZbHcEql2Z29brbgFxKA7i54pXVPoHoxKK5rzZS3VEhPxHegQKCwa5Mk4ep7OsYutAw' - }); - }); - - it('recover() fails for invalid recovery key', async () => { - const noMultipleOfTwo = VaultKeys.recover('pathway'); - const notInDict = VaultKeys.recover('hallo bonjour'); - const wrongLength = VaultKeys.recover('pathway lift'); - const invalidCrc = VaultKeys.recover(` - 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 wrong - `); - - return Promise.all([ - expect(noMultipleOfTwo).to.be.rejectedWith(Error, /input needs to be a multiple of two words/), - expect(notInDict).to.be.rejectedWith(Error, /Word not in dictionary/), - expect(wrongLength).to.be.rejectedWith(Error, /Invalid recovery key length/), - expect(invalidCrc).to.be.rejectedWith(Error, /Invalid recovery key checksum/), - ]); - }); - - describe('Prove Vault Ownership using Vault Admin Password', () => { - const wrapped = { - wrappedMasterkey: 'CMPyJiiOQXBZ8FVvFZs6UOh0kW83+eALeK3bwXfFF2CWsguJZIgCJch94liWCh9xTqW84LUZPyo6IDWbSALqbbdiwDcztT8M81/pgadhTETVtHO5Q1CFNLJ9UvY=', - wrappedOwnerPrivateKey: 'O9snY73/eVElnWRLgM404KH7WwO/Ed30Y0UrQQw6x3vxOdroJcjvPdJeSqLD2x4lVP7ceTjVt3IT2N9Mx+jhUQzqrb1E2EvEYlXrTaID1jSdBXZ6ScrI1RvU0iH9cfXf2cRy2x8QZvJyVMr34gLJ3Di/XGrnc/BrOm+aF2K4F9FJXvJFen3CnAs9ewB3Vk0A1wRLX3hW/Wx7eXt/0i1gxB8T/NcLu7xIU3+uusTHh9uajFkA5+z1+JgNHURaa1bT8j5WTtNWIHYT/sw+erMn6S0Uj1vL', - ownerPublicKey: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESzrRXmyI8VWFJg1dPUNbFcc9jZvjZEfH7ulKI1UkXAltd7RGWrcfFxqyGPcwu6AQhHUag3OvDzEr0uUQND4PXHQTXP5IDGdYhJhL+WLKjnGjQAw0rNGy5V29+aV+yseW', - salt: 'IdXyKICznXKm41gSb5OqfQ', - iterations: 1 - }; - - 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; - }); - }); - - describe('After creating new key material', () => { - let vaultKeys: VaultKeys; - - beforeEach(async () => { - vaultKeys = await TestVaultKeys.create(); - }); - - it('encryptForUser()', async () => { - const userKey = base64.parse('MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERxQR+NRN6Wga01370uBBzr2NHDbKIC56tPUEq2HX64RhITGhii8Zzbkb1HnRmdF0aq6uqmUy4jUhuxnKxsv59A6JeK7Unn+mpmm3pQAygjoGc9wrvoH4HWJSQYUlsXDu'); - - const encrypted = await vaultKeys.encryptForUser(userKey); - expect(encrypted).to.be.not.null; - }); - - it('createRecoveryKey()', async () => { - const recoveryKey = await vaultKeys.createRecoveryKey(); - - expect(recoveryKey).to.eql('water water water water water water water water water water water water water water water water water water water water water asset partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly option twist'); - }); - - describe('After creating a valid recovery key', () => { - let recoveryKey: string; - - beforeEach(async () => { - recoveryKey = await vaultKeys.createRecoveryKey(); - }); - it('recover() imports original key', async () => { - const recovered = await VaultKeys.recover(recoveryKey); - - const oldMasterKey = await crypto.subtle.exportKey('jwk', vaultKeys.masterKey); - const newMasterKey = await crypto.subtle.exportKey('jwk', recovered.masterKey); - expect(newMasterKey).to.deep.include({ - 'k': oldMasterKey.k - }); - }); - }); - }); - }); describe('UserKeys', () => { it('create()', async () => { const orig = await UserKeys.create(); @@ -178,7 +82,7 @@ describe('crypto', () => { true, ['wrapKey', 'unwrapKey'] ); - const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto ={ + const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto = { "enabled": true, "maxWotDepth": -1 } @@ -186,7 +90,7 @@ describe('crypto', () => { 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); + 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"); @@ -223,48 +127,10 @@ describe('crypto', () => { expect(encoded).to.eq('MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEem7I0xHVyliLrtQb4+mPMMkpSETsu2KZlWU2NdvCLaLwg/KXEeD5xZY7wCG9jLIQna9WpV+IOnIAzqnE3kRIjm3En7nDlPUctaSfxp1+igNHkpY65Oq8Y0g6LPGomejI'); }); }); - - describe('Hash directory id', () => { - it('root directory', async () => { - const vaultKeys = await TestVaultKeys.create(); - const result = await vaultKeys.hashDirectoryId(''); - expect(result).to.eql('VLWEHT553J5DR7OZLRJAYDIWFCXZABOD'); - }); - - it('specific directory', async () => { - const vaultKeys = await TestVaultKeys.create(); - const result = await vaultKeys.hashDirectoryId('918acfbd-a467-3f77-93f1-f4a44f9cfe9c'); - expect(result).to.eql('7C3USOO3VU7IVQRKFMRFV3QE4VEZJECV'); - }); - }); }); /* ---------- MOCKS ---------- */ -class TestVaultKeys extends VaultKeys { - constructor(masterKey: CryptoKey) { - super(masterKey); - } - - static async create() { - const raw = new Uint8Array(64); - raw.fill(0x55, 0, 32); - raw.fill(0x77, 32, 64); - const key = await crypto.subtle.importKey( - 'raw', - raw, - { - name: 'HMAC', - hash: 'SHA-256', - length: 512 - }, - true, - ['sign'] - ); - return new TestVaultKeys(key); - } -} - class TestUserKeys extends UserKeys { constructor(keypair: CryptoKeyPair) { super(keypair); diff --git a/frontend/test/common/vaultv8.spec.ts b/frontend/test/common/vaultv8.spec.ts new file mode 100644 index 000000000..16040689c --- /dev/null +++ b/frontend/test/common/vaultv8.spec.ts @@ -0,0 +1,154 @@ +import { use as chaiUse, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { before, describe } from 'mocha'; +import { base64 } from 'rfc4648'; +import { UnwrapKeyError } from '../../src/common/crypto'; +import { VaultKeys } from '../../src/common/vaultv8'; + +chaiUse(chaiAsPromised); + +describe('Vault Format 8', () => { + + before(async () => { + // since this test runs on Node, we need to replace window.crypto: + Object.defineProperty(global, 'crypto', { value: require('node:crypto').webcrypto }); + // @ts-ignore: global not defined (but will be available within Node) + global.window = { crypto: global.crypto }; + }); + + describe('VaultKeys', () => { + it('create()', async () => { + const orig = await VaultKeys.create(); + + expect(orig).to.be.not.null; + }); + + 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 + investor boot depth left theory snow whereby terminal weekly reject happiness circuit partial cup ad + `; + + const recovered = await VaultKeys.recover(recoveryKey); + + const newMasterKey = await crypto.subtle.exportKey('jwk', recovered.masterKey); + expect(newMasterKey).to.deep.include({ + 'k': 'uwHiVreDbmv47K7oZzlwZbHcEql2Z29brbgFxKA7i54pXVPoHoxKK5rzZS3VEhPxHegQKCwa5Mk4ep7OsYutAw' + }); + }); + + it('recover() fails for invalid recovery key', async () => { + const noMultipleOfTwo = VaultKeys.recover('pathway'); + const notInDict = VaultKeys.recover('hallo bonjour'); + const wrongLength = VaultKeys.recover('pathway lift'); + const invalidCrc = VaultKeys.recover(` + 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 wrong + `); + + return Promise.all([ + expect(noMultipleOfTwo).to.be.rejectedWith(Error, /input needs to be a multiple of two words/), + expect(notInDict).to.be.rejectedWith(Error, /Word not in dictionary/), + expect(wrongLength).to.be.rejectedWith(Error, /Invalid recovery key length/), + expect(invalidCrc).to.be.rejectedWith(Error, /Invalid recovery key checksum/), + ]); + }); + + describe('Prove Vault Ownership using Vault Admin Password', () => { + const wrapped = { + wrappedMasterkey: 'CMPyJiiOQXBZ8FVvFZs6UOh0kW83+eALeK3bwXfFF2CWsguJZIgCJch94liWCh9xTqW84LUZPyo6IDWbSALqbbdiwDcztT8M81/pgadhTETVtHO5Q1CFNLJ9UvY=', + wrappedOwnerPrivateKey: 'O9snY73/eVElnWRLgM404KH7WwO/Ed30Y0UrQQw6x3vxOdroJcjvPdJeSqLD2x4lVP7ceTjVt3IT2N9Mx+jhUQzqrb1E2EvEYlXrTaID1jSdBXZ6ScrI1RvU0iH9cfXf2cRy2x8QZvJyVMr34gLJ3Di/XGrnc/BrOm+aF2K4F9FJXvJFen3CnAs9ewB3Vk0A1wRLX3hW/Wx7eXt/0i1gxB8T/NcLu7xIU3+uusTHh9uajFkA5+z1+JgNHURaa1bT8j5WTtNWIHYT/sw+erMn6S0Uj1vL', + ownerPublicKey: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESzrRXmyI8VWFJg1dPUNbFcc9jZvjZEfH7ulKI1UkXAltd7RGWrcfFxqyGPcwu6AQhHUag3OvDzEr0uUQND4PXHQTXP5IDGdYhJhL+WLKjnGjQAw0rNGy5V29+aV+yseW', + salt: 'IdXyKICznXKm41gSb5OqfQ', + iterations: 1 + }; + + 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; + }); + }); + + describe('After creating new key material', () => { + let vaultKeys: VaultKeys; + + beforeEach(async () => { + vaultKeys = await TestVaultKeys.create(); + }); + + it('encryptForUser()', async () => { + const userKey = base64.parse('MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERxQR+NRN6Wga01370uBBzr2NHDbKIC56tPUEq2HX64RhITGhii8Zzbkb1HnRmdF0aq6uqmUy4jUhuxnKxsv59A6JeK7Unn+mpmm3pQAygjoGc9wrvoH4HWJSQYUlsXDu'); + + const encrypted = await vaultKeys.encryptForUser(userKey); + expect(encrypted).to.be.not.null; + }); + + it('createRecoveryKey()', async () => { + const recoveryKey = await vaultKeys.createRecoveryKey(); + + expect(recoveryKey).to.eql('water water water water water water water water water water water water water water water water water water water water water asset partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly partly option twist'); + }); + + describe('After creating a valid recovery key', () => { + let recoveryKey: string; + + beforeEach(async () => { + recoveryKey = await vaultKeys.createRecoveryKey(); + }); + it('recover() imports original key', async () => { + const recovered = await VaultKeys.recover(recoveryKey); + + const oldMasterKey = await crypto.subtle.exportKey('jwk', vaultKeys.masterKey); + const newMasterKey = await crypto.subtle.exportKey('jwk', recovered.masterKey); + expect(newMasterKey).to.deep.include({ + 'k': oldMasterKey.k + }); + }); + }); + }); + }); + + describe('Hash directory id', () => { + it('root directory', async () => { + const vaultKeys = await TestVaultKeys.create(); + const result = await vaultKeys.hashDirectoryId(''); + expect(result).to.eql('VLWEHT553J5DR7OZLRJAYDIWFCXZABOD'); + }); + + it('specific directory', async () => { + const vaultKeys = await TestVaultKeys.create(); + const result = await vaultKeys.hashDirectoryId('918acfbd-a467-3f77-93f1-f4a44f9cfe9c'); + expect(result).to.eql('7C3USOO3VU7IVQRKFMRFV3QE4VEZJECV'); + }); + }); +}); + +/* ---------- MOCKS ---------- */ + +class TestVaultKeys extends VaultKeys { + constructor(masterKey: CryptoKey) { + super(masterKey); + } + + static async create() { + const raw = new Uint8Array(64); + raw.fill(0x55, 0, 32); + raw.fill(0x77, 32, 64); + const key = await crypto.subtle.importKey( + 'raw', + raw, + { + name: 'HMAC', + hash: 'SHA-256', + length: 512 + }, + true, + ['sign'] + ); + return new TestVaultKeys(key); + } +} From b51cc59012356725a78e7a7eefc3b6089d8fc6b3 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 13 Apr 2024 14:01:53 +0200 Subject: [PATCH 06/94] move UVF code to separate file --- frontend/src/common/crypto.ts | 116 +-------------------------- frontend/src/common/uvf.ts | 117 ++++++++++++++++++++++++++++ frontend/test/common/crypto.spec.ts | 31 +------- frontend/test/common/uvf.spec.ts | 45 +++++++++++ 4 files changed, 164 insertions(+), 145 deletions(-) create mode 100644 frontend/src/common/uvf.ts create mode 100644 frontend/test/common/uvf.spec.ts diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 6747a980c..24ccf2dd1 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,9 +1,7 @@ -import * as miscreant from 'miscreant'; -import { base16, base32, base64 } from 'rfc4648'; +import { base16, base64 } from 'rfc4648'; import { JWEBuilder, JWEParser } from './jwe'; import { DB } from './util'; -import { VaultMetadataJWEAutomaticAccessGrantDto } from './backend'; export class UnwrapKeyError extends Error { readonly actualError: any; @@ -220,115 +218,3 @@ export async function getFingerprint(key: string | undefined) { } } -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/uvf.ts b/frontend/src/common/uvf.ts new file mode 100644 index 000000000..2aab43a68 --- /dev/null +++ b/frontend/src/common/uvf.ts @@ -0,0 +1,117 @@ +import * as miscreant from 'miscreant'; +import { base32, base64 } from 'rfc4648'; +import { VaultMetadataJWEAutomaticAccessGrantDto } from './backend'; +import { JWEBuilder, JWEParser } from './jwe'; + +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/test/common/crypto.spec.ts b/frontend/test/common/crypto.spec.ts index 6c9d1e608..28a74f431 100644 --- a/frontend/test/common/crypto.spec.ts +++ b/frontend/test/common/crypto.spec.ts @@ -2,9 +2,7 @@ import { use as chaiUse, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { before, describe } from 'mocha'; import { base64 } from 'rfc4648'; -import { VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/backend'; -import { UserKeys, VaultMetadata } from '../../src/common/crypto'; -import { JWEParser } from '../../src/common/jwe'; +import { UserKeys } from '../../src/common/crypto'; chaiUse(chaiAsPromised); @@ -74,33 +72,6 @@ describe('crypto', () => { }); }); - describe('VaultMetadata', () => { - // TODO review @sebi what else should we test? - it('encryptWithMasterKey() and decryptWithMasterKey()', async () => { - const uvfMasterKey = await crypto.subtle.generateKey( - { name: 'AES-KW', length: 256 }, - true, - ['wrapKey', 'unwrapKey'] - ); - 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 () => { diff --git a/frontend/test/common/uvf.spec.ts b/frontend/test/common/uvf.spec.ts new file mode 100644 index 000000000..9e9ff2ceb --- /dev/null +++ b/frontend/test/common/uvf.spec.ts @@ -0,0 +1,45 @@ +import { use as chaiUse, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { before, describe } from 'mocha'; +import { VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/backend'; +import { JWEParser } from '../../src/common/jwe'; +import { VaultMetadata } from '../../src/common/uvf'; + +chaiUse(chaiAsPromised); + +describe('Vault Format 8', () => { + + before(async () => { + // since this test runs on Node, we need to replace window.crypto: + Object.defineProperty(global, 'crypto', { value: require('node:crypto').webcrypto }); + // @ts-ignore: global not defined (but will be available within Node) + global.window = { crypto: global.crypto }; + }); + + describe('VaultMetadata', () => { + // TODO review @sebi what else should we test? + it('encryptWithMasterKey() and decryptWithMasterKey()', async () => { + const uvfMasterKey = await crypto.subtle.generateKey( + { name: 'AES-KW', length: 256 }, + true, + ['wrapKey', 'unwrapKey'] + ); + 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); + }); + }); +}); From a9f12d442c8313c5a42e9b88a1a80ffad49f3603 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 13 Apr 2024 15:57:49 +0200 Subject: [PATCH 07/94] adjust UVF payload to latest spec --- frontend/src/common/backend.ts | 5 - frontend/src/common/uvf.ts | 157 +++++++++++++++++-------------- frontend/test/common/uvf.spec.ts | 31 +++--- 3 files changed, 104 insertions(+), 89 deletions(-) diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 6fa61ca3e..4e7e4b6b6 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -440,8 +440,3 @@ export class ConflictError extends BackendError { super('Resource already exists'); } } - -export type VaultMetadataJWEAutomaticAccessGrantDto = { - enabled: boolean, - maxWotDepth: number -} diff --git a/frontend/src/common/uvf.ts b/frontend/src/common/uvf.ts index 2aab43a68..f4477956f 100644 --- a/frontend/src/common/uvf.ts +++ b/frontend/src/common/uvf.ts @@ -1,26 +1,36 @@ -import * as miscreant from 'miscreant'; -import { base32, base64 } from 'rfc4648'; -import { VaultMetadataJWEAutomaticAccessGrantDto } from './backend'; +import { base64url } from 'rfc4648'; import { JWEBuilder, JWEParser } from './jwe'; +export type MetadataPayload = { + fileFormat: 'AES-256-GCM-32k'; + nameFormat: 'AES-SIV-512-B64URL'; // TODO verify after merging https://github.com/encryption-alliance/unified-vault-format/pull/24 + seeds: Record; + initialSeed: string; + latestSeed: string; + kdf: 'HKDF-SHA512'; + kdfSalt: string; + 'org.cryptomator.automaticAccessGrant': VaultMetadataJWEAutomaticAccessGrantDto; +} + +export type VaultMetadataJWEAutomaticAccessGrantDto = { + enabled: boolean, + maxWotDepth: number +} + 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; + readonly seeds: Map; + readonly initialSeedId: number; + readonly latestSeedId: number; + readonly kdfSalt: Uint8Array; - protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, keys: Record, latestFileKey: string, nameKey: string) { + protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, seeds: Map, initialSeedId: number, latestSeedId: number, kdfSalt: Uint8Array) { this.automaticAccessGrant = automaticAccessGrant; - this.keys = keys; - this.latestFileKey = latestFileKey; - this.nameKey = nameKey; + this.seeds = seeds; + this.initialSeedId = initialSeedId; + this.latestSeedId = latestSeedId; + this.kdfSalt = kdfSalt; } /** @@ -28,24 +38,16 @@ export class VaultMetadata { * @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); + const initialSeedId = new Uint32Array(1); + const initialSeedValue = new Uint8Array(32); + const kdfSalt = new Uint8Array(32); + crypto.getRandomValues(initialSeedId); + crypto.getRandomValues(initialSeedValue); + crypto.getRandomValues(kdfSalt); + const initialSeedNo = initialSeedId[0]; + const seeds: Map = new Map(); + seeds.set(initialSeedNo, initialSeedValue); + return new VaultMetadata(automaticAccessGrant, seeds, initialSeedNo, initialSeedNo, kdfSalt); } /** @@ -56,19 +58,22 @@ export class VaultMetadata { */ 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 encodedSeeds: Record = payload['seeds']; + const seeds = new Map(); + for (const key in encodedSeeds) { + const num = parseSeedId(key); + const value = base64url.parse(encodedSeeds[key], { loose: true }); + seeds.set(num, value); } - const latestFileKey = payload['latestFileKey'] - const nameKey = payload['nameKey'] + const initialSeedId = parseSeedId(payload['initialSeed']); + const latestSeedId = parseSeedId(payload['latestSeed']); + const kdfSalt = base64url.parse(payload['kdfSalt'], { loose: true }); return new VaultMetadata( payload['org.cryptomator.automaticAccessGrant'], - keysImported, - latestFileKey, - nameKey + seeds, + initialSeedId, + latestSeedId, + kdfSalt ); } @@ -78,40 +83,48 @@ export class VaultMetadata { * @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 encodedSeeds: Record = {}; + for (const [key, value] of this.seeds) { + const seedId = stringifySeedId(key); + encodedSeeds[seedId] = base64url.stringify(value, { pad: false }); } - const payload = { - fileFormat: "AES-256-GCM-32k", - nameFormat: "AES-256-SIV", - keys: keysExported, - latestFileKey: this.latestFileKey, - nameKey: this.nameKey, + const payload: MetadataPayload = { + fileFormat: 'AES-256-GCM-32k', + nameFormat: 'AES-SIV-512-B64URL', + seeds: encodedSeeds, + initialSeed: stringifySeedId(this.initialSeedId), + latestSeed: stringifySeedId(this.latestSeedId), // TODO https://github.com/encryption-alliance/unified-vault-format/pull/21 finalize kdf - kdf: "1STEP-HMAC-SHA512", + kdf: 'HKDF-SHA512', + kdfSalt: base64url.stringify(this.kdfSalt, { pad: false }), '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); - } +} + +/** + * Parses the 4 byte seed id from its base64url-encoded form to a 32 bit integer. + * @param encoded base64url-encoded seed ID + * @returns a 32 bit number + * @throws Error if the input is invalid + */ +function parseSeedId(encoded: string): number { + const bytes = base64url.parse(encoded, { loose: true }); + if (bytes.length != 4) { + throw new Error('Malformed seed ID'); } + return new Uint32Array(bytes.buffer)[0]; +} + +/** + * Encodes a 32 bit integer denoting the 4 byte seed id as a base64url-encoded string. + * @param id numeric seed ID + * @returns a base6url-encoded seed ID + */ +function stringifySeedId(id: number): string { + const ints = new Uint32Array([id]); + const bytes = new Uint8Array(ints.buffer) + return base64url.stringify(bytes, { pad: false }); } diff --git a/frontend/test/common/uvf.spec.ts b/frontend/test/common/uvf.spec.ts index 9e9ff2ceb..c11809cae 100644 --- a/frontend/test/common/uvf.spec.ts +++ b/frontend/test/common/uvf.spec.ts @@ -1,13 +1,12 @@ import { use as chaiUse, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { before, describe } from 'mocha'; -import { VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/backend'; import { JWEParser } from '../../src/common/jwe'; -import { VaultMetadata } from '../../src/common/uvf'; +import { MetadataPayload, VaultMetadata, VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/uvf'; chaiUse(chaiAsPromised); -describe('Vault Format 8', () => { +describe('Universal Vault Format', () => { before(async () => { // since this test runs on Node, we need to replace window.crypto: @@ -16,7 +15,7 @@ describe('Vault Format 8', () => { global.window = { crypto: global.crypto }; }); - describe('VaultMetadata', () => { + describe('Vault Metadata', () => { // TODO review @sebi what else should we test? it('encryptWithMasterKey() and decryptWithMasterKey()', async () => { const uvfMasterKey = await crypto.subtle.generateKey( @@ -25,20 +24,28 @@ describe('Vault Format 8', () => { ['wrapKey', 'unwrapKey'] ); const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto = { - "enabled": true, - "maxWotDepth": -1 + enabled: true, + maxWotDepth: -1 } const orig = await VaultMetadata.create(automaticAccessGrant); expect(orig).to.be.not.null; + expect(orig.seeds.get(orig.initialSeedId)).to.not.be.undefined + expect(orig.seeds.get(orig.initialSeedId)!.length).to.eq(32) + expect(orig.initialSeedId).to.eq(orig.latestSeedId) + expect(orig.kdfSalt.length).to.eq(32) 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(decrypted.seeds).to.deep.eq(orig.seeds) + expect(decrypted.initialSeedId).to.eq(orig.initialSeedId) + expect(decrypted.latestSeedId).to.eq(orig.latestSeedId) + expect(decrypted.automaticAccessGrant).to.deep.eq(automaticAccessGrant); + const decryptedRaw: MetadataPayload = await JWEParser.parse(jwe).decryptA256kw(uvfMasterKey); + expect(decryptedRaw.fileFormat).to.eq('AES-256-GCM-32k'); + expect(decryptedRaw.initialSeed).to.eq(decryptedRaw.latestSeed); + expect(decryptedRaw.seeds[decryptedRaw.initialSeed]).to.be.not.empty; + expect(decryptedRaw.kdf).to.eq('HKDF-SHA512'); + expect(decryptedRaw.kdfSalt).to.be.not.empty expect(decryptedRaw['org.cryptomator.automaticAccessGrant']).to.deep.eq(automaticAccessGrant); }); }); From 3f77d16e2936dbda9311f99a4f0e18da6f8ab3e3 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Thu, 25 Apr 2024 22:10:23 +0200 Subject: [PATCH 08/94] new JWE API supporting compact and JSON format --- frontend/src/common/crypto.ts | 16 +- frontend/src/common/jwe.ts | 480 ++++++++++++------ frontend/src/common/uvf.ts | 7 +- frontend/src/common/vaultv8.ts | 7 +- frontend/src/components/InitialSetup.vue | 4 +- frontend/src/components/ManageSetupCode.vue | 4 +- .../components/RegenerateSetupCodeDialog.vue | 4 +- frontend/test/common/jwe.spec.ts | 87 +++- frontend/test/common/uvf.spec.ts | 4 +- 9 files changed, 424 insertions(+), 189 deletions(-) diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 522a20580..58053412a 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,5 +1,5 @@ import { base16, base64 } from 'rfc4648'; -import { JWEBuilder, JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; import { DB } from './util'; @@ -50,7 +50,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: UserKeysJWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode); + const jwe: UserKeysJWEPayload = await JWE.parseCompact(encryptedPrivateKey).decrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', 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); @@ -71,7 +71,7 @@ export class UserKeys { * Encrypts the user's private key using a key derived from the given setupCode * @param setupCode The password to protect the private key. * @returns A JWE holding the encrypted private key - * @see JWEBuilder.pbes2 + * @see Recipient.pbes2 */ public async encryptedPrivateKey(setupCode: string): Promise { const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); @@ -79,7 +79,8 @@ export class UserKeys { const payload: UserKeysJWEPayload = { key: base64.stringify(rawkey) }; - return await JWEBuilder.pbes2(setupCode).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', setupCode)); + return jwe.compactSerialization(); } finally { rawkey.fill(0x00); } @@ -89,7 +90,7 @@ export class UserKeys { * Encrypts the user's private key using the given public key * @param devicePublicKey The device's public key (DER-encoded) * @returns a JWE containing the PKCS#8-encoded private key - * @see JWEBuilder.ecdhEs + * @see Recipient.ecdhEs */ public async encryptForDevice(devicePublicKey: CryptoKey | Uint8Array): Promise { const publicKey = await UserKeys.publicKey(devicePublicKey); @@ -98,7 +99,8 @@ export class UserKeys { const payload: UserKeysJWEPayload = { key: base64.stringify(rawkey) }; - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', publicKey)); + return jwe.compactSerialization(); } finally { rawkey.fill(0x00); } @@ -115,7 +117,7 @@ export class UserKeys { const publicKey = await UserKeys.publicKey(userPublicKey); let rawKey = new Uint8Array(); try { - const payload: UserKeysJWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); + const payload: UserKeysJWEPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', 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 }); diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index ec0c33cdf..3509fb4a8 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -35,222 +35,372 @@ export class ConcatKDF { } } -export type JWEHeader = { - readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW' | 'A256KW', - readonly enc: 'A256GCM' | 'A128GCM', // A128GCM for testing only, as we use test vectors with 128 bit keys - readonly apu?: string, - readonly apv?: string, - readonly epk?: JsonWebKey, - readonly p2c?: number, - readonly p2s?: string +type Header = { + kid?: string, + enc?: 'A256GCM' | 'A128GCM', + alg?: 'ECDH-ES' | 'ECDH-ES+A256KW' | 'PBES2-HS512+A256KW' | 'A256KW', + apu?: string, + apv?: string, + epk?: JsonWebKey, + p2c?: number, + p2s?: string, + [other: string]: undefined | string | number | boolean | object; // allow further properties } +type PerRecipientProperties = { + encryptedKey: string; + header: Header; +} + +export type JWEHeader = Header; + export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; -export class JWEParser { - readonly header: JWEHeader; - readonly encryptedKey: Uint8Array; - readonly iv: Uint8Array; - readonly ciphertext: Uint8Array; - readonly tag: Uint8Array; +// #region Recipients - private constructor(readonly encodedHeader: string, readonly encodedEncryptedKey: string, readonly encodedIv: string, readonly encodedCiphertext: string, readonly encodedTag: string) { - const utf8dec = new TextDecoder(); - this.header = JSON.parse(utf8dec.decode(base64url.parse(encodedHeader, { loose: true }))); - this.encryptedKey = base64url.parse(encodedEncryptedKey, { loose: true }); - this.iv = base64url.parse(encodedIv, { loose: true }); - this.ciphertext = base64url.parse(encodedCiphertext, { loose: true }); - this.tag = base64url.parse(encodedTag, { loose: true }); - } +export abstract class Recipient { + + constructor(readonly kid: string) { }; /** - * Decodes the JWE. - * @param jwe The JWE string - * @returns Decoded JWE, ready to decrypt. + * Encryptes a CEK using the recipient-specific `alg`. + * @param cek The CEK that is used to encrypt a JWE payload. + * @param commonHeader The protected and unprotected header (not per-recipient) + * @returns The encrypted CEK and per-recipient header parameters for the used `alg`. */ - public static parse(jwe: string): JWEParser { - const [encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag] = jwe.split('.', 5); - return new JWEParser(encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag); - } + abstract encrypt(cek: CryptoKey, commonHeader: Header): Promise; /** - * Decrypts the JWE, assuming alg == ECDH-ES, enc == A256GCM and keys on the P-384 curve. - * @param recipientPrivateKey The recipient's private key - * @returns Decrypted payload + * Decrypts the CEK using the recipient-specific `alg`. + * @param header JOSE header applicable for this recipient + * @param encryptedKey Encrypted CEK for this recipient + * @returns A non-extractable CEK suitable for decryption with AES-GCM. + * @throws {UnwrapKeyError} if decryption failed */ - public async decryptEcdhEs(recipientPrivateKey: CryptoKey): Promise { - if (this.header.alg != 'ECDH-ES' || this.header.enc != 'A256GCM' || !this.header.epk) { - throw new Error('unsupported alg or enc'); - } - const ephemeralKey = await crypto.subtle.importKey('jwk', this.header.epk, ECDH_P384, false, []); - const cek = await ECDH_ES.deriveContentKey(ephemeralKey, recipientPrivateKey, 384, 32, this.header); - return this.decrypt(cek); + abstract decrypt(header: Header, encryptedKey: string): Promise; + + /** + * Create a recipient using `alg: ECDH-ES+A256KW`. Also supports `ECDH-ES` with direct key agreement for decryption + * + * @param kid The key ID used to distinguish multiple recipients + * @param recipientKey The recipients static public key for encryption or private key for decryption + * @param apu Optional information about the creator + * @param apv Optional information about the recipient + * @returns A new recipient + */ + public static ecdhEs(kid: string, recipientKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): Recipient { + return new EcdhRecipient(kid, recipientKey, apu, apv); } /** - * Decrypts the JWE, assuming alg == PBES2-HS512+A256KW and enc == A256GCM. + * Create a recipient using `alg: PBES2-HS512+A256KW`. + * + * @param kid The key ID used to distinguish multiple recipients * @param password The password to feed into the KDF - * @returns Decrypted payload - * @throws {UnwrapKeyError} if decryption failed (wrong password?) + * @param iterations The PBKDF2 iteration count (defaults to {@link PBES2.DEFAULT_ITERATION_COUNT}) - ignored and read from the header's `p2c` value during decryption + * @returns A new recipient + */ + public static pbes2(kid: string, password: string, iterations: number = PBES2.DEFAULT_ITERATION_COUNT): Recipient { + return new Pbes2Recipient(kid, password, iterations); + } + + /** + * Create a recipient using `alg: A256KW`. + * + * @param kid The key ID used to distinguish multiple recipients + * @param wrappingKey The key used to wrap the CEK + * @returns A new recipient */ - public async decryptPbes2(password: string): Promise { - if (this.header.alg != 'PBES2-HS512+A256KW' || this.header.enc != 'A256GCM' || !this.header.p2s || !this.header.p2c) { - throw new Error('unsupported alg or enc'); + public static a256kw(kid: string, wrappingKey: CryptoKey): Recipient { + return new A256kwRecipient(kid, wrappingKey); + } + +} + +class EcdhRecipient extends Recipient { + + constructor(readonly kid: string, private recipientKey: CryptoKey, private apu: Uint8Array = new Uint8Array(), private apv: Uint8Array = new Uint8Array()) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: Header): Promise { + if (this.recipientKey.type !== 'public') { + throw new Error('Recipient public key required.'); } - const saltInput = base64url.parse(this.header.p2s, { loose: true }); - const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c); + const ephemeralKey = await crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); + const header: Header = { + ...commonHeader, + alg: 'ECDH-ES+A256KW', + epk: await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey), + apu: base64url.stringify(this.apu, { pad: false }), + apv: base64url.stringify(this.apv, { pad: false }) + }; + const wrappingKey = await ECDH_ES.deriveKey(this.recipientKey, ephemeralKey.privateKey, 384, 32, header, false, { name: 'AES-KW', length: 256 }, ['wrapKey']); + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, wrappingKey, 'AES-KW')); + return { + header: header, + encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + } + } + + async decrypt(header: Header, encryptedKey: string): Promise { + if (this.recipientKey.type !== 'private') { + throw new Error('Recipient private key required.'); + } + if (header.alg === 'ECDH-ES' && header.epk) { + return this.decryptDirect(header, { name: 'AES-GCM', length: 256 }, ['decrypt']); + } else if (header.alg === 'ECDH-ES+A256KW' && header.epk) { + return this.decryptAndUnwrap(header, encryptedKey); + } else { + throw new Error('Missing or invalid header parameters.'); + } + } + + async decryptDirect(header: Header, keyAlgorithm: AesKeyAlgorithm, keyUsage: KeyUsage[]): Promise { + let keyBits: number; + switch (header.epk!.crv) { + case 'P-256': + keyBits = 256; + break; + case 'P-384': + keyBits = 384; + break; + default: + throw new Error('Unsupported curve'); + } + const epk = await crypto.subtle.importKey('jwk', header.epk!, { name: 'ECDH', namedCurve: header.epk?.crv }, false, []); + return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, 32, header, false, keyAlgorithm, keyUsage); + } + + async decryptAndUnwrap(header: Header, encryptedKey: string): Promise { + const wrappingKey = await this.decryptDirect(header, { name: 'AES-KW', length: 256 }, ['unwrapKey']); try { - const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']); - return this.decrypt(await cek); + return await crypto.subtle.unwrapKey('raw', base64url.parse(encryptedKey, { loose: true }), wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); } catch (error) { throw new UnwrapKeyError(error); } } - /** - * Decrypts the JWE, assuming alg == A256KW and enc == A256GCM. - * @param kek The key used to wrap the CEK - * @returns Decrypted payload - * @throws {UnwrapKeyError} if decryption failed (wrong kek?) - */ - public async decryptA256kw(kek: CryptoKey): Promise { - if (this.header.alg != 'A256KW' || this.header.enc != 'A256GCM') { - throw new Error('unsupported alg or enc'); +} + +class A256kwRecipient extends Recipient { + + constructor(readonly kid: string, private wrappingKey: CryptoKey) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: Header): Promise { + const header: Header = { + ...commonHeader, + alg: 'A256KW', + enc: 'A256GCM' + }; + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, this.wrappingKey, 'AES-KW')); + return { + header: header, + encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + } + } + + async decrypt(header: Header, encryptedKey: string): Promise { + if (header.alg != 'A256KW') { + throw new Error('unsupported alg'); } try { - const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, kek, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']); - return this.decrypt(await cek); + return await crypto.subtle.unwrapKey('raw', base64url.parse(encryptedKey, { loose: true }), this.wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); } catch (error) { throw new UnwrapKeyError(error); } } - private async decrypt(cek: CryptoKey): Promise { +} + +class Pbes2Recipient extends Recipient { + + constructor(readonly kid: string, private password: string, private iterations: number) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: Header): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const header: Header = { + ...commonHeader, + kid: this.kid, + alg: 'PBES2-HS512+A256KW', + p2c: this.iterations, + p2s: base64url.stringify(salt, { pad: false }) + }; + const wrappingKey = PBES2.deriveWrappingKey(this.password, 'PBES2-HS512+A256KW', salt, this.iterations); + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, await wrappingKey, 'AES-KW')); + return { + header: header, + encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + } + } + + async decrypt(header: Header, encryptedKey: string): Promise { + if (header.alg != 'PBES2-HS512+A256KW' || !header.p2s || !header.p2c) { + throw new Error('Missing or invalid header parameters.'); + } + const salt = base64url.parse(header.p2s!, { loose: true }); + const wrappingKey = await PBES2.deriveWrappingKey(this.password, 'PBES2-HS512+A256KW', salt, header.p2c!); + try { + return await crypto.subtle.unwrapKey('raw', base64url.parse(encryptedKey, { loose: true }), wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); + } catch (error) { + throw new UnwrapKeyError(error); + } + } + +} + +// #endregion +// #region JWE + +export class JWE { + + private constructor(private payload: object) { } + + public static build(payload: object): JWE { + return new JWE(payload); + } + + public static parseCompact(token: string): EncryptedJWE { + const [protectedHeader, encryptedKey, iv, ciphertext, tag] = token.split('.', 5); + const utf8 = new TextDecoder(); + const header: Header = JSON.parse(utf8.decode(base64url.parse(protectedHeader, { loose: true }))); + + return new EncryptedJWE(protectedHeader, [{ encryptedKey: encryptedKey, header: header }], iv, ciphertext, tag); + } + + public static parseJson(jwe: any): EncryptedJWE { // TODO: json: + if (!jwe.protected || !jwe.recipients || !jwe.iv || !jwe.ciphertext || !jwe.tag) { + throw new Error('Malformed JWE'); + } + return new EncryptedJWE(jwe.protected, jwe.recipients, jwe.iv, jwe.ciphertext, jwe.tag); + } + + public async encrypt(recipient: Recipient, ...moreRecipients: Recipient[]): Promise { + let protectedHeader: Header = { + enc: 'A256GCM' + } + const cek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const recipients = await Promise.all([recipient, ...moreRecipients].map(r => r.encrypt(cek, protectedHeader))); + + if (recipients.length === 1) { + protectedHeader = recipients[0].header; + } else { + protectedHeader = { + enc: recipients[0].header.enc + } + } + const utf8enc = new TextEncoder(); - const m = new Uint8Array(this.ciphertext.length + this.tag.length); - m.set(this.ciphertext, 0); - m.set(this.tag, this.ciphertext.length); - const payloadJson = new Uint8Array(await crypto.subtle.decrypt( + const encodedProtectedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(protectedHeader)), { pad: false }); + const m = utf8enc.encode(JSON.stringify(this.payload)); + const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', - iv: this.iv, - additionalData: utf8enc.encode(this.encodedHeader), + iv: iv, + additionalData: utf8enc.encode(encodedProtectedHeader), tagLength: 128 }, cek, m )); - return JSON.parse(new TextDecoder().decode(payloadJson)); + console.assert(m.byteLength > 16, 'result of GCM encryption expected to contain 128bit tag'); + const ciphertext = ciphertextAndTag.slice(0, m.byteLength - 16); + const tag = ciphertextAndTag.slice(m.byteLength - 16); + + const encodedIv = base64url.stringify(iv, { pad: false }); + const encodedCiphertext = base64url.stringify(ciphertext, { pad: false }); + const encodedTag = base64url.stringify(tag, { pad: false }); + return new EncryptedJWE(encodedProtectedHeader, recipients, encodedIv, encodedCiphertext, encodedTag) } + } -export class JWEBuilder { - private constructor(readonly header: Promise, readonly encryptedKey: Promise, readonly cek: Promise) { } - /** - * Prepares a new JWE using alg: ECDH-ES and enc: A256GCM. - * - * @param recipientPublicKey Static public key of the JWE's recipient - * @param apu Optional information about the creator - * @param apv Optional information about the recipient - * @returns A new JWEBuilder ready to encrypt the payload - */ - public static ecdhEs(recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { - /* key agreement and header params described in RFC 7518, Section 4.6: */ - const ephemeralKey = crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); - const header = (async () => { - alg: 'ECDH-ES', - enc: 'A256GCM', - epk: await crypto.subtle.exportKey('jwk', (await ephemeralKey).publicKey), - apu: base64url.stringify(apu, { pad: false }), - apv: base64url.stringify(apv, { pad: false }) - })(); - const encryptedKey = (async () => Uint8Array.of())(); // empty for Direct Key Agreement as per spec - const cek = (async () => ECDH_ES.deriveContentKey(recipientPublicKey, (await ephemeralKey).privateKey, 384, 32, await header))(); - return new JWEBuilder(header, encryptedKey, cek); +// visible for testing +export class EncryptedJWE { + + constructor(private protectedHeader: string, private perRecipient: PerRecipientProperties[], private iv: string, private ciphertext: string, private tag: string) { + if (perRecipient.length < 1) { + throw new Error('Expected at least one recipient.'); + } } - /** - * Prepares a new JWE using alg: PBES2-HS512+A256KW and enc: A256GCM. - * - * @param password The password to feed into the KDF - * @param iterations The PBKDF2 iteration count (defaults to {@link PBES2.DEFAULT_ITERATION_COUNT} ) - * @param apu Optional information about the creator - * @param apv Optional information about the recipient - * @returns A new JWEBuilder ready to encrypt the payload - */ - public static pbes2(password: string, iterations: number = PBES2.DEFAULT_ITERATION_COUNT, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { - const saltInput = crypto.getRandomValues(new Uint8Array(16)); - const header = (async () => { - alg: 'PBES2-HS512+A256KW', - enc: 'A256GCM', - p2s: base64url.stringify(saltInput, { pad: false }), - p2c: iterations, - apu: base64url.stringify(apu, { pad: false }), - apv: base64url.stringify(apv, { pad: false }) - })(); - const wrappingKey = PBES2.deriveWrappingKey(password, 'PBES2-HS512+A256KW', saltInput, iterations); - const cek = crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); - const encryptedKey = (async () => new Uint8Array(await crypto.subtle.wrapKey('raw', await cek, await wrappingKey, 'AES-KW')))(); - return new JWEBuilder(header, encryptedKey, cek); + public jsonSerialization(): any { + if (this.perRecipient.length < 1) { + throw new Error('JWE JSON Serialization requires at least one recipient.'); + } + const recipients = this.perRecipient.map(r => ({ + header: r.header, + encrypted_key: r.encryptedKey + })); + + return { + protected: this.protectedHeader, + recipients: recipients, + iv: this.iv, + ciphertext: this.ciphertext, + tag: this.tag + }; } - /** - * Prepares a new JWE using alg: A256KW and enc: A256GCM. - * - * @param kek The key used to wrap the CEK - * @returns A new JWEBuilder ready to encrypt the payload - */ - public static a256kw(kek: CryptoKey): JWEBuilder { - const header = (async () => { - alg: 'A256KW', - enc: 'A256GCM' - })(); - const cek = crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); - const encryptedKey = (async () => new Uint8Array(await crypto.subtle.wrapKey('raw', await cek, kek, 'AES-KW')))(); - return new JWEBuilder(header, encryptedKey, cek); + public compactSerialization(): string { + if (this.perRecipient.length !== 1) { + throw new Error('JWE Compact Serialization requires exactly one recipient.'); + } + return `${this.protectedHeader}.${this.perRecipient[0].encryptedKey}.${this.iv}.${this.ciphertext}.${this.tag}`; } - /** - * Builds the JWE. - * @param payload Payload to be encrypted - * @returns The JWE - */ - public async encrypt(payload: object) { + public async decrypt(recipient: Recipient): Promise { + const utf8dec = new TextDecoder(); const utf8enc = new TextEncoder(); - - /* JWE assembly and content encryption described in RFC 7516: */ - const encodedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(await this.header)), { pad: false }); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encodedIv = base64url.stringify(iv, { pad: false }); - const encodedEncryptedKey = base64url.stringify(await this.encryptedKey, { pad: false }); - const m = new Uint8Array(await crypto.subtle.encrypt( + const protectedHeader: Header = JSON.parse(utf8dec.decode(base64url.parse(this.protectedHeader, { loose: true }))); + const perRecipientData = (this.perRecipient.length === 1) + ? this.perRecipient[0] + : this.perRecipientWithKid(recipient.kid); + const combinedHeader: Header = { ...perRecipientData.header, ...protectedHeader }; + const cek = await recipient.decrypt(combinedHeader, perRecipientData.encryptedKey); + const ciphertext = base64url.parse(this.ciphertext, { loose: true }) + const tag = base64url.parse(this.tag, { loose: true }); + const ciphertextAndTag = new Uint8Array([...ciphertext, ...tag]); + const cleartext = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', - iv: iv, - additionalData: utf8enc.encode(encodedHeader), + iv: base64url.parse(this.iv, { loose: true }), + additionalData: utf8enc.encode(this.protectedHeader), tagLength: 128 }, - await this.cek, - utf8enc.encode(JSON.stringify(payload)) + cek, + ciphertextAndTag )); - console.assert(m.byteLength > 16, 'result of GCM encryption expected to contain 128bit tag'); - const ciphertext = m.slice(0, m.byteLength - 16); - const tag = m.slice(m.byteLength - 16); - const encodedCiphertext = base64url.stringify(ciphertext, { pad: false }); - const encodedTag = base64url.stringify(tag, { pad: false }); - return `${encodedHeader}.${encodedEncryptedKey}.${encodedIv}.${encodedCiphertext}.${encodedTag}`; + return JSON.parse(utf8dec.decode(cleartext)); + } + + private perRecipientWithKid(kid: string): PerRecipientProperties { + const result = this.perRecipient.find(r => r.header.kid === kid); + if (result) { + return result; + } else { + throw new Error(`JWE does not contain recipient with kid: ${kid}`); + } } + } +// #endregion +// #region Utilities + // visible for testing export class ECDH_ES { - public static async deriveContentKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + private static async deriveRawKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader): Promise { let agreedKey = new Uint8Array(); - let derivedKey = new Uint8Array(); try { const algorithmId = ECDH_ES.lengthPrefixed(new TextEncoder().encode(header.enc)); const partyUInfo = ECDH_ES.lengthPrefixed(base64url.parse(header.apu || '', { loose: true })); @@ -266,14 +416,30 @@ export class ECDH_ES { ecdhKeyBits )); const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo)]); - derivedKey = await ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); - return crypto.subtle.importKey('raw', derivedKey, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, exportable, ['encrypt', 'decrypt']); + return ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); } finally { - derivedKey.fill(0x00); agreedKey.fill(0x00); } } + public static async deriveKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean, algorithm: AesKeyAlgorithm, usage: KeyUsage[]): Promise { + let derivedKey = new Uint8Array(); + try { + derivedKey = await this.deriveRawKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header); + return crypto.subtle.importKey('raw', derivedKey, algorithm, exportable, usage); + } finally { + derivedKey.fill(0x00); + } + } + + public static async deriveContentKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + return this.deriveKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header, exportable, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, ['encrypt', 'decrypt']); + } + + public static async deriveWrappingKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + return this.deriveKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header, exportable, { name: 'AES-KW', length: desiredKeyBytes * 8 }, ['wrapKey', 'unwrapKey']); + } + public static lengthPrefixed(data: Uint8Array): Uint8Array { const result = new Uint8Array(4 + data.byteLength); new DataView(result.buffer, 0, 4).setUint32(0, data.byteLength, false); @@ -325,3 +491,5 @@ export class PBES2 { ); } } + +// #endregion diff --git a/frontend/src/common/uvf.ts b/frontend/src/common/uvf.ts index f4477956f..4b471c3df 100644 --- a/frontend/src/common/uvf.ts +++ b/frontend/src/common/uvf.ts @@ -1,5 +1,5 @@ import { base64url } from 'rfc4648'; -import { JWEBuilder, JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; export type MetadataPayload = { fileFormat: 'AES-256-GCM-32k'; @@ -57,7 +57,7 @@ export class VaultMetadata { * @returns vault metadata */ public static async decryptWithMasterKey(jwe: string, masterKey: CryptoKey): Promise { - const payload = await JWEParser.parse(jwe).decryptA256kw(masterKey); + const payload = await JWE.parseCompact(jwe).decrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', masterKey)); const encodedSeeds: Record = payload['seeds']; const seeds = new Map(); for (const key in encodedSeeds) { @@ -99,7 +99,8 @@ export class VaultMetadata { kdfSalt: base64url.stringify(this.kdfSalt, { pad: false }), 'org.cryptomator.automaticAccessGrant': this.automaticAccessGrant } - return JWEBuilder.a256kw(masterKey).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', masterKey)); + return jwe.compactSerialization(); } } diff --git a/frontend/src/common/vaultv8.ts b/frontend/src/common/vaultv8.ts index 79b25672a..6f0d1ef0f 100644 --- a/frontend/src/common/vaultv8.ts +++ b/frontend/src/common/vaultv8.ts @@ -1,7 +1,7 @@ import * as miscreant from 'miscreant'; import { base32, base64, base64url } from 'rfc4648'; import { GCM_NONCE_LEN, UnwrapKeyError, UserKeys } from './crypto'; -import { JWEBuilder, JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; import { CRC32, wordEncoder } from './util'; interface JWEPayload { @@ -67,7 +67,7 @@ export class VaultKeys { public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { let rawKey = new Uint8Array(); try { - const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); + const payload: JWEPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', userPrivateKey)); rawKey = base64.parse(payload.key); const masterKey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); return new VaultKeys(await masterKey); @@ -219,7 +219,8 @@ export class VaultKeys { const payload: JWEPayload = { key: base64.stringify(rawkey), }; - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', publicKey)); + return jwe.compactSerialization(); } finally { rawkey.fill(0x00); } diff --git a/frontend/src/components/InitialSetup.vue b/frontend/src/components/InitialSetup.vue index 4ef3ba60f..e0ce92a7a 100644 --- a/frontend/src/components/InitialSetup.vue +++ b/frontend/src/components/InitialSetup.vue @@ -192,7 +192,7 @@ import { nextTick, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { UserDto } from '../common/backend'; import { BrowserKeys, UnwrapKeyError, UserKeys } from '../common/crypto'; -import { JWEBuilder } from '../common/jwe'; +import { JWE, Recipient } from '../common/jwe'; import { debounce } from '../common/util'; import router from '../router'; import FetchError from './FetchError.vue'; @@ -266,7 +266,7 @@ async function createUserKey() { const userKeys = await UserKeys.create(); me.value.publicKey = await userKeys.encodedPublicKey(); me.value.privateKey = await userKeys.encryptedPrivateKey(setupCode.value); - me.value.setupCode = await JWEBuilder.ecdhEs(userKeys.keyPair.publicKey).encrypt({ setupCode: setupCode.value }); + me.value.setupCode = (await JWE.build({ setupCode: setupCode.value }).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', userKeys.keyPair.publicKey))).compactSerialization(); const browserKeys = await createBrowserKeys(me.value.id); await submitBrowserKeys(browserKeys, me.value, userKeys); diff --git a/frontend/src/components/ManageSetupCode.vue b/frontend/src/components/ManageSetupCode.vue index 9837cf488..20591190f 100644 --- a/frontend/src/components/ManageSetupCode.vue +++ b/frontend/src/components/ManageSetupCode.vue @@ -51,7 +51,7 @@ import { nextTick, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend from '../common/backend'; import { BrowserKeys, UserKeys } from '../common/crypto'; -import { JWEParser } from '../common/jwe'; +import { JWE, Recipient } from '../common/jwe'; import { debounce } from '../common/util'; import FetchError from './FetchError.vue'; import RegenerateSetupCodeDialog from './RegenerateSetupCodeDialog.vue'; @@ -84,7 +84,7 @@ async function fetchData() { throw new Error('Device not initialized.'); } const userKeys = await UserKeys.decryptOnBrowser(myDevice.userPrivateKey, browserKeys.keyPair.privateKey, base64.parse(me.publicKey)); - const payload : { setupCode: string } = await JWEParser.parse(me.setupCode).decryptEcdhEs(userKeys.keyPair.privateKey); + const payload : { setupCode: string } = await JWE.parseCompact(me.setupCode).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', userKeys.keyPair.privateKey)); setupCode.value = payload.setupCode; } catch (error) { console.error('Retrieving setup code failed.', error); diff --git a/frontend/src/components/RegenerateSetupCodeDialog.vue b/frontend/src/components/RegenerateSetupCodeDialog.vue index fce75f43e..715584165 100644 --- a/frontend/src/components/RegenerateSetupCodeDialog.vue +++ b/frontend/src/components/RegenerateSetupCodeDialog.vue @@ -104,7 +104,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend from '../common/backend'; import { BrowserKeys, UserKeys } from '../common/crypto'; -import { JWEBuilder } from '../common/jwe'; +import { JWE, Recipient } from '../common/jwe'; import { debounce } from '../common/util'; enum State { @@ -167,7 +167,7 @@ async function regenerateSetupCode() { const newCode = crypto.randomUUID(); const userKeys = await UserKeys.decryptOnBrowser(myDevice.userPrivateKey, browserKeys.keyPair.privateKey, base64.parse(me.publicKey)); me.privateKey = await userKeys.encryptedPrivateKey(newCode); - me.setupCode = await JWEBuilder.ecdhEs(userKeys.keyPair.publicKey).encrypt({ setupCode: newCode }); + me.setupCode = (await JWE.build({ setupCode: newCode }).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', userKeys.keyPair.publicKey))).compactSerialization(); await backend.users.putMe(me); setupCode.value = newCode; diff --git a/frontend/test/common/jwe.spec.ts b/frontend/test/common/jwe.spec.ts index 0d0b73e1b..701f1731d 100644 --- a/frontend/test/common/jwe.spec.ts +++ b/frontend/test/common/jwe.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { base64url } from 'rfc4648'; -import { ConcatKDF, ECDH_ES, ECDH_P384, JWEBuilder, JWEHeader, JWEParser, PBES2 } from '../../src/common/jwe'; +import { base64, base64url } from 'rfc4648'; +import { ConcatKDF, ECDH_ES, ECDH_P384, EncryptedJWE, JWE, JWEHeader, PBES2, Recipient } from '../../src/common/jwe'; describe('JWE', () => { before(done => { @@ -26,7 +26,53 @@ describe('JWE', () => { }); }); - describe('JWE using alg: ECDH-ES', () => { + describe('JWE serialization for single recipient', () => { + const jwe = new EncryptedJWE('protectedHeader', [{ encryptedKey: 'encryptedKey', header: { kid: 'alice' } }], 'iv', 'ciphertext', 'tag'); + + it('compactSerialization', () => { + const token = jwe.compactSerialization(); + + expect(token).to.eq('protectedHeader.encryptedKey.iv.ciphertext.tag'); + }); + + it('jsonSerialization', () => { + const token = jwe.jsonSerialization(); + + expect(token.protected).to.eq('protectedHeader'); + expect(token.iv).to.eq('iv'); + expect(token.ciphertext).to.eq('ciphertext'); + expect(token.tag).to.eq('tag'); + expect(token.recipients[0].encrypted_key).to.eq('encryptedKey'); + expect(token.recipients[0].header.kid).to.eq('alice'); + }); + }); + + describe('JWE serialization for multiple recipients', () => { + const recipients = [ + { encryptedKey: 'encryptedKey1', header: { kid: 'alice' } }, + { encryptedKey: 'encryptedKey2', header: { kid: 'bob' } } + ]; + const jwe = new EncryptedJWE('protectedHeader', recipients, 'iv', 'ciphertext', 'tag'); + + it('compactSerialization', () => { + expect(jwe.compactSerialization).to.throw(); + }); + + it('jsonSerialization', () => { + const token = jwe.jsonSerialization(); + + expect(token.protected).to.eq('protectedHeader'); + expect(token.iv).to.eq('iv'); + expect(token.ciphertext).to.eq('ciphertext'); + expect(token.tag).to.eq('tag'); + expect(token.recipients[0].encrypted_key).to.eq('encryptedKey1'); + expect(token.recipients[0].header.kid).to.eq('alice'); + expect(token.recipients[1].encrypted_key).to.eq('encryptedKey2'); + expect(token.recipients[1].header.kid).to.eq('bob'); + }); + }); + + describe('JWE using alg: ECDH-ES+A256KW', () => { it('x = decrypt(encrypt(x, pubKey), privKey)', async () => { // pkcs8: ME8CAQAwEAYHKoZIzj0CAQYFK4EEACIEODA2AgEBBDEA6QybmBitf94veD5aCLr7nlkF5EZpaXHCfq1AXm57AKQyGOjTDAF9EQB28fMywTDQ: const publicKeyJwk: JsonWebKey = { @@ -44,9 +90,21 @@ describe('JWE', () => { const orig = { hello: 'world' }; - const jwe = await JWEBuilder.ecdhEs(recipientPublicKey).encrypt(orig); + const jwe = await JWE.build(orig).encrypt(Recipient.ecdhEs('alice', recipientPublicKey)); + + const decrypted = await jwe.decrypt(Recipient.ecdhEs('alice', recipientPrivateKey)); + expect(decrypted).to.deep.eq(orig); + }); + + it('decrypt ECDH', async () => { + // JWE generated by https://dinochiesa.github.io/jwt/ + const jwe = 'eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6InpMYlRUN3M5d3lZeGpHdGZxeHVDQk81TTNTMTlNRzJHUGJzTlJhMFEwT2MiLCJ5IjoiTGpmVG9jLXpIcWRqcHA4VURPSl9aS1JLQ0FoSGpSSmFGS0FJbjdTR1RXdyJ9fQ..30WmoR8Qp1VA5NMr.TBRC30hVRwi_W_HIe03JWNY.zvfl5wQuG8vhUjhr1-f8jQ'; + const rawRecipientPrivateKey = base64.parse('MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzMoA2cm0rNRLA3zZa2VzYxd1QLFTdOxsMQ+6V6faoLmhRANCAAR4M7kZS/VdkdOQG56ELvL2/3L8ti+yQeQBv0BuyUJqWHMOv13VLZOKTnvtFrAZbjM6lONayft9qSr43thfj1Pb', { loose: true }); + const recipientPrivateKey = await crypto.subtle.importKey('pkcs8', rawRecipientPrivateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + const orig = { hello: 'world' }; + const recipient = Recipient.ecdhEs('alice', recipientPrivateKey); - const decrypted = await JWEParser.parse(jwe).decryptEcdhEs(recipientPrivateKey); + const decrypted = await JWE.parseCompact(jwe).decrypt(recipient); expect(decrypted).to.deep.eq(orig); }); @@ -56,19 +114,22 @@ describe('JWE', () => { describe('JWE using alg: PBES2-HS512+A256KW', () => { it('x = decrypt(encrypt(x, pass), pass)', async () => { const orig = { hello: 'world' }; + const recipient = Recipient.pbes2('password', 'topsecret', 1000); - const jwe = await JWEBuilder.pbes2('topsecret', 1000).encrypt(orig); + const jwe = await JWE.build(orig).encrypt(recipient); - const decrypted = await JWEParser.parse(jwe).decryptPbes2('topsecret'); + const decrypted = await jwe.decrypt(recipient) expect(decrypted).to.deep.eq(orig); }); it('encrypt JWE used in Java unit tests', async () => { const orig = { key: 'ME8CAQAwEAYHKoZIzj0CAQYFK4EEACIEODA2AgEBBDEA6QybmBitf94veD5aCLr7nlkF5EZpaXHCfq1AXm57AKQyGOjTDAF9EQB28fMywTDQ' }; - const jwe = await JWEBuilder.pbes2('123456', 1000).encrypt(orig); + const recipient = Recipient.pbes2('password', '123456', 1000); + const jwe = await JWE.build(orig).encrypt(recipient); + const token = jwe.compactSerialization(); - expect(jwe).not.to.be.null; + expect(token).not.to.be.null; }); // TODO: add some more decrypt-only tests with JWE from 3rd party @@ -78,10 +139,11 @@ describe('JWE', () => { it('x = decrypt(encrypt(x, kek), kek)', async () => { const kek = await crypto.subtle.generateKey({ name: 'AES-KW', length: 256 }, false, ['wrapKey', 'unwrapKey']); const orig = { hello: 'world' }; + const recipient = Recipient.a256kw('kw', kek); - const jwe = await JWEBuilder.a256kw(kek).encrypt(orig); + const jwe = await JWE.build(orig).encrypt(recipient); - const decrypted = await JWEParser.parse(jwe).decryptA256kw(kek); + const decrypted = await jwe.decrypt(recipient); expect(decrypted).to.deep.eq(orig); }); @@ -91,8 +153,9 @@ describe('JWE', () => { const rawKek = base64url.parse('y_uxz8iAtcOXlqMYpm2jASvDWokpCYMtwkthFSK6IF0', { loose: true }); const kek = await crypto.subtle.importKey('raw', rawKek, 'AES-KW', false, ['unwrapKey']); const orig = { hello: 'world' }; + const recipient = Recipient.a256kw('kw', kek); - const decrypted = await JWEParser.parse(jwe).decryptA256kw(kek); + const decrypted = await JWE.parseCompact(jwe).decrypt(recipient); expect(decrypted).to.deep.eq(orig); }); }); diff --git a/frontend/test/common/uvf.spec.ts b/frontend/test/common/uvf.spec.ts index c11809cae..0b532d460 100644 --- a/frontend/test/common/uvf.spec.ts +++ b/frontend/test/common/uvf.spec.ts @@ -1,7 +1,7 @@ import { use as chaiUse, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { before, describe } from 'mocha'; -import { JWEParser } from '../../src/common/jwe'; +import { JWE, Recipient } from '../../src/common/jwe'; import { MetadataPayload, VaultMetadata, VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/uvf'; chaiUse(chaiAsPromised); @@ -40,7 +40,7 @@ describe('Universal Vault Format', () => { expect(decrypted.initialSeedId).to.eq(orig.initialSeedId) expect(decrypted.latestSeedId).to.eq(orig.latestSeedId) expect(decrypted.automaticAccessGrant).to.deep.eq(automaticAccessGrant); - const decryptedRaw: MetadataPayload = await JWEParser.parse(jwe).decryptA256kw(uvfMasterKey); + const decryptedRaw: MetadataPayload = await JWE.parseCompact(jwe).decrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', uvfMasterKey)); expect(decryptedRaw.fileFormat).to.eq('AES-256-GCM-32k'); expect(decryptedRaw.initialSeed).to.eq(decryptedRaw.latestSeed); expect(decryptedRaw.seeds[decryptedRaw.initialSeed]).to.be.not.empty; From 3646379647e44f8104a6fd7cdacd95855ecb122b Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 26 Apr 2024 17:02:07 +0200 Subject: [PATCH 09/94] use correct `AlgorithmID` for Concat KDF --- frontend/src/common/jwe.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 3509fb4a8..d24e79a03 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -172,7 +172,7 @@ class EcdhRecipient extends Recipient { throw new Error('Unsupported curve'); } const epk = await crypto.subtle.importKey('jwk', header.epk!, { name: 'ECDH', namedCurve: header.epk?.crv }, false, []); - return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, 32, header, false, keyAlgorithm, keyUsage); + return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, keyAlgorithm.length / 8, header, false, keyAlgorithm, keyUsage); } async decryptAndUnwrap(header: Header, encryptedKey: string): Promise { @@ -402,10 +402,12 @@ export class ECDH_ES { private static async deriveRawKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader): Promise { let agreedKey = new Uint8Array(); try { - const algorithmId = ECDH_ES.lengthPrefixed(new TextEncoder().encode(header.enc)); + const algOrEnc = header.alg === 'ECDH-ES' ? header.enc : header.alg; // see definition of AlgorithmID in RFC 7518, Section 4.6.2 + const algorithmId = ECDH_ES.lengthPrefixed(new TextEncoder().encode(algOrEnc)); const partyUInfo = ECDH_ES.lengthPrefixed(base64url.parse(header.apu || '', { loose: true })); const partyVInfo = ECDH_ES.lengthPrefixed(base64url.parse(header.apv || '', { loose: true })); const suppPubInfo = new ArrayBuffer(4); + const suppPrivInfo = new Uint8Array(); new DataView(suppPubInfo).setUint32(0, desiredKeyBytes * 8, false); agreedKey = new Uint8Array(await crypto.subtle.deriveBits( { @@ -415,7 +417,7 @@ export class ECDH_ES { privateKey, ecdhKeyBits )); - const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo)]); + const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo), ...suppPrivInfo]); return ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); } finally { agreedKey.fill(0x00); From f1c14fd486a3835711dc495332f1cd2f78967b65 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 26 Apr 2024 17:02:16 +0200 Subject: [PATCH 10/94] added tests --- frontend/test/common/jwe.spec.ts | 39 +++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/frontend/test/common/jwe.spec.ts b/frontend/test/common/jwe.spec.ts index 701f1731d..5abac143f 100644 --- a/frontend/test/common/jwe.spec.ts +++ b/frontend/test/common/jwe.spec.ts @@ -96,7 +96,7 @@ describe('JWE', () => { expect(decrypted).to.deep.eq(orig); }); - it('decrypt ECDH', async () => { + it('decrypt ECDH-ES', async () => { // JWE generated by https://dinochiesa.github.io/jwt/ const jwe = 'eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6InpMYlRUN3M5d3lZeGpHdGZxeHVDQk81TTNTMTlNRzJHUGJzTlJhMFEwT2MiLCJ5IjoiTGpmVG9jLXpIcWRqcHA4VURPSl9aS1JLQ0FoSGpSSmFGS0FJbjdTR1RXdyJ9fQ..30WmoR8Qp1VA5NMr.TBRC30hVRwi_W_HIe03JWNY.zvfl5wQuG8vhUjhr1-f8jQ'; const rawRecipientPrivateKey = base64.parse('MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzMoA2cm0rNRLA3zZa2VzYxd1QLFTdOxsMQ+6V6faoLmhRANCAAR4M7kZS/VdkdOQG56ELvL2/3L8ti+yQeQBv0BuyUJqWHMOv13VLZOKTnvtFrAZbjM6lONayft9qSr43thfj1Pb', { loose: true }); @@ -108,7 +108,31 @@ describe('JWE', () => { expect(decrypted).to.deep.eq(orig); }); - // TODO: add some more decrypt-only tests with JWE from 3rd party + it('decrypt ECDH-ES+A256KW 1', async () => { + // JWE generated by https://dinochiesa.github.io/jwt/ + const jwe = 'eyJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJGX3lIQWlEQkNMajdVdW43NXZtTGpsSkRnSFRmVnFPUW51X0g0cGhNbjVZIiwieSI6IlFMRThtd1V2M0tDU3pjNmtqT0R2QnhEQjFqRFdUVnE1N052UlpUcFZNZFUifSwiZW5jIjoiQTI1NkdDTSIsImFsZyI6IkVDREgtRVMrQTI1NktXIn0.ziLX2llBDAO0Ha_EV2QyLVGo47qN0c2QpSt-tu35yn8t6Q-elpVG3A.QXT0c27wNHVbvcQN.z82EXvOQ_IWaCGcSmlizzFY.JkrlVlJin8E4uH4WEjPs0w'; + const rawRecipientPrivateKey = base64.parse('MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8w/B3QWfLRwtt+pCYScLE8CzqWOYGYl/t2gwmb89AA2hRANCAAR/S7NvYpmAinsrGaDaFNmN8eQFRpwc5alP4Clu8hV//hcrN9DMHGbO2lsKk//ktpUNXt4Fj4UIsBw3ABUchbxI', { loose: true }); + const recipientPrivateKey = await crypto.subtle.importKey('pkcs8', rawRecipientPrivateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + const orig = { hello: 'world' }; + const recipient = Recipient.ecdhEs('alice', recipientPrivateKey); + + const decrypted = await JWE.parseCompact(jwe).decrypt(recipient); + expect(decrypted).to.deep.eq(orig); + }); + + // + + it('decrypt ECDH-ES+A256KW 2', async () => { + // JWE generated by nimbus-jose-jwt + const jwe = 'eyJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTM4NCIsIngiOiJDMndad2lrUUc2NFVjNWdHRHV4VWhVb0dHbEpDVnI4ZHFjMFZkelZIUDl3WUtmSTNCYl8tVnQ3VGtfRER5N1RoIiwieSI6IlNTMTNRS18wLWJYQ1J4OHkyZXFETUNPU0I5OUhqOGtHQWh3b2hzWmwwYi1vamdiTzBsNUpKQW4wYmFCTWE4d1YifSwiZW5jIjoiQTI1NkdDTSIsImFsZyI6IkVDREgtRVMrQTI1NktXIn0.VZPzMmLm31EfxoOhVmLRIu0ILMs473FKeLd8jhaCrzHlLhXS0S2VSA.7W9iNEn-1B1PnfuQ.6y_7YaOXUCCXvuaxwbHLtpc.yHRzBceUHw6huMv4jFApxg'; + const privateKeyJwk: JsonWebKey = { "kty": "EC", "crv": "P-384", "x": "I47LcQjeZZ5_HxIbw3e5nwaA2S7BFr0CFfiGaay9q3OTNNxFm-O4pjQ2wntSS3hu", "y": "mDFWsM-fkpHpM6JmJczl31PkjrfCbz6B_IhYcMKsJc3NBIO1ALRvmsjrX0aTRH_e", "d": "tgit5ED1PpM6rwIDqMovYkKSZ8n5V05wc_sHi-AiqwM9atiTsO3w2E2wDa-L1lFN" }; + const privateKey = await crypto.subtle.importKey('jwk', privateKeyJwk, { name: 'ECDH', namedCurve: 'P-384' }, false, ['deriveBits']); + const orig = { hello: 'world' }; + const recipient = Recipient.ecdhEs('alice', privateKey); + + const decrypted = await JWE.parseCompact(jwe).decrypt(recipient); + expect(decrypted).to.deep.eq(orig); + }); }); describe('JWE using alg: PBES2-HS512+A256KW', () => { @@ -132,7 +156,16 @@ describe('JWE', () => { expect(token).not.to.be.null; }); - // TODO: add some more decrypt-only tests with JWE from 3rd party + it('decrypt PBES2-HS512+A256KW', async () => { + // JWE generated by nimbus-jose-jwt + const jwe = 'eyJwMnMiOiI1REp2cFJVTzJkOWlUdjdHc24wTlp3IiwicDJjIjoxMDAwLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUEJFUzItSFM1MTIrQTI1NktXIn0.uOPjfcgWobRPtoO124qLyv9XSRWEw1rmdzDqroH6iTBQyyNMKIjdPw.dIH9LVbMH5aAmCMj.hsIdH2JSliAV2ueFErovkHY.OA2nayTnJYTQO04HGvlaMA'; + const orig = { hello: 'world' }; + + const recipient = Recipient.pbes2('password', 'topsecret'); + + const decrypted = await JWE.parseCompact(jwe).decrypt(recipient); + expect(decrypted).to.deep.eq(orig); + }); }); describe('JWE using alg: A256KW', () => { From aa1690f9fed78cce44434b6fcd1a74ff96babe02 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sun, 28 Apr 2024 14:13:19 +0200 Subject: [PATCH 11/94] type cleanup --- frontend/src/common/jwe.ts | 85 +++++++++++++++++--------------- frontend/test/common/jwe.spec.ts | 6 +-- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index d24e79a03..894b1577a 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -35,7 +35,7 @@ export class ConcatKDF { } } -type Header = { +export type JWEHeader = { kid?: string, enc?: 'A256GCM' | 'A128GCM', alg?: 'ECDH-ES' | 'ECDH-ES+A256KW' | 'PBES2-HS512+A256KW' | 'A256KW', @@ -47,12 +47,18 @@ type Header = { [other: string]: undefined | string | number | boolean | object; // allow further properties } -type PerRecipientProperties = { - encryptedKey: string; - header: Header; +type JsonJWE = { + protected: string, + recipients: PerRecipientProperties[] + iv: string, + ciphertext: string, + tag: string } -export type JWEHeader = Header; +type PerRecipientProperties = { + encrypted_key: string; + header: JWEHeader; +} export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { name: 'ECDH', @@ -71,7 +77,7 @@ export abstract class Recipient { * @param commonHeader The protected and unprotected header (not per-recipient) * @returns The encrypted CEK and per-recipient header parameters for the used `alg`. */ - abstract encrypt(cek: CryptoKey, commonHeader: Header): Promise; + abstract encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise; /** * Decrypts the CEK using the recipient-specific `alg`. @@ -80,7 +86,7 @@ export abstract class Recipient { * @returns A non-extractable CEK suitable for decryption with AES-GCM. * @throws {UnwrapKeyError} if decryption failed */ - abstract decrypt(header: Header, encryptedKey: string): Promise; + abstract decrypt(header: JWEHeader, encryptedKey: string): Promise; /** * Create a recipient using `alg: ECDH-ES+A256KW`. Also supports `ECDH-ES` with direct key agreement for decryption @@ -126,12 +132,12 @@ class EcdhRecipient extends Recipient { super(kid); } - async encrypt(cek: CryptoKey, commonHeader: Header): Promise { + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { if (this.recipientKey.type !== 'public') { throw new Error('Recipient public key required.'); } const ephemeralKey = await crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); - const header: Header = { + const header: JWEHeader = { ...commonHeader, alg: 'ECDH-ES+A256KW', epk: await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey), @@ -142,11 +148,11 @@ class EcdhRecipient extends Recipient { const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, wrappingKey, 'AES-KW')); return { header: header, - encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) } } - async decrypt(header: Header, encryptedKey: string): Promise { + async decrypt(header: JWEHeader, encryptedKey: string): Promise { if (this.recipientKey.type !== 'private') { throw new Error('Recipient private key required.'); } @@ -159,7 +165,7 @@ class EcdhRecipient extends Recipient { } } - async decryptDirect(header: Header, keyAlgorithm: AesKeyAlgorithm, keyUsage: KeyUsage[]): Promise { + async decryptDirect(header: JWEHeader, keyAlgorithm: AesKeyAlgorithm, keyUsage: KeyUsage[]): Promise { let keyBits: number; switch (header.epk!.crv) { case 'P-256': @@ -175,7 +181,7 @@ class EcdhRecipient extends Recipient { return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, keyAlgorithm.length / 8, header, false, keyAlgorithm, keyUsage); } - async decryptAndUnwrap(header: Header, encryptedKey: string): Promise { + async decryptAndUnwrap(header: JWEHeader, encryptedKey: string): Promise { const wrappingKey = await this.decryptDirect(header, { name: 'AES-KW', length: 256 }, ['unwrapKey']); try { return await crypto.subtle.unwrapKey('raw', base64url.parse(encryptedKey, { loose: true }), wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); @@ -192,20 +198,19 @@ class A256kwRecipient extends Recipient { super(kid); } - async encrypt(cek: CryptoKey, commonHeader: Header): Promise { - const header: Header = { + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { + const header: JWEHeader = { ...commonHeader, - alg: 'A256KW', - enc: 'A256GCM' + alg: 'A256KW' }; const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, this.wrappingKey, 'AES-KW')); return { header: header, - encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) } } - async decrypt(header: Header, encryptedKey: string): Promise { + async decrypt(header: JWEHeader, encryptedKey: string): Promise { if (header.alg != 'A256KW') { throw new Error('unsupported alg'); } @@ -224,9 +229,9 @@ class Pbes2Recipient extends Recipient { super(kid); } - async encrypt(cek: CryptoKey, commonHeader: Header): Promise { + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); - const header: Header = { + const header: JWEHeader = { ...commonHeader, kid: this.kid, alg: 'PBES2-HS512+A256KW', @@ -237,11 +242,11 @@ class Pbes2Recipient extends Recipient { const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, await wrappingKey, 'AES-KW')); return { header: header, - encryptedKey: base64url.stringify(encryptedKey, { pad: false }) + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) } } - async decrypt(header: Header, encryptedKey: string): Promise { + async decrypt(header: JWEHeader, encryptedKey: string): Promise { if (header.alg != 'PBES2-HS512+A256KW' || !header.p2s || !header.p2c) { throw new Error('Missing or invalid header parameters.'); } @@ -270,12 +275,12 @@ export class JWE { public static parseCompact(token: string): EncryptedJWE { const [protectedHeader, encryptedKey, iv, ciphertext, tag] = token.split('.', 5); const utf8 = new TextDecoder(); - const header: Header = JSON.parse(utf8.decode(base64url.parse(protectedHeader, { loose: true }))); + const header: JWEHeader = JSON.parse(utf8.decode(base64url.parse(protectedHeader, { loose: true }))); - return new EncryptedJWE(protectedHeader, [{ encryptedKey: encryptedKey, header: header }], iv, ciphertext, tag); + return new EncryptedJWE(protectedHeader, [{ encrypted_key: encryptedKey, header: header }], iv, ciphertext, tag); } - public static parseJson(jwe: any): EncryptedJWE { // TODO: json: + public static parseJson(jwe: JsonJWE): EncryptedJWE { // TODO: json: if (!jwe.protected || !jwe.recipients || !jwe.iv || !jwe.ciphertext || !jwe.tag) { throw new Error('Malformed JWE'); } @@ -283,20 +288,23 @@ export class JWE { } public async encrypt(recipient: Recipient, ...moreRecipients: Recipient[]): Promise { - let protectedHeader: Header = { + let protectedHeader: JWEHeader = { enc: 'A256GCM' } const cek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); - const recipients = await Promise.all([recipient, ...moreRecipients].map(r => r.encrypt(cek, protectedHeader))); + const perRecipientData = await Promise.all([recipient, ...moreRecipients].map(r => r.encrypt(cek, protectedHeader))); - if (recipients.length === 1) { - protectedHeader = recipients[0].header; + if (perRecipientData.length === 1) { + protectedHeader = { + ...perRecipientData[0].header + }; } else { protectedHeader = { - enc: recipients[0].header.enc + enc: perRecipientData[0].header.enc } } + perRecipientData.forEach(r => delete r.header.enc); const utf8enc = new TextEncoder(); const encodedProtectedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(protectedHeader)), { pad: false }); @@ -318,7 +326,7 @@ export class JWE { const encodedIv = base64url.stringify(iv, { pad: false }); const encodedCiphertext = base64url.stringify(ciphertext, { pad: false }); const encodedTag = base64url.stringify(tag, { pad: false }); - return new EncryptedJWE(encodedProtectedHeader, recipients, encodedIv, encodedCiphertext, encodedTag) + return new EncryptedJWE(encodedProtectedHeader, perRecipientData, encodedIv, encodedCiphertext, encodedTag) } } @@ -333,15 +341,14 @@ export class EncryptedJWE { } } - public jsonSerialization(): any { + public jsonSerialization(): JsonJWE { if (this.perRecipient.length < 1) { throw new Error('JWE JSON Serialization requires at least one recipient.'); } const recipients = this.perRecipient.map(r => ({ header: r.header, - encrypted_key: r.encryptedKey + encrypted_key: r.encrypted_key })); - return { protected: this.protectedHeader, recipients: recipients, @@ -355,18 +362,18 @@ export class EncryptedJWE { if (this.perRecipient.length !== 1) { throw new Error('JWE Compact Serialization requires exactly one recipient.'); } - return `${this.protectedHeader}.${this.perRecipient[0].encryptedKey}.${this.iv}.${this.ciphertext}.${this.tag}`; + return `${this.protectedHeader}.${this.perRecipient[0].encrypted_key}.${this.iv}.${this.ciphertext}.${this.tag}`; } public async decrypt(recipient: Recipient): Promise { const utf8dec = new TextDecoder(); const utf8enc = new TextEncoder(); - const protectedHeader: Header = JSON.parse(utf8dec.decode(base64url.parse(this.protectedHeader, { loose: true }))); + const protectedHeader: JWEHeader = JSON.parse(utf8dec.decode(base64url.parse(this.protectedHeader, { loose: true }))); const perRecipientData = (this.perRecipient.length === 1) ? this.perRecipient[0] : this.perRecipientWithKid(recipient.kid); - const combinedHeader: Header = { ...perRecipientData.header, ...protectedHeader }; - const cek = await recipient.decrypt(combinedHeader, perRecipientData.encryptedKey); + const combinedHeader: JWEHeader = { ...perRecipientData.header, ...protectedHeader }; + const cek = await recipient.decrypt(combinedHeader, perRecipientData.encrypted_key); const ciphertext = base64url.parse(this.ciphertext, { loose: true }) const tag = base64url.parse(this.tag, { loose: true }); const ciphertextAndTag = new Uint8Array([...ciphertext, ...tag]); diff --git a/frontend/test/common/jwe.spec.ts b/frontend/test/common/jwe.spec.ts index 5abac143f..0a27d14fe 100644 --- a/frontend/test/common/jwe.spec.ts +++ b/frontend/test/common/jwe.spec.ts @@ -27,7 +27,7 @@ describe('JWE', () => { }); describe('JWE serialization for single recipient', () => { - const jwe = new EncryptedJWE('protectedHeader', [{ encryptedKey: 'encryptedKey', header: { kid: 'alice' } }], 'iv', 'ciphertext', 'tag'); + const jwe = new EncryptedJWE('protectedHeader', [{ encrypted_key: 'encryptedKey', header: { kid: 'alice' } }], 'iv', 'ciphertext', 'tag'); it('compactSerialization', () => { const token = jwe.compactSerialization(); @@ -49,8 +49,8 @@ describe('JWE', () => { describe('JWE serialization for multiple recipients', () => { const recipients = [ - { encryptedKey: 'encryptedKey1', header: { kid: 'alice' } }, - { encryptedKey: 'encryptedKey2', header: { kid: 'bob' } } + { encrypted_key: 'encryptedKey1', header: { kid: 'alice' } }, + { encrypted_key: 'encryptedKey2', header: { kid: 'bob' } } ]; const jwe = new EncryptedJWE('protectedHeader', recipients, 'iv', 'ciphertext', 'tag'); From e6e028ad46982a6c96d135cd4404720f507d3f00 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sun, 28 Apr 2024 16:30:15 +0200 Subject: [PATCH 12/94] include recovery key in UVF metadata --- frontend/src/common/crypto.ts | 18 ++++++- frontend/src/common/jwe.ts | 25 +++++++--- frontend/src/common/uvf.ts | 82 ++++++++++++++++++-------------- frontend/test/common/uvf.spec.ts | 44 ++++++++--------- 4 files changed, 104 insertions(+), 65 deletions(-) diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 58053412a..627443de9 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,4 +1,4 @@ -import { base16, base64 } from 'rfc4648'; +import { base16, base64, base64url } from 'rfc4648'; import { JWE, Recipient } from './jwe'; import { DB } from './util'; @@ -220,3 +220,19 @@ export async function getFingerprint(key: string | undefined) { } } +/** + * Computes the JWK Thumbprint (RFC 7638) using SHA-256. + * @param key An EC key + */ +export async function getJwkThumbprint(key: CryptoKey): Promise { + // see https://datatracker.ietf.org/doc/html/rfc7638#section-3.2 + if (key.algorithm.name !== 'ECDH') { + throw new Error('Method only implemented for EC keys.'); + } + const jwk = await crypto.subtle.exportKey('jwk', key); + const algo = key.algorithm as EcKeyAlgorithm; + const orderedJson = `{"crv":"${algo.namedCurve}","kty":"${jwk.kty}","x":${jwk.x},"y":${jwk.y}}`; + const bytes = new TextEncoder().encode(orderedJson); + const hashBuffer = await crypto.subtle.digest('SHA-256', bytes); + return base64url.stringify(new Uint8Array(hashBuffer), { pad: false }); +} diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 894b1577a..bf1998530 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -44,10 +44,11 @@ export type JWEHeader = { epk?: JsonWebKey, p2c?: number, p2s?: string, + jku?: string, [other: string]: undefined | string | number | boolean | object; // allow further properties } -type JsonJWE = { +export type JsonJWE = { protected: string, recipients: PerRecipientProperties[] iv: string, @@ -98,6 +99,9 @@ export abstract class Recipient { * @returns A new recipient */ public static ecdhEs(kid: string, recipientKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): Recipient { + if (recipientKey?.type === 'secret' || recipientKey?.algorithm.name !== 'ECDH') { + throw new Error('Unsupported recipient key'); + } return new EcdhRecipient(kid, recipientKey, apu, apv); } @@ -121,6 +125,9 @@ export abstract class Recipient { * @returns A new recipient */ public static a256kw(kid: string, wrappingKey: CryptoKey): Recipient { + if (wrappingKey?.type !== 'secret' || wrappingKey?.algorithm.name !== 'AES-KW') { + throw new Error('Unsupported wrapping key'); + } return new A256kwRecipient(kid, wrappingKey); } @@ -139,6 +146,7 @@ class EcdhRecipient extends Recipient { const ephemeralKey = await crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); const header: JWEHeader = { ...commonHeader, + kid: this.kid, alg: 'ECDH-ES+A256KW', epk: await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey), apu: base64url.stringify(this.apu, { pad: false }), @@ -201,6 +209,7 @@ class A256kwRecipient extends Recipient { async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { const header: JWEHeader = { ...commonHeader, + kid: this.kid, alg: 'A256KW' }; const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, this.wrappingKey, 'AES-KW')); @@ -266,10 +275,10 @@ class Pbes2Recipient extends Recipient { export class JWE { - private constructor(private payload: object) { } + private constructor(private payload: object, private protectedHeader: JWEHeader) { } - public static build(payload: object): JWE { - return new JWE(payload); + public static build(payload: object, protectedHeader: JWEHeader = {}): JWE { + return new JWE(payload, protectedHeader); } public static parseCompact(token: string): EncryptedJWE { @@ -289,6 +298,7 @@ export class JWE { public async encrypt(recipient: Recipient, ...moreRecipients: Recipient[]): Promise { let protectedHeader: JWEHeader = { + ...this.protectedHeader, enc: 'A256GCM' } const cek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); @@ -297,14 +307,15 @@ export class JWE { if (perRecipientData.length === 1) { protectedHeader = { + ...protectedHeader, ...perRecipientData[0].header }; } else { - protectedHeader = { - enc: perRecipientData[0].header.enc + protectedHeader.enc = perRecipientData[0].header.enc; + for (let key of Object.keys(protectedHeader)) { + perRecipientData.forEach(r => delete r.header[key]); } } - perRecipientData.forEach(r => delete r.header.enc); const utf8enc = new TextEncoder(); const encodedProtectedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(protectedHeader)), { pad: false }); diff --git a/frontend/src/common/uvf.ts b/frontend/src/common/uvf.ts index 4b471c3df..ab979c55c 100644 --- a/frontend/src/common/uvf.ts +++ b/frontend/src/common/uvf.ts @@ -1,7 +1,8 @@ import { base64url } from 'rfc4648'; -import { JWE, Recipient } from './jwe'; +import { getJwkThumbprint } from './crypto'; +import { JWE, JWEHeader, JsonJWE, Recipient } from './jwe'; -export type MetadataPayload = { +type MetadataPayload = { fileFormat: 'AES-256-GCM-32k'; nameFormat: 'AES-SIV-512-B64URL'; // TODO verify after merging https://github.com/encryption-alliance/unified-vault-format/pull/24 seeds: Record; @@ -12,30 +13,32 @@ export type MetadataPayload = { 'org.cryptomator.automaticAccessGrant': VaultMetadataJWEAutomaticAccessGrantDto; } -export type VaultMetadataJWEAutomaticAccessGrantDto = { +type VaultMetadataJWEAutomaticAccessGrantDto = { enabled: boolean, maxWotDepth: number } -export class VaultMetadata { +// const MEMBER_KEY_DESIGNATION: AesKeyGenParams = { +// name: 'AES', +// length: 256 +// }; + +// const MEMBER_KEY_USAGE: KeyUsage[] = ['wrapKey', 'unwrapKey']; - readonly automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto; - readonly seeds: Map; - readonly initialSeedId: number; - readonly latestSeedId: number; - readonly kdfSalt: Uint8Array; +export class VaultMetadata { - protected constructor(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, seeds: Map, initialSeedId: number, latestSeedId: number, kdfSalt: Uint8Array) { - this.automaticAccessGrant = automaticAccessGrant; - this.seeds = seeds; - this.initialSeedId = initialSeedId; - this.latestSeedId = latestSeedId; - this.kdfSalt = kdfSalt; + private constructor( + readonly automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, + readonly seeds: Map, + readonly initialSeedId: number, + readonly latestSeedId: number, + readonly kdfSalt: Uint8Array) { } /** - * Creates new vault metadata with a new file key and name key - * @returns new vault metadata + * Creates a new UVF vault + * @param automaticAccessGrant Configuration instructing the client how to automatically deal with permission requests + * @returns new vault */ public static async create(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto): Promise { const initialSeedId = new Uint32Array(1); @@ -46,23 +49,24 @@ export class VaultMetadata { crypto.getRandomValues(kdfSalt); const initialSeedNo = initialSeedId[0]; const seeds: Map = new Map(); + // const memberKey = await crypto.subtle.generateKey(MEMBER_KEY_DESIGNATION, false, MEMBER_KEY_USAGE); seeds.set(initialSeedNo, initialSeedValue); return new VaultMetadata(automaticAccessGrant, seeds, initialSeedNo, initialSeedNo, kdfSalt); } /** - * Decrypts the vault metadata using the vault masterkey - * @param jwe JWE containing the vault key - * @param masterKey the vault masterKey - * @returns vault metadata + * Decrypts the vault metadata using the members' vault key + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param memberKey the vault members' wrapping key + * @returns Decrypted vault metadata */ - public static async decryptWithMasterKey(jwe: string, masterKey: CryptoKey): Promise { - const payload = await JWE.parseCompact(jwe).decrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', masterKey)); - const encodedSeeds: Record = payload['seeds']; + public static async decryptWithMemberKey(uvfMetadataFile: string, memberKey: CryptoKey): Promise { + const json: JsonJWE = JSON.parse(uvfMetadataFile); + const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey)); const seeds = new Map(); - for (const key in encodedSeeds) { + for (const key in payload.seeds) { const num = parseSeedId(key); - const value = base64url.parse(encodedSeeds[key], { loose: true }); + const value = base64url.parse(payload.seeds[key], { loose: true }); seeds.set(num, value); } const initialSeedId = parseSeedId(payload['initialSeed']); @@ -78,29 +82,37 @@ export class VaultMetadata { } /** - * Encrypts the vault metadata using the given vault masterKey - * @param userPublicKey The recipient's public key (DER-encoded) - * @returns a JWE containing this Masterkey + * Encrypts the vault metadata + * @param memberKey the vault members' AES wrapping key + * @param recoveryKey the public part of the recovery EC key pair + * @returns `vault.uvf` file contents */ - public async encryptWithMasterKey(masterKey: CryptoKey): Promise { + public async encrypt(memberKey: CryptoKey, recoveryKey: CryptoKey): Promise { + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprint(recoveryKey)}`; + const protectedHeader: JWEHeader = { + jku: 'jku.jwks' // URL relative to /api/vaults/{vaultid}/ + }; + const jwe = await JWE.build(this.payload(), protectedHeader).encrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey), Recipient.ecdhEs(recoveryKeyID, recoveryKey)); // TODO + const json = jwe.jsonSerialization(); + return JSON.stringify(json); + } + + public payload(): MetadataPayload { const encodedSeeds: Record = {}; for (const [key, value] of this.seeds) { const seedId = stringifySeedId(key); encodedSeeds[seedId] = base64url.stringify(value, { pad: false }); } - const payload: MetadataPayload = { + return { fileFormat: 'AES-256-GCM-32k', nameFormat: 'AES-SIV-512-B64URL', seeds: encodedSeeds, initialSeed: stringifySeedId(this.initialSeedId), latestSeed: stringifySeedId(this.latestSeedId), - // TODO https://github.com/encryption-alliance/unified-vault-format/pull/21 finalize kdf kdf: 'HKDF-SHA512', kdfSalt: base64url.stringify(this.kdfSalt, { pad: false }), 'org.cryptomator.automaticAccessGrant': this.automaticAccessGrant - } - const jwe = await JWE.build(payload).encrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', masterKey)); - return jwe.compactSerialization(); + }; } } diff --git a/frontend/test/common/uvf.spec.ts b/frontend/test/common/uvf.spec.ts index 0b532d460..1ec800156 100644 --- a/frontend/test/common/uvf.spec.ts +++ b/frontend/test/common/uvf.spec.ts @@ -1,8 +1,7 @@ import { use as chaiUse, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { before, describe } from 'mocha'; -import { JWE, Recipient } from '../../src/common/jwe'; -import { MetadataPayload, VaultMetadata, VaultMetadataJWEAutomaticAccessGrantDto } from '../../src/common/uvf'; +import { VaultMetadata } from '../../src/common/uvf'; chaiUse(chaiAsPromised); @@ -15,38 +14,39 @@ describe('Universal Vault Format', () => { global.window = { crypto: global.crypto }; }); - describe('Vault Metadata', () => { + describe('UVF Metadata', () => { // TODO review @sebi what else should we test? - it('encryptWithMasterKey() and decryptWithMasterKey()', async () => { - const uvfMasterKey = await crypto.subtle.generateKey( + it('encrypt() and decryptWithMemberKey()', async () => { + const vaultMemberKey = await crypto.subtle.generateKey( { name: 'AES-KW', length: 256 }, - true, + false, ['wrapKey', 'unwrapKey'] ); - const automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto = { - enabled: true, - maxWotDepth: -1 - } - const orig = await VaultMetadata.create(automaticAccessGrant); + const recoveryKey = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-384' }, + false, + ['deriveKey'] + ); + + const orig = await VaultMetadata.create({ enabled: true, maxWotDepth: -1 }); expect(orig).to.be.not.null; expect(orig.seeds.get(orig.initialSeedId)).to.not.be.undefined expect(orig.seeds.get(orig.initialSeedId)!.length).to.eq(32) expect(orig.initialSeedId).to.eq(orig.latestSeedId) expect(orig.kdfSalt.length).to.eq(32) - const jwe: string = await orig.encryptWithMasterKey(uvfMasterKey); - expect(jwe).to.be.not.null; - const decrypted: VaultMetadata = await VaultMetadata.decryptWithMasterKey(jwe, uvfMasterKey); + + const uvfMetadata: string = await orig.encrypt(vaultMemberKey, recoveryKey.publicKey); + expect(uvfMetadata).to.be.not.null; + + const decrypted: VaultMetadata = await VaultMetadata.decryptWithMemberKey(uvfMetadata, vaultMemberKey); + const decryptedPayload = decrypted.payload(); expect(decrypted.seeds).to.deep.eq(orig.seeds) expect(decrypted.initialSeedId).to.eq(orig.initialSeedId) expect(decrypted.latestSeedId).to.eq(orig.latestSeedId) - expect(decrypted.automaticAccessGrant).to.deep.eq(automaticAccessGrant); - const decryptedRaw: MetadataPayload = await JWE.parseCompact(jwe).decrypt(Recipient.a256kw('org.cryptomator.hub.masterkey', uvfMasterKey)); - expect(decryptedRaw.fileFormat).to.eq('AES-256-GCM-32k'); - expect(decryptedRaw.initialSeed).to.eq(decryptedRaw.latestSeed); - expect(decryptedRaw.seeds[decryptedRaw.initialSeed]).to.be.not.empty; - expect(decryptedRaw.kdf).to.eq('HKDF-SHA512'); - expect(decryptedRaw.kdfSalt).to.be.not.empty - expect(decryptedRaw['org.cryptomator.automaticAccessGrant']).to.deep.eq(automaticAccessGrant); + expect(decrypted.automaticAccessGrant).to.deep.eq(orig.automaticAccessGrant); + expect(decryptedPayload.fileFormat).to.eq('AES-256-GCM-32k'); + expect(decryptedPayload.nameFormat).to.eq('AES-SIV-512-B64URL'); + expect(decryptedPayload.kdf).to.eq('HKDF-SHA512'); }); }); }); From a85cf3c29c2bab4a6acee7a6bb34bfb4abf6696c Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sun, 28 Apr 2024 20:13:14 +0200 Subject: [PATCH 13/94] create UVF-based vault --- frontend/src/common/uvf.ts | 141 ++++++++++++++++-- frontend/src/common/vaultv8.ts | 6 +- frontend/src/components/CreateVault.vue | 73 ++++++--- frontend/src/components/InitialSetup.vue | 2 +- frontend/src/components/ManageSetupCode.vue | 2 +- .../components/RegenerateSetupCodeDialog.vue | 2 +- frontend/test/common/uvf.spec.ts | 75 ++++++++-- 7 files changed, 247 insertions(+), 54 deletions(-) diff --git a/frontend/src/common/uvf.ts b/frontend/src/common/uvf.ts index ab979c55c..ea5c0f4f1 100644 --- a/frontend/src/common/uvf.ts +++ b/frontend/src/common/uvf.ts @@ -1,5 +1,5 @@ -import { base64url } from 'rfc4648'; -import { getJwkThumbprint } from './crypto'; +import { base64, base64url } from 'rfc4648'; +import { UserKeys, getJwkThumbprint } from './crypto'; import { JWE, JWEHeader, JsonJWE, Recipient } from './jwe'; type MetadataPayload = { @@ -18,13 +18,128 @@ type VaultMetadataJWEAutomaticAccessGrantDto = { maxWotDepth: number } -// const MEMBER_KEY_DESIGNATION: AesKeyGenParams = { -// name: 'AES', -// length: 256 -// }; +type MemberKeyPayload = { + key: string +} + +// #region Member Key +/** + * The AES Key Wrap Key used to encapsulate the UVF Vault Metadata CEK for a vault member. + * This key is encrypted for each vault member individually, using the user's public key. + */ +export class MemberKey { + public static readonly KEY_DESIGNATION: AesKeyGenParams = { name: 'AES-KW', length: 256 }; + + public static readonly KEY_USAGE: KeyUsage[] = ['wrapKey', 'unwrapKey']; + + protected constructor(readonly key: CryptoKey) { } + + /** + * Creates a new vault member key + * @returns new key + */ + public static async create(): Promise { + const key = await crypto.subtle.generateKey(MemberKey.KEY_DESIGNATION, true, MemberKey.KEY_USAGE); + return new MemberKey(key); + } + + /** + * Decrypts the vault's member key using the member's private key + * @param jwe JWE containing the encrypted member key + * @param userPrivateKey The user's private key + * @returns The masterkey + */ + public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { + let rawKey = new Uint8Array(); + try { + const payload: MemberKeyPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', userPrivateKey)); + rawKey = base64url.parse(payload.key); + const masterKey = crypto.subtle.importKey('raw', rawKey, MemberKey.KEY_DESIGNATION, true, MemberKey.KEY_USAGE); + return new MemberKey(await masterKey); + } finally { + rawKey.fill(0x00); + } + } + + /** + * Encrypts this member key using the given public key + * @param userPublicKey The user's public key (DER-encoded) + * @returns a JWE containing this member key + */ + public async encryptForUser(userPublicKey: CryptoKey | Uint8Array): Promise { + // TODO: remove Uint8Array support? + if (userPublicKey instanceof Uint8Array) { + userPublicKey = await crypto.subtle.importKey('spki', userPublicKey, UserKeys.KEY_DESIGNATION, false, []); + } + const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.key)); + try { + const payload: MemberKeyPayload = { + key: base64.stringify(rawkey), + }; + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', userPublicKey)); + return jwe.compactSerialization(); + } finally { + rawkey.fill(0x00); + } + } -// const MEMBER_KEY_USAGE: KeyUsage[] = ['wrapKey', 'unwrapKey']; +} +// #endregion +// #region Recovery Key +/** + * The Recovery Key Pair used to encapsulate the UVF Vault Metadata CEK for recovery purposes. + */ +export class RecoveryKey { + public static readonly KEY_DESIGNATION: EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; + + public static readonly KEY_USAGE: KeyUsage[] = ['deriveKey', 'deriveBits']; + + protected constructor(readonly publicKey: CryptoKey, readonly privateKey?: CryptoKey) { } + + /** + * Creates a new vault member key + * @returns new key + */ + public static async create(): Promise { + const keypair = await crypto.subtle.generateKey(RecoveryKey.KEY_DESIGNATION, false, RecoveryKey.KEY_USAGE); + return new RecoveryKey(keypair.publicKey, keypair.privateKey); + } + + /** + * Loads the public key of the recovery key pair. + * @param publicKey the JWK-encoded public key + * @returns recovery key for encrypting vault metadata + */ + public static async loadJwk(publicKey: JsonWebKey): Promise { + const key = await crypto.subtle.importKey('jwk', publicKey, RecoveryKey.KEY_DESIGNATION, false, RecoveryKey.KEY_USAGE); + return new RecoveryKey(key); + } + + /** + * Restores the Recovery Key Pair + * @param recoveryKey the encoded recovery key + * @returns complete recovery key for decrypting vault metadata + */ + public static recover(recoveryKey: string) { + // TODO + } + + /** + * Encodes the private key + * @returns private key in a human-readable encoding + */ + public serialize(): string { + return 'TODO'; // TODO + } + +} +// #endregion + +// #region Vault metadata +/** + * The UVF Metadata file + */ export class VaultMetadata { private constructor( @@ -49,7 +164,6 @@ export class VaultMetadata { crypto.getRandomValues(kdfSalt); const initialSeedNo = initialSeedId[0]; const seeds: Map = new Map(); - // const memberKey = await crypto.subtle.generateKey(MEMBER_KEY_DESIGNATION, false, MEMBER_KEY_USAGE); seeds.set(initialSeedNo, initialSeedValue); return new VaultMetadata(automaticAccessGrant, seeds, initialSeedNo, initialSeedNo, kdfSalt); } @@ -60,9 +174,9 @@ export class VaultMetadata { * @param memberKey the vault members' wrapping key * @returns Decrypted vault metadata */ - public static async decryptWithMemberKey(uvfMetadataFile: string, memberKey: CryptoKey): Promise { + public static async decryptWithMemberKey(uvfMetadataFile: string, memberKey: MemberKey): Promise { const json: JsonJWE = JSON.parse(uvfMetadataFile); - const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey)); + const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey.key)); const seeds = new Map(); for (const key in payload.seeds) { const num = parseSeedId(key); @@ -87,12 +201,12 @@ export class VaultMetadata { * @param recoveryKey the public part of the recovery EC key pair * @returns `vault.uvf` file contents */ - public async encrypt(memberKey: CryptoKey, recoveryKey: CryptoKey): Promise { - const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprint(recoveryKey)}`; + public async encrypt(memberKey: MemberKey, recoveryKey: RecoveryKey): Promise { + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprint(recoveryKey.publicKey)}`; const protectedHeader: JWEHeader = { jku: 'jku.jwks' // URL relative to /api/vaults/{vaultid}/ }; - const jwe = await JWE.build(this.payload(), protectedHeader).encrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey), Recipient.ecdhEs(recoveryKeyID, recoveryKey)); // TODO + const jwe = await JWE.build(this.payload(), protectedHeader).encrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey.key), Recipient.ecdhEs(recoveryKeyID, recoveryKey.publicKey)); const json = jwe.jsonSerialization(); return JSON.stringify(json); } @@ -116,6 +230,7 @@ export class VaultMetadata { } } +// #endregion /** * Parses the 4 byte seed id from its base64url-encoded form to a 32 bit integer. diff --git a/frontend/src/common/vaultv8.ts b/frontend/src/common/vaultv8.ts index 6f0d1ef0f..bf18a5994 100644 --- a/frontend/src/common/vaultv8.ts +++ b/frontend/src/common/vaultv8.ts @@ -59,7 +59,7 @@ export class VaultKeys { /** - * Decrypts the vault's masterkey (vault8 and uvf) using the user's private key + * Decrypts the vault's masterkey using the user's private key * @param jwe JWE containing the vault key * @param userPrivateKey The user's private key * @returns The masterkey @@ -67,7 +67,7 @@ export class VaultKeys { public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { let rawKey = new Uint8Array(); try { - const payload: JWEPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', userPrivateKey)); + const payload: JWEPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', userPrivateKey)); rawKey = base64.parse(payload.key); const masterKey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); return new VaultKeys(await masterKey); @@ -219,7 +219,7 @@ export class VaultKeys { const payload: JWEPayload = { key: base64.stringify(rawkey), }; - const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userKey', publicKey)); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', publicKey)); return jwe.compactSerialization(); } finally { rawkey.fill(0x00); diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index b62582a6b..8389b432a 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -22,7 +22,7 @@
-