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 da50595f8..edfb52847 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -255,11 +255,18 @@ public Response removeAuthority(@PathParam("vaultId") UUID vaultId, @PathParam(" @VaultRole(VaultAccess.Role.OWNER) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "list devices requiring access rights", description = "lists all devices owned by vault members, that don't have a device-specific masterkey yet") + @Operation(summary = "list members requiring access tokens", description = "lists all members, that have permissions but lack an access token") @APIResponse(responseCode = "200") @APIResponse(responseCode = "403", description = "not a vault owner") - public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { - return userRepo.findRequiringAccessGrant(vaultId).map(UserDto::justPublicInfo).toList(); + public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { + return effectiveVaultAccessRepo.findMembersWithoutAccessTokens(vaultId).map(access -> { + if (access.getAuthority() instanceof User u) { + return MemberDto.fromEntity(u, access.getRole()); + } else { + // findMembersWithoutAccessTokens() should only return users, not groups. + throw new IllegalStateException(); + } + }).toList(); } /** @@ -344,6 +351,38 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr } } + @GET + @Path("/{vaultId}/uvf/vault.uvf") + @RolesAllowed("user") + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get the vault.uvf file") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "404", description = "unknown vault") + public String getUvfMetadata(@PathParam("vaultId") UUID vaultId) { + var vault = vaultRepo.findById(vaultId); + if (vault == null || vault.getUvfMetadataFile() == null) { + throw new NotFoundException(); + } + return vault.getUvfMetadataFile(); + } + + @GET + @Path("/{vaultId}/uvf/jwks.json") + @RolesAllowed("user") + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get public vault keys", description = "retrieves a JWK Set containing public keys related to this vault") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "404", description = "unknown vault") + public String getUvfKeys(@PathParam("vaultId") UUID vaultId) { + var vault = vaultRepo.findById(vaultId); + if (vault == null || vault.getUvfMetadataFile() == null) { + throw new NotFoundException(); + } + return vault.getUvfKeySet(); + } + @POST @Path("/{vaultId}/access-tokens") @RolesAllowed("user") @@ -431,7 +470,9 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu // set regardless of whether vault is new or existing: vault.setName(vaultDto.name); vault.setDescription(vaultDto.description); - vault.setArchived(existingVault.isEmpty() ? false : vaultDto.archived); + vault.setArchived(existingVault.isPresent() && vaultDto.archived); + vault.setUvfMetadataFile(vaultDto.uvfMetadataFile); + vault.setUvfKeySet(vaultDto.uvfKeySet); vaultRepo.persistAndFlush(vault); // trigger PersistenceException before we continue with if (existingVault.isEmpty()) { @@ -512,14 +553,18 @@ public record VaultDto(@JsonProperty("id") UUID id, @JsonProperty("description") @NoHtmlOrScriptChars String description, @JsonProperty("archived") boolean archived, @JsonProperty("creationTime") Instant creationTime, + @JsonProperty("uvfMetadataFile") String uvfMetadataFile, + @JsonProperty("uvfKeySet") String uvfKeySet, // Legacy properties ("Vault Admin Password"): - @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") Integer iterations, - @JsonProperty("salt") @OnlyBase64Chars String salt, + @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") Integer iterations, @JsonProperty("salt") @OnlyBase64Chars String salt, @JsonProperty("authPublicKey") @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars String authPrivateKey + ) { public static VaultDto fromEntity(Vault entity) { - return new VaultDto(entity.getId(), entity.getName(), entity.getDescription(), entity.isArchived(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getMasterkey(), entity.getIterations(), entity.getSalt(), entity.getAuthenticationPublicKey(), entity.getAuthenticationPrivateKey()); + return new VaultDto(entity.getId(), entity.getName(), entity.getDescription(), entity.isArchived(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getUvfMetadataFile(), entity.getUvfKeySet(), + // legacy properties: + entity.getMasterkey(), entity.getIterations(), entity.getSalt(), entity.getAuthenticationPublicKey(), entity.getAuthenticationPrivateKey()); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java index e55f4b5b1..bcb69ee3c 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java @@ -9,6 +9,9 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import org.hibernate.annotations.Immutable; @@ -19,6 +22,7 @@ import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.Stream; @Entity @Immutable @@ -62,11 +66,29 @@ SELECT count(DISTINCT u) FROM EffectiveVaultAccess eva WHERE eva.id.vaultId = :vaultId AND eva.id.authorityId = :authorityId """) +@NamedQuery(name = "EffectiveVaultAccess.findMembersWithoutAccessTokens", query = """ + SELECT eva + FROM EffectiveVaultAccess eva + INNER JOIN User u ON u.id = eva.id.authorityId + LEFT JOIN AccessToken token ON token.id.vaultId = eva.id.vaultId AND token.id.userId = eva.id.authorityId + WHERE eva.id.vaultId = :vaultId AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL + """ +) public class EffectiveVaultAccess { @EmbeddedId private EffectiveVaultAccess.Id id; + @ManyToOne + @MapsId("vaultId") + @JoinColumn(name = "vault_id") + private Vault vault; + + @ManyToOne + @MapsId("authorityId") + @JoinColumn(name = "authority_id") + private Authority authority; + public Id getId() { return id; } @@ -75,6 +97,30 @@ public void setId(Id id) { this.id = id; } + public Vault getVault() { + return vault; + } + + public void setVault(Vault vault) { + this.vault = vault; + } + + public Authority getAuthority() { + return authority; + } + + public void setAuthority(Authority authority) { + this.authority = authority; + } + + public VaultAccess.Role getRole() { + return id.role; + } + + public void setRole(VaultAccess.Role role) { + this.id.role = role; + } + @Embeddable public static class Id implements Serializable { @@ -170,8 +216,12 @@ public long countSeatOccupyingUsersOfGroup(String groupId) { public Collection listRoles(UUID vaultId, String authorityId) { return find("#EffectiveVaultAccess.findByAuthorityAndVault", Parameters.with("vaultId", vaultId).and("authorityId", authorityId)).stream() - .map(eva -> eva.getId().getRole()) + .map(EffectiveVaultAccess::getRole) .collect(Collectors.toUnmodifiableSet()); } + + public Stream findMembersWithoutAccessTokens(UUID vaultId) { + return find("#EffectiveVaultAccess.findMembersWithoutAccessTokens", Parameters.with("vaultId", vaultId)).stream(); + } } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/User.java b/backend/src/main/java/org/cryptomator/hub/entities/User.java index 5ab528f61..50e9d2d0c 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/User.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/User.java @@ -20,15 +20,6 @@ @Entity @Table(name = "user_details") @DiscriminatorValue("USER") -@NamedQuery(name = "User.requiringAccessGrant", - query = """ - SELECT u - FROM User u - INNER JOIN EffectiveVaultAccess perm ON u.id = perm.id.authorityId - LEFT JOIN u.accessTokens token ON token.id.vaultId = :vaultId AND token.id.userId = u.id - WHERE perm.id.vaultId = :vaultId AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL - """ -) @NamedQuery(name = "User.getEffectiveGroupUsers", query = """ SELECT DISTINCT u FROM User u @@ -175,10 +166,6 @@ public int hashCode() { @ApplicationScoped public static class Repository implements PanacheRepositoryBase { - public Stream findRequiringAccessGrant(UUID vaultId) { - return find("#User.requiringAccessGrant", Parameters.with("vaultId", vaultId)).stream(); - } - public long countEffectiveGroupUsers(String groupdId) { return count("#User.countEffectiveGroupUsers", Parameters.with("groupId", groupdId)); } 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 ed1bdf077..e8acd432f 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java @@ -105,6 +105,12 @@ public class Vault { @Column(name = "archived", nullable = false) private boolean archived; + @Column(name = "uvf_metadata_file") + private String uvfMetadataFile; + + @Column(name = "uvf_jwks") + private String uvfKeySet; + public Optional getAuthenticationPublicKeyOptional() { if (authenticationPublicKey == null) { return Optional.empty(); @@ -230,6 +236,22 @@ public void setArchived(boolean archived) { this.archived = archived; } + public String getUvfMetadataFile() { + return uvfMetadataFile; + } + + public void setUvfMetadataFile(String uvfMetadataFile) { + this.uvfMetadataFile = uvfMetadataFile; + } + + public String getUvfKeySet() { + return uvfKeySet; + } + + public void setUvfKeySet(String uvfKeySet) { + this.uvfKeySet = uvfKeySet; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -240,12 +262,14 @@ public boolean equals(Object o) { && Objects.equals(salt, vault.salt) && Objects.equals(iterations, vault.iterations) && Objects.equals(masterkey, vault.masterkey) - && Objects.equals(archived, vault.archived); + && Objects.equals(archived, vault.archived) + && Objects.equals(uvfMetadataFile, vault.uvfMetadataFile) + && Objects.equals(uvfKeySet, vault.uvfKeySet); } @Override public int hashCode() { - return Objects.hash(id, name, salt, iterations, masterkey, archived); + return Objects.hash(id, name, salt, iterations, masterkey, archived, uvfMetadataFile, uvfKeySet); } @Override @@ -255,12 +279,12 @@ public String toString() { ", members=" + directMembers.stream().map(Authority::getId).collect(Collectors.joining(", ")) + ", accessToken=" + accessTokens.stream().map(a -> a.getId().toString()).collect(Collectors.joining(", ")) + ", name='" + name + '\'' + - ", archived='" + archived + '\'' + ", salt='" + salt + '\'' + ", iterations='" + iterations + '\'' + ", masterkey='" + masterkey + '\'' + - ", authenticationPublicKey='" + authenticationPublicKey + '\'' + - ", authenticationPrivateKey='" + authenticationPrivateKey + '\'' + + ", archived='" + archived + '\'' + + ", uvfMetadataFile='" + uvfMetadataFile + '\'' + + ", uvfKeySet='" + uvfKeySet + '\'' + '}'; } diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png index 715ec7feb..cd2460e62 100644 Binary files a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png and b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png differ diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/V21__UVF_Support.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V21__UVF_Support.sql new file mode 100644 index 000000000..781b0dad1 --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V21__UVF_Support.sql @@ -0,0 +1,2 @@ +ALTER TABLE vault ADD uvf_metadata_file VARCHAR UNIQUE; -- vault.uvf file, encrypted as JWE +ALTER TABLE vault ADD uvf_jwks VARCHAR UNIQUE; -- encoded as JWKs \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java index b80fa56be..34f7d776c 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -56,7 +56,6 @@ import static org.hamcrest.Matchers.comparesEqualTo; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.text.IsEqualIgnoringCase.equalToIgnoringCase; @QuarkusTest @@ -93,10 +92,12 @@ 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_UVF_METADATA_FILE = "{json}"; + private static final String VALID_UVF_RECOVERY_KEY = "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_UVF_METADATA_FILE, VALID_UVF_RECOVERY_KEY, VALID_MASTERKEY, 8, VALID_SALT, VALID_AUTH_PUB, VALID_AUTH_PRI); var violations = validator.validate(dto); MatcherAssert.assertThat(violations, Matchers.empty()); } @@ -265,7 +266,7 @@ public void testUnlock() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 403 for missing role") public void testCreateVaultWithMissingRole() { 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"), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -288,7 +289,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"), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -313,7 +314,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"), "uvfMetadata4", "uvfKeySet4","masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100004444") @@ -329,7 +330,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", "doNotUpdate", "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -614,11 +615,11 @@ public void addGroupToVault() { @Test @Order(3) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members does contain group2 with memberSize=2") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/members does contain group2") public void getMembersOfVault1a() { given().when().get("/vaults/{vaultId}/members", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body("find { it.id == 'group2' }.memberSize", equalTo(2)); + .body("id", hasItems("group2")); } @Test @@ -780,7 +781,7 @@ public void testUpdateVaultDespiteLicenseExceeded() { Assumptions.assumeTrue(effectiveVaultAccessRepo.countSeatOccupyingUsers() == 5); var vaultId = "7E57C0DE-0000-4000-8000-000100001111"; - var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault 1", "This is a testvault.", false, Instant.parse("2222-11-11T11:11:11Z"), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault 1", "This is a testvault.", false, Instant.parse("2222-11-11T11:11:11Z"), "doNotUpdate", "doNotUpdate", "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", vaultId) @@ -806,7 +807,7 @@ public void testCreateVaultExceedingSeats() throws SQLException { Assumptions.assumeTrue(effectiveVaultAccessRepo.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"), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") .then().statusCode(402); 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 ee02b474b..1aa04f42b 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 @@ -34,15 +34,18 @@ 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 + ), ('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 + ), ('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 + ); 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 0f5656773..747cf27eb 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -47,6 +47,8 @@ export type VaultDto = { salt?: string; authPublicKey?: string; authPrivateKey?: string; + uvfMetadataFile?: string; + uvfKeySet?: string; }; export type DeviceDto = { @@ -187,15 +189,14 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 404, 409)); } - public async getUsersRequiringAccessGrant(vaultId: string): Promise { - return axiosAuth.get(`/vaults/${vaultId}/users-requiring-access-grant`) + public async getUsersRequiringAccessGrant(vaultId: string): Promise<(MemberDto & UserDto)[]> { + return axiosAuth.get<(MemberDto & UserDto)[]>(`/vaults/${vaultId}/users-requiring-access-grant`) .then(response => response.data.map(AuthorityService.fillInMissingPicture)) .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() }; - return axiosAuth.put(`/vaults/${vaultId}`, body) + public async createOrUpdateVault(vault: VaultDto): Promise { + return axiosAuth.put(`/vaults/${vault.id}`, vault) .then(response => response.data) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404)); } diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 37ad9e35b..da156c7ff 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,7 +1,16 @@ -import * as miscreant from 'miscreant'; -import { base16, base32, base64, base64url } from 'rfc4648'; -import { JWEBuilder, JWEParser } from './jwe'; -import { UTF8, CRC32, DB, wordEncoder } from './util'; +import { base16, base64, base64url } from 'rfc4648'; +import { VaultDto } from './backend'; +import { JWE, Recipient } from './jwe'; +import { DB, UTF8 } from './util'; + +/** + * Represents a JSON Web Key (JWK) as defined in RFC 7517. + * @see https://datatracker.ietf.org/doc/html/rfc7517#section-5 + */ +export type JsonWebKeySet = { + keys: JsonWebKey & { kid?: string }[] // RFC defines kid, but webcrypto spec does not +} + export class UnwrapKeyError extends Error { readonly actualError: unknown; @@ -11,29 +20,15 @@ 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 JWEPayload { - key: string +export interface AccessTokenPayload { + /** + * The vault key (base64-encoded DER-formatted) + */ + key: string, + [key: string]: string | number | boolean | object | undefined } -interface UserKeyPayload { +interface UserKeyPayload extends AccessTokenPayload { /** * @deprecated use `ecdhPrivateKey` instead */ @@ -42,222 +37,60 @@ interface UserKeyPayload { ecdsaPrivateKey: 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; +export const GCM_NONCE_LEN = 12; - protected constructor(masterkey: CryptoKey) { - this.masterKey = masterkey; - } +export interface AccessTokenProducing { /** - * Creates a new masterkey - * @returns A new masterkey + * Creates a user-specific access token for the given vault. + * @param userPublicKey the public key of the user + * @param isOwner whether to also include owner secrets for this user (UVF only) */ - public static async create(): Promise { - const key = crypto.subtle.generateKey( - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - return new VaultKeys(await key); - } + encryptForUser(userPublicKey: CryptoKey | Uint8Array, isOwner?: boolean): Promise; - /** - * 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 - */ - 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 = UTF8.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 = await 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 = await 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 = await crypto.subtle.importKey( - 'spki', - decodedPublicKey, - { name: 'ECDSA', namedCurve: 'P-384' }, - true, - ['verify'] - ); - return [new VaultKeys(masterkey), { privateKey: privKey, publicKey: pubKey }]; - } catch (error) { - throw new UnwrapKeyError(error); - } - } +export interface VaultTemplateProducing { /** - * 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 + * Produces a zip file containing the vault template. + * @param apiURL absolute base URL of the API + * @param vault The vault */ - 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); - } + exportTemplate(apiURL: string, vault: VaultDto): Promise; - 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 unsignedToken = base64url.stringify(UTF8.encode(header), { pad: false }) + '.' + base64url.stringify(UTF8.encode(payloadJson), { pad: false }); - const encodedUnsignedToken = UTF8.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 = UTF8.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); - } - } +/** + * Represents a vault member by their public key. + */ +export class OtherVaultMember { + protected constructor(readonly publicKey: Promise) { } /** - * Encrypts this masterkey using the given public key - * @param userPublicKey The recipient's public key (DER-encoded) - * @returns a JWE containing this Masterkey + * Creates a new vault member with the given public key + * @param publicKey The public key of the vault member + * @returns A vault member with the given public key */ - public async encryptForUser(userPublicKey: CryptoKey | BufferSource): Promise { - const publicKey = await asPublicKey(userPublicKey, UserKeys.ECDH_KEY_DESIGNATION); - 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); - } + public static withPublicKey(publicKey: CryptoKey | BufferSource): OtherVaultMember { + const keyPromise = asPublicKey(publicKey, UserKeys.ECDH_KEY_DESIGNATION); + return new OtherVaultMember(keyPromise); } /** - * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes + * Creates an access token for this vault member. + * @param payload The payload to encrypt + * @return A ECDH-ES encrypted JWE containing the encrypted payload */ - 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); + public async createAccessToken(payload: AccessTokenPayload): Promise { + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', await this.publicKey)); + return jwe.compactSerialization(); } } +/** + * The current user's key pair. + */ export class UserKeys { public static readonly ECDH_PRIV_KEY_USAGES: KeyUsage[] = ['deriveBits']; @@ -291,7 +124,7 @@ export class UserKeys { * @throws {UnwrapKeyError} when attempting to decrypt the private key using an incorrect setupCode */ public static async recover(privateKeys: string, setupCode: string, userEcdhPublicKey: CryptoKey | BufferSource, userEcdsaPublicKey?: CryptoKey | BufferSource): Promise { - const jwe: UserKeyPayload = await JWEParser.parse(privateKeys).decryptPbes2(setupCode); + const jwe: UserKeyPayload = await JWE.parseCompact(privateKeys).decrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', setupCode)); return UserKeys.createFromJwe(jwe, userEcdhPublicKey, userEcdsaPublicKey); } @@ -304,20 +137,20 @@ export class UserKeys { * @returns The user's key pair */ public static async decryptOnBrowser(jwe: string, browserPrivateKey: CryptoKey, userEcdhPublicKey: CryptoKey | BufferSource, userEcdsaPublicKey?: CryptoKey | BufferSource): Promise { - const payload: UserKeyPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); + const payload: UserKeyPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', browserPrivateKey)); return UserKeys.createFromJwe(payload, userEcdhPublicKey, userEcdsaPublicKey); } private static async createFromJwe(jwe: UserKeyPayload, ecdhPublicKey: CryptoKey | BufferSource, ecdsaPublicKey?: CryptoKey | BufferSource): Promise { const ecdhKeyPair: CryptoKeyPair = { publicKey: await asPublicKey(ecdhPublicKey, UserKeys.ECDH_KEY_DESIGNATION), - privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdhPrivateKey ?? jwe.key, { loose: true }), UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_PRIV_KEY_USAGES) + privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdhPrivateKey ?? jwe.key, { loose: true }).slice(), UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_PRIV_KEY_USAGES) }; let ecdsaKeyPair: CryptoKeyPair; if (jwe.ecdsaPrivateKey && ecdsaPublicKey) { ecdsaKeyPair = { publicKey: await asPublicKey(ecdsaPublicKey, UserKeys.ECDSA_KEY_DESIGNATION, UserKeys.ECDSA_PUB_KEY_USAGES), - privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdsaPrivateKey, { loose: true }), UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_PRIV_KEY_USAGES) + privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdsaPrivateKey, { loose: true }).slice(), UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_PRIV_KEY_USAGES) }; } else { // ECDSA key was added in Hub 1.4.0. If it's missing, we generate a new one. @@ -347,24 +180,37 @@ 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. + * @param p2c Optional number of iterations for PBKDF2. * @returns A JWE holding the encrypted private key - * @see JWEBuilder.pbes2 + * @see Recipient.pbes2 */ - public async encryptWithSetupCode(setupCode: string, iterations?: number): Promise { + public async encryptWithSetupCode(setupCode: string, p2c?: number): Promise { const payload = await this.prepareForEncryption(); - return await JWEBuilder.pbes2(setupCode, iterations).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', setupCode, p2c)); + return jwe.compactSerialization(); } /** * 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 { + public async encryptForDevice(devicePublicKey: CryptoKey | BufferSource): Promise { const publicKey = await asPublicKey(devicePublicKey, BrowserKeys.KEY_DESIGNATION); const payload = await this.prepareForEncryption(); - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', publicKey)); + return jwe.compactSerialization(); + } + + /** + * Decrypts the access token using the user's ECDH private key + * @param jwe The encrypted access token + * @returns The token's payload + */ + public async decryptAccessToken(jwe: string): Promise { + const payload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', this.ecdhKeyPair.privateKey)); + return payload; } private async prepareForEncryption(): Promise { @@ -467,6 +313,18 @@ export async function asPublicKey(publicKey: CryptoKey | BufferSource, keyDesign /** * Computes the JWK Thumbprint (RFC 7638) using SHA-256. * @param key A key to compute the thumbprint for + * @returns The thumbprint as a base64url-encoded string + * @throws Error if the key is not supported + */ +export async function getJwkThumbprintStr(key: JsonWebKey | CryptoKey): Promise { + const thumbprint = await getJwkThumbprint(key); + return base64url.stringify(new Uint8Array(thumbprint), { pad: false }); +} + +/** + * Computes the JWK Thumbprint (RFC 7638) using SHA-256. + * @param key A key to compute the thumbprint for + * @returns The thumbprint as a Uint8Array * @throws Error if the key is not supported */ export async function getJwkThumbprint(key: JsonWebKey | CryptoKey): Promise { diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index a5b6fe74a..8b1be4205 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -12,7 +12,7 @@ export class ConcatKDF { * @param otherInfo Optional context info binding the derived key to a key agreement (see e.g. RFC 7518, Section 4.6.2) * @returns key data */ - public static async kdf(z: Uint8Array, keyDataLen: number, otherInfo: Uint8Array): Promise { + public static async kdf(z: Uint8Array, keyDataLen: number, otherInfo: Uint8Array): Promise> { const hashLen = 32; // output length of SHA-256 const reps = Math.ceil(keyDataLen / hashLen); if (reps >= 0xFFFFFFFF) { @@ -37,13 +37,31 @@ export class ConcatKDF { } export type JWEHeader = { - readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW', - readonly enc: 'A256GCM' | 'A128GCM', - readonly apu?: string, - readonly apv?: string, - readonly epk?: JsonWebKey, - readonly p2c?: number, - readonly p2s?: string + 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, + jku?: string, + cty?: 'json', + crit?: string[], + [other: string]: undefined | string | number | boolean | object; // allow further properties +} + +export type JsonJWE = { + protected: string, + recipients: PerRecipientProperties[] + iv: string, + ciphertext: string, + tag: string +} + +type PerRecipientProperties = { + encrypted_key: string; + header: JWEHeader; } export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { @@ -51,174 +69,353 @@ export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { namedCurve: 'P-384' }; -export class JWEParser { - readonly header: JWEHeader; - readonly encryptedKey: Uint8Array; - readonly iv: Uint8Array; - readonly ciphertext: Uint8Array; - readonly tag: Uint8Array; - - private constructor(readonly encodedHeader: string, readonly encodedEncryptedKey: string, readonly encodedIv: string, readonly encodedCiphertext: string, readonly encodedTag: string) { - this.header = JSON.parse(UTF8.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 }); - } +// #region Recipients + +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: JWEHeader): 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'); + 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 + * + * @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 { + if (recipientKey?.type === 'secret' || recipientKey?.algorithm.name !== 'ECDH') { + throw new Error('Unsupported recipient key'); } - 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); + 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 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 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 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); + } +} + +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: 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: JWEHeader = { + ...commonHeader, + kid: this.kid, + 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, + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) + }; + } + + async decrypt(header: JWEHeader, 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: JWEHeader, 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 saltInput = base64url.parse(this.header.p2s, { loose: true }); - const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c); + const epk = await crypto.subtle.importKey('jwk', header.epk!, { name: 'ECDH', namedCurve: header.epk?.crv }, false, []); + return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, keyAlgorithm.length / 8, header, false, keyAlgorithm, keyUsage); + } + + 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 }) as Uint8Array, wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); + } catch (error) { + throw new UnwrapKeyError(error); + } + } +} + +class A256kwRecipient extends Recipient { + constructor(readonly kid: string, private wrappingKey: CryptoKey) { + super(kid); + } + + 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')); + return { + header: header, + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) + }; + } + + async decrypt(header: JWEHeader, encryptedKey: string): Promise { + if (header.alg != 'A256KW') { + throw new Error('unsupported alg'); + } + try { + return await crypto.subtle.unwrapKey('raw', base64url.parse(encryptedKey, { loose: true }) as Uint8Array, this.wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); + } catch (error) { + throw new UnwrapKeyError(error); + } + } +} + +class Pbes2Recipient extends Recipient { + constructor(readonly kid: string, private password: string, private iterations: number) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const header: JWEHeader = { + ...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, + encrypted_key: base64url.stringify(encryptedKey, { pad: false }) + }; + } + + 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.'); + } + const salt = base64url.parse(header.p2s, { loose: true }); + const wrappingKey = await PBES2.deriveWrappingKey(this.password, 'PBES2-HS512+A256KW', salt, header.p2c); 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 }) as Uint8Array, 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, private protectedHeader: JWEHeader) { } + + public static build(payload: object, protectedHeader: JWEHeader = {}): JWE { + return new JWE(payload, protectedHeader); + } - private async decrypt(cek: CryptoKey): Promise { - 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( + public static parseCompact(token: string): EncryptedJWE { + const [protectedHeader, encryptedKey, iv, ciphertext, tag] = token.split('.', 5); + const utf8 = new TextDecoder(); + const header: JWEHeader = JSON.parse(utf8.decode(base64url.parse(protectedHeader, { loose: true }))); + + return new EncryptedJWE(protectedHeader, [{ encrypted_key: encryptedKey, header: header }], iv, ciphertext, tag); + } + + public static parseJson(jwe: JsonJWE): EncryptedJWE { + 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: JWEHeader = { + ...this.protectedHeader, + enc: 'A256GCM' + }; + const cek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const perRecipientData = await Promise.all([recipient, ...moreRecipients].map(r => r.encrypt(cek, protectedHeader))); + + if (perRecipientData.length === 1) { + protectedHeader = { + ...protectedHeader, + ...perRecipientData[0].header + }; + } else { + protectedHeader.enc = perRecipientData[0].header.enc; + for (const key of Object.keys(protectedHeader)) { + perRecipientData.forEach(r => delete r.header[key]); + } + } + + const encodedProtectedHeader = base64url.stringify(UTF8.encode(JSON.stringify(protectedHeader)), { pad: false }); + const m = UTF8.encode(JSON.stringify(this.payload)); + const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', - iv: this.iv, - additionalData: UTF8.encode(this.encodedHeader), + iv: iv, + additionalData: UTF8.encode(encodedProtectedHeader), tagLength: 128 }, cek, m )); - return JSON.parse(UTF8.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, perRecipientData, encodedIv, encodedCiphertext, encodedTag); } } -export class JWEBuilder { - private constructor(readonly header: Promise, readonly encryptedKey: Promise, readonly cek: Promise) { } +// 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: 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 = Promise.resolve(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); + 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.encrypted_key + })); + return { + protected: this.protectedHeader, + recipients: recipients, + iv: this.iv, + ciphertext: this.ciphertext, + tag: this.tag + }; } - /** - * 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 compactSerialization(): string { + if (this.perRecipient.length !== 1) { + throw new Error('JWE Compact Serialization requires exactly one recipient.'); + } + return `${this.protectedHeader}.${this.perRecipient[0].encrypted_key}.${this.iv}.${this.ciphertext}.${this.tag}`; } - /** - * Builds the JWE. - * @param payload Payload to be encrypted - * @returns The JWE - */ - public async encrypt(payload: object) { - /* JWE assembly and content encryption described in RFC 7516: */ - const encodedHeader = base64url.stringify(UTF8.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( + public async decrypt(recipient: Recipient): Promise { + const protectedHeader: JWEHeader = JSON.parse(UTF8.decode(base64url.parse(this.protectedHeader, { loose: true }))); + const perRecipientData = (this.perRecipient.length === 1) + ? this.perRecipient[0] + : this.perRecipientWithKid(recipient.kid); + 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]); + const cleartext = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', - iv: iv, - additionalData: UTF8.encode(encodedHeader), + iv: base64url.parse(this.iv, { loose: true }) as Uint8Array, + additionalData: UTF8.encode(this.protectedHeader), tagLength: 128 }, - await this.cek, - UTF8.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(UTF8.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(UTF8.encode(header.enc)); + const algOrEnc = header.alg === 'ECDH-ES' ? header.enc : header.alg; // see definition of AlgorithmID in RFC 7518, Section 4.6.2 + if (!algOrEnc) { + throw new Error('Missing alg or enc header parameter'); + } + const algorithmId = ECDH_ES.lengthPrefixed(UTF8.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( { @@ -228,15 +425,31 @@ export class ECDH_ES { privateKey, 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']); + const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo), ...suppPrivInfo]); + 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); @@ -250,7 +463,6 @@ export class PBES2 { public static readonly DEFAULT_ITERATION_COUNT = 1000000; private static readonly NULL_BYTE = Uint8Array.of(0x00); - // TODO: can we dedup this with crypto.ts's PBKDF2? Or is the latter unused anyway, once we migrate all ciphertext to JWE containers public static async deriveWrappingKey(password: string, alg: 'PBES2-HS512+A256KW' | 'PBES2-HS256+A128KW', salt: Uint8Array, iterations: number, extractable: boolean = false): Promise { let hash, keyLen; if (alg == 'PBES2-HS512+A256KW') { @@ -287,3 +499,5 @@ export class PBES2 { ); } } + +// #endregion diff --git a/frontend/src/common/jwt.ts b/frontend/src/common/jwt.ts index cab542ab9..9908e7aca 100644 --- a/frontend/src/common/jwt.ts +++ b/frontend/src/common/jwt.ts @@ -9,6 +9,16 @@ export type JWTHeader = { } export class JWT { + public header: any; + public payload: any; + public signature: Uint8Array; + + private constructor(header: any, payload: any, signature: Uint8Array) { + this.header = header; + this.payload = payload; + this.signature = signature; + } + /** * Creates an ES384 JWT (signed with ECDSA using P-384 and SHA-384). * diff --git a/frontend/src/common/universalVaultFormat.ts b/frontend/src/common/universalVaultFormat.ts new file mode 100644 index 000000000..99b259e2b --- /dev/null +++ b/frontend/src/common/universalVaultFormat.ts @@ -0,0 +1,531 @@ +import JSZip from 'jszip'; +import { base32, base64, base64url } from 'rfc4648'; +import { VaultDto } from './backend'; +import { AccessTokenPayload, AccessTokenProducing, JsonWebKeySet, OtherVaultMember, UserKeys, VaultTemplateProducing, getJwkThumbprintStr } from './crypto'; +import { JWE, JWEHeader, JsonJWE, Recipient } from './jwe'; +import { CRC32, UTF8, wordEncoder } from './util'; + +type MetadataPayload = { + fileFormat: 'AES-256-GCM-32k'; + nameFormat: 'AES-SIV-512-B64URL'; + seeds: Record; + initialSeed: string; + latestSeed: string; + kdf: 'HKDF-SHA512'; + kdfSalt: string; + 'org.cryptomator.automaticAccessGrant': VaultMetadataJWEAutomaticAccessGrantDto; +} + +type VaultMetadataJWEAutomaticAccessGrantDto = { + enabled: boolean, + maxWotDepth: number +} + +type UvfAccessTokenPayload = AccessTokenPayload & { + /** + * optional private key of the recovery key pair (PKCS8-encoded; only shared with vault owners) + */ + recoveryKey?: 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 | AesKeyAlgorithm = { 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); + } + + /** + * Creates a new vault member key + * @param encodedKey base64-encoded raw 256 bit key (as retrieved from {@link AccessTokenPayload#key}) + * @returns new key + */ + public static async load(encodedKey: string): Promise { + let rawKey: Uint8Array = new Uint8Array(); + try { + rawKey = base64.parse(encodedKey).slice(); + const memberKey = await crypto.subtle.importKey('raw', rawKey, MemberKey.KEY_DESIGNATION, true, MemberKey.KEY_USAGE); + return new MemberKey(memberKey); + } finally { + rawKey.fill(0x00); + } + } + + /** + * Encodes the key + * @returns member key in base64-encoded raw format + */ + public async serializeKey(): Promise { + const bytes = await crypto.subtle.exportKey('raw', this.key); + return base64.stringify(new Uint8Array(bytes), { pad: true }); + } +} +// #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_USAGES: 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, true, RecoveryKey.KEY_USAGES); + return new RecoveryKey(keypair.publicKey, keypair.privateKey); + } + + /** + * Imports the public key of the recovery key pair. + * @param publicKey the DER-encoded public key + * @param publicKey the PKCS8-encoded private key + * @returns recovery key for encrypting vault metadata + */ + public static async import(publicKey: CryptoKey | Uint8Array, privateKey?: CryptoKey | Uint8Array): Promise { + if (publicKey instanceof Uint8Array) { + publicKey = await crypto.subtle.importKey('spki', publicKey, RecoveryKey.KEY_DESIGNATION, true, []); + } + if (privateKey instanceof Uint8Array) { + privateKey = await crypto.subtle.importKey('pkcs8', privateKey, RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + } + return new RecoveryKey(publicKey, privateKey); + } + + /** + * Restores the Recovery Key Pair + * @param recoveryKey the encoded recovery key + * @returns complete recovery key for decrypting vault metadata + * @throws DecodeUvfRecoveryKeyError, if passing a malformed recovery key + */ + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + let decoded; + try { + decoded = wordEncoder.decode(recoveryKey); + } catch (error) { + throw new DecodeUvfRecoveryKeyError(error instanceof Error ? error.message : 'Internal error. See console log for more info.'); + } + + const paddingLength = decoded[decoded.length - 1]; + if (paddingLength > 0x03) { + throw new DecodeUvfRecoveryKeyError('Invalid padding'); + } + const unpadded = decoded.subarray(0, -paddingLength); + const checksum = unpadded.subarray(-2); + const rawkey = unpadded.slice(0, -2); + const crc32 = CRC32.compute(rawkey); + if (checksum[0] !== (crc32 & 0xFF) + || checksum[1] !== (crc32 >> 8 & 0xFF)) { + throw new DecodeUvfRecoveryKeyError('Invalid recovery key checksum.'); + } + + // construct new RecoveryKey from recovered key + const privateKey = await crypto.subtle.importKey('pkcs8', rawkey, RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + delete jwk.d; // remove private part + const publicKey = await crypto.subtle.importKey('jwk', jwk, RecoveryKey.KEY_DESIGNATION, true, []); + return new RecoveryKey(publicKey, privateKey); + } + + /** + * Encodes the private key as a list of words + * @returns private key in a human-readable encoding + */ + public async createRecoveryKey(): Promise { + if (!this.privateKey) { + throw new Error('Private key not available'); + } + const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey)); + + // 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]); + + // add 1-3 bytes of padding: + const numPaddingBytes = 3 - (combined.length % 3); + const padding = new Uint8Array(numPaddingBytes); + padding.fill(numPaddingBytes & 0xFF); // 01 or 02 02 or 03 03 03 + const padded = new Uint8Array([...combined, ...padding]); + + // encode using human-readable words: + return wordEncoder.encodePadded(padded); + } + + /** + * Encodes the private key + * @returns private key in base64-encoded DER format + */ + public async serializePrivateKey(): Promise { + if (!this.privateKey) { + throw new Error('Private key not available'); + } + const bytes = await crypto.subtle.exportKey('pkcs8', this.privateKey); + return base64.stringify(new Uint8Array(bytes), { pad: true }); + } + + /** + * Encodes the public key + * @returns public key in JWK format + */ + public async serializePublicKey(): Promise { + const jwk = await crypto.subtle.exportKey('jwk', this.publicKey); + const thumbprint = await getJwkThumbprintStr(jwk); + return JSON.stringify({ + kid: `org.cryptomator.hub.recoverykey.${thumbprint}`, + kty: jwk.kty, + crv: jwk.crv, + x: jwk.x, + y: jwk.y + }); + } +} + +export class DecodeUvfRecoveryKeyError extends Error { + constructor(message: string) { + super(message); + } +} + +// #endregion + +// #region Vault metadata +/** + * The UVF Metadata file + */ +export class VaultMetadata { + private constructor( + readonly automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, + readonly seeds: Map>, + readonly initialSeedId: number, + readonly latestSeedId: number, + readonly kdfSalt: Uint8Array) { + if (!seeds.has(initialSeedId)) { + throw new Error('Initial seed is missing'); + } + if (!seeds.has(latestSeedId)) { + throw new Error('Latest seed is missing'); + } + } + + /** + * 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 Uint8Array(4); + const initialSeedValue = new Uint8Array(32); + const kdfSalt = new Uint8Array(32); + crypto.getRandomValues(initialSeedId); + crypto.getRandomValues(initialSeedValue); + crypto.getRandomValues(kdfSalt); + const initialSeedNo = new DataView(initialSeedId.buffer).getInt32(0, false); + const seeds: Map = new Map(); + seeds.set(initialSeedNo, initialSeedValue); + return new VaultMetadata(automaticAccessGrant, seeds, initialSeedNo, initialSeedNo, kdfSalt); + } + + public get initialSeed(): Uint8Array { + if (!this.seeds.has(this.initialSeedId)) { + throw new Error('Illegal State'); + } + return this.seeds.get(this.initialSeedId)!; + } + + public get latestSeed(): Uint8Array { + if (!this.seeds.has(this.latestSeedId)) { + throw new Error('Illegal State'); + } + return this.seeds.get(this.latestSeedId)!; + } + + /** + * 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 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.key)); + return VaultMetadata.createFromJson(payload); + } + + /** + * Decrypts the vault metadata using the recovery key + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param recoveryKey the vault's recovery key + * @returns Decrypted vault metadata + */ + public static async decryptWithRecoveryKey(uvfMetadataFile: string, recoveryKey: RecoveryKey): Promise { + if (!recoveryKey.privateKey) { + throw new Error('Recovery key does not have a private key'); + } + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprintStr(recoveryKey.publicKey)}`; + const json: JsonJWE = JSON.parse(uvfMetadataFile); + const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.ecdhEs(recoveryKeyID, recoveryKey.privateKey)); + return VaultMetadata.createFromJson(payload); + } + + public static async createFromJson(payload: MetadataPayload): Promise { + const seeds = new Map>(); + for (const key in payload.seeds) { + const num = parseSeedId(key); + const value = base64url.parse(payload.seeds[key], { loose: true }).slice(); + seeds.set(num, value); + } + const initialSeedId = parseSeedId(payload['initialSeed']); + const latestSeedId = parseSeedId(payload['latestSeed']); + const kdfSalt = base64url.parse(payload['kdfSalt'], { loose: true }).slice(); + return new VaultMetadata( + payload['org.cryptomator.automaticAccessGrant'], + seeds, + initialSeedId, + latestSeedId, + kdfSalt + ); + } + + /** + * Encrypts the vault metadata + * @param apiURL absolute base URL of the API + * @param vault the corresponding vault + * @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 encrypt(apiURL: string, vault: VaultDto, memberKey: MemberKey, recoveryKey: RecoveryKey): Promise { + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprintStr(recoveryKey.publicKey)}`; + // see https://github.com/encryption-alliance/unified-vault-format/tree/develop/vault%20metadata#jose-header + const protectedHeader: JWEHeader = { + // enc: 'A256GCM', // will be set by JWE.build() + cty: 'json', + crit: ['uvf.spec.version'], + 'uvf.spec.version': 1, + 'cloud.katta.origin': `${apiURL}/vaults/${vault.id}/uvf/vault.uvf`, // single source of truth for this vault + jku: 'jwks.json', // URL relative to cloud.katta.origin + }; + 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); + } + + public payload(): MetadataPayload { + const encodedSeeds: Record = {}; + for (const [key, value] of this.seeds) { + const seedId = stringifySeedId(key); + encodedSeeds[seedId] = base64url.stringify(value, { pad: false }); + } + return { + fileFormat: 'AES-256-GCM-32k', + nameFormat: 'AES-SIV-512-B64URL', + seeds: encodedSeeds, + initialSeed: stringifySeedId(this.initialSeedId), + latestSeed: stringifySeedId(this.latestSeedId), + kdf: 'HKDF-SHA512', + kdfSalt: base64url.stringify(this.kdfSalt, { pad: false }), + 'org.cryptomator.automaticAccessGrant': this.automaticAccessGrant + }; + } +} +// #endregion +// #region UVF + +/** + * A UVF-formatted Vault + */ +export class UniversalVaultFormat implements AccessTokenProducing, VaultTemplateProducing { + private constructor(readonly metadata: VaultMetadata, readonly memberKey: MemberKey, readonly recoveryKey: RecoveryKey) { } + + public static async create(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto): Promise { + const metadata = await VaultMetadata.create(automaticAccessGrant); + const memberKey = await MemberKey.create(); + const recoveryKey = await RecoveryKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + public static async forTesting(metadata: VaultMetadata) { + const memberKey = await MemberKey.create(); + const recoveryKey = await RecoveryKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + /** + * Decrypts a UVF vault. + * @param vault The vault to decrypt + * @param accessToken The vault member's access token + * @param userKeyPair THe vault member's key pair + * @returns The decrypted vault + */ + public static async decrypt(vault: VaultDto, accessToken: string, userKeyPair: UserKeys): Promise { + if (!vault.uvfMetadataFile || !vault.uvfKeySet) { + throw new Error('Not a UVF vault.'); + } + const jwks = JSON.parse(vault.uvfKeySet) as JsonWebKeySet; + const recoveryPublicKey = await this.getRecoveryPublicKeyFromJwks(jwks); + const payload = await userKeyPair.decryptAccessToken(accessToken) as UvfAccessTokenPayload; + const memberKey = await MemberKey.load(payload.key); + const metadata = await VaultMetadata.decryptWithMemberKey(vault.uvfMetadataFile, memberKey); + let recoveryKey: RecoveryKey; + if (payload.recoveryKey) { + recoveryKey = await RecoveryKey.import(recoveryPublicKey, base64.parse(payload.recoveryKey).slice()); + } else { + recoveryKey = await RecoveryKey.import(recoveryPublicKey); + } + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + /** + * Recover the `vault.uvf` file using the recovery key. After recovery, all access tokens need to be re-issued. + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param recoveryKey the vault's recovery key encoded into human-readable words + * @returns The recovered vault + */ + public static async recover(uvfMetadataFile: string, recoveryKey: string): Promise { + const recoveryKeyPair = await RecoveryKey.recover(recoveryKey); + const metadata = await VaultMetadata.decryptWithRecoveryKey(uvfMetadataFile, recoveryKeyPair); + const memberKey = await MemberKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKeyPair); + } + + private static async getRecoveryPublicKeyFromJwks(jwks: JsonWebKeySet): Promise { + for (const key of jwks.keys) { + if (key.kid?.startsWith('org.cryptomator.hub.recoverykey.')) { + const thumbprint = await getJwkThumbprintStr(key as JsonWebKey); + if (key.kid === `org.cryptomator.hub.recoverykey.${thumbprint}`) { + return await crypto.subtle.importKey('jwk', key as JsonWebKey, RecoveryKey.KEY_DESIGNATION, true, []); + } + } + } + throw new Error('Recovery key not found in JWKS'); + } + + /** + * Creates the `vault.uvf` file + * @param apiURL absolute base URL of the API + * @param vault the vault + * @returns `vault.uvf` file contents + */ + public async createMetadataFile(apiURL: string, vault: VaultDto): Promise { + return this.metadata.encrypt(apiURL, vault, this.memberKey, this.recoveryKey); + } + + public async computeRootDirId(): Promise> { + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveBits']); + const rootDirId = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('rootDirId') }, initialSeed, 256); + return new Uint8Array(rootDirId); + } + + public async computeRootDirIdHash(rootDirId: Uint8Array): Promise { + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveKey']); + const hmacKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('hmac') }, initialSeed, { name: 'HMAC', hash: 'SHA-256', length: 512 }, false, ['sign']); + const rootDirHash = await crypto.subtle.sign('HMAC', hmacKey, rootDirId); + return base32.stringify(new Uint8Array(rootDirHash).slice(0, 20)); + } + + public async encryptFile(content: Uint8Array, seedId: number): Promise { + const seed = this.metadata.seeds.get(seedId); + if (!seed) { + throw new Error('Seed not found'); + } + if (content.length > 32 * 1024) { + throw new Error('Only files up to 32k are supported.'); + } + const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + + // general header: + const generalHeader = new ArrayBuffer(8); + const view = new DataView(generalHeader); + view.setUint32(0, 0x75766601); // magic bytes "uvf1" + view.setUint32(4, seedId); + + // format-specific header: + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveKey']); + const headerKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('fileHeader') }, initialSeed, { name: 'AES-GCM', length: 256 }, false, ['wrapKey']); + const headerNonce = new Uint8Array(12); + crypto.getRandomValues(headerNonce); + const encryptedFileKeyAndTag = await crypto.subtle.wrapKey('raw', fileKey, headerKey, { name: 'AES-GCM', iv: headerNonce, additionalData: generalHeader }); + + // complete header: + const header = new Uint8Array([...new Uint8Array(generalHeader), ...headerNonce, ...new Uint8Array(encryptedFileKeyAndTag)]); + + // encrypt chunk 0: + const blockNonce = new Uint8Array(12); + crypto.getRandomValues(blockNonce); + const blockAd = new Uint8Array([0x00, 0x00, 0x00, 0x00, ...headerNonce]); + const blockCiphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: blockNonce, additionalData: blockAd }, fileKey, content); + + // result: + return new Uint8Array([...header, ...blockNonce, ...new Uint8Array(blockCiphertext)]); + } + + /** @inheritdoc */ + public async exportTemplate(apiURL: string, vault: VaultDto): Promise { + const rootDirId = await this.computeRootDirId(); + const rootDirHash = await this.computeRootDirIdHash(rootDirId); + const dirFile = await this.encryptFile(rootDirId, this.metadata.initialSeedId); + const zip = new JSZip(); + zip.file('vault.uvf', this.createMetadataFile(apiURL, vault)); + const rootDir = zip.folder('d')?.folder(rootDirHash.substring(0, 2))?.folder(rootDirHash.substring(2)); // TODO verify after merging https://github.com/encryption-alliance/unified-vault-format/pull/24 + rootDir?.file('dir.uvf', dirFile); + return zip.generateAsync({ type: 'blob' }); + } + + /** @inheritdoc */ + public async encryptForUser(userPublicKey: CryptoKey | Uint8Array, isOwner?: boolean): Promise { + const payload: UvfAccessTokenPayload = { + key: await this.memberKey.serializeKey(), + recoveryKey: isOwner && this.recoveryKey.privateKey ? await this.recoveryKey.serializePrivateKey() : undefined + }; + return OtherVaultMember.withPublicKey(userPublicKey).createAccessToken(payload); + } +} + +/** + * Decodes a base64url-encoded 32 bit big endian number. + * @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 DataView(bytes.buffer).getInt32(0, false); +} + +/** + * Encodes the seed ID as a 32 bit big endian integer and base64url-encodes it. + * @param id numeric seed ID + * @returns a base6url-encoded seed ID + */ +function stringifySeedId(id: number): string { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, id, false); + return base64url.stringify(bytes, { pad: false }); +} diff --git a/frontend/src/common/userdata.ts b/frontend/src/common/userdata.ts index 1ff1518cd..3252ca4f0 100644 --- a/frontend/src/common/userdata.ts +++ b/frontend/src/common/userdata.ts @@ -1,7 +1,7 @@ import { base64 } from 'rfc4648'; import backend, { DeviceDto, UserDto } from './backend'; import { BrowserKeys, UserKeys } from './crypto'; -import { JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; class UserData { #me?: Promise; @@ -65,12 +65,12 @@ class UserData { * * @see UserDto.ecdhPublicKey */ - public get ecdhPublicKey(): Promise { + public get ecdhPublicKey(): Promise> { return this.me.then(me => { if (!me.ecdhPublicKey) { throw new Error('User not initialized.'); } - return base64.parse(me.ecdhPublicKey); + return base64.parse(me.ecdhPublicKey).slice(); }); } @@ -79,9 +79,9 @@ class UserData { * * @see UserDto.ecdsaPublicKey */ - public get ecdsaPublicKey(): Promise { + public get ecdsaPublicKey(): Promise | undefined> { return this.me.then(me => { - return me.ecdsaPublicKey ? base64.parse(me.ecdsaPublicKey) : undefined; + return me.ecdsaPublicKey ? base64.parse(me.ecdsaPublicKey).slice() : undefined; }); } @@ -154,6 +154,16 @@ class UserData { return userKeys; } + public async decryptSetupCode(userKeys: UserKeys): Promise { + const me = await this.me; + if (me.setupCode) { + const payload: { setupCode: string } = await JWE.parseCompact(me.setupCode).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', userKeys.ecdhKeyPair.privateKey)); + return payload.setupCode; + } else { + throw new Error('User not set up yet.'); + } + } + /** * Updates the stored user keys, if the ECDSA key was missing before (added in 1.4.0) * @param userKeys The user keys that contain the ECDSA key @@ -161,11 +171,11 @@ class UserData { private async addEcdsaKeyIfMissing(userKeys: UserKeys) { const me = await this.me; if (me.setupCode && !me.ecdsaPublicKey) { - const payload: { setupCode: string } = await JWEParser.parse(me.setupCode).decryptEcdhEs(userKeys.ecdhKeyPair.privateKey); + const setupCode = await this.decryptSetupCode(userKeys); me.ecdsaPublicKey = await userKeys.encodedEcdsaPublicKey(); - me.privateKeys = await userKeys.encryptWithSetupCode(payload.setupCode); + me.privateKeys = await userKeys.encryptWithSetupCode(setupCode); for (const device of me.devices) { - device.userPrivateKey = await userKeys.encryptForDevice(base64.parse(device.publicKey)); + device.userPrivateKey = await userKeys.encryptForDevice(base64.parse(device.publicKey).slice()); } await backend.users.putMe(me); } diff --git a/frontend/src/common/util.ts b/frontend/src/common/util.ts index b4213b5de..5c00c3be6 100644 --- a/frontend/src/common/util.ts +++ b/frontend/src/common/util.ts @@ -9,7 +9,7 @@ export class UTF8 { * @param data string to encode * @returns Uint8Array containing the UTF-8 NFC encoded string */ - public static encode(data: string): Uint8Array { + public static encode(data: string): Uint8Array { return UTF8.encoder.encode(data.normalize('NFC')); } diff --git a/frontend/src/common/vaultFormat8.ts b/frontend/src/common/vaultFormat8.ts new file mode 100644 index 000000000..c2c4f3f77 --- /dev/null +++ b/frontend/src/common/vaultFormat8.ts @@ -0,0 +1,309 @@ +import JSZip from 'jszip'; +import * as miscreant from 'miscreant'; +import { base32, base64, base64url } from 'rfc4648'; +import { VaultDto } from './backend'; +import config, { absFrontendBaseURL } from './config'; +import { AccessTokenProducing, GCM_NONCE_LEN, OtherVaultMember, UnwrapKeyError, UserKeys, VaultTemplateProducing } from './crypto'; +import { CRC32, UTF8, wordEncoder } from './util'; + +interface VaultConfigPayload { + jti: string + format: number + cipherCombo: string + shorteningThreshold: number +} + +interface VaultConfigHeaderHub { + clientId: string + authEndpoint: string + tokenEndpoint: string + authSuccessUrl: string + authErrorUrl: string + apiBaseUrl: string + // deprecated: + devicesResourceUrl: string +} + +export class VaultFormat8 implements AccessTokenProducing, VaultTemplateProducing { + // 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( + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultFormat8(await key); + } + + /** + * Decrypts the vault's masterkey using the user's private key + * @param jwe JWE containing the vault key + * @param userKeyPair The current user's key pair + * @returns The masterkey + */ + public static async decryptWithUserKey(jwe: string, userKeyPair: UserKeys): Promise { + let rawKey = new Uint8Array(); + try { + const payload = await userKeyPair.decryptAccessToken(jwe); + rawKey = base64.parse(payload.key); + const masterKey = crypto.subtle.importKey('raw', rawKey, VaultFormat8.MASTERKEY_KEY_DESIGNATION, true, ['sign']); + return new VaultFormat8(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<[VaultFormat8, CryptoKeyPair]> { + // pbkdf2: + const encodedPw = UTF8.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 = await crypto.subtle.unwrapKey( + 'raw', + decodedMasterKey.slice(GCM_NONCE_LEN), + await kek, + { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + const privKey = await 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 = await crypto.subtle.importKey( + 'spki', + decodedPublicKey, + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['verify'] + ); + return [new VaultFormat8(masterkey), { privateKey: privKey, publicKey: pubKey }]; + } catch (error) { + throw new UnwrapKeyError(error); + } + } + + /** + * Restore the master key from a given recovery key and verify the masterkey matches with the given vault config. On success, creates a new admin signature key pair. + * @param vaultMetadataToken Content of the alleged matching vault metadata file vault.cryptomator + * @param recoveryKey The recovery key + * @returns The recovered master key + * @throws Error, if passing a malformed recovery key or the recovery key does not match with the vault metadata + */ + public static async recoverAndVerify(vaultMetadataToken: string, recoveryKey: string) { + const vault = await this.recover(recoveryKey); + + const sigSeparatorIndex = vaultMetadataToken.lastIndexOf('.'); + const headerPlusPayload = vaultMetadataToken.slice(0, sigSeparatorIndex); + const signature = vaultMetadataToken.slice(sigSeparatorIndex + 1, vaultMetadataToken.length); + const message = UTF8.encode(headerPlusPayload); + const digest = await crypto.subtle.sign( + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + vault.masterKey, + message + ); + const base64urlDigest = base64url.stringify(new Uint8Array(digest), { pad: false }); + if (!(signature === base64urlDigest)) { + throw new Error('Recovery key does not match vault file.'); + } + + return vault; + } + + /** + * 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 DecodeVf8RecoveryKeyError, if passing a malformed recovery key + */ + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + let decoded; + try { + decoded = wordEncoder.decode(recoveryKey); + } catch (error) { + throw new DecodeVf8RecoveryKeyError(error instanceof Error ? error.message : 'Internal error. See console log for more info.'); + } + + if (decoded.length !== 66) { + throw new DecodeVf8RecoveryKeyError('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 DecodeVf8RecoveryKeyError('Invalid recovery key checksum.'); + } + + // construct new VaultKeys from recovered key + const key = crypto.subtle.importKey( + 'raw', + decodedKey, + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultFormat8(await key); + } + + /** @inheritdoc */ + public async exportTemplate(apiURL: string, vault: VaultDto): Promise { + const cfg = config.get(); + + const kid = `hub+${apiURL}vaults/${vault.id}`; + + const hubConfig: VaultConfigHeaderHub = { + clientId: cfg.keycloakClientIdCryptomator, + authEndpoint: cfg.keycloakAuthEndpoint, + tokenEndpoint: cfg.keycloakTokenEndpoint, + authSuccessUrl: `${absFrontendBaseURL}unlock-success?vault=${vault.id}`, + authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vault.id}`, + apiBaseUrl: apiURL, + devicesResourceUrl: `${apiURL}devices/`, + }; + + const jwtPayload: VaultConfigPayload = { + jti: vault.id, + format: 8, + cipherCombo: 'SIV_GCM', + shorteningThreshold: 220 + }; + + const vaultConfigToken = await this.createVaultConfig(kid, hubConfig, jwtPayload); + const rootDirHash = await this.hashDirectoryId(''); + + const zip = new JSZip(); + zip.file('vault.cryptomator', vaultConfigToken); + zip.folder('d')?.folder(rootDirHash.substring(0, 2))?.folder(rootDirHash.substring(2)); + return zip.generateAsync({ type: 'blob' }); + } + + private 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 unsignedToken = base64url.stringify(UTF8.encode(header), { pad: false }) + '.' + base64url.stringify(UTF8.encode(payloadJson), { pad: false }); + const encodedUnsignedToken = UTF8.encode(unsignedToken); + const signature = await crypto.subtle.sign( + 'HMAC', + this.masterKey, + encodedUnsignedToken + ); + return unsignedToken + '.' + base64url.stringify(new Uint8Array(signature), { pad: false }); + } + + // visible for testing + public async hashDirectoryId(cleartextDirectoryId: string): Promise { + const dirHash = UTF8.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); + } + } + + /** + * Encodes the key masterkey + * @returns master key in base64-encoded raw format + */ + public async serializeMasterKey(): Promise { + const bytes = await crypto.subtle.exportKey('raw', this.masterKey); + return base64.stringify(new Uint8Array(bytes), { pad: true }); + } + + /** @inheritdoc */ + public async encryptForUser(userPublicKey: CryptoKey | Uint8Array): Promise { + return OtherVaultMember.withPublicKey(userPublicKey).createAccessToken({ + key: await this.serializeMasterKey(), + }); + } + + /** + * 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 class DecodeVf8RecoveryKeyError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts deleted file mode 100644 index ba6b380f5..000000000 --- a/frontend/src/common/vaultconfig.ts +++ /dev/null @@ -1,47 +0,0 @@ -import JSZip from 'jszip'; -import config, { absBackendBaseURL, absFrontendBaseURL } from '../common/config'; -import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/crypto'; - -export class VaultConfig { - readonly vaultConfigToken: string; - private readonly rootDirHash: string; - - private constructor(vaultConfigToken: string, rootDirHash: string) { - this.vaultConfigToken = vaultConfigToken; - this.rootDirHash = rootDirHash; - } - - public static async create(vaultId: string, vaultKeys: VaultKeys): Promise { - const cfg = config.get(); - - const kid = `hub+${absBackendBaseURL}vaults/${vaultId}`; - - const hubConfig: VaultConfigHeaderHub = { - clientId: cfg.keycloakClientIdCryptomator, - authEndpoint: cfg.keycloakAuthEndpoint, - tokenEndpoint: cfg.keycloakTokenEndpoint, - authSuccessUrl: `${absFrontendBaseURL}unlock-success?vault=${vaultId}`, - authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vaultId}`, - apiBaseUrl: absBackendBaseURL, - devicesResourceUrl: `${absBackendBaseURL}devices/`, - }; - - const jwtPayload: VaultConfigPayload = { - jti: vaultId, - format: 8, - cipherCombo: 'SIV_GCM', - shorteningThreshold: 220 - }; - - const vaultConfigToken = await vaultKeys.createVaultConfig(kid, hubConfig, jwtPayload); - const rootDirHash = await vaultKeys.hashDirectoryId(''); - return new VaultConfig(vaultConfigToken, rootDirHash); - } - - public async exportTemplate(): Promise { - const zip = new JSZip(); - zip.file('vault.cryptomator', this.vaultConfigToken); - 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 fe5d84fb6..5fd7171ac 100644 --- a/frontend/src/components/ArchiveVaultDialog.vue +++ b/frontend/src/components/ArchiveVaultDialog.vue @@ -79,10 +79,11 @@ function show() { async function archiveVault() { onArchiveVaultError.value = null; - const v = props.vault; + const dto = { ...props.vault }; + dto.archived = true; try { - const vaultDto = await backend.vaults.createOrUpdateVault(v.id, v.name, true, v.description); - emit('archived', vaultDto); + const updatedVault = await backend.vaults.createOrUpdateVault(dto); + emit('archived', updatedVault); open.value = false; } catch (error) { console.error('Archiving vault failed.', error); diff --git a/frontend/src/components/ClaimVaultOwnershipDialog.vue b/frontend/src/components/ClaimVaultOwnershipDialog.vue index 362eee317..133a89380 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 { VaultFormat8 } from '../common/vaultFormat8'; class FormValidationFailedError extends Error { constructor() { @@ -85,7 +86,7 @@ const props = defineProps<{ const emit = defineEmits<{ close: [] - action: [vaultKeys: VaultKeys, ownerSigningKey: CryptoKeyPair] + action: [vaultKeys: VaultFormat8, ownerSigningKey: CryptoKeyPair] }>(); defineExpose({ @@ -103,7 +104,7 @@ async function authenticateVaultAdmin() { throw new FormValidationFailedError(); } if (props.vault.masterkey && props.vault.authPrivateKey && props.vault.authPublicKey && props.vault.salt && props.vault.iterations) { - const [vaultKeys, ownerKeyPair] = await VaultKeys.decryptWithAdminPassword(password.value, props.vault.masterkey, props.vault.authPrivateKey, props.vault.authPublicKey, props.vault.salt, props.vault.iterations); + const [vaultKeys, ownerKeyPair] = await VaultFormat8.decryptWithAdminPassword(password.value, props.vault.masterkey, props.vault.authPrivateKey, props.vault.authPublicKey, props.vault.salt, props.vault.iterations); emit('action', vaultKeys, ownerKeyPair); open.value = false; } else { diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index c94d0642c..7a8af3eef 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -3,9 +3,8 @@ {{ t('common.loading') }} -
- -
+
+
@@ -21,16 +20,67 @@

+
-