diff --git a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java index 571157d50..6c994e5da 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java @@ -24,7 +24,6 @@ import org.cryptomator.hub.entities.LegacyAccessToken; import org.cryptomator.hub.entities.LegacyDevice; import org.cryptomator.hub.entities.User; -import org.cryptomator.hub.entities.events.DeviceRemovedEvent; import org.cryptomator.hub.entities.events.EventLogger; import org.cryptomator.hub.validation.NoHtmlOrScriptChars; import org.cryptomator.hub.validation.OnlyBase64Chars; @@ -102,7 +101,7 @@ public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("device } device.setName(dto.name); device.setPublickey(dto.publicKey); - device.setUserPrivateKey(dto.userPrivateKey); + device.setUserPrivateKeys(dto.userPrivateKeys); try { deviceRepo.persistAndFlush(device); @@ -173,12 +172,12 @@ public record DeviceDto(@JsonProperty("id") @ValidId String id, @JsonProperty("name") @NoHtmlOrScriptChars @NotBlank String name, @JsonProperty("type") Device.Type type, @JsonProperty("publicKey") @NotNull @OnlyBase64Chars String publicKey, - @JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKey, + @JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKeys, // singular name for history reasons (don't break client compatibility) @JsonProperty("owner") @ValidId String ownerId, @JsonProperty("creationTime") Instant creationTime) { public static DeviceDto fromEntity(Device entity) { - return new DeviceDto(entity.getId(), entity.getName(), entity.getType(), entity.getPublickey(), entity.getUserPrivateKey(), entity.getOwner().getId(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS)); + return new DeviceDto(entity.getId(), entity.getName(), entity.getType(), entity.getPublickey(), entity.getUserPrivateKeys(), entity.getOwner().getId(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS)); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java index 1ed02ec7d..14542f009 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java @@ -14,24 +14,34 @@ public final class UserDto extends AuthorityDto { public final String email; @JsonProperty("devices") public final Set devices; - @JsonProperty("publicKey") - public final String publicKey; - @JsonProperty("privateKey") - public final String privateKey; + @JsonProperty("ecdhPublicKey") + public final String ecdhPublicKey; + @JsonProperty("ecdsaPublicKey") + public final String ecdsaPublicKey; + @JsonProperty("privateKey") // singular name for history reasons (don't break client compatibility) + public final String privateKeys; @JsonProperty("setupCode") public final String setupCode; + @Deprecated + @JsonProperty("publicKey") + public final String legacyEcdhPublicKey; + UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set devices, - @Nullable @JsonProperty("publicKey") @OnlyBase64Chars String publicKey, @Nullable @JsonProperty("privateKey") @ValidJWE String privateKey, @Nullable @JsonProperty("setupCode") @ValidJWE String setupCode) { + @Nullable @JsonProperty("ecdhPublicKey") @OnlyBase64Chars String ecdhPublicKey, @Nullable @JsonProperty("ecdsaPublicKey") @OnlyBase64Chars String ecdsaPublicKey, @Nullable @JsonProperty("privateKeys") @ValidJWE String privateKeys, @Nullable @JsonProperty("setupCode") @ValidJWE String setupCode) { super(id, Type.USER, name, pictureUrl); this.email = email; this.devices = devices; - this.publicKey = publicKey; - this.privateKey = privateKey; + this.ecdhPublicKey = ecdhPublicKey; + this.ecdsaPublicKey = ecdsaPublicKey; + this.privateKeys = privateKeys; this.setupCode = setupCode; + + // duplicate fields to maintain backwards compatibility: + this.legacyEcdhPublicKey = ecdhPublicKey; } public static UserDto justPublicInfo(User user) { - return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), Set.of(), user.getPublicKey(), null, null); + return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), Set.of(), user.getEcdhPublicKey(), user.getEcdsaPublicKey(),null, null); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java index f754a1320..0b2000578 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -20,7 +20,6 @@ import org.cryptomator.hub.entities.User; import org.cryptomator.hub.entities.Vault; import org.cryptomator.hub.entities.events.EventLogger; -import org.cryptomator.hub.entities.events.VaultAccessGrantedEvent; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; @@ -71,14 +70,37 @@ public Response putMe(@Nullable @Valid UserDto dto) { user.setPictureUrl(jwt.getClaim("picture")); user.setEmail(jwt.getClaim("email")); if (dto != null) { - user.setPublicKey(dto.publicKey); - user.setPrivateKey(dto.privateKey); + user.setEcdhPublicKey(dto.ecdhPublicKey); + user.setEcdsaPublicKey(dto.ecdsaPublicKey); + user.setPrivateKeys(dto.privateKeys); user.setSetupCode(dto.setupCode); + updateDevices(user, dto); } userRepo.persist(user); return Response.created(URI.create(".")).build(); } + /** + * Updates those devices that are present in both the entity and the DTO. No devices are added or removed. + * + * @param userEntity The persistent entity + * @param userDto The DTO + */ + private void updateDevices(User userEntity, UserDto userDto) { + var devices = userEntity.devices.stream().collect(Collectors.toUnmodifiableMap(Device::getId, Function.identity())); + var updatedDevices = userDto.devices.stream() + .filter(d -> devices.containsKey(d.id())) // only look at DTOs for which we find a matching existing entity + .map(dto -> { + var device = devices.get(dto.id()); + device.setType(dto.type()); + device.setName(dto.name()); + device.setPublickey(dto.publicKey()); + device.setUserPrivateKeys(dto.userPrivateKeys()); + return device; + }); + deviceRepo.persist(updatedDevices); + } + @POST @Path("/me/access-tokens") @RolesAllowed("user") @@ -117,9 +139,9 @@ public Response updateMyAccessTokens(@NotNull Map tokens) { @APIResponse(responseCode = "404", description = "no user matching the subject of the JWT passed as Bearer Token") public UserDto getMe(@QueryParam("withDevices") boolean withDevices) { User user = userRepo.findById(jwt.getSubject()); - Function mapDevices = d -> new DeviceResource.DeviceDto(d.getId(), d.getName(), d.getType(), d.getPublickey(), d.getUserPrivateKey(), d.getOwner().getId(), d.getCreationTime().truncatedTo(ChronoUnit.MILLIS)); + Function mapDevices = d -> new DeviceResource.DeviceDto(d.getId(), d.getName(), d.getType(), d.getPublickey(), d.getUserPrivateKeys(), d.getOwner().getId(), d.getCreationTime().truncatedTo(ChronoUnit.MILLIS)); var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.of(); - return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), devices, user.getPublicKey(), user.getPrivateKey(), user.getSetupCode()); + return new UserDto(user.getId(), user.getName(), user.getPictureUrl(), user.getEmail(), devices, user.getEcdhPublicKey(), user.getEcdsaPublicKey(), user.getPrivateKeys(), user.getSetupCode()); } @POST @@ -131,8 +153,8 @@ public UserDto getMe(@QueryParam("withDevices") boolean withDevices) { @APIResponse(responseCode = "204", description = "deleted keys, devices and access permissions") public Response resetMe() { User user = userRepo.findById(jwt.getSubject()); - user.setPublicKey(null); - user.setPrivateKey(null); + user.setEcdhPublicKey(null); + user.setPrivateKeys(null); user.setSetupCode(null); userRepo.persist(user); deviceRepo.deleteByOwner(user.getId()); 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 53955810f..c46646db7 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -313,7 +313,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr } var user = userRepo.findById(jwt.getSubject()); - if (user.getPublicKey() == null) { + if (user.getEcdhPublicKey() == null) { throw new ActionRequiredException("User account not initialized."); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Device.java b/backend/src/main/java/org/cryptomator/hub/entities/Device.java index 3d64a359e..04ba69e6d 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Device.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Device.java @@ -56,8 +56,8 @@ public enum Type { @Column(name = "publickey", nullable = false) private String publickey; - @Column(name = "user_privatekey", nullable = false) - private String userPrivateKey; + @Column(name = "user_privatekeys", nullable = false) + private String userPrivateKeys; @Column(name = "creation_time", nullable = false) private Instant creationTime; @@ -102,12 +102,12 @@ public void setPublickey(String publickey) { this.publickey = publickey; } - public String getUserPrivateKey() { - return userPrivateKey; + public String getUserPrivateKeys() { + return userPrivateKeys; } - public void setUserPrivateKey(String userPrivateKey) { - this.userPrivateKey = userPrivateKey; + public void setUserPrivateKeys(String userPrivateKeys) { + this.userPrivateKeys = userPrivateKeys; } public Instant getCreationTime() { @@ -126,7 +126,7 @@ public String toString() { ", name='" + name + '\'' + ", type='" + type + '\'' + ", publickey='" + publickey + '\'' + - ", userPrivateKey='" + userPrivateKey + '\'' + + ", userPrivateKey='" + userPrivateKeys + '\'' + ", creationTime='" + creationTime + '\'' + '}'; } @@ -141,13 +141,13 @@ public boolean equals(Object o) { && Objects.equals(this.name, other.name) && Objects.equals(this.type, other.type) && Objects.equals(this.publickey, other.publickey) - && Objects.equals(this.userPrivateKey, other.userPrivateKey) + && Objects.equals(this.userPrivateKeys, other.userPrivateKeys) && Objects.equals(this.creationTime, other.creationTime); } @Override public int hashCode() { - return Objects.hash(id, owner, name, type, publickey, userPrivateKey, creationTime); + return Objects.hash(id, owner, name, type, publickey, userPrivateKeys, creationTime); } @ApplicationScoped 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 8bca0b0ca..0727a9458 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/User.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/User.java @@ -26,7 +26,7 @@ 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.publicKey IS NOT NULL + WHERE perm.id.vaultId = :vaultId AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL """ ) @NamedQuery(name = "User.getEffectiveGroupUsers", query = """ @@ -49,11 +49,14 @@ public class User extends Authority { @Column(name = "email") private String email; - @Column(name = "publickey") - private String publicKey; + @Column(name = "ecdh_publickey") + private String ecdhPublicKey; - @Column(name = "privatekey") - private String privateKey; + @Column(name = "ecdsa_publickey") + private String ecdsaPublicKey; + + @Column(name = "privatekeys") + private String privateKeys; @Column(name = "setupcode") private String setupCode; @@ -74,20 +77,28 @@ public void setEmail(String email) { this.email = email; } - public String getPublicKey() { - return publicKey; + public String getEcdhPublicKey() { + return ecdhPublicKey; + } + + public void setEcdhPublicKey(String ecdhPublicKey) { + this.ecdhPublicKey = ecdhPublicKey; + } + + public String getEcdsaPublicKey() { + return ecdsaPublicKey; } - public void setPublicKey(String publicKey) { - this.publicKey = publicKey; + public void setEcdsaPublicKey(String ecdsaPublicKey) { + this.ecdsaPublicKey = ecdsaPublicKey; } - public String getPrivateKey() { - return privateKey; + public String getPrivateKeys() { + return privateKeys; } - public void setPrivateKey(String privateKey) { - this.privateKey = privateKey; + public void setPrivateKeys(String privateKeys) { + this.privateKeys = privateKeys; } public String getSetupCode() { @@ -128,14 +139,15 @@ public boolean equals(Object o) { return super.equals(that) // && Objects.equals(pictureUrl, that.pictureUrl) // && Objects.equals(email, that.email) // - && Objects.equals(publicKey, that.publicKey) // - && Objects.equals(privateKey, that.privateKey) // + && Objects.equals(ecdhPublicKey, that.ecdhPublicKey) // + && Objects.equals(ecdsaPublicKey, that.ecdsaPublicKey) // + && Objects.equals(privateKeys, that.privateKeys) // && Objects.equals(setupCode, that.setupCode); } @Override public int hashCode() { - return Objects.hash(super.getId(), pictureUrl, email, publicKey, privateKey, setupCode); + return Objects.hash(super.getId(), pictureUrl, email, ecdhPublicKey, privateKeys, setupCode); } @ApplicationScoped 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 c19e352b3..19bce5b4b 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/V15__User_ECDSA.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V15__User_ECDSA.sql new file mode 100644 index 000000000..c21b56590 --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V15__User_ECDSA.sql @@ -0,0 +1,4 @@ +ALTER TABLE "user_details" RENAME COLUMN "publickey" TO "ecdh_publickey"; +ALTER TABLE "user_details" RENAME COLUMN "privatekey" TO "privatekeys"; +ALTER TABLE "user_details" ADD "ecdsa_publickey" VARCHAR; +ALTER TABLE "device" RENAME COLUMN "user_privatekey" TO "user_privatekeys"; \ 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 cd2899643..5d254fbaf 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -446,7 +446,7 @@ public void testGetUsersRequiringAccess1() throws SQLException { try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" UPDATE "user_details" - SET publickey='public2', privatekey='private2', setupcode='setup2' + SET ecdh_publickey='public2', ecdsa_publickey='ecdsa_public2', privatekeys='private2', setupcode='setup2' WHERE id='user2'; """); } @@ -458,7 +458,7 @@ public void testGetUsersRequiringAccess1() throws SQLException { try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" UPDATE - "user_details" SET publickey=NULL, privatekey=NULL, setupcode=NULL + "user_details" SET ecdh_publickey=NULL, ecdsa_publickey=NULL, privatekeys=NULL, setupcode=NULL WHERE id='user2'; """); } @@ -488,7 +488,7 @@ public void testGetUsersRequiringAccess2() throws SQLException { try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" UPDATE - "user_details" SET publickey='public2', privatekey='private2', setupcode='setup2' + "user_details" SET ecdh_publickey='ecdh_public2', ecdsa_publickey='ecdsa_public2', privatekeys='private2', setupcode='setup2' WHERE id='user2'; """); } @@ -500,7 +500,7 @@ public void testGetUsersRequiringAccess2() throws SQLException { try (var c = dataSource.getConnection(); var s = c.createStatement()) { s.execute(""" UPDATE - "user_details" SET publickey=NULL, privatekey=NULL, setupcode=NULL + "user_details" SET ecdh_publickey=NULL, ecdsa_publickey=NULL, privatekeys=NULL, setupcode=NULL WHERE id='user2'; """); } @@ -559,7 +559,7 @@ public void setup() throws SQLException { // user999 will be deleted in #cleanup() s.execute(""" INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); - INSERT INTO "user_details" ("id", "publickey", "privatekey", "setupcode") VALUES ('user999', 'public999', 'private999', 'setup999'); + INSERT INTO "user_details" ("id", "ecdh_publickey", "ecdsa_publickey", "privatekeys", "setupcode") VALUES ('user999', 'ecdh_public999', 'ecdsa_public999', 'private999', 'setup999'); INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999') """); } diff --git a/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql b/backend/src/test/resources/org/cryptomator/hub/flyway/V9999__Test_Data.sql index 3ac426e73..ee02b474b 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 @@ -14,10 +14,10 @@ VALUES ('group1', 'GROUP', 'Group Name 1'), ('group2', 'GROUP', 'Group Name 2'); -INSERT INTO "user_details" ("id", "publickey", "privatekey", "setupcode") +INSERT INTO "user_details" ("id", "ecdh_publickey", "ecdsa_publickey", "privatekeys", "setupcode") VALUES - ('user1', 'public1', 'private1', 'setup1'), - ('user2', NULL, NULL, NULL); + ('user1', 'ecdh_public1', 'ecdsa_public1', 'private1', 'setup1'), + ('user2', NULL, NULL, NULL, NULL); INSERT INTO "group_details" ("id") VALUES @@ -52,7 +52,7 @@ VALUES ('7E57C0DE-0000-4000-8000-000100002222', 'group2', 'OWNER'), ('7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'); -INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time", "user_privatekey") +INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time", "user_privatekeys") VALUES ('device1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user1.device1'), ('device2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user2.device2'), diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index 880a844f5..fb2072a4f 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -72,7 +72,8 @@ export type UserDto = { email: string; devices: DeviceDto[]; accessibleVaults: VaultDto[]; - publicKey?: string; + ecdhPublicKey?: string; + ecdsaPublicKey?: string; privateKey?: string; setupCode?: string; } diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index cb1d5a510..a2cc0d6f0 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -33,6 +33,15 @@ interface JWEPayload { key: string } +interface UserKeyPayload { + /** + * @deprecated use `ecdhPrivateKey` instead + */ + key: string, + ecdhPrivateKey: string, + ecdsaPrivateKey: string, +} + const GCM_NONCE_LEN = 12; export class VaultKeys { @@ -219,8 +228,8 @@ export class VaultKeys { * @param userPublicKey The recipient's public key (DER-encoded) * @returns a JWE containing this Masterkey */ - public async encryptForUser(userPublicKey: Uint8Array): Promise { - const publicKey = await crypto.subtle.importKey('spki', userPublicKey, UserKeys.KEY_DESIGNATION, false, []); + 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 = { @@ -251,51 +260,87 @@ export class VaultKeys { } export class UserKeys { - public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; + public static readonly ECDH_KEY_USAGES: KeyUsage[] = ['deriveBits']; - public static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { - name: 'ECDH', - namedCurve: 'P-384' - }; + public static readonly ECDH_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; - readonly keyPair: CryptoKeyPair; + public static readonly ECDSA_KEY_USAGES: KeyUsage[] = ['sign']; - protected constructor(keyPair: CryptoKeyPair) { - this.keyPair = keyPair; - } + public static readonly ECDSA_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { name: 'ECDSA', namedCurve: 'P-384' }; + + + protected constructor(readonly ecdhKeyPair: CryptoKeyPair, readonly ecdsaKeyPair: CryptoKeyPair) { } /** - * Creates a new user key pair - * @returns A new user key pair + * Creates new user key pairs + * @returns A set of new user key pairs */ public static async create(): Promise { - const keyPair = crypto.subtle.generateKey(UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); - return new UserKeys(await keyPair); + const ecdhKeyPair = crypto.subtle.generateKey(UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_KEY_USAGES); + const ecdsaKeyPair = crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES); + return new UserKeys(await ecdhKeyPair, await ecdsaKeyPair); } /** - * Recovers the user key pair using a recovery code. All other information can be retrieved from the backend. - * @param encodedPublicKey The public key (base64-encoded SPKI) - * @param encryptedPrivateKey The JWE holding the encrypted private key - * @param setupCode The password used to protect the private key + * Recovers the user key pairs using a recovery code. All other information can be retrieved from the backend. + * @param encodedEcdhPublicKey The ECDH public key (base64-encoded SPKI) + * @param encodedEcdsaPublicKey The ECDSA public key (base64-encoded SPKI) + * @param privateKeys The JWE holding the encrypted private keys + * @param setupCode The password used to protect the private keys * @returns Decrypted UserKeys * @throws {UnwrapKeyError} when attempting to decrypt the private key using an incorrect setupCode */ - public static async recover(encodedPublicKey: string, encryptedPrivateKey: string, setupCode: string): Promise { - const jwe: JWEPayload = await JWEParser.parse(encryptedPrivateKey).decryptPbes2(setupCode); - const decodedPublicKey = base64.parse(encodedPublicKey, { loose: true }); - const decodedPrivateKey = base64.parse(jwe.key, { loose: true }); - const privateKey = crypto.subtle.importKey('pkcs8', decodedPrivateKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); - const publicKey = crypto.subtle.importKey('spki', decodedPublicKey, UserKeys.KEY_DESIGNATION, true, []); - return new UserKeys({ privateKey: await privateKey, publicKey: await publicKey }); + public static async recover(privateKeys: string, setupCode: string, userEcdhPublicKey: CryptoKey | BufferSource, userEcdsaPublicKey?: CryptoKey | BufferSource): Promise { + const jwe: UserKeyPayload = await JWEParser.parse(privateKeys).decryptPbes2(setupCode); + return UserKeys.createFromJwe(jwe, userEcdhPublicKey, userEcdsaPublicKey); + } + + /** + * Decrypts the user's private key using the browser's private key + * @param jwe JWE containing the PKCS#8-encoded private key + * @param browserPrivateKey The browser's private key + * @param userEcdhPublicKey User's public ECDH key + * @param userEcdsaPublicKey User's public ECDSA key (will be generated if missing - added in Hub 1.4.0) + * @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); + 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_KEY_USAGES) + }; + let ecdsaKeyPair: CryptoKeyPair; + if (jwe.ecdsaPrivateKey && ecdsaPublicKey) { + ecdsaKeyPair = { + publicKey: await asPublicKey(ecdsaPublicKey, UserKeys.ECDSA_KEY_DESIGNATION), + privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdsaPrivateKey, { loose: true }), UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES) + }; + } else { + // ECDSA key was added in Hub 1.4.0. If it's missing, we generate a new one. + ecdsaKeyPair = await crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES); + } + return new UserKeys(ecdhKeyPair, ecdsaKeyPair); } /** - * Gets the base64-encoded public key in SPKI format. + * Gets the base64-encoded ECDH public key in SPKI format. * @returns base64-encoded public key */ - public async encodedPublicKey(): Promise { - const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + public async encodedEcdhPublicKey(): Promise { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.ecdhKeyPair.publicKey)); + return base64.stringify(publicKey); + } + + /** + * Gets the base64-encoded ECDSA public key in SPKI format. + * @returns base64-encoded public key + */ + public async encodedEcdsaPublicKey(): Promise { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.ecdsaKeyPair.publicKey)); return base64.stringify(publicKey); } @@ -305,16 +350,9 @@ export class UserKeys { * @returns A JWE holding the encrypted private key * @see JWEBuilder.pbes2 */ - public async encryptedPrivateKey(setupCode: string): Promise { - const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); - try { - const payload: JWEPayload = { - key: base64.stringify(rawkey) - }; - return await JWEBuilder.pbes2(setupCode).encrypt(payload); - } finally { - rawkey.fill(0x00); - } + public async encryptWithSetupCode(setupCode: string, iterations?: number): Promise { + const payload = await this.prepareForEncryption(); + return await JWEBuilder.pbes2(setupCode, iterations).encrypt(payload); } /** @@ -324,43 +362,23 @@ export class UserKeys { * @see JWEBuilder.ecdhEs */ public async encryptForDevice(devicePublicKey: CryptoKey | Uint8Array): Promise { - const publicKey = await UserKeys.publicKey(devicePublicKey); - const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.keyPair.privateKey)); - try { - const payload: JWEPayload = { - key: base64.stringify(rawkey) - }; - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); - } finally { - rawkey.fill(0x00); - } + const publicKey = await asPublicKey(devicePublicKey, BrowserKeys.KEY_DESIGNATION); + const payload = await this.prepareForEncryption(); + return JWEBuilder.ecdhEs(publicKey).encrypt(payload); } - /** - * Decrypts the user's private key using the browser's private key - * @param jwe JWE containing the PKCS#8-encoded private key - * @param browserPrivateKey The browser's private key - * @param userPublicKey User public key - * @returns The user's key pair - */ - public static async decryptOnBrowser(jwe: string, browserPrivateKey: CryptoKey, userPublicKey: CryptoKey | BufferSource): Promise { - const publicKey = await UserKeys.publicKey(userPublicKey); - let rawKey = new Uint8Array(); + private async prepareForEncryption(): Promise { + const encodedEcdhPrivateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.ecdhKeyPair.privateKey)); + const encodedEcdsaPrivateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.ecdsaKeyPair.privateKey)); try { - const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); - rawKey = base64.parse(payload.key); - const privateKey = await crypto.subtle.importKey('pkcs8', rawKey, UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); - return new UserKeys({ publicKey: publicKey, privateKey: privateKey }); + return { + key: base64.stringify(encodedEcdhPrivateKey), // redundant for backwards compatibility + ecdhPrivateKey: base64.stringify(encodedEcdhPrivateKey), + ecdsaPrivateKey: base64.stringify(encodedEcdsaPrivateKey) + }; } finally { - rawKey.fill(0x00); - } - } - - private static async publicKey(publicKey: CryptoKey | BufferSource): Promise { - if (publicKey instanceof CryptoKey) { - return publicKey; - } else { - return await crypto.subtle.importKey('spki', publicKey, UserKeys.KEY_DESIGNATION, true, []); + encodedEcdhPrivateKey.fill(0x00); + encodedEcdsaPrivateKey.fill(0x00); } } } @@ -368,7 +386,7 @@ export class UserKeys { export class BrowserKeys { public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; - private static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + public static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; @@ -438,6 +456,14 @@ export class BrowserKeys { } } +async function asPublicKey(publicKey: CryptoKey | BufferSource, keyDesignation: EcKeyImportParams): Promise { + if (publicKey instanceof CryptoKey) { + return publicKey; + } else { + return await crypto.subtle.importKey('spki', publicKey, keyDesignation, true, []); + } +} + export async function getFingerprint(key: string | undefined) { if (key) { const encodedKey = new TextEncoder().encode(key); diff --git a/frontend/src/common/userdata.ts b/frontend/src/common/userdata.ts new file mode 100644 index 000000000..800f6c80b --- /dev/null +++ b/frontend/src/common/userdata.ts @@ -0,0 +1,143 @@ +import { base64 } from 'rfc4648'; +import backend, { DeviceDto, UserDto } from './backend'; +import { BrowserKeys, UserKeys } from './crypto'; +import { JWEParser } from './jwe'; + +class UserData { + + #me?: Promise; + #browserKeys?: Promise; + + /** + * Gets the user DTO representing the currently logged in user. + */ + public get me(): Promise { + if (!this.#me) { + this.#me = backend.users.me(true); + } + return this.#me; + } + + /** + * Gets the device key pair stored for this user in the currently used browser. + */ + public get browserKeys(): Promise { + return this.me.then(me => { + if (!this.#browserKeys) { + this.#browserKeys = BrowserKeys.load(me.id); + } + return this.#browserKeys; + }); + } + + /** + * Gets the device that represents the currently used browser. + */ + public get browser(): Promise { + return this.me.then(async me => { + const browserKeys = await this.browserKeys; + const browserId = await browserKeys?.id(); + return browserId ? me.devices.find(d => d.id === browserId) : undefined; + }); + } + + /** + * Gets the ECDH public key of the user. + * + * @see UserDto.ecdhPublicKey + */ + public get ecdhPublicKey(): Promise { + return this.me.then(me => { + if (!me.ecdhPublicKey) { + throw new Error('User not initialized.'); + } + return base64.parse(me.ecdhPublicKey); + }); + } + + /** + * Gets the ECDSA public key of the user, if available. + * + * @see UserDto.ecdsaPublicKey + */ + public get ecdsaPublicKey(): Promise { + return this.me.then(me => { + return me.ecdsaPublicKey ? base64.parse(me.ecdsaPublicKey) : undefined; + }); + } + + /** + * Invalidates the cached user data and reloads it in the backend. + */ + public async reload() { + this.#me = backend.users.me(true); + this.#browserKeys = undefined; + } + + /** + * Creates a new browser key pair for the user. + * This does not change the device DTO stored in the backend. + * @returns A new browser key pair for the user. + */ + public async createBrowserKeys(): Promise { + const me = await this.me; + const browserKeys = await BrowserKeys.create(); + await browserKeys.store(me.id); + this.#browserKeys = Promise.resolve(browserKeys); + return browserKeys; + } + + /** + * Decrypts the user keys using the setup code. + * @param setupCode the setup code + * @returns The user's key pairs + */ + public async decryptUserKeysWithSetupCode(setupCode: string): Promise { + const me = await this.me; + if (!me.privateKey) { + throw new Error('User not initialized.'); + } + const userKeys = await UserKeys.recover(me.privateKey, setupCode, await this.ecdhPublicKey, await this.ecdsaPublicKey); + await this.addEcdsaKeyIfMissing(userKeys); + return userKeys; + } + + /** + * Decrypts the user keys using the device key stored in the currently used browser. + * @returns The user's key pairs + */ + public async decryptUserKeysWithBrowser(): Promise { + const browserKeys = await this.browserKeys; + if (!browserKeys) { + throw new Error('Browser keys not found.'); + } + const browser = await this.browser; + if (!browser) { + throw new Error('Device not initialized.'); + } + const userKeys = await UserKeys.decryptOnBrowser(browser.userPrivateKey, browserKeys.keyPair.privateKey, await this.ecdhPublicKey, await this.ecdsaPublicKey); + await this.addEcdsaKeyIfMissing(userKeys); + return userKeys; + } + + /** + * 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 + */ + 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); + me.ecdsaPublicKey = await userKeys.encodedEcdsaPublicKey(); + me.privateKey = await userKeys.encryptWithSetupCode(payload.setupCode); + for (const device of me.devices) { + device.userPrivateKey = await userKeys.encryptForDevice(base64.parse(device.publicKey)); + } + await backend.users.putMe(me); + } + } + +} + +const instance = new UserData(); +export default instance; diff --git a/frontend/src/components/AuthenticatedMain.vue b/frontend/src/components/AuthenticatedMain.vue index bb9e6610c..eaffe4055 100644 --- a/frontend/src/components/AuthenticatedMain.vue +++ b/frontend/src/components/AuthenticatedMain.vue @@ -21,7 +21,8 @@