diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java index c9e77a8f0..32e5ef892 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -23,6 +23,7 @@ import org.cryptomator.hub.entities.AuditEventVaultMemberRemove; import org.cryptomator.hub.entities.AuditEventVaultUpdate; import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.VaultAccess; import org.cryptomator.hub.license.LicenseHolder; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; @@ -107,7 +108,7 @@ static AuditEventDto fromEntity(AuditEvent entity) { } else if (entity instanceof AuditEventVaultKeyRetrieve evt) { return new AuditEventVaultKeyRetrieveDto(evt.id, evt.timestamp, AuditEventVaultKeyRetrieve.TYPE, evt.retrievedBy, evt.vaultId, evt.result); } else if (entity instanceof AuditEventVaultMemberAdd evt) { - return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId); + return new AuditEventVaultMemberAddDto(evt.id, evt.timestamp, AuditEventVaultMemberAdd.TYPE, evt.addedBy, evt.vaultId, evt.authorityId, evt.role); } else if (entity instanceof AuditEventVaultMemberRemove evt) { return new AuditEventVaultMemberRemoveDto(evt.id, evt.timestamp, AuditEventVaultMemberRemove.TYPE, evt.removedBy, evt.vaultId, evt.authorityId); } else { @@ -140,7 +141,7 @@ record AuditEventVaultKeyRetrieveDto(long id, Instant timestamp, String type, @J } record AuditEventVaultMemberAddDto(long id, Instant timestamp, String type, @JsonProperty("addedBy") String addedBy, @JsonProperty("vaultId") UUID vaultId, - @JsonProperty("authorityId") String authorityId) implements AuditEventDto { + @JsonProperty("authorityId") String authorityId, @JsonProperty("role") VaultAccess.Role role) implements AuditEventDto { } record AuditEventVaultMemberRemoveDto(long id, Instant timestamp, String type, @JsonProperty("removedBy") String removedBy, @JsonProperty("vaultId") UUID vaultId, diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java index 01eff0ed0..e9697d03e 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java @@ -31,12 +31,13 @@ protected AuthorityDto(String id, Type type, String name, String pictureUrl) { } static AuthorityDto fromEntity(Authority a) { - if (a instanceof User u) { - return new UserDto(u.id, u.name, u.pictureUrl, u.email, null); - } else if (a instanceof Group) { - return new GroupDto(a.id, a.name); + // TODO refactor to JEP 441 in JDK 21 + if (a instanceof User user) { + return UserDto.justPublicInfo(user); + } else if (a instanceof Group group) { + return GroupDto.fromEntity(group); } else { - throw new IllegalArgumentException("Authority of this type does not exist"); + throw new IllegalStateException("authority is not of type user or group"); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java index 966fb5ca2..36ccfd9f1 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java @@ -33,7 +33,7 @@ public List search(@QueryParam("query") @NotBlank String query) { @RolesAllowed("admin") @Produces(MediaType.APPLICATION_JSON) @NoCache - @Operation(summary = "lists all authorities matching the given ids", description ="lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found") + @Operation(summary = "lists all authorities matching the given ids", description = "lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found") @APIResponse(responseCode = "200") public List getSome(@QueryParam("ids") List authorityIds) { return Authority.findAllInList(authorityIds).map(AuthorityDto::fromEntity).toList(); diff --git a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java index 8f00884c2..1fb18c6db 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/BillingResource.java @@ -6,6 +6,7 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; @@ -51,10 +52,10 @@ public BillingDto get() { @RolesAllowed("admin") @Consumes(MediaType.TEXT_PLAIN) @Operation(summary = "set the token") - @APIResponse(responseCode = "204") + @APIResponse(responseCode = "204", description = "token set") @APIResponse(responseCode = "400", description = "token is invalid (e.g., expired or invalid signature)") @APIResponse(responseCode = "403", description = "only admins are allowed to set the token") - public Response setToken(@ValidJWS String token) { + public Response setToken(@NotNull @ValidJWS String token) { try { licenseHolder.set(token); return Response.status(Response.Status.NO_CONTENT).build(); @@ -69,7 +70,7 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has public static BillingDto create(String hubId, LicenseHolder licenseHolder) { var seats = licenseHolder.getNoLicenseSeats(); - var remainingSeats = Math.max(seats - EffectiveVaultAccess.countEffectiveVaultUsers(), 0); + var remainingSeats = Math.max(seats - EffectiveVaultAccess.countSeatOccupyingUsers(), 0); var managedInstance = licenseHolder.isManagedInstance(); return new BillingDto(hubId, false, null, (int) seats, (int) remainingSeats, null, null, managedInstance); } @@ -78,7 +79,7 @@ public static BillingDto fromDecodedJwt(DecodedJWT jwt, LicenseHolder licenseHol var id = jwt.getId(); var email = jwt.getSubject(); var totalSeats = jwt.getClaim("seats").asInt(); - var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countEffectiveVaultUsers(), 0); + var remainingSeats = Math.max(totalSeats - (int) EffectiveVaultAccess.countSeatOccupyingUsers(), 0); var issuedAt = jwt.getIssuedAt().toInstant(); var expiresAt = jwt.getExpiresAt().toInstant(); var managedInstance = licenseHolder.isManagedInstance(); 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 05bd96310..b03e49d18 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java @@ -3,15 +3,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.persistence.PersistenceException; +import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; -import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -19,27 +20,32 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.cryptomator.hub.entities.Device; import org.cryptomator.hub.entities.AuditEventDeviceRegister; import org.cryptomator.hub.entities.AuditEventDeviceRemove; +import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.LegacyDevice; import org.cryptomator.hub.entities.User; import org.cryptomator.hub.validation.NoHtmlOrScriptChars; -import org.cryptomator.hub.validation.OnlyBase64UrlChars; +import org.cryptomator.hub.validation.OnlyBase64Chars; import org.cryptomator.hub.validation.ValidId; +import org.cryptomator.hub.validation.ValidJWE; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.hibernate.exception.ConstraintViolationException; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.NoCache; import java.net.URI; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.Set; @Path("/devices") public class DeviceResource { + private static final Logger LOG = Logger.getLogger(DeviceResource.class); + @Inject JsonWebToken jwt; @@ -60,25 +66,52 @@ public List getSome(@QueryParam("ids") List deviceIds) { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "adds a device", description = "the device will be owned by the currently logged-in user") - @APIResponse(responseCode = "201", description = "device created") - @APIResponse(responseCode = "409", description = "Device already exists") - public Response create(@Valid DeviceDto deviceDto, @PathParam("deviceId") @ValidId String deviceId) { - if (deviceId == null || deviceId.trim().length() == 0 || deviceDto == null) { - return Response.status(Response.Status.BAD_REQUEST).entity("deviceId or deviceDto cannot be empty").build(); + @Operation(summary = "creates or updates a device", description = "the device will be owned by the currently logged-in user") + @APIResponse(responseCode = "201", description = "Device created or updated") + @APIResponse(responseCode = "409", description = "Conflicting device name") + public Response createOrUpdate(@Valid @NotNull DeviceDto dto, @PathParam("deviceId") @ValidId String deviceId) { + Device device; + try { + device = Device.findByIdAndUser(deviceId, jwt.getSubject()); + } catch (NoResultException e) { + device = new Device(); + device.id = deviceId; + device.owner = User.findById(jwt.getSubject()); + device.creationTime = Instant.now().truncatedTo(ChronoUnit.MILLIS); + device.type = dto.type != null ? dto.type : Device.Type.DESKTOP; // default to desktop for backwards compatibility + + if (LegacyDevice.deleteById(device.id)) { + assert LegacyDevice.findById(device.id) == null; + LOG.info("Deleted Legacy Device during re-registration of Device " + deviceId); + } } - User currentUser = User.findById(jwt.getSubject()); - var device = deviceDto.toDevice(currentUser, deviceId, Instant.now().truncatedTo(ChronoUnit.MILLIS)); + device.name = dto.name; + device.publickey = dto.publicKey; + device.userPrivateKey = dto.userPrivateKey; try { device.persistAndFlush(); AuditEventDeviceRegister.log(jwt.getSubject(), deviceId, device.name, device.type); return Response.created(URI.create(".")).build(); - } catch (PersistenceException e) { - if (e instanceof ConstraintViolationException) { - throw new ClientErrorException(Response.Status.CONFLICT, e); - } else { - throw new InternalServerErrorException(e); - } + } catch (ConstraintViolationException e) { + throw new ClientErrorException(Response.Status.CONFLICT, e); + } + } + + @GET + @Path("/{deviceId}") + @RolesAllowed("user") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Transactional + @Operation(summary = "get the device", description = "the device must be owned by the currently logged-in user") + @APIResponse(responseCode = "200", description = "Device found") + @APIResponse(responseCode = "404", description = "Device not found or owned by a different user") + public DeviceDto get(@PathParam("deviceId") @ValidId String deviceId) { + try { + Device device = Device.findByIdAndUser(deviceId, jwt.getSubject()); + return DeviceDto.fromEntity(device); + } catch (NoResultException e) { + throw new NotFoundException(e); } } @@ -109,24 +142,13 @@ public Response remove(@PathParam("deviceId") @ValidId String deviceId) { public record DeviceDto(@JsonProperty("id") @ValidId String id, @JsonProperty("name") @NoHtmlOrScriptChars @NotBlank String name, @JsonProperty("type") Device.Type type, - @JsonProperty("publicKey") @OnlyBase64UrlChars String publicKey, + @JsonProperty("publicKey") @NotNull @OnlyBase64Chars String publicKey, + @JsonProperty("userPrivateKey") @NotNull @ValidJWE String userPrivateKey, @JsonProperty("owner") @ValidId String ownerId, - @JsonProperty("accessTo") @Valid Set accessTo, @JsonProperty("creationTime") Instant creationTime) { - public Device toDevice(User user, String id, Instant creationTime) { - var device = new Device(); - device.id = id; - device.owner = user; - device.name = name; - device.type = type != null ? type : Device.Type.DESKTOP; // default to desktop for backwards compatibility - device.publickey = publicKey; - device.creationTime = creationTime; - return device; - } - public static DeviceDto fromEntity(Device entity) { - return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.owner.id, Set.of(), entity.creationTime.truncatedTo(ChronoUnit.MILLIS)); + return new DeviceDto(entity.id, entity.name, entity.type, entity.publickey, entity.userPrivateKey, entity.owner.id, entity.creationTime.truncatedTo(ChronoUnit.MILLIS)); } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java b/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java index ccbc3d0cf..37137e619 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java +++ b/backend/src/main/java/org/cryptomator/hub/api/PaymentRequiredException.java @@ -3,7 +3,7 @@ import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.core.Response; -public class PaymentRequiredException extends ClientErrorException { +class PaymentRequiredException extends ClientErrorException { public PaymentRequiredException() { super(Response.Status.PAYMENT_REQUIRED); } 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 70c2c9bcf..4e4f3b02b 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java @@ -1,7 +1,10 @@ package org.cryptomator.hub.api; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.Nullable; import org.cryptomator.hub.entities.User; +import org.cryptomator.hub.validation.OnlyBase64Chars; +import org.cryptomator.hub.validation.ValidJWE; import java.util.Set; @@ -11,14 +14,24 @@ 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("setupCode") + public final String setupCode; - UserDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("email") String email, @JsonProperty("devices") Set devices) { + 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) { super(id, Type.USER, name, pictureUrl); this.email = email; this.devices = devices; + this.publicKey = publicKey; + this.privateKey = privateKey; + this.setupCode = setupCode; } - public static UserDto fromEntity(User user) { - return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of()); + public static UserDto justPublicInfo(User user) { + return new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of(), user.publicKey, 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 88a4bc91c..84604ea26 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -1,8 +1,11 @@ package org.cryptomator.hub.api; +import jakarta.annotation.Nullable; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; @@ -10,7 +13,6 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.cryptomator.hub.entities.AccessToken; import org.cryptomator.hub.entities.Device; import org.cryptomator.hub.entities.User; import org.eclipse.microprofile.jwt.JsonWebToken; @@ -35,10 +37,11 @@ public class UsersResource { @PUT @Path("/me") @RolesAllowed("user") + @Consumes(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "sync the logged-in user from the remote user provider to hub") + @Operation(summary = "update the logged-in user") @APIResponse(responseCode = "201", description = "user created or updated") - public Response syncMe() { + public Response putMe(@Nullable @Valid UserDto dto) { var userId = jwt.getSubject(); User user = User.findById(userId); if (user == null) { @@ -48,6 +51,11 @@ public Response syncMe() { user.name = jwt.getName(); user.pictureUrl = jwt.getClaim("picture"); user.email = jwt.getClaim("email"); + if (dto != null) { + user.publicKey = dto.publicKey; + user.privateKey = dto.privateKey; + user.setupCode = dto.setupCode; + } user.persist(); return Response.created(URI.create(".")).build(); } @@ -59,16 +67,13 @@ public Response syncMe() { @NoCache @Transactional @Operation(summary = "get the logged-in user") - public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam("withAccessibleVaults") boolean withAccessibleVaults) { + @APIResponse(responseCode = "200", description = "returns the current user") + @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 = User.findById(jwt.getSubject()); - Function mapAccessibleVaults = - a -> new VaultResource.VaultDto(a.vault.id, a.vault.name, a.vault.description, a.vault.archived, a.vault.creationTime.truncatedTo(ChronoUnit.MILLIS), null, 0, null, null, null); - Function mapDevices = withAccessibleVaults // - ? d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, d.accessTokens.stream().map(mapAccessibleVaults).collect(Collectors.toSet()), d.creationTime.truncatedTo(ChronoUnit.MILLIS)) // - : d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.owner.id, Set.of(), d.creationTime.truncatedTo(ChronoUnit.MILLIS)); - return withDevices // - ? new UserDto(user.id, user.name, user.pictureUrl, user.email, user.devices.stream().map(mapDevices).collect(Collectors.toSet())) - : new UserDto(user.id, user.name, user.pictureUrl, user.email, Set.of()); + Function mapDevices = d -> new DeviceResource.DeviceDto(d.id, d.name, d.type, d.publickey, d.userPrivateKey, d.owner.id, d.creationTime.truncatedTo(ChronoUnit.MILLIS)); + var devices = withDevices ? user.devices.stream().map(mapDevices).collect(Collectors.toSet()) : Set.of(); + return new UserDto(user.id, user.name, user.pictureUrl, user.email, devices, user.publicKey, user.privateKey, user.setupCode); } @GET @@ -77,7 +82,7 @@ public UserDto getMe(@QueryParam("withDevices") boolean withDevices, @QueryParam @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "list all users") public List getAll() { - return User.findAll().stream().map(UserDto::fromEntity).toList(); + return User.findAll().stream().map(UserDto::justPublicInfo).toList(); } } \ No newline at end of file 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 37135b13d..541c24624 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -3,9 +3,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.Nullable; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -13,9 +13,9 @@ import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; -import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; @@ -31,12 +31,14 @@ import org.cryptomator.hub.entities.AuditEventVaultMemberAdd; import org.cryptomator.hub.entities.AuditEventVaultMemberRemove; import org.cryptomator.hub.entities.AuditEventVaultUpdate; -import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.Authority; import org.cryptomator.hub.entities.EffectiveGroupMembership; import org.cryptomator.hub.entities.EffectiveVaultAccess; import org.cryptomator.hub.entities.Group; +import org.cryptomator.hub.entities.LegacyAccessToken; import org.cryptomator.hub.entities.User; import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.entities.VaultAccess; import org.cryptomator.hub.filters.ActiveLicense; import org.cryptomator.hub.filters.VaultAdminOnlyFilter; import org.cryptomator.hub.license.LicenseHolder; @@ -46,6 +48,8 @@ import org.cryptomator.hub.validation.ValidJWE; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.ParameterIn; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.hibernate.exception.ConstraintViolationException; @@ -55,6 +59,7 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.UUID; +import java.util.stream.Stream; @Path("/vaults") @RegisterForReflection(targets = {UUID[].class}) @@ -70,14 +75,20 @@ public class VaultResource { LicenseHolder license; @GET - @Path("/") + @Path("/accessible") @RolesAllowed("user") @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "list all accessible vaults", description = "list all vaults that are not archived and have been shared with the currently logged in user or a group this user is part of") - public List getAccessible() { + @Operation(summary = "list all accessible vaults", description = "list all (non-archived) vaults that have been shared with the currently logged in user or a group in wich this user is") + public List getAccessible(@Nullable @QueryParam("role") VaultAccess.Role role) { var currentUserId = jwt.getSubject(); - var resultStream = Vault.findAccessibleByUser(currentUserId); + // TODO refactor to JEP 441 in JDK 21 + final Stream resultStream; + if (role == null) { + resultStream = Vault.findAccessibleByUser(currentUserId); + } else { + resultStream = Vault.findAccessibleByUser(currentUserId, role); + } return resultStream.map(VaultDto::fromEntity).toList(); } @@ -97,7 +108,7 @@ public List getSomeVaults(@QueryParam("ids") List vaultIds) { @RolesAllowed("admin") @Produces(MediaType.APPLICATION_JSON) @Transactional - @Operation(summary = "list all accessible vaults", description = "list all vaults in the system") + @Operation(summary = "list all vaults", description = "list all vaults in the system") public List getAllVaults() { return Vault.findAll().stream().map(VaultDto::fromEntity).toList(); } @@ -118,7 +129,7 @@ public List getMembers(@PathParam("vaultId") UUID vaultId) { return vault.directMembers.stream().map(authority -> { if (authority instanceof User u) { - return UserDto.fromEntity(u); + return UserDto.justPublicInfo(u); } else if (authority instanceof Group g) { return GroupDto.fromEntity(g); } else { @@ -133,32 +144,26 @@ public List getMembers(@PathParam("vaultId") UUID vaultId) { @VaultAdminOnlyFilter @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "adds a member to this vault") - @APIResponse(responseCode = "201", description = "member added") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") + @Operation(summary = "adds a user to this vault or updates her role") + @Parameter(name = "role", in = ParameterIn.QUERY, description = "the role to grant to this user (defaults to MEMBER)") + @APIResponse(responseCode = "200", description = "user's role updated") + @APIResponse(responseCode = "201", description = "user added") @APIResponse(responseCode = "402", description = "all seats in license used") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or user not found") - @APIResponse(responseCode = "409", description = "user is already a direct member of the vault") + @APIResponse(responseCode = "403", description = "not a vault owner") + @APIResponse(responseCode = "404", description = "user not found") @ActiveLicense - public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); + public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId, @QueryParam("role") @DefaultValue("MEMBER") VaultAccess.Role role) { + var vault = Vault.findById(vaultId); // // should always be found, since @VaultRole filter would have triggered var user = User.findByIdOptional(userId).orElseThrow(NotFoundException::new); if (!EffectiveVaultAccess.isUserOccupyingSeat(userId)) { //for new user, we need to check if a license seat is available - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats >= license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats"); } } - if (vault.directMembers.contains(user)) { - return Response.status(Response.Status.CONFLICT).build(); - } - vault.directMembers.add(user); - vault.persist(); - AuditEventVaultMemberAdd.log(jwt.getSubject(), vaultId, userId); - return Response.status(Response.Status.CREATED).build(); + return addAuthority(vault, user, role); } @PUT @@ -167,29 +172,44 @@ public Response addUser(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @VaultAdminOnlyFilter @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "adds a group to this vault") - @APIResponse(responseCode = "201", description = "member added") - @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") + @Operation(summary = "adds a group to this vault or updates its role") + @Parameter(name = "role", in = ParameterIn.QUERY, description = "the role to grant to this group (defaults to MEMBER)") + @APIResponse(responseCode = "200", description = "group's role updated") + @APIResponse(responseCode = "201", description = "group added") @APIResponse(responseCode = "402", description = "used seats + (number of users in group not occupying a seats) exceeds number of total avaible seats in license") - @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or group not found") - @APIResponse(responseCode = "409", description = "group is already a direct member of the vault") + @APIResponse(responseCode = "403", description = "not a vault owner") + @APIResponse(responseCode = "404", description = "group not found") @ActiveLicense - public Response addGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId") @ValidId String groupId) { + public Response addGroup(@PathParam("vaultId") UUID vaultId, @PathParam("groupId") @ValidId String groupId, @QueryParam("role") @DefaultValue("MEMBER") VaultAccess.Role role) { + var vault = Vault.findById(vaultId); // should always be found, since @VaultRole filter would have triggered + var group = Group.findByIdOptional(groupId).orElseThrow(NotFoundException::new); + //usersInGroup - usersInGroupAndPartOfAtLeastOneVault + usersOfAtLeastOneVault - if (EffectiveGroupMembership.countEffectiveGroupUsers(groupId) - EffectiveVaultAccess.countEffectiveVaultUsersOfGroup(groupId) + EffectiveVaultAccess.countEffectiveVaultUsers() > license.getAvailableSeats()) { + if (EffectiveGroupMembership.countEffectiveGroupUsers(groupId) - EffectiveVaultAccess.countSeatOccupyingUsersOfGroup(groupId) + EffectiveVaultAccess.countSeatOccupyingUsers() > license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users greater than or equal to the available license seats"); } - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - var group = Group.findByIdOptional(groupId).orElseThrow(NotFoundException::new); - if (vault.directMembers.contains(group)) { - return Response.status(Response.Status.CONFLICT).build(); + return addAuthority(vault, group, role); + } + + private Response addAuthority(Vault vault, Authority authority, VaultAccess.Role role) { + var id = new VaultAccess.Id(vault.id, authority.id); + var existingAccess = VaultAccess.findByIdOptional(id); + if (existingAccess.isPresent()) { + var access = existingAccess.get(); + access.role = role; + access.persist(); + // TODO log event? + return Response.ok().build(); + } else { + var access = new VaultAccess(); + access.vault = vault; + access.authority = authority; + access.role = role; + access.persist(); + AuditEventVaultMemberAdd.log(jwt.getSubject(), vault.id, authority.id, role); + return Response.created(URI.create(".")).build(); } - vault.directMembers.add(group); - vault.persist(); - AuditEventVaultMemberAdd.log(jwt.getSubject(), vaultId, groupId); - return Response.status(Response.Status.CREATED).build(); } @DELETE @@ -223,15 +243,16 @@ public Response removeGroup(@PathParam("vaultId") UUID vaultId, @PathParam("grou } private Response removeAuthority(UUID vaultId, String authorityId) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); - vault.directMembers.removeIf(e -> e.id.equals(authorityId)); - vault.persist(); - AuditEventVaultMemberRemove.log(jwt.getSubject(), vaultId, authorityId); - return Response.status(Response.Status.NO_CONTENT).build(); + if (VaultAccess.deleteById(new VaultAccess.Id(vaultId, authorityId))) { + AuditEventVaultMemberRemove.log(jwt.getSubject(), vaultId, authorityId); + return Response.status(Response.Status.NO_CONTENT).build(); + } else { + throw new NotFoundException(); + } } @GET - @Path("/{vaultId}/devices-requiring-access-grant") + @Path("/{vaultId}/users-requiring-access-grant") @RolesAllowed("user") @VaultAdminOnlyFilter @Transactional @@ -241,82 +262,112 @@ private Response removeAuthority(UUID vaultId, String authorityId) { @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") @APIResponse(responseCode = "404", description = "vault not found") - public List getDevicesRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { - return Device.findRequiringAccessGrant(vaultId).map(DeviceResource.DeviceDto::fromEntity).toList(); + public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { + return User.findRequiringAccessGrant(vaultId).map(UserDto::justPublicInfo).toList(); } + @Deprecated(forRemoval = true) @GET @Path("/{vaultId}/keys/{deviceId}") @RolesAllowed("user") @Transactional @Produces(MediaType.TEXT_PLAIN) - @Operation(summary = "get the device-specific masterkey of a non-archived vault") + @Operation(summary = "get the device-specific masterkey of a non-archived vault", deprecated = true) @APIResponse(responseCode = "200") @APIResponse(responseCode = "402", description = "number of effective vault users exceeds available license seats") - @APIResponse(responseCode = "403", description = "device not authorized to access this vault") - @APIResponse(responseCode = "404", description = "vault or device not found") + @APIResponse(responseCode = "403", description = "not authorized to access this vault") @APIResponse(responseCode = "410", description = "Vault is archived") @ActiveLicense - public Response unlock(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId) { + public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId) { var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); if (vault.archived) { throw new GoneException("Vault is archived."); } - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats > license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); } - var access = AccessToken.unlock(vaultId, deviceId, jwt.getSubject()); + var access = LegacyAccessToken.unlock(vaultId, deviceId, jwt.getSubject()); if (access != null) { AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.SUCCESS); var subscriptionStateHeaderName = "Hub-Subscription-State"; var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter return Response.ok(access.jwe).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build(); - } else if (Device.findById(deviceId) == null) { - throw new NotFoundException("No such device."); } else { AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.UNAUTHORIZED); throw new ForbiddenException("Access to this device not granted."); } } + @GET + @Path("/{vaultId}/access-token") + @RolesAllowed("user") + @Transactional + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "get the user-specific vault key", description = "retrieves a jwe containing the vault key, encrypted for the current user") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "402", description = "number of effective vault users exceeds available license seats") + @APIResponse(responseCode = "403", description = "user not authorized to access this vault") + @APIResponse(responseCode = "404", description = "unknown vault") + @APIResponse(responseCode = "410", description = "Vault is archived") + @ActiveLicense // may throw 402 + public String unlock(@PathParam("vaultId") UUID vaultId) { + var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); + if (vault.archived) { + throw new GoneException("Vault is archived."); + } + + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); + if (usedSeats > license.getAvailableSeats()) { + throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); + } + + var access = AccessToken.unlock(vaultId, jwt.getSubject()); + if (access != null) { + AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.SUCCESS); + return access.vaultKey; + } else if (Vault.findById(vaultId) == null) { + throw new NotFoundException("No such vault."); + } else { + AuditEventVaultKeyRetrieve.log(jwt.getSubject(), vaultId, AuditEventVaultKeyRetrieve.Result.UNAUTHORIZED); + throw new ForbiddenException("Access to this vault not granted."); + } + } + @PUT - @Path("/{vaultId}/keys/{deviceId}") + @Path("/{vaultId}/access-tokens/{userId}") @RolesAllowed("user") @VaultAdminOnlyFilter @Transactional @Consumes(MediaType.TEXT_PLAIN) - @Operation(summary = "adds a device-specific masterkey to a non-archived vault") - @APIResponse(responseCode = "201", description = "device-specific key stored") + @Operation(summary = "adds a user-specific vault key") + @APIResponse(responseCode = "201", description = "user-specific key stored") @APIResponse(responseCode = "401", description = "VaultAdminAuthorizationJWT not provided") @APIResponse(responseCode = "403", description = "VaultAdminAuthorizationJWT expired or not yet valid") - @APIResponse(responseCode = "404", description = "vault or device not found") + @APIResponse(responseCode = "404", description = "vault or userId not found") @APIResponse(responseCode = "409", description = "Access to vault for device already granted") @APIResponse(responseCode = "410", description = "Vault is archived") - public Response grantAccess(@PathParam("vaultId") UUID vaultId, @PathParam("deviceId") @ValidId String deviceId, @ValidJWE String jwe) { - var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); + public Response grantAccess(@PathParam("vaultId") UUID vaultId, @PathParam("userId") @ValidId String userId, @NotNull @ValidJWE String vaultKey) { + var vault = Vault.findByIdOptional(vaultId).orElseThrow(NotFoundException::new); // should always be found, since @VaultAdminOnlyFilter already checked the jwt matching this vault if (vault.archived) { throw new GoneException("Vault is archived."); } - var device = Device.findByIdOptional(deviceId).orElseThrow(NotFoundException::new); + + var user = User.findByIdOptional(userId).orElseThrow(NotFoundException::new); var access = new AccessToken(); access.vault = vault; - access.device = device; - access.jwe = jwe; + access.user = user; + access.vaultKey = vaultKey; try { access.persistAndFlush(); - AuditEventVaultAccessGrant.log(jwt.getSubject(), vaultId, device.owner.id); + AuditEventVaultAccessGrant.log(jwt.getSubject(), vaultId, userId); return Response.created(URI.create(".")).build(); - } catch (PersistenceException e) { - if (e instanceof ConstraintViolationException) { - throw new ClientErrorException(Response.Status.CONFLICT, e); - } else { - throw new InternalServerErrorException(e); - } + } catch (ConstraintViolationException e) { + throw new ClientErrorException(Response.Status.CONFLICT, e); } } @@ -348,7 +399,7 @@ public VaultDto get(@PathParam("vaultId") UUID vaultId) { @APIResponse(responseCode = "201", description = "new vault created") @APIResponse(responseCode = "402", description = "all seats in licence in use during creation of new vault") public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNull VaultDto vaultDto) { - var currentUser = User.findById(jwt.getSubject()); + User currentUser = User.findById(jwt.getSubject()); Vault vault; boolean isCreated = false; try { @@ -356,7 +407,7 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu } catch (NoSuchElementException _e) { if (!EffectiveVaultAccess.isUserOccupyingSeat(currentUser.id)) { //for new vaults, we need to check that a licence seat is available if the user does not already have access to a vault. - var usedSeats = EffectiveVaultAccess.countEffectiveVaultUsers(); + var usedSeats = EffectiveVaultAccess.countSeatOccupyingUsers(); if (usedSeats >= license.getAvailableSeats()) { throw new PaymentRequiredException("Number of effective vault users exceeds available license seats"); } @@ -371,16 +422,21 @@ public Response createOrUpdate(@PathParam("vaultId") UUID vaultId, @Valid @NotNu vault.salt = vaultDto.salt; vault.authenticationPublicKey = vaultDto.authPublicKey; vault.authenticationPrivateKey = vaultDto.authPrivateKey; - vault.directMembers.add(currentUser); } //update new or existing vault vault.name = vaultDto.name; vault.description = vaultDto.description; vault.archived = isCreated ? false : vaultDto.archived; - vault.persistAndFlush(); + + vault.persistAndFlush(); // trigger PersistenceException before we continue with if (isCreated) { + var access = new VaultAccess(); + access.vault = vault; + access.authority = currentUser; + access.role = VaultAccess.Role.OWNER; + access.persist(); AuditEventVaultCreate.log(currentUser.id, vault.id, vault.name, vault.description); - AuditEventVaultMemberAdd.log(currentUser.id, vaultId, currentUser.id); + AuditEventVaultMemberAdd.log(currentUser.id, vaultId, currentUser.id, VaultAccess.Role.OWNER); return Response.created(URI.create(".")).contentLocation(URI.create(".")).entity(VaultDto.fromEntity(vault)).type(MediaType.APPLICATION_JSON).build(); } else { AuditEventVaultUpdate.log(currentUser.id, vault.id, vault.name, vault.description, vault.archived); @@ -394,9 +450,9 @@ public record VaultDto(@JsonProperty("id") UUID id, @JsonProperty("description") @NoHtmlOrScriptChars String description, @JsonProperty("archived") boolean archived, @JsonProperty("creationTime") Instant creationTime, - @JsonProperty("masterkey") @OnlyBase64Chars String masterkey, @JsonProperty("iterations") int iterations, - @JsonProperty("salt") @OnlyBase64Chars String salt, - @JsonProperty("authPublicKey") @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars String authPrivateKey + @JsonProperty("masterkey") @NotNull @OnlyBase64Chars String masterkey, @JsonProperty("iterations") int iterations, + @JsonProperty("salt") @NotNull @OnlyBase64Chars String salt, + @JsonProperty("authPublicKey") @NotNull @OnlyBase64Chars String authPublicKey, @JsonProperty("authPrivateKey") @NotNull @OnlyBase64Chars String authPrivateKey ) { public static VaultDto fromEntity(Vault entity) { diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java b/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java index da761c32c..9e911c2e0 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java @@ -1,7 +1,6 @@ package org.cryptomator.hub.entities; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; -import io.quarkus.panache.common.Parameters; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -11,7 +10,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; -import jakarta.persistence.NamedQuery; import jakarta.persistence.NoResultException; import jakarta.persistence.Table; @@ -21,16 +19,6 @@ @Entity @Table(name = "access_token") -@NamedQuery(name = "AccessToken.get", query = """ - SELECT a - FROM Vault v - INNER JOIN v.effectiveMembers u - INNER JOIN u.devices d - INNER JOIN d.accessTokens a ON a.id.deviceId = d.id AND a.id.vaultId = v.id - WHERE v.id = :vaultId - AND u.id = :userId - AND d.id = :deviceId - """) @RegisterForReflection(targets = {UUID[].class}) public class AccessToken extends PanacheEntityBase { @@ -38,21 +26,58 @@ public class AccessToken extends PanacheEntityBase { public AccessId id = new AccessId(); @ManyToOne(optional = false, cascade = {CascadeType.REMOVE}) - @MapsId("deviceId") - @JoinColumn(name = "device_id") - public Device device; + @MapsId("userId") + @JoinColumn(name = "user_id") + public User user; @ManyToOne(optional = false, cascade = {CascadeType.REMOVE}) @MapsId("vaultId") @JoinColumn(name = "vault_id") public Vault vault; - @Column(name = "jwe", nullable = false) - public String jwe; + @Column(name = "vault_masterkey", nullable = false) + public String vaultKey; + + public static AccessToken unlock(UUID vaultId, String userId) { + /* + * FIXME remove this native query and add the named query again as soon as Hibernate ORM ships version 6.2.8 or 6.3.0 + * See https://github.com/quarkusio/quarkus/issues/35386 for further information + */ - public static AccessToken unlock(UUID vaultId, String deviceId, String userId) { try { - return find("#AccessToken.get", Parameters.with("deviceId", deviceId).and("vaultId", vaultId).and("userId", userId)).firstResult(); + var query = getEntityManager() + .createNativeQuery(""" + select + a1_0."user_id", + a1_0."vault_id", + u1_0."id", + u1_1."name", + u1_0."email", + u1_0."picture_url", + u1_0."privatekey", + u1_0."publickey", + u1_0."setupcode", + a1_0."vault_masterkey" + from + "user_details" u1_0 + join + "authority" u1_1 + on u1_0."id"=u1_1."id" + join + "effective_vault_access" e1_0 + on u1_0."id"=e1_0."authority_id" + join + "access_token" a1_0 + on u1_0."id"=a1_0."user_id" + and a1_0."vault_id"=:vaultId + and a1_0."user_id"=u1_0."id" + where + e1_0."vault_id"=:vaultId + and u1_0."id"=:userId + """, AccessToken.class) + .setParameter("vaultId", vaultId) + .setParameter("userId", userId); + return (AccessToken) query.getSingleResult(); } catch (NoResultException e) { return null; } @@ -64,34 +89,34 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; AccessToken other = (AccessToken) o; return Objects.equals(id, other.id) - && Objects.equals(device, other.device) + && Objects.equals(user, other.user) && Objects.equals(vault, other.vault) - && Objects.equals(jwe, other.jwe); + && Objects.equals(vaultKey, other.vaultKey); } @Override public int hashCode() { - return Objects.hash(id, device, vault, jwe); + return Objects.hash(id, user, vault, vaultKey); } @Override public String toString() { return "Access{" + "id=" + id + - ", device=" + device.id + + ", user=" + user.id + ", vault=" + vault.id + - ", jwe='" + jwe + '\'' + + ", vaultKey='" + vaultKey + '\'' + '}'; } @Embeddable public static class AccessId implements Serializable { - public String deviceId; + public String userId; public UUID vaultId; - public AccessId(String deviceId, UUID vaultId) { - this.deviceId = deviceId; + public AccessId(String userId, UUID vaultId) { + this.userId = userId; this.vaultId = vaultId; } @@ -103,19 +128,19 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; AccessId other = (AccessId) o; - return Objects.equals(deviceId, other.deviceId) // + return Objects.equals(userId, other.userId) // && Objects.equals(vaultId, other.vaultId); } @Override public int hashCode() { - return Objects.hash(deviceId, vaultId); + return Objects.hash(userId, vaultId); } @Override public String toString() { return "AccessId{" + - "deviceId='" + deviceId + '\'' + + "userId='" + userId + '\'' + ", vaultId='" + vaultId + '\'' + '}'; } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java index 1239efb85..a033a848f 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/AuditEventVaultMemberAdd.java @@ -4,6 +4,8 @@ import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import java.time.Instant; @@ -27,6 +29,10 @@ public class AuditEventVaultMemberAdd extends AuditEvent { @Column(name = "authority_id") public String authorityId; + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public VaultAccess.Role role; + @Override public boolean equals(Object o) { if (this == o) return true; @@ -35,20 +41,22 @@ public boolean equals(Object o) { return super.equals(that) // && Objects.equals(addedBy, that.addedBy) // && Objects.equals(vaultId, that.vaultId) // - && Objects.equals(authorityId, that.authorityId); + && Objects.equals(authorityId, that.authorityId) // + && Objects.equals(role, that.role); } @Override public int hashCode() { - return Objects.hash(id, addedBy, vaultId, authorityId); + return Objects.hash(id, addedBy, vaultId, authorityId, role); } - public static void log(String addedBy, UUID vaultId, String authorityId) { + public static void log(String addedBy, UUID vaultId, String authorityId, VaultAccess.Role role) { var event = new AuditEventVaultMemberAdd(); event.timestamp = Instant.now(); event.addedBy = addedBy; event.vaultId = vaultId; event.authorityId = authorityId; + event.role = role; event.persist(); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Authority.java b/backend/src/main/java/org/cryptomator/hub/entities/Authority.java index 26c2bd0ca..d9dc1600a 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Authority.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Authority.java @@ -5,18 +5,14 @@ import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; import jakarta.persistence.NamedQuery; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.stream.Stream; @Entity @@ -35,15 +31,12 @@ WHERE LOWER(a.name) LIKE :name FROM Authority a WHERE a.id IN :ids """) -public class Authority extends PanacheEntityBase { +public class Authority extends PanacheEntityBase { // TODO make sealed? @Id @Column(name = "id", nullable = false) public String id; - @OneToMany(mappedBy = "owner", orphanRemoval = true, fetch = FetchType.LAZY) - public Set devices = new HashSet<>(); - @Column(name = "name", nullable = false) public String name; @@ -59,7 +52,6 @@ public static Stream findAllInList(List ids) { public String toString() { return "Authority{" + "id='" + id + '\'' + - ", devices=" + devices.size() + ", name='" + name + '\'' + '}'; } 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 75b803790..e0c20559f 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Device.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Device.java @@ -11,28 +11,18 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.NamedQuery; -import jakarta.persistence.OneToMany; +import jakarta.persistence.NoResultException; import jakarta.persistence.Table; import java.time.Instant; -import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Set; -import java.util.UUID; import java.util.stream.Stream; @Entity @Table(name = "device") -@NamedQuery(name = "Device.requiringAccessGrant", - query = """ - SELECT d - FROM Vault v - INNER JOIN v.effectiveMembers m - INNER JOIN m.devices d - LEFT JOIN d.accessTokens a ON a.id.vaultId = :vaultId AND a.id.deviceId = d.id - WHERE v.id = :vaultId AND a.vault IS NULL - """ +@NamedQuery(name = "Device.findByIdAndOwner", + query = "SELECT d FROM Device d WHERE d.id = :deviceId AND d.owner.id = :userId" ) @NamedQuery(name = "Device.allInList", query = """ @@ -52,10 +42,7 @@ public enum Type { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_id", updatable = false, nullable = false) - public Authority owner; - - @OneToMany(mappedBy = "device", fetch = FetchType.LAZY) - public Set accessTokens = new HashSet<>(); + public User owner; @Column(name = "name", nullable = false) public String name; @@ -67,6 +54,9 @@ public enum Type { @Column(name = "publickey", nullable = false) public String publickey; + @Column(name = "user_privatekey", nullable = false) + public String userPrivateKey; + @Column(name = "creation_time", nullable = false) public Instant creationTime; @@ -78,6 +68,8 @@ public String toString() { ", name='" + name + '\'' + ", type='" + type + '\'' + ", publickey='" + publickey + '\'' + + ", userPrivateKey='" + userPrivateKey + '\'' + + ", creationTime='" + creationTime + '\'' + '}'; } @@ -90,16 +82,18 @@ public boolean equals(Object o) { && Objects.equals(this.owner, other.owner) && Objects.equals(this.name, other.name) && Objects.equals(this.type, other.type) - && Objects.equals(this.publickey, other.publickey); + && Objects.equals(this.publickey, other.publickey) + && Objects.equals(this.userPrivateKey, other.userPrivateKey) + && Objects.equals(this.creationTime, other.creationTime); } @Override public int hashCode() { - return Objects.hash(id, owner, name, type, publickey); + return Objects.hash(id, owner, name, type, publickey, userPrivateKey, creationTime); } - public static Stream findRequiringAccessGrant(UUID vaultId) { - return find("#Device.requiringAccessGrant", Parameters.with("vaultId", vaultId)).stream(); + public static Device findByIdAndUser(String deviceId, String userId) throws NoResultException { + return find("#Device.findByIdAndOwner", Parameters.with("deviceId", deviceId).and("userId", userId)).singleResult(); } public static Stream findAllInList(List ids) { 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 8d4826119..36baf5c38 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java @@ -4,86 +4,59 @@ import io.quarkus.panache.common.Parameters; import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.NamedQuery; import jakarta.persistence.Table; import org.hibernate.annotations.Immutable; -import java.io.Serializable; -import java.util.Objects; import java.util.UUID; @Entity @Immutable @Table(name = "effective_vault_access") -@NamedQuery(name = "EffectiveVaultAccess.countVaultAccessesOfUser", query = """ - SELECT count(eva) - FROM EffectiveVaultAccess eva - WHERE eva.id.authorityId = :userId +@NamedQuery(name = "EffectiveVaultAccess.countSeatsOccupiedByUser", query = """ + SELECT count(eva) + FROM EffectiveVaultAccess eva + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived + WHERE eva.id.authorityId = :userId """) -@NamedQuery(name = "EffectiveVaultAccess.countEVUs", query = """ - SELECT count( DISTINCT u) - FROM User u - INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId +@NamedQuery(name = "EffectiveVaultAccess.countSeatOccupyingUsers", query = """ + SELECT count(DISTINCT u) + FROM User u + INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived """) -@NamedQuery(name = "EffectiveVaultAccess.countEVUsInGroup", query = """ - SELECT count( DISTINCT u) - FROM User u - INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId - INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId - WHERE egm.id.groupId = :groupId +@NamedQuery(name = "EffectiveVaultAccess.countSeatOccupyingUsersOfGroup", query = """ + SELECT count(DISTINCT u) + FROM User u + INNER JOIN EffectiveVaultAccess eva ON u.id = eva.id.authorityId + INNER JOIN EffectiveGroupMembership egm ON u.id = egm.id.memberId + INNER JOIN Vault v ON eva.id.vaultId = v.id AND NOT v.archived + WHERE egm.id.groupId = :groupId """) @RegisterForReflection(targets = {UUID[].class}) public class EffectiveVaultAccess extends PanacheEntityBase { @EmbeddedId - public EffectiveVaultAccessId id; + public VaultAccess.Id id; - public static boolean isUserOccupyingSeat(String userId) { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countVaultAccessesOfUser", Parameters.with("userId", userId)) > 0; - } + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public VaultAccess.Role role; - public static long countEffectiveVaultUsers() { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countEVUs"); + public static boolean isUserOccupyingSeat(String userId) { + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatsOccupiedByUser", Parameters.with("userId", userId)) > 0; } - public static long countEffectiveVaultUsersOfGroup(String groupId) { - return EffectiveVaultAccess.count("#EffectiveVaultAccess.countEVUsInGroup", Parameters.with("groupId", groupId)); + public static long countSeatOccupyingUsers() { + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatOccupyingUsers"); } - @Embeddable - public static class EffectiveVaultAccessId implements Serializable { - - @Column(name = "vault_id") - public UUID vaultId; - - @Column(name = "authority_id") - public String authorityId; - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o instanceof EffectiveVaultAccessId evaId) { - return Objects.equals(vaultId, evaId.vaultId) // - && Objects.equals(authorityId, evaId.authorityId); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hash(vaultId, authorityId); - } - - @Override - public String toString() { - return "EffectiveVaultAccessId{" + - "vaultId='" + vaultId + '\'' + - ", authorityId='" + authorityId + '\'' + - '}'; - } + public static long countSeatOccupyingUsersOfGroup(String groupId) { + return EffectiveVaultAccess.count("#EffectiveVaultAccess.countSeatOccupyingUsersOfGroup", Parameters.with("groupId", groupId)); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java b/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java new file mode 100644 index 000000000..782f804db --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/LegacyAccessToken.java @@ -0,0 +1,107 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.NamedNativeQuery; +import jakarta.persistence.NoResultException; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "access_token_legacy") +@NamedNativeQuery(name = "LegacyAccessToken.get", resultClass = LegacyAccessToken.class, query = """ + SELECT t.device_id, t.vault_id, t.jwe + FROM access_token_legacy t + INNER JOIN device_legacy d ON d.id = t.device_id + INNER JOIN effective_vault_access a ON a.vault_id = t.vault_id AND a.authority_id = d.owner_id + WHERE t.vault_id = :vaultId AND d.id = :deviceId AND d.owner_id = :userId + """) +@Deprecated +public class LegacyAccessToken extends PanacheEntityBase { + + @EmbeddedId + public AccessId id = new AccessId(); + + @Column(name = "jwe", nullable = false) + public String jwe; + + public static LegacyAccessToken unlock(UUID vaultId, String deviceId, String userId) { + try { + return getEntityManager().createNamedQuery("LegacyAccessToken.get", LegacyAccessToken.class) // + .setParameter("deviceId", deviceId) // + .setParameter("vaultId", vaultId) // + .setParameter("userId", userId) // + .getSingleResult(); + } catch (NoResultException e) { + return null; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LegacyAccessToken other = (LegacyAccessToken) o; + return Objects.equals(id, other.id) + && Objects.equals(jwe, other.jwe); + } + + @Override + public int hashCode() { + return Objects.hash(id, jwe); + } + + @Override + public String toString() { + return "LegacyAccessToken{" + + "id=" + id + + ", jwe='" + jwe + '\'' + + '}'; + } + + @Embeddable + public static class AccessId implements Serializable { + + @Column(name = "device_id", nullable = false) + public String deviceId; + + @Column(name = "vault_id", nullable = false) + public UUID vaultId; + + public AccessId(String deviceId, UUID vaultId) { + this.deviceId = deviceId; + this.vaultId = vaultId; + } + + public AccessId() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AccessId other = (AccessId) o; + return Objects.equals(deviceId, other.deviceId) // + && Objects.equals(vaultId, other.vaultId); + } + + @Override + public int hashCode() { + return Objects.hash(deviceId, vaultId); + } + + @Override + public String toString() { + return "LegacyAccessTokenId{" + + "deviceId='" + deviceId + '\'' + + ", vaultId='" + vaultId + '\'' + + '}'; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java b/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java new file mode 100644 index 000000000..1769013b4 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/LegacyDevice.java @@ -0,0 +1,18 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Deprecated +@Entity +@Table(name = "device_legacy") +public class LegacyDevice extends PanacheEntityBase { + + @Id + @Column(name = "id", nullable = false) + public String id; + +} 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 73515dc84..0ea867b7e 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/User.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/User.java @@ -1,15 +1,32 @@ package org.cryptomator.hub.entities; +import io.quarkus.panache.common.Parameters; import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; @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 + """ +) public class User extends Authority { @Column(name = "picture_url") @@ -18,6 +35,21 @@ public class User extends Authority { @Column(name = "email") public String email; + @Column(name = "publickey") + public String publicKey; + + @Column(name = "privatekey") + public String privateKey; + + @Column(name = "setupcode") + public String setupCode; + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) + public Set accessTokens = new HashSet<>(); + + @OneToMany(mappedBy = "owner", orphanRemoval = true, fetch = FetchType.LAZY) + public Set devices = new HashSet<>(); + @Override public boolean equals(Object o) { if (this == o) return true; @@ -25,12 +57,19 @@ public boolean equals(Object o) { User that = (User) o; return super.equals(that) // && Objects.equals(pictureUrl, that.pictureUrl) // - && Objects.equals(email, that.email); + && Objects.equals(email, that.email) // + && Objects.equals(publicKey, that.publicKey) // + && Objects.equals(privateKey, that.privateKey) // + && Objects.equals(setupCode, that.setupCode); } @Override public int hashCode() { - return Objects.hash(id, pictureUrl, email); + return Objects.hash(id, pictureUrl, email, publicKey, privateKey, setupCode); + } + + public static Stream findRequiringAccessGrant(UUID vaultId) { + return find("#User.requiringAccessGrant", Parameters.with("vaultId", vaultId)).stream(); } } 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 f969d8727..e9ed5b866 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java @@ -30,9 +30,15 @@ query = """ SELECT DISTINCT v FROM Vault v - LEFT JOIN v.effectiveMembers m - WHERE m.id = :userId - AND NOT v.archived + INNER JOIN EffectiveVaultAccess a ON a.id.vaultId = v.id AND a.id.authorityId = :userId + WHERE NOT v.archived + """) +@NamedQuery(name = "Vault.accessibleByUserAndRole", + query = """ + SELECT DISTINCT v + FROM Vault v + INNER JOIN EffectiveVaultAccess a ON a.id.vaultId = v.id AND a.id.authorityId = :userId + WHERE a.role = :role AND NOT v.archived """) @NamedQuery(name = "Vault.allInList", query = """ @@ -49,6 +55,7 @@ public class Vault extends PanacheEntityBase { public UUID id; @ManyToMany + @Immutable @JoinTable(name = "vault_access", joinColumns = @JoinColumn(name = "vault_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id") @@ -64,7 +71,7 @@ public class Vault extends PanacheEntityBase { public Set effectiveMembers = new HashSet<>(); @OneToMany(mappedBy = "vault", fetch = FetchType.LAZY) - public Set accessTokens = new HashSet<>(); // rename to accesstokens? + public Set accessTokens = new HashSet<>(); @Column(name = "name", nullable = false) public String name; @@ -97,6 +104,10 @@ public static Stream findAccessibleByUser(String userId) { return find("#Vault.accessibleByUser", Parameters.with("userId", userId)).stream(); } + public static Stream findAccessibleByUser(String userId, VaultAccess.Role role) { + return find("#Vault.accessibleByUserAndRole", Parameters.with("userId", userId).and("role", role)).stream(); + } + public static Stream findAllInList(List ids) { return find("#Vault.allInList", Parameters.with("ids", ids)).stream(); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java new file mode 100644 index 000000000..b445cac14 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/VaultAccess.java @@ -0,0 +1,91 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +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.Table; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "vault_access") +public class VaultAccess extends PanacheEntityBase { + + @EmbeddedId + public VaultAccess.Id id = new VaultAccess.Id(); + + @ManyToOne + @MapsId("vaultId") + @JoinColumn(name = "vault_id") + public Vault vault; + + @ManyToOne + @MapsId("authorityId") + @JoinColumn(name = "authority_id") + public Authority authority; + + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + public Role role; + + public enum Role { + /** + * User with access to vault contents. + */ + MEMBER, + + /** + * User with administrative privileges on a vault. + */ + OWNER + } + + @Embeddable + public static class Id implements Serializable { + + @Column(name = "vault_id") + public UUID vaultId; + + @Column(name = "authority_id") + public String authorityId; + + public Id(UUID vaultId, String authorityId) { + this.vaultId = vaultId; + this.authorityId = authorityId; + } + + public Id() { + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof Id other) { + return Objects.equals(this.vaultId, other.vaultId) && Objects.equals(this.authorityId, other.authorityId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(vaultId, authorityId); + } + + @Override + public String toString() { + return "VaultAccess.Id{" + + "vaultId='" + vaultId + '\'' + + ", authorityId='" + authorityId + '\'' + + '}'; + } + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java index 237a412f8..8cd68fdfe 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64Chars.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[+/A-Za-z0-9]+=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java b/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java deleted file mode 100644 index 0c28c9052..000000000 --- a/backend/src/main/java/org/cryptomator/hub/validation/OnlyBase64UrlChars.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.cryptomator.hub.validation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.*; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -@Pattern(regexp = "[-_A-Za-z0-9]+=*") -@NotNull -@Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) -@Retention(RUNTIME) -@Constraint(validatedBy = {}) -@Documented -public @interface OnlyBase64UrlChars { - String message() default "Input is not a valid base64url encoded string"; - - Class[] groups() default {}; - - Class[] payload() default {}; -} diff --git a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java index 97bede999..e902eb0e7 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWE.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java index 0a24b53a6..1226eb4b9 100644 --- a/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java +++ b/backend/src/main/java/org/cryptomator/hub/validation/ValidJWS.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import java.lang.annotation.Documented; @@ -13,7 +12,6 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; @Pattern(regexp = "[-_A-Za-z0-9]+=*\\.[-_A-Za-z0-9]*=*\\.[-_A-Za-z0-9]*=*") -@NotNull @Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE, PARAMETER}) @Retention(RUNTIME) @Constraint(validatedBy = {}) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 751bb4d93..18fcfa1a4 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -77,6 +77,8 @@ quarkus.flyway.locations=classpath:org/cryptomator/hub/flyway # HTTP Security Headers see e.g. https://owasp.org/www-project-secure-headers/#div-bestpractices quarkus.http.header."Content-Security-Policy".value=default-src 'self'; connect-src 'self' api.cryptomator.org; object-src 'none'; child-src 'self'; img-src * data:; frame-ancestors 'none' %dev.quarkus.http.header."Content-Security-Policy".value=default-src 'self'; connect-src 'self' api.cryptomator.org localhost:8180; object-src 'none'; child-src 'self'; img-src * data:; frame-ancestors 'none' +# dev-ui needs very permissive CSP: +# %dev.quarkus.http.header."Content-Security-Policy".value=default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data:; connect-src 'self' api.cryptomator.org localhost:8180; quarkus.http.header."Referrer-Policy".value=no-referrer quarkus.http.header."Strict-Transport-Security".value=max-age=31536000; includeSubDomains quarkus.http.header."X-Content-Type-Options".value=nosniff 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 1ef70a42b..c19e352b3 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/V14__User_Keys.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V14__User_Keys.sql new file mode 100644 index 000000000..ce1c8ea8b --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V14__User_Keys.sql @@ -0,0 +1,52 @@ +-- users will generate a new key pair during first login in the browser: +ALTER TABLE "user_details" ADD "publickey" VARCHAR(255); -- base64-encoded SPKI DER (RFC 5280, 4.1.2.7) +ALTER TABLE "user_details" ADD "privatekey" VARCHAR(2000); -- private key, encrypted using setup code (JWE PBES2) +ALTER TABLE "user_details" ADD "setupcode" VARCHAR(2000); -- setup code, encrypted using user's public key (JWE ECDH-ES) + +-- keep existing device-based access tokens for continuous unlock from old clients. +ALTER TABLE "access_token" RENAME TO "access_token_legacy"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_PK" TO "ACCESS_LEGACY_PK"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_FK_DEVICE" TO "ACCESS_LEGACY_FK_DEVICE"; +ALTER TABLE "access_token_legacy" RENAME CONSTRAINT "ACCESS_FK_VAULT" TO "ACCESS_LEGACY_FK_VAULT"; +ALTER TABLE "device" RENAME TO "device_legacy"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_PK" TO "DEVICE_LEGACY_PK"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_FK_USER" TO "DEVICE_LEGACY_FK_USER"; +ALTER TABLE "device_legacy" RENAME CONSTRAINT "DEVICE_UNIQUE_NAME_PER_OWNER" TO "DEVICE_LEGACY_UNIQUE_NAME_PER_OWNER"; +COMMENT ON COLUMN "device_legacy"."publickey" IS 'Note: This contains base64url-encoded data for historic reasons.'; + +-- new device table with non-null user_privatekey: +CREATE TABLE "device" +( + "id" VARCHAR(255) NOT NULL, + "owner_id" VARCHAR(255) NOT NULL, + "name" VARCHAR(255) NOT NULL, + "type" VARCHAR(50) NOT NULL DEFAULT 'DESKTOP', + "publickey" VARCHAR(255) NOT NULL, + "user_privatekey" VARCHAR(2000) NOT NULL UNIQUE, -- private key, encrypted using device's public key (JWE ECDH-ES) + "creation_time" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "DEVICE_PK" PRIMARY KEY ("id"), + CONSTRAINT "DEVICE_FK_USER" FOREIGN KEY ("owner_id") REFERENCES "user_details" ("id") ON DELETE CASCADE, + CONSTRAINT "DEVICE_UNIQUE_NAME_PER_OWNER" UNIQUE ("owner_id", "name") +); + +-- new access tokens will be issued for users (not devices): +CREATE TABLE "access_token" +( + "user_id" VARCHAR(255) COLLATE "C" NOT NULL, + "vault_id" UUID NOT NULL, + "vault_masterkey" VARCHAR(2000) NOT NULL UNIQUE, -- private key, encrypted using user's public key (JWE ECDH-ES) + CONSTRAINT "ACCESS_PK" PRIMARY KEY ("user_id", "vault_id"), + CONSTRAINT "ACCESS_FK_USER" FOREIGN KEY ("user_id") REFERENCES "user_details" ("id") ON DELETE CASCADE, + CONSTRAINT "ACCESS_FK_VAULT" FOREIGN KEY ("vault_id") REFERENCES "vault" ("id") ON DELETE CASCADE +); + +ALTER TABLE "vault_access" ADD "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER'; +ALTER TABLE "audit_event_vault_member_add" ADD "role" VARCHAR(50) NOT NULL DEFAULT 'MEMBER'; + +-- @formatter:off +CREATE OR REPLACE VIEW "effective_vault_access" ("vault_id", "authority_id", "role") AS + SELECT "va"."vault_id", "va"."authority_id", "va"."role" FROM "vault_access" "va" + UNION + SELECT "va"."vault_id", "gm"."member_id", "va"."role" FROM "vault_access" "va" + INNER JOIN "effective_group_membership" "gm" ON "va"."authority_id" = "gm"."group_id"; +-- @formatter:on \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java b/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java index 2d12f18c1..fe4021728 100644 --- a/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java +++ b/backend/src/test/java/org/cryptomator/hub/KeycloakRemoteUserProviderTest.java @@ -15,6 +15,7 @@ import org.keycloak.representations.idm.UserRepresentation; import org.mockito.Mockito; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -121,9 +122,9 @@ public void testListGroups() { Assertions.assertEquals("grpName3001", resultGroup2.name); Assertions.assertEquals(2, resultGroup2.members.size()); - var membersGroup2 = resultGroup2.members.stream().toList(); - var member1Group2 = (User) membersGroup2.get(1); - var member2Group2 = (User) membersGroup2.get(0); + var membersGroup2 = resultGroup2.members.stream().sorted(Comparator.comparing(a -> a.id)).toList(); + var member1Group2 = (User) membersGroup2.get(0); + var member2Group2 = (User) membersGroup2.get(1); Assertions.assertEquals("id3000", member1Group2.id); Assertions.assertEquals("username3000", member1Group2.name); diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java index a66e75f71..2f0017d40 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuthorityResourceTest.java @@ -23,9 +23,6 @@ @DisplayName("Resource /authorities") public class AuthorityResourceTest { - @Inject - AgroalDataSource dataSource; - @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); diff --git a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java index ddb3bbaba..5ceb9e607 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/DeviceResourceTest.java @@ -9,17 +9,21 @@ import io.restassured.http.ContentType; import jakarta.inject.Inject; import org.cryptomator.hub.entities.Device; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; import java.sql.SQLException; import java.time.Instant; -import java.util.Set; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasSize; @@ -41,9 +45,11 @@ public static void beforeAll() { @OidcSecurity(claims = { @Claim(key = "sub", value = "user1") }) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class AsAuthorzedUser1 { @Test + @Order(1) @DisplayName("PUT /devices/device1 without DTO returns 400") public void testCreateNoDeviceDto() { given().contentType(ContentType.JSON).body("") @@ -52,58 +58,120 @@ public void testCreateNoDeviceDto() { } @Test - @DisplayName("PUT /devices/ with DTO returns 400") + @Order(1) + @DisplayName("PUT /devices/ with DTO returns 400") public void testCreateNoDeviceId() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z")); given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "\u0020") //a whitespace + .when().put("/devices/{deviceId}", " ") //a whitespace .then().statusCode(400); } @Test - @DisplayName("PUT /devices/device1 returns 409") - public void testCreate1() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "owner1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + @Order(1) + @DisplayName("PUT /devices/deviceX returns 409 due to non-unique name") + public void testCreateX() { + var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "jwe.jwe.jwe.user1.deviceX", "user1", Instant.parse("2020-02-20T20:20:20Z")); given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "device1") + .when().put("/devices/{deviceId}", "deviceX") .then().statusCode(409); } @Test - @DisplayName("PUT /devices/deviceX returns 409 due to non-unique name") - public void testCreateX() { - var deviceDto = new DeviceResource.DeviceDto("deviceX", "Computer 1", Device.Type.DESKTOP, "publickey1", "owner1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + @Order(1) + @DisplayName("GET /devices/device1 returns 200") + public void testGet1() { + given().when().get("/devices/{deviceId}", "device1") + .then().statusCode(200) + .body("id", is("device1")) + .body("name", is("Computer 1")) + .body("userPrivateKey", is("jwe.jwe.jwe.user1.device1")); + } - given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "deviceX") - .then().statusCode(409); + @Test + @Order(1) + @DisplayName("GET /devices/device2 returns 404 (owned by other user)") + public void testGet2() { + given().when().get("/devices/{deviceId}", "device2") + .then().statusCode(404); } @Test - @DisplayName("PUT /devices/device999 returns 201") - public void testCreate2() throws SQLException { - var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999", Device.Type.DESKTOP, "publickey999", "owner1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + @Order(1) + @DisplayName("GET /devices/noSuchDevice returns 404 (no such device)") + public void testGetNonExistingDeviceToken() { + when().get("/devices/{deviceId}", "noSuchDevice") + .then().statusCode(404); + } + + @Test + @Order(2) + @DisplayName("PUT /devices/device999 returns 201 (creating new device)") + public void testCreate999() throws SQLException { + try (var s = dataSource.getConnection().createStatement()) { + s.execute(""" + INSERT INTO "device_legacy" ("id", "owner_id", "name", "type", "publickey", "creation_time") + VALUES + ('device999', 'user1', 'Computer 999', 'DESKTOP', 'publickey999', '2020-02-20 20:20:20') + """); + } + + var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z")); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device999") .then().statusCode(201); try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "device" WHERE "id" = 'device999'; + var rs = s.executeQuery(""" + SELECT * FROM "device_legacy" WHERE "id" = 'device999'; """); + Assertions.assertFalse(rs.next()); } } @Test - @DisplayName("DELETE /devices/ returns 400") + @Order(3) + @DisplayName("GET /devices/device999 returns 200") + public void testGet999AfterCreate() { + given().when().get("/devices/{deviceId}", "device999") + .then().statusCode(200) + .body("id", is("device999")) + .body("name", is("Computer 999")); + } + + @Test + @Order(4) + @DisplayName("PUT /devices/device999 returns 201 (updating existing device)") + public void testUpdate1() { + var deviceDto = new DeviceResource.DeviceDto("device999", "Computer 999 got a new name", Device.Type.DESKTOP, "publickey999", "jwe.jwe.jwe.user1.device999", "user1", Instant.parse("2020-02-20T20:20:20Z")); + + given().contentType(ContentType.JSON).body(deviceDto) + .when().put("/devices/{deviceId}", "device999") + .then().statusCode(201); + } + + @Test + @Order(5) + @DisplayName("GET /devices/device999 returns 200 (with updated name)") + public void testGet999AfterUpdate() { + given().when().get("/devices/{deviceId}", "device999") + .then().statusCode(200) + .body("id", is("device999")) + .body("name", is("Computer 999 got a new name")); + } + + @Test + @Order(6) + @DisplayName("DELETE /devices/ returns 400") public void testDeleteNoDeviceId() { - when().delete("/devices/{deviceId}", "\u0020") //a whitespace + when().delete("/devices/{deviceId}", " ") //a whitespace .then().statusCode(400); } @Test + @Order(6) @DisplayName("DELETE /devices/device0 returns 404") public void testDeleteNotExisting() { when().delete("/devices/{deviceId}", "device0") // @@ -111,6 +179,7 @@ public void testDeleteNotExisting() { } @Test + @Order(6) @DisplayName("DELETE /devices/device2 returns 404") public void testDeleteNotOwner() { when().delete("/devices/{deviceId}", "device2") // @@ -118,15 +187,9 @@ public void testDeleteNotOwner() { } @Test + @Order(6) @DisplayName("DELETE /devices/device999 returns 204") - public void testDeleteValid() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time") - VALUES ('device999', 'user1', 'To Be Deleted', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'); - """); - } - + public void testDeleteValid() { when().delete("/devices/{deviceId}", "device999") // .then().statusCode(204); } @@ -141,7 +204,7 @@ public class AsAnonymous { @Test @DisplayName("PUT /devices/device1 returns 401") public void testCreate1() { - var deviceDto = new DeviceResource.DeviceDto("device1", "Computer 1", Device.Type.DESKTOP, "publickey1", "user1", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); + var deviceDto = new DeviceResource.DeviceDto("device1", "Device 1", Device.Type.BROWSER, "publickey1", "jwe.jwe.jwe.user1.device1", "user1", Instant.parse("2020-02-20T20:20:20Z")); given().contentType(ContentType.JSON).body(deviceDto) .when().put("/devices/{deviceId}", "device1") diff --git a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java index 66128368c..150623494 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceTest.java @@ -59,18 +59,7 @@ public void testGetMe2() { when().get("/users/me?withDevices=true") .then().statusCode(200) .body("id", is("user1")) - .body("devices.id", hasItems("device1")) - .body("devices.accessTo.flatten()", empty()); - } - - @Test - @DisplayName("GET /users/me?withDevices=true&withAccessibleVaults=true returns 200") - public void testGetMe3() { - when().get("/users/me?withDevices=true&withAccessibleVaults=true") - .then().statusCode(200) - .body("id", is("user1")) - .body("devices.id", hasItems("device1")) - .body("devices.accessTo.id.flatten()", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"))); + .body("devices.id", hasItems("device1")); } @Test diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java index 7dde2b88d..e036694e2 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceTest.java @@ -11,7 +11,6 @@ import io.restassured.http.ContentType; import jakarta.inject.Inject; import jakarta.validation.Validator; -import org.cryptomator.hub.entities.Device; import org.cryptomator.hub.filters.VaultAdminOnlyFilterProvider; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; @@ -38,7 +37,6 @@ import java.time.Instant; import java.util.Base64; import java.util.Map; -import java.util.Set; import java.util.UUID; import static io.restassured.RestAssured.given; @@ -75,7 +73,7 @@ public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecEx vault1AdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-000100001111")).withIssuedAt(Instant.now()).sign(algorithmVault1); vault2AdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-000100002222")).withIssuedAt(Instant.now()).sign(algorithmVault2); - vaultArchivedAdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-0001AAAAAAAA")).withIssuedAt(Instant.now()).sign(algorithmVaultArchived); + vaultArchivedAdminJWT = JWT.create().withHeader(Map.of("vaultId", "7E57C0DE-0000-4000-8000-00010000AAAA")).withIssuedAt(Instant.now()).sign(algorithmVaultArchived); } private static PrivateKey getPrivateKey(String keyBytes) throws NoSuchAlgorithmException, InvalidKeySpecException { @@ -111,9 +109,9 @@ public void testValidDto() { public class AsAuthorizedUser1 { @Test - @DisplayName("GET /vaults returns 200") + @DisplayName("GET /vaults/accessible returns 200") public void testGetSharedOrOwnedNotArchived() { - when().get("/vaults") + when().get("/vaults/accessible") .then().statusCode(200) .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"))); } @@ -141,33 +139,73 @@ public void testGetAccess() { } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1 returns 200 using user access") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token returns 200 using user access") public void testUnlock1() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body(is("jwe1")); + .body(is("jwe.jwe.jwe.vault1.user1")); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device3 returns 200 using group access") + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/access-token returns 200 using group access") public void testUnlock2() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device3") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body(is("jwe3")); + .body(is("jwe.jwe.jwe.vault2.user1")); } @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 404") - public void testUnlock3() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "noSuchDevice") - .then().statusCode(404); + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-token returns 410 for archived vaults") + public void testUnlockArchived() { + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-00010000AAAA") + .then().statusCode(410); } - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device2 returns 403") - public void testUnlock4() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device2") - .then().statusCode(403); + @Nested + @DisplayName("legacy unlock") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + public class LegacyUnlock { + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/legacyDevice1 returns 200 using user access") + public void testUnlock1() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "legacyDevice1") + .then().statusCode(200) + .body(is("legacy.jwe.jwe.vault1.device1")); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/legacyDevice3 returns 200 using group access") + public void testUnlock2() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "legacyDevice3") + .then().statusCode(200) + .body(is("legacy.jwe.jwe.vault2.device3")); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/noSuchDevice returns 403") // legacy unlock must not encourage to register a legacy device by responding with 404 here + public void testUnlock3() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "noSuchDevice") + .then().statusCode(403); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/legacyDevice2 returns 403") + public void testUnlock4() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "legacyDevice2") + .then().statusCode(403); + } + + @Test + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/keys/someDevice returns 410 for archived vaults") + public void testUnlockArchived() { + when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-00010000AAAA", "legacyDevice1") + .then().statusCode(410); + } + } } @@ -200,6 +238,90 @@ public void testGetAccess2() { } } + @Nested + @DisplayName("As vault admin user1") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public class CreateVaults { + + @Test + @Order(1) + @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"); + + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100003333")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 3")) + .body("archived", equalTo(false)) + .body("masterkey", equalTo("masterkey3")) + .body("iterations", equalTo(42)) + .body("salt", equalTo("NaCl")) + .body("authPublicKey", equalTo("authPubKey3")) + .body("authPrivateKey", equalTo("authPrvKey3")); + } + + @Test + @Order(1) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD returns 400 due to malformed request body") + public void testCreateVault2() { + given().contentType(ContentType.JSON) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD") // invalid body (expected json) + .then().statusCode(400); + } + + @Test + @Order(1) + @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"); + + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100004444") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100004444")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 4")) + .body("archived", equalTo(false)) + .body("masterkey", equalTo("masterkey4")) + .body("iterations", equalTo(42)) + .body("salt", equalTo("NaCl")) + .body("authPublicKey", equalTo("authPubKey4")) + .body("authPrivateKey", equalTo("authPrvKey4")); + } + + @Test + @Order(2) + @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"); + given().contentType(ContentType.JSON) + .body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") + .then().statusCode(200) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100003333")) + .body("name", equalTo("VaultUpdated")) + .body("description", equalTo("Vault updated.")) + .body("archived", equalTo(true)) + .body("creationTime", not("2222-11-11T11:11:11Z")) + .body("masterkey", equalTo("masterkey3")) + .body("iterations", equalTo(42)) + .body("salt", equalTo("NaCl")) + .body("authPublicKey", equalTo("authPubKey3")) + .body("authPrivateKey", equalTo("authPrvKey3")); + } + + } + @Nested @DisplayName("As vault admin user1") @TestSecurity(user = "User Name 1", roles = {"user"}) @@ -210,68 +332,67 @@ public void testGetAccess2() { public class GrantAccess { @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device3 returns 201") - public void testGrantAccess1() { + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens/user999 returns 201") + public void testGrantAccess1() throws SQLException { + try (var s = dataSource.getConnection().createStatement()) { + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999') + """); + } + given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault1.device3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device3") + .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault1.user999") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-000100001111", "user999") .then().statusCode(201); + + try (var s = dataSource.getConnection().createStatement()) { + s.execute(""" + DELETE FROM "authority" WHERE "id" = 'user999'; + """); + } } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1 returns 409 due to user access already granted") + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens/user1 returns 409 due to user access already granted") public void testGrantAccess2() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe1.jwe1.jwe1.jwe1.jwe1") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") + .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault1.user1") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-000100001111", "user1") .then().statusCode(409); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device3 returns 409 due to group access already granted") - @TestSecurity(user = "User Name 2", roles = {"user"}) //we switch here for easy usage - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user2") - }) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/access-tokens/user1 returns 400 (vault admin jwt can not be checked for nonexisting vault)") public void testGrantAccess3() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device3") - .then().statusCode(409); + given() + .header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) + .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault666.user1") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "user1") + .then().statusCode(400); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/nonExistingDevice returns 404") + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens/nonExistingUser returns 404 (no such user)") public void testGrantAccess4() { given() .header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "nonExistingDevice") + .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.user666") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-000100001111", "nonExistingUser") .then().statusCode(404); } @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001AAAAAAAA/keys/someDevice returns 410") + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-00010000AAAA/access-tokens/user1 returns 410") public void testGrantAccessArchived() { given() .header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vaultArchivedAdminJWT) .contentType(ContentType.TEXT).body("jwe3.jwe3.jwe3.jwe3.jwe3") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-0001AAAAAAAA", "someDevice") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-00010000AAAA", "user1") .then().statusCode(410); } - - @AfterAll - public void deleteData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "access_token" - WHERE "device_id" = 'device3' - AND "vault_id" = '7E57C0DE-0000-4000-8000-000100001111'; - """); - } - } - } @Nested @@ -324,10 +445,10 @@ public void getAccess1() { @Test @Order(5) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant does not contains device2") - public void testGetDevicesRequiringAccess1() { + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant does not contains device2") + public void testGetUsersRequiringAccess1() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + .when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) .body("id", not(hasItems("device2"))); } @@ -351,78 +472,38 @@ public void getMembers2() { .body("id", hasItems("user2")); } - @Test - @Order(8) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/users/user2 returns 409 - user2 already direct member of vault2") - public void testGrantAccessAgain() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/users/{usersId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") - .then().statusCode(409); - } - - @Test - @Order(9) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/groups/group1 returns 409 - group1 already direct member of vault2") - public void testAddingMemberAgainFails() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().put("/vaults/{vaultId}/groups/{groupId}", "7E57C0DE-0000-4000-8000-000100002222", "group1") - .then().statusCode(409); - } - @Test @Order(10) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains device2") - public void testGetDevicesRequiringAccess2() { + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant contains device2") + public void testGetUsersRequiringAccess2() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + .when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body("id", hasItems("device2")); + .body("id", hasItems("user2")); } @Test @Order(11) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/keys/device2 returns 201") + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100002222/access-tokens/user2 returns 201") public void testGrantAccess1() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) .given().contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.device2") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100002222", "device2") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-000100002222", "user2") .then().statusCode(201); } @Test @Order(12) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains not device2") - public void testGetDevicesRequiringAccess3() { + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/users-requiring-access-grant contains not user2") + public void testGetUsersRequiringAccess3() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") + .when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") .then().statusCode(200) - .body("id", not(hasItems("device2"))); + .body("id", not(hasItems("user2"))); } @Test @Order(13) - @DisplayName("PUT /devices/device9999 returns 201") - public void testCreateDevice2() { - var deviceDto = new DeviceResource.DeviceDto("device9999", "Computer 9999", Device.Type.DESKTOP, "publickey9999", "user2", Set.of(), Instant.parse("2020-02-20T20:20:20Z")); - - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .given().contentType(ContentType.JSON).body(deviceDto) - .when().put("/devices/{deviceId}", "device9999") - .then().statusCode(201); - } - - @Test - @Order(14) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/devices-requiring-access-grant contains not device9999") - public void testGetDevicesRequiringAccess4() { - given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100002222") - .then().statusCode(200) - .body("id", hasItems("device9999")); - } - - @Test - @Order(15) @DisplayName("DELETE /vaults/7E57C0DE-0000-4000-8000-000100002222/members/user2 returns 204") public void testRevokeAccess() { // previously added in testGrantAccess() given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) @@ -431,7 +512,7 @@ public void testRevokeAccess() { // previously added in testGrantAccess() } @Test - @Order(16) + @Order(14) @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100002222/access does not contain user2") public void getMembers3() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault2AdminJWT) @@ -451,6 +532,18 @@ public void getMembers3() { @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class ManageAccessAsUser1 { + @BeforeAll + public void setup() throws SQLException { + try (var s = dataSource.getConnection().createStatement()) { + // user999 will be deleted in #cleanup() + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "group_membership" ("group_id", "member_id") VALUES ('group2', 'user999') + """); + } + } + @Test @Order(1) @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/groups/group3000 returns 404") @@ -481,41 +574,32 @@ public void getMembers1() { @Test @Order(4) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant contains device999") - public void testGetDevicesRequiringAccess3() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - // device999 will be deleted in #cleanup() - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES - ('device999', 'user2', 'Computer 999', 'publickey90', '2020-02-20 20:20:20'); - """); - } - + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant contains user999") + public void testGetUsersRequiringAccess3() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") + .when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body("id", hasItems("device999")); + .body("id", hasItems("user999")); } @Test @Order(5) - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device999 returns 201") + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111/access-tokens/user999 returns 201") public void testGrantAccess2() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.device93") - .when().put("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device999") + .contentType(ContentType.TEXT).body("jwe.jwe.jwe.vault2.user999") + .when().put("/vaults/{vaultId}/access-tokens/{userId}", "7E57C0DE-0000-4000-8000-000100001111", "user999") .then().statusCode(201); } @Test @Order(6) - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant contains not device999") - public void testGetDevicesRequiringAccess4() { + @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant does no longer contain user999") + public void testGetUsersRequiringAccess4() { given().header(VaultAdminOnlyFilterProvider.VAULT_ADMIN_AUTHORIZATION, vault1AdminJWT) - .when().get("/vaults/{vaultId}/devices-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") + .when().get("/vaults/{vaultId}/users-requiring-access-grant", "7E57C0DE-0000-4000-8000-000100001111") .then().statusCode(200) - .body("id", not(hasItems("device999"))); + .body("id", not(hasItems("user999"))); } @Test @@ -541,7 +625,7 @@ public void getMembers2() { public void cleanup() throws SQLException { try (var s = dataSource.getConnection().createStatement()) { s.execute(""" - DELETE FROM "device" WHERE ID = 'device999'; + DELETE FROM "authority" WHERE ID = 'user999'; """); } } @@ -576,7 +660,6 @@ public void setup() throws SQLException { VALUES ('group91'); - INSERT INTO "user_details" ("id") VALUES ('user91'), @@ -594,7 +677,7 @@ public void setup() throws SQLException { INSERT INTO "vault_access" ("vault_id", "authority_id") VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'); + ('7E57C0DE-0000-4000-8000-00010000AAAA', 'user95_A'); """); } } @@ -645,8 +728,81 @@ public void testUnlockBlockedIfLicenseExceeded() throws SQLException { } //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-000100001111", "device1") + when().get("/vaults/{vaultId}/access-token", "7E57C0DE-0000-4000-8000-000100001111") + .then().statusCode(402); + } + + @Test + @Order(5) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 returns 201 not exceeding seats because user already has access to an existing vault") + public void testCreateVaultNotExceedingSeats() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 3", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") + .then().statusCode(201) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) + .body("name", equalTo("My Vault")) + .body("description", equalTo("Test vault 3")) + .body("masterkey", equalTo("masterkey3")) + .body("salt", equalTo("NaCl")) + .body("iterations", equalTo(42)) + .body("authPublicKey", equalTo("authPubKey3")) + .body("authPrivateKey", equalTo("authPrvKey3")) + .body("archived", equalTo(false)); + } + + @Test + @Order(6) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF4444 exceeding the license returns 402") + public void testCreateVaultExceedingSeats() throws SQLException { + try (var s = dataSource.getConnection().createStatement()) { + s.execute(""" + DELETE FROM "vault_access" + WHERE "authority_id" IN ('user1', 'group1'); + """); + } + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF4444"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", "Test vault 4", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); + given().contentType(ContentType.JSON).body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF4444") .then().statusCode(402); + + try (var s = dataSource.getConnection().createStatement()) { + s.execute(""" + INSERT INTO "vault_access" + VALUES + ('7E57C0DE-0000-4000-8000-000100001111', 'user1', 'OWNER'), + ('7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'); + """); + } + } + + @Test + @Order(7) + @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 returns 200 with only updated name, description and archive flag, despite exceeding license") + public void testUpdateVaultDespiteLicenseExceeded() { + //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); + + var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2222-11-11T11:11:11Z"), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + given().contentType(ContentType.JSON) + .body(vaultDto) + .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") + .then().statusCode(200) + .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) + .body("name", equalTo("VaultUpdated")) + .body("description", equalTo("Vault updated.")) + .body("masterkey", equalTo("masterkey3")) + .body("salt", equalTo("NaCl")) + .body("iterations", equalTo(42)) + .body("authPublicKey", equalTo("authPubKey3")) + .body("authPrivateKey", equalTo("authPrvKey3")) + .body("archived", equalTo(true)); } @AfterAll @@ -668,13 +824,13 @@ public class AsAnonymous { @DisplayName("401 Unauthorized") @ParameterizedTest(name = "{0} {1}") @CsvSource(value = { - "GET, /vaults", + "GET, /vaults/accessible", "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111", "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/members", "PUT, /vaults/7E57C0DE-0000-4000-8000-000100001111/users/user1", "DELETE, /vaults/7E57C0DE-0000-4000-8000-000100001111/users/user1", - "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/devices-requiring-access-grant", - "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/device1" + "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/users-requiring-access-grant", + "GET, /vaults/7E57C0DE-0000-4000-8000-000100001111/access-token" }) public void testGet(String method, String path) { when().request(method, path) @@ -707,41 +863,10 @@ public void testGetAllVaultsAsUser() { public void testGetAllVaultsAsAdmin() { when().get("/vaults/all") .then().statusCode(200) - .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001AAAAAAAA"))); + .body("id", hasItems(equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100001111"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-000100002222"), equalToIgnoringCase("7E57C0DE-0000-4000-8000-00010000AAAA"))); } } - @Nested - @DisplayName("GET /vaults/{vaultid}/keys/{deviceId}") - @TestSecurity(user = "User Name 1", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user1") - }) - public class Unlock { - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-000100001111/keys/iDoNotExist returns 404 for not-existing device") - public void testUnlockNotExistingDevice() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "iDoNotExist") - .then().statusCode(404); - } - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD/keys/someDevice returns 404 for not-existing vaults") - public void testUnlockNotExistingVault() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD", "someDevice") - .then().statusCode(404); - } - - @Test - @DisplayName("GET /vaults/7E57C0DE-0000-4000-8000-0001AAAAAAAA/keys/someDevice returns 410 for archived vaults") - public void testUnlockArchived() { - when().get("/vaults/{vaultId}/keys/{deviceId}", "7E57C0DE-0000-4000-8000-0001AAAAAAAA", "someDevice") - .then().statusCode(410); - } - - } - @Nested @DisplayName("/vaults/some") public class GetSomeVaults { @@ -793,266 +918,4 @@ public void testListSomeVaultsAsUser() { .then().statusCode(403); } } - - @Nested - @DisplayName("PUT /vaults/{vaultid}") - @TestSecurity(user = "User Name 1", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user1") - }) - @TestInstance(TestInstance.Lifecycle.PER_CLASS) - public class CreateOrUpdate { - - @BeforeAll - public void insertData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey", "archived") - VALUES - ('7E57C0DE-0000-4000-8000-0001FFFF1111', 'Vault U', 'Vault to update.', - '2020-02-20T20:20:20Z', 'saltU', 42, 'masterkeyU', 'authPubKeyU', 'authPrvKeyU', FALSE); - - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user96', 'USER', 'user name 96'), - ('user97', 'USER', 'user name 97'); - - INSERT INTO "user_details" ("id") - VALUES - ('user96'), - ('user97'); - """); - - } - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF2222 returns 201") - public void testCreateVault1() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF2222"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF2222") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF2222")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-BADBADBADBAD returns 400") - public void testCreateVault2() { - given().contentType(ContentType.JSON) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-BADBADBADBAD") - .then().statusCode(400); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF3333 returns 201 not exceeding seats but user does not have a vault yet") - @TestSecurity(user = "User Name 96", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user96") - }) - public void testCreateVault3() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF3333")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF4444 exceeding the license returns 402") - @TestSecurity(user = "User Name 97", roles = {"user"}) - @OidcSecurity(claims = { - @Claim(key = "sub", value = "user97") - }) - public void testCreateVault4() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user91', 'USER', 'user name 91'), - ('user92', 'USER', 'user name 92'), - ('user93', 'USER', 'user name 93'), - ('user94', 'USER', 'user name 94'), - ('user95_A', 'USER', 'user name Archived'), - ('group91', 'GROUP', 'group name 91'); - - INSERT INTO "group_details" ("id") - VALUES - ('group91'); - - INSERT INTO "user_details" ("id") - VALUES - ('user91'), - ('user92'), - ('user93'), - ('user94'), - ('user95_A'); - - INSERT INTO "group_membership" ("group_id", "member_id") - VALUES - ('group91', 'user91'), - ('group91', 'user92'), - ('group91', 'user93'), - ('group91', 'user94'); - - INSERT INTO "vault_access" ("vault_id", "authority_id") - VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'), - ('7E57C0DE-0000-4000-8000-000100001111', 'group91'); - """); - } - //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF4444"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", false, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF4444") - .then().statusCode(402); - - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "authority" - WHERE "id" IN ('user91', 'user92', 'user93', 'user94', 'user95_A', 'group91'); - """); - } - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFFAAAA returns 201 ignoring archived flag") - public void testCreateVaultIgnoringArchived() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFFAAAA"); - var vaultDto = new VaultResource.VaultDto(uuid, "Test Vault", "Vault to create", true, Instant.parse("2112-12-21T21:12:21Z"), "masterkeyC", 42, "saltC", "authPubKeyC", "authPrvKeyC"); - given().contentType(ContentType.JSON).body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFFAAAA") - .then().statusCode(201) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFFAAAA")) - .body("name", equalTo("Test Vault")) - .body("description", equalTo("Vault to create")) - .body("masterkey", equalTo("masterkeyC")) - .body("salt", equalTo("saltC")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyC")) - .body("authPrivateKey", equalTo("authPrvKeyC")) - .body("archived", equalTo(false)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF1111 returns 200 with only updated name, description and archive flag") - public void testUpdateVault() { - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF1111"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2112-12-21T21:12:21Z"), "someVaule", -1, "someVaule", "someValue", "someValue"); - given().contentType(ContentType.JSON) - .body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF1111") - .then().statusCode(200) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF1111")) - .body("name", equalTo("VaultUpdated")) - .body("description", equalTo("Vault updated.")) - .body("creationTime", equalTo("2020-02-20T20:20:20Z")) - .body("masterkey", equalTo("masterkeyU")) - .body("salt", equalTo("saltU")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyU")) - .body("authPrivateKey", equalTo("authPrvKeyU")) - .body("archived", equalTo(true)); - } - - @Test - @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-0001FFFF1111 returns 200 with only updated name, description and archive flag, exceeding license") - public void testUpdateVault1() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - INSERT INTO "authority" ("id", "type", "name") - VALUES - ('user91', 'USER', 'user name 91'), - ('user92', 'USER', 'user name 92'), - ('user93', 'USER', 'user name 93'), - ('user94', 'USER', 'user name 94'), - ('user95_A', 'USER', 'user name Archived'), - ('group91', 'GROUP', 'group name 91'); - - INSERT INTO "group_details" ("id") - VALUES - ('group91'); - - INSERT INTO "user_details" ("id") - VALUES - ('user91'), - ('user92'), - ('user93'), - ('user94'), - ('user95_A'); - - INSERT INTO "group_membership" ("group_id", "member_id") - VALUES - ('group91', 'user91'), - ('group91', 'user92'), - ('group91', 'user93'), - ('group91', 'user94'); - - INSERT INTO "vault_access" ("vault_id", "authority_id") - VALUES - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user95_A'), - ('7E57C0DE-0000-4000-8000-000100001111', 'group91'); - """); - } - //Assumptions.assumeTrue(EffectiveVaultAccess.countEffectiveVaultUsers() > 5); - - var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF1111"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", "Vault updated.", true, Instant.parse("2112-12-21T21:12:21Z"), "someVaule", -1, "someVaule", "someValue", "someValue"); - given().contentType(ContentType.JSON) - .body(vaultDto) - .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF1111") - .then().statusCode(200) - .body("id", equalToIgnoringCase("7E57C0DE-0000-4000-8000-0001FFFF1111")) - .body("name", equalTo("VaultUpdated")) - .body("description", equalTo("Vault updated.")) - .body("creationTime", equalTo("2020-02-20T20:20:20Z")) - .body("masterkey", equalTo("masterkeyU")) - .body("salt", equalTo("saltU")) - .body("iterations", equalTo(42)) - .body("authPublicKey", equalTo("authPubKeyU")) - .body("authPrivateKey", equalTo("authPrvKeyU")) - .body("archived", equalTo(true)); - - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "authority" - WHERE "id" IN ('user91', 'user92', 'user93', 'user94', 'user95_A', 'group91'); - """); - } - } - - @AfterAll - public void deleteData() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - s.execute(""" - DELETE FROM "vault" - WHERE "id" IN ('7E57C0DE-0000-4000-8000-0001FFFF1111','7E57C0DE-0000-4000-8000-0001FFFF2222'); - - DELETE FROM "authority" - WHERE "id" IN ('user96', 'user97'); - """); - } - } - - } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java b/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java index 6c3d224f0..a861ec1bc 100644 --- a/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java +++ b/backend/src/test/java/org/cryptomator/hub/entities/EntityIntegrationTest.java @@ -1,11 +1,9 @@ package org.cryptomator.hub.entities; import io.agroal.api.AgroalDataSource; -import io.quarkus.panache.common.Parameters; import io.quarkus.test.TestTransaction; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; -import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceException; import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.Assertions; @@ -14,7 +12,6 @@ import java.sql.SQLException; import java.time.Instant; -import java.util.List; import java.util.UUID; @QuarkusTest @@ -24,24 +21,21 @@ public class EntityIntegrationTest { @Inject AgroalDataSource dataSource; - @Inject - EntityManager entityManager; - @Test @TestTransaction - @DisplayName("Removing a Device cascades to Access") - public void removingDeviceCascadesToAccess() throws SQLException { + @DisplayName("Removing a User cascades to Access") + public void removingUserCascadesToAccess() throws SQLException { try (var s = dataSource.getConnection().createStatement()) { // test data will be removed via @TestTransaction s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES ('device999', 'user1', 'Computer 999', 'publickey999', '2020-02-20 20:20:20'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe4'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id") VALUES ('user999'); + INSERT INTO "access_token" ("user_id", "vault_id", "vault_masterkey") VALUES ('user999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe4'); """); } - var deleted = Device.deleteById("device999"); - var matchAfter = AccessToken.findAll().stream().anyMatch(a -> "device999".equals(a.device.id)); + var deleted = User.deleteById("user999"); + var matchAfter = AccessToken.findAll().stream().anyMatch(a -> "user999".equals(a.user.id)); Assertions.assertTrue(deleted); Assertions.assertFalse(matchAfter); } @@ -56,7 +50,9 @@ public void testAddNonUniqueDeviceName() { conflictingDevice.name = existingDevice.name; conflictingDevice.owner = existingDevice.owner; conflictingDevice.publickey = "XYZ"; + conflictingDevice.userPrivateKey = "ABC"; conflictingDevice.creationTime = Instant.parse("2020-02-20T20:20:20Z"); + conflictingDevice.type = Device.Type.DESKTOP; PersistenceException thrown = Assertions.assertThrows(PersistenceException.class, conflictingDevice::persistAndFlush); Assertions.assertInstanceOf(ConstraintViolationException.class, thrown); @@ -64,29 +60,11 @@ public void testAddNonUniqueDeviceName() { @Test @TestTransaction - @DisplayName("Retrieve the correct token when a device has access to multiple vaults") - public void testGetCorrectTokenForDeviceWithAcessToMultipleVaults() throws SQLException { - try (var s = dataSource.getConnection().createStatement()) { - // test data will be removed via @TestTransaction - s.execute(""" - INSERT INTO "device" ("id", "owner_id", "name", "publickey", "creation_time") - VALUES ('device999', 'user1', 'Computer 999', 'publickey999', '2020-02-20 20:20:20'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100001111', 'jwe4'); - INSERT INTO "access_token" ("device_id", "vault_id", "jwe") VALUES ('device999', '7E57C0DE-0000-4000-8000-000100002222', 'jwe5'); - """); - } - - List tokens = AccessToken - .find("#AccessToken.get", Parameters.with("deviceId", "device999") - .and("vaultId", UUID.fromString("7E57C0DE-0000-4000-8000-000100001111")) - .and("userId", "user1")) - .stream().toList(); - - var token = tokens.get(0); - - Assertions.assertEquals(1, tokens.size()); + @DisplayName("Retrieve the correct token when a user has access to multiple vaults") + public void testGetCorrectTokenForDeviceWithAcessToMultipleVaults() { + var token = AccessToken.unlock(UUID.fromString("7E57C0DE-0000-4000-8000-000100001111"), "user1"); Assertions.assertEquals(UUID.fromString("7E57C0DE-0000-4000-8000-000100001111"), token.vault.id); - Assertions.assertEquals("device999", token.device.id); - Assertions.assertEquals("jwe4", token.jwe); + Assertions.assertEquals("user1", token.user.id); + Assertions.assertEquals("jwe.jwe.jwe.vault1.user1", token.vaultKey); } } \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java b/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java index f6820abed..00140a9ae 100644 --- a/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java +++ b/backend/src/test/java/org/cryptomator/hub/filters/VaultAdminOnlyFilterProviderTestIT.java @@ -119,7 +119,7 @@ public void testMalformedKeyInDatabase() throws SQLException { try (var s = dataSource.getConnection().createStatement()) { s.execute(""" INSERT INTO "vault" ("id", "name", "description", "creation_time", "salt", "iterations", "masterkey", "auth_pubkey", "auth_prvkey") - VALUES ('7E57C0DE-0000-4000-8000-000100003000', 'Vault 1000', 'This is a testvault.', '2020-02-20 20:20:20', 'salt3000', 'iterations3000', 'masterkey3000', 'pubkey', 'prvkey') + VALUES ('7E57C0DE-0000-4000-8000-000100003000', 'Vault 1000', 'This is a testvault.', '2020-02-20 20:20:20', 'salt3000', 3000, 'masterkey3000', 'pubkey', 'prvkey') """); Assertions.assertThrows(VaultAdminValidationFailedException.class, () -> vaultAdminOnlyFilterProvider.filter(context)); diff --git a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java index 855b65255..b54e2a071 100644 --- a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java +++ b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResource.java @@ -28,12 +28,6 @@ public Response probeOnlyBase64Chars(@PathParam("b64string") @OnlyBase64Chars St return Response.ok().build(); } - @GET - @Path("/onlybase64urlchars/{b64urlstring}") - public Response probeOnlyBase64UrlChars(@PathParam("b64urlstring") @OnlyBase64UrlChars String base64UrlString) { - return Response.ok().build(); - } - @GET @Path("/validjwe/{jwe}") public Response probeValidJWE(@PathParam("jwe") @ValidJWE String jwe) { diff --git a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java index 4f8893576..33504ef23 100644 --- a/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java +++ b/backend/src/test/java/org/cryptomator/hub/validation/ValidationTestResourceTest.java @@ -100,28 +100,6 @@ public void testOnlyBase64CharsInvalid(String toTest) { } } - @Nested - @DisplayName("Test @OnlyBase64UrlChars") - public class Base64UrlCharsTest { - - @DisplayName("Strings only containing base64url-chars are accepted") - @ParameterizedTest - @ValueSource(strings = {"abcdefghijklmnopqrstuvwxyz0123456789-_", "bGln-HQgd2_yaw==", "-======"}) - public void testOnlyBase64UrlCharsValid(String toTest) { - when().get("/test/onlybase64urlchars/{b64String}", toTest) - .then().statusCode(200); - } - - @DisplayName("Strings containing not-base64url-chars (or wrong order) are rejected") - @ParameterizedTest - @ValueSource(strings = {"foo+/", "\u5207ä=", "abc==abc", "==="}) - @ArgumentsSource(MalicousStringsProvider.class) - public void testOnlyBase64UrlCharsInvalid(String toTest) { - when().get("/test/onlybase64urlchars/{b64String}", toTest) - .then().statusCode(400); - } - } - @Nested @DisplayName("Test @ValidJWE") public class JWETest { 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 1e106f0f4..bf83cd79e 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 @@ -39,29 +39,42 @@ VALUES 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', FALSE), - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault Archived', 'This is a archived vault.', '2020-02-20 20:20:20', 'salt3', 42, 'masterkey3', + ('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); -INSERT INTO "vault_access" ("vault_id", "authority_id") +INSERT INTO "vault_access" ("vault_id", "authority_id", "role") VALUES - ('7E57C0DE-0000-4000-8000-000100001111', 'user1'), - ('7E57C0DE-0000-4000-8000-000100001111', 'user2'), - ('7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user1'), - ('7E57C0DE-0000-4000-8000-000100002222', 'group1'); /* user1, part of group1, has access to vault2 */ + ('7E57C0DE-0000-4000-8000-000100001111', 'user1', 'OWNER'), + ('7E57C0DE-0000-4000-8000-000100001111', 'user2', 'MEMBER'), + ('7E57C0DE-0000-4000-8000-00010000AAAA', 'user1', 'MEMBER'), + ('7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'); -INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time") +INSERT INTO "device" ("id", "owner_id", "name", "type", "publickey", "creation_time", "user_privatekey") VALUES - ('device1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'), - ('device2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20'), - ('device3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20'); /* user1 is part of group1 */ + ('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'), + ('device3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20', 'jwe.jwe.jwe.user1.device3'); -INSERT INTO "access_token" ("device_id", "vault_id", "jwe") +INSERT INTO "access_token" ("user_id", "vault_id", "vault_masterkey") VALUES - ('device1', '7E57C0DE-0000-4000-8000-000100001111', 'jwe1'), - ('device2', '7E57C0DE-0000-4000-8000-000100001111', 'jwe2'), - ('device3', '7E57C0DE-0000-4000-8000-000100002222', 'jwe3'); -- device3 of user1, part of group1, has access to vault2 + ('user1', '7E57C0DE-0000-4000-8000-000100001111', 'jwe.jwe.jwe.vault1.user1'), -- direct access + ('user2', '7E57C0DE-0000-4000-8000-000100001111', 'jwe.jwe.jwe.vault1.user2'), -- direct access + ('user1', '7E57C0DE-0000-4000-8000-000100002222', 'jwe.jwe.jwe.vault2.user1'); -- access via group1 + +-- DEPRECATED: +INSERT INTO "device_legacy" ("id", "owner_id", "name", "type", "publickey", "creation_time") +VALUES + ('legacyDevice1', 'user1', 'Computer 1', 'DESKTOP', 'publickey1', '2020-02-20 20:20:20'), + ('legacyDevice2', 'user2', 'Computer 2', 'DESKTOP', 'publickey2', '2020-02-20 20:20:20'), + ('legacyDevice3', 'user1', 'Computer 3', 'DESKTOP', 'publickey3', '2020-02-20 20:20:20'); + +INSERT INTO "access_token_legacy" ("device_id", "vault_id", "jwe") +VALUES + ('legacyDevice1', '7E57C0DE-0000-4000-8000-000100001111', 'legacy.jwe.jwe.vault1.device1'), -- direct access + ('legacyDevice2', '7E57C0DE-0000-4000-8000-000100001111', 'legacy.jwe.jwe.vault1.device2'), -- direct access + ('legacyDevice3', '7E57C0DE-0000-4000-8000-000100002222', 'legacy.jwe.jwe.vault2.device3'); -- access via group1 INSERT INTO "audit_event" ("id", "timestamp", "type") VALUES @@ -93,15 +106,15 @@ INSERT INTO "audit_event_vault_create" ("id", "created_by", "vault_id", "vault_n VALUES (10, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'Vault 1', 'This is a testvault.'), (20, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'Vault 2', 'This is a testvault.'), - (30, 'user2', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault 3', 'This is a testvault.'); + (30, 'user2', '7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault 3', 'This is a testvault.'); -INSERT INTO "audit_event_vault_member_add" ("id", "added_by", "vault_id", "authority_id") +INSERT INTO "audit_event_vault_member_add" ("id", "added_by", "vault_id", "authority_id", "role") VALUES - (11, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1'), - (12, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2'), - (21, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'user1'), - (22, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1'), - (31, 'user2', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user2'); + (11, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1', 'OWNER'), + (12, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2', 'MEMBER'), + (21, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'user1', 'MEMBER'), + (22, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1', 'MEMBER'), + (31, 'user2', '7E57C0DE-0000-4000-8000-00010000AAAA', 'user1', 'MEMBER'); INSERT INTO "audit_event_vault_member_remove" ("id", "removed_by", "vault_id", "authority_id") VALUES @@ -127,9 +140,9 @@ INSERT INTO "audit_event_vault_access_grant" ("id", "granted_by", "vault_id", "a VALUES (2000, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user1'), (2001, 'user1', '7E57C0DE-0000-4000-8000-000100001111', 'user2'), - (2002, 'user1', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'user1'), + (2002, 'user1', '7E57C0DE-0000-4000-8000-00010000AAAA', 'user1'), (2003, 'user1', '7E57C0DE-0000-4000-8000-000100002222', 'group1'); INSERT INTO "audit_event_vault_update" ("id", "updated_by", "vault_id", "vault_name", "vault_description", "vault_archived") VALUES - (3000, 'user1', '7E57C0DE-0000-4000-8000-0001AAAAAAAA', 'Vault Archived', 'This is a archived vault.', TRUE); + (3000, 'user1', '7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault Archived', 'This is a archived vault.', TRUE); diff --git a/frontend/src/common/auditlog.ts b/frontend/src/common/auditlog.ts index 3c3b1c3e2..893708288 100644 --- a/frontend/src/common/auditlog.ts +++ b/frontend/src/common/auditlog.ts @@ -52,6 +52,7 @@ export type AuditEventVaultMemberAddDto = AuditEventDto & { addedBy: string; vaultId: string; authorityId: string; + role: 'MEMBER' | 'OWNER'; } export type AuditEventVaultMemberRemoveDto = AuditEventDto & { diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index b21b082cd..fe8e14efd 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -1,4 +1,4 @@ -import AxiosStatic, { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios'; +import AxiosStatic, { AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios'; import { JdenticonConfig, toSvg } from 'jdenticon'; import { base64 } from 'rfc4648'; import authPromise from './auth'; @@ -23,9 +23,9 @@ axiosAuth.interceptors.request.use(async request => { try { const token = await authPromise.then(auth => auth.bearerToken()); if (request.headers) { - request.headers['Authorization'] = `Bearer ${token}`; + request.headers.setAuthorization(`Bearer ${token}`); } else { - request.headers = { 'Authorization': `Bearer ${token}` } as AxiosRequestHeaders; + request.headers = AxiosHeaders.from({ 'Authorization': `Bearer ${token}` }); } return request; } catch (err: unknown) { @@ -54,7 +54,7 @@ export type DeviceDto = { name: string; type: 'BROWSER' | 'DESKTOP' | 'MOBILE'; publicKey: string; - accessTo: VaultDto[]; + userPrivateKey: string; creationTime: Date; }; @@ -84,7 +84,8 @@ export abstract class AuthorityDto { } export class UserDto extends AuthorityDto { - constructor(public id: string, public name: string, public type: AuthorityType, public email: string, public devices: DeviceDto[], pictureUrl?: string) { + constructor(public id: string, public name: string, public type: AuthorityType, public email: string, public devices: DeviceDto[], public accessibleVaults: VaultDto[], pictureUrl?: string, + public publicKey?: string, public privateKey?: string, public setupCode?: string) { super(id, name, type, pictureUrl); } @@ -112,7 +113,7 @@ export class UserDto extends AuthorityDto { } static copy(obj: UserDto): UserDto { - return new UserDto(obj.id, obj.name, obj.type, obj.email, obj.devices, obj.pictureUrl); + return new UserDto(obj.id, obj.name, obj.type, obj.email, obj.devices, obj.accessibleVaults, obj.pictureUrl, obj.publicKey, obj.privateKey, obj.setupCode); } } @@ -172,8 +173,9 @@ export interface VaultIdHeader extends JWTHeader { } class VaultService { - public async listAccessible(): Promise { - return axiosAuth.get('/vaults').then(response => response.data); + public async listAccessible(role?: 'MEMBER' | 'OWNER'): Promise { + const queryParams = role ? { role: role } : {}; + return axiosAuth.get('/vaults/accessible', { params: queryParams }).then(response => response.data); } public async listSome(vaultsIds: string[]): Promise { @@ -222,9 +224,9 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 404, 409)); } - public async getDevicesRequiringAccessGrant(vaultId: string, vaultKeys: VaultKeys): Promise { + public async getUsersRequiringAccessGrant(vaultId: string, vaultKeys: VaultKeys): Promise { let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - return axiosAuth.get(`/vaults/${vaultId}/devices-requiring-access-grant`, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) + return axiosAuth.get(`/vaults/${vaultId}/users-requiring-access-grant`, { headers: { 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) .then(response => response.data).catch(err => rethrowAndConvertIfExpected(err, 403)); } @@ -235,9 +237,9 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 404)); } - public async grantAccess(vaultId: string, deviceId: string, jwe: string, vaultKeys: VaultKeys) { + public async grantAccess(vaultId: string, userId: string, jwe: string, vaultKeys: VaultKeys) { let vaultAdminAuthorizationJWT = await this.buildVaultAdminAuthorizationJWT(vaultId, vaultKeys); - await axiosAuth.put(`/vaults/${vaultId}/keys/${deviceId}`, jwe, { headers: { 'Content-Type': 'text/plain', 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) + await axiosAuth.put(`/vaults/${vaultId}/access-tokens/${userId}`, jwe, { headers: { 'Content-Type': 'text/plain', 'Cryptomator-Vault-Admin-Authorization': vaultAdminAuthorizationJWT } }) .catch((error) => rethrowAndConvertIfExpected(error, 404, 409)); } @@ -267,15 +269,19 @@ class DeviceService { return axiosAuth.delete(`/devices/${deviceId}`) .catch((error) => rethrowAndConvertIfExpected(error, 404)); } + + public async putDevice(device: DeviceDto): Promise> { + return axiosAuth.put(`/devices/${device.id}`, device); + } } class UserService { - public async syncMe(): Promise { - return axiosAuth.put('/users/me'); + public async putMe(dto?: UserDto): Promise { + return axiosAuth.put('/users/me', dto); } - public async me(withDevices: boolean = false, withAccessibleVaults: boolean = false): Promise { - return axiosAuth.get(`/users/me?withDevices=${withDevices}&withAccessibleVaults=${withAccessibleVaults}`).then(response => UserDto.copy(response.data)); + public async me(withDevices: boolean = false): Promise { + return axiosAuth.get(`/users/me?withDevices=${withDevices}`).then(response => UserDto.copy(response.data)); } public async listAll(): Promise { diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 482e8cde7..2a7a04617 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,6 +1,6 @@ import * as miscreant from 'miscreant'; -import { base32, base64, base64url } from 'rfc4648'; -import { JWE } from './jwe'; +import { base16, base32, base64, base64url } from 'rfc4648'; +import { JWEBuilder, JWEParser } from './jwe'; import { JWT, JWTHeader } from './jwt'; import { CRC32, wordEncoder } from './util'; @@ -37,8 +37,34 @@ interface JWEPayload { key: string } +const GCM_NONCE_LEN = 12; +const PBKDF2_ITERATION_COUNT = 1000000; + +async function pbkdf2(password: string, hash: 'SHA-256' | 'SHA-512', salt: Uint8Array, iterations: number, keyParams: AesDerivedKeyParams): Promise { + const encodedPw = new TextEncoder().encode(password); + const pwKey = await crypto.subtle.importKey( + 'raw', + encodedPw, + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: hash, + salt: salt, + iterations: iterations + }, + pwKey, + keyParams, + false, + ['wrapKey', 'unwrapKey'] + ); +} + export class VaultKeys { - private static readonly SIGNATURE_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + private static readonly SIGNATURE_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { // TODO: remove with "vault admin password" name: 'ECDSA', namedCurve: 'P-384' }; @@ -58,8 +84,6 @@ export class VaultKeys { length: 256 }; - private static readonly GCM_NONCE_LEN = 12; - private static readonly PBKDF2_ITERATION_COUNT = 1000000; readonly masterKey: CryptoKey; readonly signatureKeyPair: CryptoKeyPair; @@ -86,43 +110,20 @@ export class VaultKeys { return new VaultKeys(await key, await keyPair); } - private static async pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise { - const encodedPw = new TextEncoder().encode(password); - const pwKey = await crypto.subtle.importKey( - 'raw', - encodedPw, - 'PBKDF2', - false, - ['deriveKey'] - ); - return await crypto.subtle.deriveKey( - { - name: 'PBKDF2', - hash: 'SHA-256', - salt: salt, - iterations: iterations - }, - pwKey, - VaultKeys.KEK_KEY_DESIGNATION, - false, - ['wrapKey', 'unwrapKey'] - ); - } - /** * Protects the key material. Must only be called for a newly created masterkey, otherwise it will fail. * @param password Password used for wrapping * @returns The wrapped key material + * @deprecated TO be replaced by ECDH-based key encapsulation */ public async wrap(password: string): Promise { // salt: - const salt = new Uint8Array(16); - crypto.getRandomValues(salt); + const salt = crypto.getRandomValues(new Uint8Array(16)); const encodedSalt = base64.stringify(salt); // kek: - const kek = VaultKeys.pbkdf2(password, salt, VaultKeys.PBKDF2_ITERATION_COUNT); + const kek = pbkdf2(password, 'SHA-256', salt, PBKDF2_ITERATION_COUNT, VaultKeys.KEK_KEY_DESIGNATION); // masterkey: - const masterKeyIv = crypto.getRandomValues(new Uint8Array(VaultKeys.GCM_NONCE_LEN)); + const masterKeyIv = crypto.getRandomValues(new Uint8Array(GCM_NONCE_LEN)); const wrappedMasterKey = new Uint8Array(await crypto.subtle.wrapKey( 'raw', this.masterKey, @@ -131,7 +132,7 @@ export class VaultKeys { )); const encodedMasterKey = base64.stringify(new Uint8Array([...masterKeyIv, ...wrappedMasterKey])); // secretkey: - const secretKeyIv = crypto.getRandomValues(new Uint8Array(VaultKeys.GCM_NONCE_LEN)); + const secretKeyIv = crypto.getRandomValues(new Uint8Array(GCM_NONCE_LEN)); const wrappedSecretKey = new Uint8Array(await crypto.subtle.wrapKey( 'pkcs8', this.signatureKeyPair.privateKey, @@ -143,7 +144,7 @@ export class VaultKeys { const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.signatureKeyPair.publicKey)); const encodedPublicKey = base64.stringify(publicKey); // result: - return new WrappedVaultKeys(encodedMasterKey, encodedSecretKey, encodedPublicKey, encodedSalt, VaultKeys.PBKDF2_ITERATION_COUNT); + return new WrappedVaultKeys(encodedMasterKey, encodedSecretKey, encodedPublicKey, encodedSalt, PBKDF2_ITERATION_COUNT); } /** @@ -152,27 +153,28 @@ export class VaultKeys { * @param wrapped The wrapped key material * @returns The unwrapped key material. * @throws WrongPasswordError, if the wrong password is used + * @deprecated TO be replaced by ECDH-based key encapsulation */ public static async unwrap(password: string, wrapped: WrappedVaultKeys): Promise { - const kek = VaultKeys.pbkdf2(password, base64.parse(wrapped.salt, { loose: true }), wrapped.iterations); + const kek = pbkdf2(password, 'SHA-256', base64.parse(wrapped.salt, { loose: true }), wrapped.iterations, VaultKeys.KEK_KEY_DESIGNATION); const decodedMasterKey = base64.parse(wrapped.masterkey, { loose: true }); const decodedPrivateKey = base64.parse(wrapped.signaturePrivateKey, { loose: true }); const decodedPublicKey = base64.parse(wrapped.signaturePublicKey, { loose: true }); try { const masterkey = crypto.subtle.unwrapKey( 'raw', - decodedMasterKey.slice(VaultKeys.GCM_NONCE_LEN), + decodedMasterKey.slice(GCM_NONCE_LEN), await kek, - { name: 'AES-GCM', iv: decodedMasterKey.slice(0, VaultKeys.GCM_NONCE_LEN) }, + { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign'] ); const signPrivKey = crypto.subtle.unwrapKey( 'pkcs8', - decodedPrivateKey.slice(VaultKeys.GCM_NONCE_LEN), + decodedPrivateKey.slice(GCM_NONCE_LEN), await kek, - { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, VaultKeys.GCM_NONCE_LEN) }, + { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, VaultKeys.SIGNATURE_KEY_DESIGNATION, false, ['sign'] @@ -264,29 +266,17 @@ export class VaultKeys { /** * Encrypts this masterkey using the given public key - * @param devicePublicKey The recipient's public key (DER-encoded) + * @param userPublicKey The recipient's public key (DER-encoded) * @returns a JWE containing this Masterkey */ - public async encryptForDevice(devicePublicKey: Uint8Array): Promise { - const publicKey = await crypto.subtle.importKey( - 'spki', - devicePublicKey, - { - name: 'ECDH', - namedCurve: 'P-384' - }, - false, - [] - ); - + public async encryptForUser(userPublicKey: Uint8Array): Promise { + const publicKey = await crypto.subtle.importKey('spki', userPublicKey, UserKeys.KEY_DESIGNATION, false, []); const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); try { const payload: JWEPayload = { key: base64.stringify(rawkey) }; - const payloadJson = new TextEncoder().encode(JSON.stringify(payload)); - - return JWE.build(payloadJson, publicKey); + return JWEBuilder.ecdhEs(publicKey).encrypt(payload); } finally { rawkey.fill(0x00); } @@ -313,3 +303,198 @@ export class VaultKeys { return wordEncoder.encodePadded(combined); } } + +export class UserKeys { + public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; + + public static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' + }; + + readonly keyPair: CryptoKeyPair; + + protected constructor(keyPair: CryptoKeyPair) { + this.keyPair = keyPair; + } + + /** + * Creates a new user key pair + * @returns A new user key pair + */ + public static async create(): Promise { + const keyPair = crypto.subtle.generateKey(UserKeys.KEY_DESIGNATION, true, UserKeys.KEY_USAGES); + return new UserKeys(await keyPair); + } + + /** + * 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 + * @returns + */ + 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 }); + } + + /** + * Gets the base64-encoded 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)); + return base64.stringify(publicKey); + } + + /** + * Encrypts the user's private key using a key derived from the given setupCode + * @param setupCode The password to protect the private key. + * @returns A JWE holding the encrypted private key + * @see JWEBuilder.pbes2 + */ + 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); + } + } + + /** + * 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 + */ + 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); + } + } + + /** + * 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(); + 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 }); + } 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, []); + } + } +} + +export class BrowserKeys { + public static readonly KEY_USAGES: KeyUsage[] = ['deriveBits']; + + private static readonly KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' + }; + + readonly keyPair: CryptoKeyPair; + + protected constructor(keyPair: CryptoKeyPair) { + this.keyPair = keyPair; + } + + /** + * Creates a new device key pair for this browser + * @returns A new device key pair + */ + public static async create(): Promise { + const keyPair = crypto.subtle.generateKey(BrowserKeys.KEY_DESIGNATION, false, BrowserKeys.KEY_USAGES); + return new BrowserKeys(await keyPair); + } + + /** + * Attempts to load previously stored key pair from the browser's IndexedDB. + * @returns a promise resolving to the loaded browser key pair + */ + public static async load(userId: string): Promise { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('hub'); + req.onsuccess = evt => { resolve(req.result); }; + req.onerror = evt => { reject(req.error); }; + req.onupgradeneeded = evt => { req.result.createObjectStore('keys'); }; + }); + return new Promise((resolve, reject) => { + const transaction = db.transaction('keys', 'readonly'); + const keyStore = transaction.objectStore('keys'); + const query = keyStore.get(userId); + query.onsuccess = evt => { resolve(query.result); }; + query.onerror = evt => { reject(query.error); }; + }).then((keyPair) => { + return new BrowserKeys(keyPair); + }).finally(() => { + db.close(); + }); + } + + /** + * Stores the key pair in the browser's IndexedDB. See https://www.w3.org/TR/WebCryptoAPI/#concepts-key-storage + * @returns a promise that will resolve if the key pair has been saved + */ + public async store(userId: string): Promise { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('hub'); + req.onsuccess = evt => { resolve(req.result); }; + req.onerror = evt => { reject(req.error); }; + req.onupgradeneeded = evt => { req.result.createObjectStore('keys'); }; + }); + return new Promise((resolve, reject) => { + const transaction = db.transaction('keys', 'readwrite'); + const keyStore = transaction.objectStore('keys'); + const query = keyStore.put(this.keyPair, userId); + query.onsuccess = evt => { transaction.commit(); resolve(); }; + query.onerror = evt => { reject(query.error); }; + }).finally(() => { + db.close(); + }); + } + + public async id(): Promise { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + const hash = new Uint8Array(await crypto.subtle.digest({ name: 'SHA-256' }, publicKey)); + return base16.stringify(hash).toUpperCase(); + } + + public async encodedPublicKey() { + const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', this.keyPair.publicKey)); + return base64.stringify(publicKey); + } +} diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 66c363647..833b4cf39 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -1,25 +1,16 @@ import { base64url } from 'rfc4648'; +// visible for testing export class ConcatKDF { /** * KDF as defined in NIST SP 800-56A Rev. 2 Section 5.8.1 using SHA-256 * * @param z A shared secret * @param keyDataLen Desired key length (in bytes) - * @param algorithmId Purpose of the derived key material - * @param partyUInfo Public information about party U - * @param partyVInfo Public information about party V - * @param suppPubInfo Mutually known public information (optional) - * @param suppPrivInfo Mutually known private information (optional) + * @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, algorithmId: Uint8Array, partyUInfo: Uint8Array, partyVInfo: Uint8Array, suppPubInfo: Uint8Array = new Uint8Array(), suppPrivInfo: Uint8Array = new Uint8Array()): Promise { - // AlgorithmID || PartyUInfo || PartyVInfo {|| SuppPubInfo }{|| SuppPrivInfo } - const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...suppPubInfo, ...suppPrivInfo]); - return this.kdfInternal(z, keyDataLen, new Uint8Array(otherInfo)); - } - - private static async kdfInternal(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) { @@ -43,90 +34,254 @@ export class ConcatKDF { } } -export class JWEHeader { - constructor(readonly alg: string, readonly enc: string, readonly epk: JsonWebKey | null, readonly apu?: string, readonly apv?: string) { } +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 } -export class JWE { +export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { + name: 'ECDH', + namedCurve: 'P-384' +}; + +export class JWEParser { + readonly header: JWEHeader; + readonly encryptedKey: Uint8Array; + readonly iv: Uint8Array; + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; + + private constructor(readonly encodedHeader: string, readonly encodedEncryptedKey: string, readonly encodedIv: string, readonly encodedCiphertext: string, readonly encodedTag: string) { + const utf8dec = new TextDecoder(); + this.header = JSON.parse(utf8dec.decode(base64url.parse(encodedHeader, { loose: true }))); + this.encryptedKey = base64url.parse(encodedEncryptedKey, { loose: true }); + this.iv = base64url.parse(encodedIv, { loose: true }); + this.ciphertext = base64url.parse(encodedCiphertext, { loose: true }); + this.tag = base64url.parse(encodedTag, { loose: true }); + } + /** - * Creates a JWE using ECDH-ES using the P-384 curve and AES-256-GCM for payload encryption. - * - * See RFC 7516 + RFC 7518, Section 4.6 - * - * @param payload The secret payload - * @param devicePublicKey The recipient's public key - * @param apu Optional public information about the producer (PartyUInfo) - * @param apv Optional public information about the recipient (PartyVInfo) + * Decodes the JWE. + * @param jwe The JWE string + * @returns Decoded JWE, ready to decrypt. */ - public static async build(payload: Uint8Array, recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): Promise { - /* key agreement and header params described in RFC 7518, Section 4.6: */ - const ephemeralKey = await crypto.subtle.generateKey( + public static parse(jwe: string): JWEParser { + const [encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag] = jwe.split('.', 5); + return new JWEParser(encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag); + } + + /** + * 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 + */ + public async decryptEcdhEs(recipientPrivateKey: CryptoKey): Promise { + if (this.header.alg != 'ECDH-ES' || this.header.enc != 'A256GCM' || !this.header.epk) { + throw new Error('unsupported alg or enc'); + } + const ephemeralKey = await crypto.subtle.importKey('jwk', this.header.epk, ECDH_P384, false, []); + const cek = await ECDH_ES.deriveContentKey(ephemeralKey, recipientPrivateKey, 384, 32, this.header); + return this.decrypt(cek); + } + + /** + * Decrypts the JWE, assuming alg == PBES2-HS512+A256KW and enc == A256GCM. + * @param password The password to feed into the KDF + * @returns Decrypted payload + */ + 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'); + } + const saltInput = base64url.parse(this.header.p2s, { loose: true }); + const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c); + const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']); + return this.decrypt(await cek); + } + + private async decrypt(cek: CryptoKey): Promise { + const utf8enc = new TextEncoder(); + const m = new Uint8Array(this.ciphertext.length + this.tag.length); + m.set(this.ciphertext, 0); + m.set(this.tag, this.ciphertext.length); + const payloadJson = new Uint8Array(await crypto.subtle.decrypt( { - name: 'ECDH', - namedCurve: 'P-384' + name: 'AES-GCM', + iv: this.iv, + additionalData: utf8enc.encode(this.encodedHeader), + tagLength: 128 }, - false, - ['deriveBits'] - ); - const alg = 'ECDH-ES'; - const enc = 'A256GCM'; - const epk = await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey); - const header = new JWEHeader(alg, enc, epk, base64url.stringify(apu, { pad: false }), base64url.stringify(apv, { pad: false })); + cek, + m + )); + return JSON.parse(new TextDecoder().decode(payloadJson)); + } +} + +export class JWEBuilder { + private constructor(readonly header: Promise, readonly encryptedKey: Promise, readonly cek: Promise) { } + + /** + * Prepares a new JWE using alg: ECDH-ES and enc: A256GCM. + * + * @param recipientPublicKey Static public key of the JWE's recipient + * @param apu Optional information about the creator + * @param apv Optional information about the recipient + * @returns A new JWEBuilder ready to encrypt the payload + */ + public static ecdhEs(recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { + /* key agreement and header params described in RFC 7518, Section 4.6: */ + const ephemeralKey = crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); + const header = (async () => { + alg: 'ECDH-ES', + enc: 'A256GCM', + epk: await crypto.subtle.exportKey('jwk', (await ephemeralKey).publicKey), + apu: base64url.stringify(apu, { pad: false }), + apv: base64url.stringify(apv, { pad: false }) + })(); + const encryptedKey = (async () => Uint8Array.of())(); // empty for Direct Key Agreement as per spec + const cek = (async () => ECDH_ES.deriveContentKey(recipientPublicKey, (await ephemeralKey).privateKey, 384, 32, await header))(); + return new JWEBuilder(header, encryptedKey, cek); + } + + /** + * 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); + } + + /** + * Builds the JWE. + * @param payload Payload to be encrypted + * @returns The JWE + */ + public async encrypt(payload: object) { + const utf8enc = new TextEncoder(); /* JWE assembly and content encryption described in RFC 7516: */ - const encodedHeader = base64url.stringify(new TextEncoder().encode(JSON.stringify(header)), { pad: false }); + const encodedHeader = base64url.stringify(utf8enc.encode(JSON.stringify(await this.header)), { pad: false }); const iv = crypto.getRandomValues(new Uint8Array(12)); const encodedIv = base64url.stringify(iv, { pad: false }); - const encodedEncryptedKey = ''; // empty for Direct Key Agreement as per spec - const cek = await this.deriveKey(recipientPublicKey, ephemeralKey.privateKey, 384, 32, header); + const encodedEncryptedKey = base64url.stringify(await this.encryptedKey, { pad: false }); const m = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv, - additionalData: new TextEncoder().encode(encodedHeader), + additionalData: utf8enc.encode(encodedHeader), tagLength: 128 }, - cek, - payload + await this.cek, + utf8enc.encode(JSON.stringify(payload)) )); 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 `${encodedHeader}.${encodedEncryptedKey}.${encodedIv}.${encodedCiphertext}.${encodedTag}`; } +} - // visible for testing - public static async deriveKey(recipientPublicKey: CryptoKey, ephemeralSecretKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { +// 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 { let agreedKey = new Uint8Array(); let derivedKey = new Uint8Array(); try { - const algorithmId = this.lengthPrefixed(new TextEncoder().encode(header.enc)); - const partyUInfo = this.lengthPrefixed(base64url.parse(header.apu || '', { loose: true })); - const partyVInfo = this.lengthPrefixed(base64url.parse(header.apv || '', { loose: true })); + const algorithmId = ECDH_ES.lengthPrefixed(new TextEncoder().encode(header.enc)); + 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); new DataView(suppPubInfo).setUint32(0, desiredKeyBytes * 8, false); agreedKey = new Uint8Array(await crypto.subtle.deriveBits( { name: 'ECDH', - public: recipientPublicKey + public: publicKey }, - ephemeralSecretKey, + privateKey, ecdhKeyBits )); - derivedKey = await ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, algorithmId, partyUInfo, partyVInfo, new Uint8Array(suppPubInfo)); - return crypto.subtle.importKey('raw', derivedKey, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, exportable, ['encrypt']); + 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']); } finally { derivedKey.fill(0x00); agreedKey.fill(0x00); } } - private static lengthPrefixed(data: Uint8Array): Uint8Array { - const result = new ArrayBuffer(4 + data.byteLength); - new DataView(result, 0, 4).setUint32(0, data.byteLength, false); - new Uint8Array(result).set(data, 4); - return new Uint8Array(result); + public static lengthPrefixed(data: Uint8Array): Uint8Array { + const result = new Uint8Array(4 + data.byteLength); + new DataView(result.buffer, 0, 4).setUint32(0, data.byteLength, false); + result.set(data, 4); + return result; + } +} + +// visible for testing +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') { + hash = 'SHA-512'; + keyLen = 256; + } else if (alg == 'PBES2-HS256+A128KW') { + hash = 'SHA-256'; + keyLen = 128; + } else { + throw new Error('only PBES2-HS512+A256KW and PBES2-HS256+A128KW supported'); + } + const utf8enc = new TextEncoder(); + const encodedPw = utf8enc.encode(password); + const pwKey = crypto.subtle.importKey( + 'raw', + encodedPw, + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: hash, + salt: new Uint8Array([...utf8enc.encode(alg), ...PBES2.NULL_BYTE, ...salt]), // see https://www.rfc-editor.org/rfc/rfc7518#section-4.8.1.1 + iterations: iterations + }, + await pwKey, + { + name: 'AES-KW', + length: keyLen + }, + extractable, + ['wrapKey', 'unwrapKey'] + ); } } diff --git a/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue b/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue index 9a42c94e7..51322d610 100644 --- a/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue +++ b/frontend/src/components/AuditLogDetailsVaultMemberAdd.vue @@ -31,6 +31,16 @@ {{ event.authorityId }} +
+
+ role +
+
+ Member + Owner + {{ event.role }} +
+
diff --git a/frontend/src/components/AuthenticatedMain.vue b/frontend/src/components/AuthenticatedMain.vue index 18d0063f9..bb9e6610c 100644 --- a/frontend/src/components/AuthenticatedMain.vue +++ b/frontend/src/components/AuthenticatedMain.vue @@ -35,7 +35,7 @@ onMounted(fetchData); async function fetchData() { onFetchError.value = null; try { - me.value = await backend.users.me(true, true); + me.value = await backend.users.me(); } catch (error) { console.error('Retrieving logged in user failed.', error); onFetchError.value = error instanceof Error ? error : new Error('Unknown Error'); diff --git a/frontend/src/components/CreateVault.vue b/frontend/src/components/CreateVault.vue index c480e5ed6..78940ee6a 100644 --- a/frontend/src/components/CreateVault.vue +++ b/frontend/src/components/CreateVault.vue @@ -6,7 +6,7 @@
-
+
@@ -101,7 +101,7 @@
-
+
@@ -162,7 +162,7 @@
-
+
@@ -198,6 +198,7 @@ import { ClipboardIcon } from '@heroicons/vue/20/solid'; import { ArrowPathIcon, CheckIcon, KeyIcon } from '@heroicons/vue/24/outline'; import { ArrowDownTrayIcon } from '@heroicons/vue/24/solid'; import { saveAs } from 'file-saver'; +import { base64 } from 'rfc4648'; import { computed, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { PaymentRequiredError } from '../common/backend'; @@ -315,10 +316,16 @@ async function createVault() { throw new Error('Invalid state'); } processing.value = true; + const owner = await backend.users.me(); + if (!owner.publicKey) { + throw new Error('Invalid state'); + } const vaultId = crypto.randomUUID(); vaultConfig.value = await VaultConfig.create(vaultId, vaultKeys.value); const wrapped = await vaultKeys.value.wrap(password.value); + const ownerJwe = await vaultKeys.value.encryptForUser(base64.parse(owner.publicKey)); await backend.vaults.createOrUpdateVault(vaultId, vaultName.value, vaultDescription.value, false, wrapped.masterkey, wrapped.iterations, wrapped.salt, wrapped.signaturePublicKey, wrapped.signaturePrivateKey); + await backend.vaults.grantAccess(vaultId, owner.id, ownerJwe, vaultKeys.value); state.value = State.Finished; } catch (error) { console.error('Creating vault failed.', error); diff --git a/frontend/src/components/DeviceList.vue b/frontend/src/components/DeviceList.vue index bbb165546..e28013927 100644 --- a/frontend/src/components/DeviceList.vue +++ b/frontend/src/components/DeviceList.vue @@ -8,6 +8,7 @@
+
+
{{ device.name }}
+
{{ t('deviceList.thisDevice') }}
+
@@ -66,7 +70,7 @@ {{ d(device.creationTime, 'short') }} - {{ t('common.remove') }} + {{ t('common.remove') }} @@ -90,26 +94,62 @@ import { ComputerDesktopIcon, DevicePhoneMobileIcon, WindowIcon } from '@heroico import { onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import backend, { DeviceDto, NotFoundError, UserDto } from '../common/backend'; +import { BrowserKeys } from '../common/crypto'; import FetchError from './FetchError.vue'; const { t, d } = useI18n({ useScope: 'global' }); const me = ref(); +const myDevice = ref(); const onFetchError = ref(); const onRemoveDeviceError = ref< {[id: string]: Error} >({}); -onMounted(fetchData); +onMounted(async () => { + await fetchData(); + await determineMyDevice(); +}); async function fetchData() { onFetchError.value = null; try { - me.value = await backend.users.me(true, true); + me.value = await backend.users.me(true); } catch (error) { console.error('Retrieving device list failed.', error); onFetchError.value = error instanceof Error ? error : new Error('Unknown Error'); } } +async function determineMyDevice() { + if (me.value == null) { + throw new Error('User not initialized.'); + } + const browserKeys = await BrowserKeys.load(me.value.id); + const browserId = await browserKeys.id(); + myDevice.value = me.value.devices.find(d => d.id == browserId); +} + +/* + * Use existing device to authorize another device: + */ +// async function validateDevice(device: DeviceDto) { +// if (!me.value || !me.value.publicKey) { +// throw new Error('User keys not initialized.'); +// } +// /* decrypt user key on this browser: */ +// const userPublicKey = crypto.subtle.importKey('spki', base64.parse(me.value.publicKey), UserKeys.KEY_DESIGNATION, false, []); +// const browserKeys = await BrowserKeys.load(me.value.id); +// const browserId = await browserKeys.id(); +// const browser = me.value.devices.find(d => d.id === browserId); +// if (!browser || !browser.userPrivateKey) { +// throw new Error('Browser not validated.'); +// } +// const userKeys = await UserKeys.decryptOnBrowser(browser.userPrivateKey, browserKeys.keyPair.privateKey, await userPublicKey); + +// /* encrypt user key for device */ +// device.userPrivateKey = await userKeys.encryptForDevice(base64url.parse(device.publicKey)); +// await backend.devices.putDevice(device); +// } + async function removeDevice(device: DeviceDto) { delete onRemoveDeviceError.value[device.id]; try { diff --git a/frontend/src/components/GrantPermissionDialog.vue b/frontend/src/components/GrantPermissionDialog.vue index f3b3fff8f..a12f2195f 100644 --- a/frontend/src/components/GrantPermissionDialog.vue +++ b/frontend/src/components/GrantPermissionDialog.vue @@ -29,7 +29,7 @@