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 de2a30888..b587ebafd 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -24,6 +24,7 @@ import org.cryptomator.hub.entities.events.EmergencyAccessRecoveryStartedEvent; import org.cryptomator.hub.entities.events.EmergencyAccessSettingsUpdatedEvent; import org.cryptomator.hub.entities.events.EmergencyAccessSetupEvent; +import org.cryptomator.hub.entities.events.SettingAutoGrantUpdateEvent; import org.cryptomator.hub.entities.events.SettingWotUpdateEvent; import org.cryptomator.hub.entities.events.SignedWotIdEvent; import org.cryptomator.hub.entities.events.UserAccountResetEvent; @@ -52,7 +53,7 @@ public class AuditLogResource { private static final Set EVENT_TYPES = Set.of(DeviceRegisteredEvent.TYPE, DeviceRemovedEvent.TYPE, UserAccountResetEvent.TYPE, UserKeysChangeEvent.TYPE, UserSetupCodeChangeEvent.TYPE, - SettingWotUpdateEvent.TYPE, SignedWotIdEvent.TYPE, VaultCreatedEvent.TYPE, VaultUpdatedEvent.TYPE, VaultAccessGrantedEvent.TYPE, + SettingAutoGrantUpdateEvent.TYPE, SettingWotUpdateEvent.TYPE, SignedWotIdEvent.TYPE, VaultCreatedEvent.TYPE, VaultUpdatedEvent.TYPE, VaultAccessGrantedEvent.TYPE, VaultKeyRetrievedEvent.TYPE, VaultMemberAddedEvent.TYPE, VaultMemberRemovedEvent.TYPE, VaultMemberUpdatedEvent.TYPE, VaultOwnershipClaimedEvent.TYPE, EmergencyAccessSetupEvent.TYPE, EmergencyAccessSettingsUpdatedEvent.TYPE, EmergencyAccessRecoveryStartedEvent.TYPE, EmergencyAccessRecoveryApprovedEvent.TYPE, EmergencyAccessRecoveryCompletedEvent.TYPE, EmergencyAccessRecoveryAbortedEvent.TYPE); @@ -116,6 +117,7 @@ public List getAllEvents(@QueryParam("startDate") Instant startDa @JsonSubTypes({ // @JsonSubTypes.Type(value = DeviceRegisteredEventDto.class, name = DeviceRegisteredEvent.TYPE), // @JsonSubTypes.Type(value = DeviceRemovedEventDto.class, name = DeviceRemovedEvent.TYPE), // + @JsonSubTypes.Type(value = SettingAutoGrantUpdateEventDto.class, name = SettingAutoGrantUpdateEvent.TYPE), // @JsonSubTypes.Type(value = SettingWotUpdateEventDto.class, name = SettingWotUpdateEvent.TYPE), // @JsonSubTypes.Type(value = SignedWotIdEventDto.class, name = SignedWotIdEvent.TYPE), // @JsonSubTypes.Type(value = UserAccountResetEventDto.class, name = UserAccountResetEvent.TYPE), // @@ -149,13 +151,14 @@ static AuditEventDto fromEntity(AuditEvent entity) { case DeviceRegisteredEvent evt -> new DeviceRegisteredEventDto(evt.getId(), evt.getTimestamp(), DeviceRegisteredEvent.TYPE, evt.getRegisteredBy(), evt.getDeviceId(), evt.getDeviceName(), evt.getDeviceType()); case DeviceRemovedEvent evt -> new DeviceRemovedEventDto(evt.getId(), evt.getTimestamp(), DeviceRemovedEvent.TYPE, evt.getRemovedBy(), evt.getDeviceId()); case SignedWotIdEvent evt -> new SignedWotIdEventDto(evt.getId(), evt.getTimestamp(), SignedWotIdEvent.TYPE, evt.getUserId(), evt.getSignerId(), evt.getSignerKey(), evt.getSignature()); + case SettingAutoGrantUpdateEvent evt -> new SettingAutoGrantUpdateEventDto(evt.getId(), evt.getTimestamp(), SettingAutoGrantUpdateEvent.TYPE, evt.getUpdatedBy(), evt.isEnabled(), evt.getTrustThreshold(), evt.isAllowOverride()); case SettingWotUpdateEvent evt -> new SettingWotUpdateEventDto(evt.getId(), evt.getTimestamp(), SettingWotUpdateEvent.TYPE, evt.getUpdatedBy(), evt.getWotMaxDepth(), evt.getWotIdVerifyLen()); case UserAccountResetEvent evt -> new UserAccountResetEventDto(evt.getId(), evt.getTimestamp(), UserAccountResetEvent.TYPE, evt.getResetBy()); case UserKeysChangeEvent evt -> new UserKeysChangeEventDto(evt.getId(), evt.getTimestamp(), UserKeysChangeEvent.TYPE, evt.getChangedBy(), evt.getUserName()); case UserSetupCodeChangeEvent evt -> new UserSetupCodeChangeEventDto(evt.getId(), evt.getTimestamp(), UserSetupCodeChangeEvent.TYPE, evt.getChangedBy()); case VaultCreatedEvent evt -> new VaultCreatedEventDto(evt.getId(), evt.getTimestamp(), VaultCreatedEvent.TYPE, evt.getCreatedBy(), evt.getVaultId(), evt.getVaultName(), evt.getVaultDescription()); case VaultUpdatedEvent evt -> new VaultUpdatedEventDto(evt.getId(), evt.getTimestamp(), VaultUpdatedEvent.TYPE, evt.getUpdatedBy(), evt.getVaultId(), evt.getVaultName(), evt.getVaultDescription(), evt.isVaultArchived()); - case VaultAccessGrantedEvent evt -> new VaultAccessGrantedEventDto(evt.getId(), evt.getTimestamp(), VaultAccessGrantedEvent.TYPE, evt.getGrantedBy(), evt.getVaultId(), evt.getAuthorityId()); + case VaultAccessGrantedEvent evt -> new VaultAccessGrantedEventDto(evt.getId(), evt.getTimestamp(), VaultAccessGrantedEvent.TYPE, evt.getGrantedBy(), evt.getVaultId(), evt.getAuthorityId(), evt.isAutomatic()); case VaultKeyRetrievedEvent evt -> new VaultKeyRetrievedEventDto(evt.getId(), evt.getTimestamp(), VaultKeyRetrievedEvent.TYPE, evt.getRetrievedBy(), evt.getVaultId(), evt.getResult(), evt.getIpAddress(), evt.getDeviceId()); case VaultMemberAddedEvent evt -> new VaultMemberAddedEventDto(evt.getId(), evt.getTimestamp(), VaultMemberAddedEvent.TYPE, evt.getAddedBy(), evt.getVaultId(), evt.getAuthorityId(), evt.getRole()); case VaultMemberRemovedEvent evt -> new VaultMemberRemovedEventDto(evt.getId(), evt.getTimestamp(), VaultMemberRemovedEvent.TYPE, evt.getRemovedBy(), evt.getVaultId(), evt.getAuthorityId()); @@ -183,6 +186,10 @@ record SignedWotIdEventDto(long id, Instant timestamp, String type, @JsonPropert @JsonProperty("signature") String signature) implements AuditEventDto { } + record SettingAutoGrantUpdateEventDto(long id, Instant timestamp, String type, @JsonProperty("updatedBy") String updatedBy, @JsonProperty("enabled") boolean enabled, + @JsonProperty("trustThreshold") int trustThreshold, @JsonProperty("allowOverride") boolean allowOverride) implements AuditEventDto { + } + record SettingWotUpdateEventDto(long id, Instant timestamp, String type, @JsonProperty("updatedBy") String updatedBy, @JsonProperty("wotMaxDepth") int wotMaxDepth, @JsonProperty("wotIdVerifyLen") int wotIdVerifyLen) implements AuditEventDto { } @@ -205,7 +212,7 @@ record VaultUpdatedEventDto(long id, Instant timestamp, String type, @JsonProper } record VaultAccessGrantedEventDto(long id, Instant timestamp, String type, @JsonProperty("grantedBy") String grantedBy, @JsonProperty("vaultId") UUID vaultId, - @JsonProperty("authorityId") String authorityId) implements AuditEventDto { + @JsonProperty("authorityId") String authorityId, @JsonProperty("automatic") boolean automatic) implements AuditEventDto { } record VaultKeyRetrievedEventDto(long id, Instant timestamp, String type, @JsonProperty("retrievedBy") String retrievedBy, @JsonProperty("vaultId") UUID vaultId, diff --git a/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java b/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java index 5256311f3..6e78faa84 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java @@ -65,6 +65,9 @@ public Response put(@NotNull @Valid SettingsDto dto) { var oldMinMembers = settings.getDefaultMinMembers(); var oldAllowChoosingEmergencyCouncil = settings.isAllowChoosingEmergencyCouncil(); var oldEmergencyAccessEnabled = settings.isEmergencyAccessEnabled(); + var oldAutoGrantEnabled = settings.isAutomaticAccessGrantEnabled(); + var oldAutoGrantTrustThreshold = settings.getAutomaticAccessGrantTrustThreshold(); + var oldAutoGrantAllowOverride = settings.isAllowAutomaticAccessGrantOverride(); settings.setWotMaxDepth(dto.wotMaxDepth); settings.setWotIdVerifyLen(dto.wotIdVerifyLen); settings.setDefaultRequiredEmergencyKeyShares(dto.defaultRequiredEmergencyKeyShares); @@ -72,6 +75,9 @@ public Response put(@NotNull @Valid SettingsDto dto) { settings.setAllowChoosingEmergencyCouncil(dto.allowChoosingEmergencyCouncil); settings.setEmergencyCouncilMemberIds(dto.emergencyCouncilMemberIds); settings.setEmergencyAccessEnabled(dto.enableEmergencyAccess); + settings.setAutomaticAccessGrantEnabled(dto.enableAutomaticAccessGrant); + settings.setAutomaticAccessGrantTrustThreshold(dto.automaticAccessGrantTrustThreshold); + settings.setAllowAutomaticAccessGrantOverride(dto.allowAutomaticAccessGrantOverride); settingsRepo.persist(settings); if (oldWotMaxDepth != dto.wotMaxDepth || oldWotIdVerifyLen != dto.wotIdVerifyLen) { eventLogger.logWotSettingUpdated(jwt.getSubject(), dto.wotIdVerifyLen, dto.wotMaxDepth); @@ -85,6 +91,11 @@ public Response put(@NotNull @Valid SettingsDto dto) { var councilMemberIds = "[" + dto.emergencyCouncilMemberIds.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(", ")) + "]"; eventLogger.logEmergencyAccessSettingsUpdated(jwt.getSubject(), dto.enableEmergencyAccess, councilMemberIds, dto.defaultRequiredEmergencyKeyShares, dto.defaultMinMembers, dto.allowChoosingEmergencyCouncil); } + if (oldAutoGrantEnabled != dto.enableAutomaticAccessGrant + || oldAutoGrantTrustThreshold != dto.automaticAccessGrantTrustThreshold + || oldAutoGrantAllowOverride != dto.allowAutomaticAccessGrantOverride) { + eventLogger.logAutoGrantSettingUpdated(jwt.getSubject(), dto.enableAutomaticAccessGrant, dto.automaticAccessGrantTrustThreshold, dto.allowAutomaticAccessGrantOverride); + } return Response.status(Response.Status.NO_CONTENT).build(); } @@ -95,10 +106,13 @@ public record SettingsDto(@JsonProperty("hubId") String hubId, @JsonProperty("defaultRequiredEmergencyKeyShares") @Min(0) int defaultRequiredEmergencyKeyShares, @JsonProperty("defaultMinMembers") @Min(0) int defaultMinMembers, @JsonProperty("allowChoosingEmergencyCouncil") boolean allowChoosingEmergencyCouncil, - @JsonProperty("emergencyCouncilMemberIds") @NotNull Set emergencyCouncilMemberIds) { + @JsonProperty("emergencyCouncilMemberIds") @NotNull Set emergencyCouncilMemberIds, + @JsonProperty("enableAutomaticAccessGrant") boolean enableAutomaticAccessGrant, + @JsonProperty("automaticAccessGrantTrustThreshold") @Min(-1) @Max(9) int automaticAccessGrantTrustThreshold, + @JsonProperty("allowAutomaticAccessGrantOverride") boolean allowAutomaticAccessGrantOverride) { public static SettingsDto fromEntity(Settings entity) { - return new SettingsDto(entity.getHubId(), entity.getWotMaxDepth(), entity.getWotIdVerifyLen(), entity.isEmergencyAccessEnabled(), entity.getDefaultRequiredEmergencyKeyShares(), entity.getDefaultMinMembers(), entity.isAllowChoosingEmergencyCouncil(), entity.getEmergencyCouncilMemberIds()); + return new SettingsDto(entity.getHubId(), entity.getWotMaxDepth(), entity.getWotIdVerifyLen(), entity.isEmergencyAccessEnabled(), entity.getDefaultRequiredEmergencyKeyShares(), entity.getDefaultMinMembers(), entity.isAllowChoosingEmergencyCouncil(), entity.getEmergencyCouncilMemberIds(), entity.isAutomaticAccessGrantEnabled(), entity.getAutomaticAccessGrantTrustThreshold(), entity.isAllowAutomaticAccessGrantOverride()); } } 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 0d6ff0784..ee955c3a9 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -163,7 +163,7 @@ public Response updateMyAccessTokens(@NotNull Map tokens) { } token.setVaultKey(entry.getValue()); accessTokenRepo.persist(token); - eventLogger.logVaultAccessGranted(user.getId(), vault.getId(), user.getId()); + eventLogger.logVaultAccessGranted(user.getId(), vault.getId(), user.getId(), false); } return Response.ok().build(); } 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 6f459b871..6e22dc360 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/VaultResource.java @@ -5,9 +5,13 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.common.annotation.RunOnVirtualThread; import io.vertx.core.http.HttpServerRequest; import org.jspecify.annotations.Nullable; import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.event.Event; import jakarta.inject.Inject; import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; @@ -45,6 +49,8 @@ import org.cryptomator.hub.entities.VaultAccess.Role; import org.cryptomator.hub.entities.events.EventLogger; import org.cryptomator.hub.entities.events.VaultKeyRetrievedEvent; +import org.cryptomator.hub.events.VaultMembersJoined; +import org.cryptomator.hub.events.VaultMembersJoinedBroadcaster; import org.cryptomator.hub.filters.ActiveLicense; import org.cryptomator.hub.filters.VaultRole; import org.cryptomator.hub.keycloak.RealmRole; @@ -63,6 +69,7 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import java.net.URI; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.HashSet; @@ -96,10 +103,12 @@ public class VaultResource { private final LicenseHolder license; private final VaultUnlockMetrics vaultUnlockMetrics; private final HttpServerRequest request; // @RequestScoped bean, injected as a client proxy resolving against the current request + private final VaultMembersJoinedBroadcaster vaultMembersJoinedBroadcaster; + private final Event vaultMembersJoinedEvent; @Inject @SuppressWarnings("deprecation") - VaultResource(EventLogger eventLogger, AccessToken.Repository accessTokenRepo, Device.Repository deviceRepo, Group.Repository groupRepo, User.Repository userRepo, Authority.Repository authorityRepo, EffectiveVaultAccess.Repository effectiveVaultAccessRepo, LegacyAccessToken.Repository legacyAccessTokenRepo, Vault.Repository vaultRepo, VaultAccess.Repository vaultAccessRepo, JsonWebToken jwt, LicenseHolder license, VaultUnlockMetrics vaultUnlockMetrics, HttpServerRequest request) { + VaultResource(EventLogger eventLogger, AccessToken.Repository accessTokenRepo, Device.Repository deviceRepo, Group.Repository groupRepo, User.Repository userRepo, Authority.Repository authorityRepo, EffectiveVaultAccess.Repository effectiveVaultAccessRepo, LegacyAccessToken.Repository legacyAccessTokenRepo, Vault.Repository vaultRepo, VaultAccess.Repository vaultAccessRepo, JsonWebToken jwt, LicenseHolder license, VaultUnlockMetrics vaultUnlockMetrics, HttpServerRequest request, VaultMembersJoinedBroadcaster vaultMembersJoinedBroadcaster, Event vaultMembersJoinedEvent) { this.eventLogger = eventLogger; this.accessTokenRepo = accessTokenRepo; this.deviceRepo = deviceRepo; @@ -114,6 +123,8 @@ public class VaultResource { this.license = license; this.vaultUnlockMetrics = vaultUnlockMetrics; this.request = request; + this.vaultMembersJoinedBroadcaster = vaultMembersJoinedBroadcaster; + this.vaultMembersJoinedEvent = vaultMembersJoinedEvent; } @GET @@ -166,6 +177,37 @@ public List getAllVaults() { return vaultRepo.findAll().stream().map(VaultDto::fromEntity).toList(); } + @GET + @Path("/users-requiring-access-grant") + @RolesAllowed("user") + @RunOnVirtualThread + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "list pending access grants the caller could perform, grouped by vault", + description = """ + Long-polling endpoint for the automatic access grant flow. Returns members without an access token on + vaults the caller holds a token for (i.e. can decrypt and therefore re-share). The Web-of-Trust decision + and the vault's encrypted trust threshold / enabled flag are evaluated client-side — the server cannot + see them, and the recursive WoT view would be too costly to join here. Returns immediately if such + pending grants exist; otherwise blocks up to `wait` seconds and returns the next snapshot (or an empty + map on timeout). The client avoids re-evaluating candidates it has already ruled out (and backs off when + only such candidates remain). Best effort — events on other backend instances will not wake this call.""") + @APIResponse(responseCode = "200") + public Map> getUsersRequiringAccessGrant(@QueryParam("wait") @DefaultValue("25") @Min(0) int wait) throws InterruptedException { + var callerId = jwt.getSubject(); + try (var ticket = vaultMembersJoinedBroadcaster.subscribe()) { + var initial = queryPendingAccessGrants(callerId); + if (!initial.isEmpty()) { + return initial; + } + ticket.awaitChange(Duration.ofSeconds(wait)); + return queryPendingAccessGrants(callerId); + } + } + + private Map> queryPendingAccessGrants(String currentUserId) { + return QuarkusTransaction.requiringNew().call(() -> effectiveVaultAccessRepo.findMembersWithoutAccessTokens(currentUserId)); + } + @GET @Path("/{vaultId}/members") @RolesAllowed("user") @@ -244,6 +286,9 @@ public Response setDirectMembers(@PathParam("vaultId") UUID vaultId, @NotEmpty M vaultAccessRepo.persist(addedMembers); vaultAccessRepo.persist(updatedMembers); + if (!addedMembers.isEmpty()) { + vaultMembersJoinedEvent.fire(new VaultMembersJoined()); + } return Response.noContent().build(); } @@ -316,6 +361,7 @@ private Response addAuthority(Vault vault, Authority authority, VaultAccess.Role access.setRole(role); vaultAccessRepo.persist(access); eventLogger.logVaultMemberAdded(jwt.getSubject(), vault.getId(), authority.getId(), role); + vaultMembersJoinedEvent.fire(new VaultMembersJoined()); return Response.created(URI.create(".")).build(); } } @@ -344,11 +390,18 @@ public Response removeAuthority(@PathParam("vaultId") UUID vaultId, @PathParam(" @VaultRole(value = VaultAccess.Role.OWNER, bypassForEmergencyAccess = true) // may throw 403 @Transactional @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "list users requiring access rights", description = "lists all users, who don't have a user-specific vault key yet") + @Operation(summary = "list members requiring access tokens", description = "lists all members, that have permissions but lack an access token") @APIResponse(responseCode = "200") @APIResponse(responseCode = "403", description = "not a vault owner") - public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { - return userRepo.findRequiringAccessGrant(vaultId).map(UserDto::justPublicInfo).toList(); + public List getUsersRequiringAccessGrant(@PathParam("vaultId") UUID vaultId) { + return effectiveVaultAccessRepo.findMembersWithoutAccessTokensForVault(vaultId).map(access -> { + if (access.getAuthority() instanceof User u) { + return MemberDto.fromEntity(u, access.getRole()); + } else { + // findMembersWithoutAccessTokens() should only return users, not groups. + throw new IllegalStateException(); + } + }).toList(); } /** @@ -467,6 +520,38 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr } } + @GET + @Path("/{vaultId}/uvf/vault.uvf") + @RolesAllowed("user") + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get the vault.uvf file") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "404", description = "unknown vault") + public String getUvfMetadata(@PathParam("vaultId") UUID vaultId) { + var vault = vaultRepo.findById(vaultId); + if (vault == null || vault.getUvfMetadataFile() == null) { + throw new NotFoundException(); + } + return vault.getUvfMetadataFile(); + } + + @GET + @Path("/{vaultId}/uvf/jwks.json") + @RolesAllowed("user") + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get public vault keys", description = "retrieves a JWK Set containing public keys related to this vault") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "404", description = "unknown vault") + public String getUvfKeys(@PathParam("vaultId") UUID vaultId) { + var vault = vaultRepo.findById(vaultId); + if (vault == null || vault.getUvfMetadataFile() == null) { + throw new NotFoundException(); + } + return vault.getUvfKeySet(); + } + @POST @Path("/{vaultId}/access-tokens") @RolesAllowed("user") @@ -479,8 +564,6 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr @APIResponse(responseCode = "403", description = "not a vault owner or emergency access council member") @APIResponse(responseCode = "404", description = "at least one user has not been found") public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map tokens) { - var vault = vaultRepo.findById(vaultId); // should always be found, since @VaultRole filter would have triggered - // check number of available seats long occupiedSeats = effectiveVaultAccessRepo.countSeatOccupyingUsers(); long usersWithoutSeat = tokens.size() - effectiveVaultAccessRepo.countSeatsOccupiedByUsers(tokens.keySet().stream().toList()); @@ -489,6 +572,47 @@ public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map tokens) { + // Only users who are genuinely pending (effective access, but no token yet) may be granted via this member-callable + // endpoint; this prevents it from being used to grant access to arbitrary users (adding members stays owner-gated). + var pendingUserIds = effectiveVaultAccessRepo.findMembersWithoutAccessTokensForVault(vaultId) + .map(eva -> eva.getId().authorityId()) + .collect(Collectors.toSet()); + if (!pendingUserIds.containsAll(tokens.keySet())) { + var notWaiting = tokens.keySet().stream() + .filter(Predicate.not(pendingUserIds::contains)) + .collect(Collectors.joining(", ")); + throw new BadRequestException("User(s) not awaiting an access grant for this vault: " + notWaiting); + } + + grantAccessTokens(vaultId, tokens, true); + return Response.ok().build(); + } + + /** + * Persists access tokens for the given users, recording each grant in the audit log. + * + * @param vaultId the vault to grant access to + * @param tokens map from user id to the per-user-encrypted vault key + * @param automatic whether the grant is performed by the automatic access grant flow (recorded in the audit log) + */ + private void grantAccessTokens(UUID vaultId, Map tokens, boolean automatic) { + var vault = vaultRepo.findById(vaultId); // should always be found, since @VaultRole filter would have triggered for (var entry : tokens.entrySet()) { var userId = entry.getKey(); var token = accessTokenRepo.findById(new AccessToken.AccessId(userId, vaultId)); @@ -499,9 +623,8 @@ public Response grantAccess(@PathParam("vaultId") UUID vaultId, @NotEmpty Map emergencyKeyShares, + @JsonProperty("uvfMetadataFile") String uvfMetadataFile, + @JsonProperty("uvfKeySet") String uvfKeySet, // Legacy properties ("Vault Admin Password"): - @JsonProperty("masterkey") @OnlyBase64Chars @Nullable String masterkey, @JsonProperty("iterations") @Nullable Integer iterations, - @JsonProperty("salt") @OnlyBase64Chars @Nullable String salt, + @JsonProperty("masterkey") @OnlyBase64Chars @Nullable String masterkey, @JsonProperty("iterations") @Nullable Integer iterations, @JsonProperty("salt") @OnlyBase64Chars @Nullable String salt, @JsonProperty("authPublicKey") @OnlyBase64Chars @Nullable String authPublicKey, @JsonProperty("authPrivateKey") @OnlyBase64Chars @Nullable String authPrivateKey ) { public static VaultDto fromEntity(Vault entity) { - return new VaultDto(entity.getId(), entity.getName(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getDescription(), entity.isArchived(), entity.getRequiredEmergencyKeyShares(), entity.getEmergencyKeyShares(), entity.getMasterkey(), entity.getIterations(), entity.getSalt(), entity.getAuthenticationPublicKey(), entity.getAuthenticationPrivateKey()); + return new VaultDto(entity.getId(), entity.getName(), entity.getCreationTime().truncatedTo(ChronoUnit.MILLIS), entity.getDescription(), entity.isArchived(), entity.getRequiredEmergencyKeyShares(), entity.getEmergencyKeyShares(), + // uvf: + entity.getUvfMetadataFile(), entity.getUvfKeySet(), + // legacy properties: + entity.getMasterkey(), entity.getIterations(), entity.getSalt(), entity.getAuthenticationPublicKey(), entity.getAuthenticationPrivateKey()); } } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java index be9761a31..06f3118ed 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java @@ -76,6 +76,24 @@ SELECT COUNT(DISTINCT u.id) FROM EffectiveVaultAccess eva WHERE eva.id.vaultId = :vaultId AND eva.id.authorityId = :authorityId """) +@NamedQuery(name = "EffectiveVaultAccess.findMembersWithoutAccessTokens", query = """ + SELECT eva + FROM EffectiveVaultAccess eva + INNER JOIN FETCH eva.authority u + LEFT JOIN AccessToken token ON token.id.vaultId = eva.id.vaultId AND token.id.userId = u.id + WHERE eva.id.vaultId = :vaultId AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL + """ +) +@NamedQuery(name = "EffectiveVaultAccess.findMembersWithoutAccessTokensForAccessibleVaults", query = """ + SELECT DISTINCT eva + FROM EffectiveVaultAccess eva + INNER JOIN eva.authority u + INNER JOIN Vault v ON v.id = eva.id.vaultId + INNER JOIN AccessToken callerToken ON callerToken.id.vaultId = eva.id.vaultId AND callerToken.id.userId = :currentUser + LEFT JOIN AccessToken token ON token.id.vaultId = eva.id.vaultId AND token.id.userId = u.id + WHERE v.archived = false AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL AND u.enabled + """ +) public class EffectiveVaultAccess { @EmbeddedId @@ -147,6 +165,34 @@ public Collection listRoles(UUID vaultId, String authorityId) .collect(Collectors.toUnmodifiableSet()); } + /** + * @param vaultId ID of a vault + * @return ids of vault members who have no access token for the given vault yet + * @see #findMembersWithoutAccessTokens(String) + */ + public Stream findMembersWithoutAccessTokensForVault(UUID vaultId) { + return find("#EffectiveVaultAccess.findMembersWithoutAccessTokens", Map.of("vaultId", vaultId)).stream(); + } + + /** + * Finds the pending access grants the given user could perform, grouped by vault id. Limited to vaults the user + * holds an access token for (i.e. can decrypt and therefore re-share). The Web-of-Trust decision and the vault's + * own (encrypted, tamper-proof) trust threshold / enabled flag are evaluated client-side — deliberately not here: + * the {@code effective_wot} view is a recursive transitive-closure computation that would be too costly to join + * on this long-poll hot path, and the client already avoids re-evaluating ruled-out candidates via its blocklists. + * + * @param currentUserId ID of the currently logged-in user + * @return ids of vault members who have no access token yet, on vaults the user can decrypt, grouped by vault id + * @see #findMembersWithoutAccessTokensForVault(UUID) + */ + public Map> findMembersWithoutAccessTokens(String currentUserId) { + return find("#EffectiveVaultAccess.findMembersWithoutAccessTokensForAccessibleVaults", Map.of("currentUser", currentUserId)) + .stream() + .collect(Collectors.groupingBy( + eva -> eva.getId().vaultId(), + Collectors.mapping(eva -> eva.getId().authorityId(), Collectors.toSet()))); + } + public Stream usersSeatedOnOtherVaults(UUID vaultId) { return find("#EffectiveVaultAccess.usersSeatedOnOtherVaults", Map.of("vaultId", vaultId)).project(String.class).stream(); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Settings.java b/backend/src/main/java/org/cryptomator/hub/entities/Settings.java index 0bf7d7ea1..4c31df4c8 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Settings.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Settings.java @@ -50,6 +50,21 @@ public class Settings { @Column(name = "allow_choosing_emergency_council", nullable = false) private boolean allowChoosingEmergencyCouncil; + @Column(name = "enable_automatic_access_grant", nullable = false) + private boolean enableAutomaticAccessGrant; + + // Default Web of Trust distance (number of signatures in the trust chain from an existing vault member to a new + // member) up to which access may be granted automatically: + // -1 = trust check disabled (grant regardless of any WoT relationship) + // 0 = self-signed identities only (no practical use case) + // 1 = direct trust (an existing member has signed the new member's key directly) + // >=2 = transitive trust (a chain of up to N signatures) + @Column(name = "automatic_access_grant_trust_threshold", nullable = false) + private int automaticAccessGrantTrustThreshold; + + @Column(name = "allow_automatic_access_grant_override", nullable = false) + private boolean allowAutomaticAccessGrantOverride; + @ElementCollection @CollectionTable( name = "default_emergency_council", @@ -130,6 +145,30 @@ public void setAllowChoosingEmergencyCouncil(boolean allowChoosingEmergencyCounc this.allowChoosingEmergencyCouncil = allowChoosingEmergencyCouncil; } + public boolean isAutomaticAccessGrantEnabled() { + return enableAutomaticAccessGrant; + } + + public void setAutomaticAccessGrantEnabled(boolean enableAutomaticAccessGrant) { + this.enableAutomaticAccessGrant = enableAutomaticAccessGrant; + } + + public int getAutomaticAccessGrantTrustThreshold() { + return automaticAccessGrantTrustThreshold; + } + + public void setAutomaticAccessGrantTrustThreshold(int automaticAccessGrantTrustThreshold) { + this.automaticAccessGrantTrustThreshold = automaticAccessGrantTrustThreshold; + } + + public boolean isAllowAutomaticAccessGrantOverride() { + return allowAutomaticAccessGrantOverride; + } + + public void setAllowAutomaticAccessGrantOverride(boolean allowAutomaticAccessGrantOverride) { + this.allowAutomaticAccessGrantOverride = allowAutomaticAccessGrantOverride; + } + public Set getEmergencyCouncilMemberIds() { return Set.copyOf(emergencyCouncilMemberIds); } @@ -151,6 +190,9 @@ public String toString() { ", defaultRequiredEmergencyKeyShares=" + defaultRequiredEmergencyKeyShares + ", defaultMinMembers=" + defaultMinMembers + ", allowChoosingEmergencyCouncil=" + allowChoosingEmergencyCouncil + + ", enableAutomaticAccessGrant=" + enableAutomaticAccessGrant + + ", automaticAccessGrantTrustThreshold=" + automaticAccessGrantTrustThreshold + + ", allowAutomaticAccessGrantOverride=" + allowAutomaticAccessGrantOverride + ", emergencyCouncilMemberIds= [" + String.join(", ", emergencyCouncilMemberIds) + "]" + '}'; } @@ -169,12 +211,15 @@ public boolean equals(Object o) { && defaultRequiredEmergencyKeyShares == settings.defaultRequiredEmergencyKeyShares && defaultMinMembers == settings.defaultMinMembers && allowChoosingEmergencyCouncil == settings.allowChoosingEmergencyCouncil + && enableAutomaticAccessGrant == settings.enableAutomaticAccessGrant + && automaticAccessGrantTrustThreshold == settings.automaticAccessGrantTrustThreshold + && allowAutomaticAccessGrantOverride == settings.allowAutomaticAccessGrantOverride && Objects.equals(emergencyCouncilMemberIds, settings.emergencyCouncilMemberIds); } @Override public int hashCode() { - return Objects.hash(id, hubId, licenseKey, wotMaxDepth, wotIdVerifyLen, enableEmergencyAccess, defaultRequiredEmergencyKeyShares, defaultMinMembers, allowChoosingEmergencyCouncil, emergencyCouncilMemberIds); + return Objects.hash(id, hubId, licenseKey, wotMaxDepth, wotIdVerifyLen, enableEmergencyAccess, defaultRequiredEmergencyKeyShares, defaultMinMembers, allowChoosingEmergencyCouncil, enableAutomaticAccessGrant, automaticAccessGrantTrustThreshold, allowAutomaticAccessGrantOverride, emergencyCouncilMemberIds); } @ApplicationScoped diff --git a/backend/src/main/java/org/cryptomator/hub/entities/User.java b/backend/src/main/java/org/cryptomator/hub/entities/User.java index 861b5f8a9..957d4fdbb 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/User.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/User.java @@ -21,7 +21,6 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Gatherers; import java.util.stream.Stream; @@ -29,14 +28,6 @@ @Entity @Table(name = "user_details") @DiscriminatorValue("USER") -@NamedQuery(name = "User.requiringAccessGrant", - query = """ - SELECT u - FROM User u - INNER JOIN EffectiveVaultAccess perm ON u.id = perm.id.authorityId - LEFT JOIN u.accessTokens token ON token.id.vaultId = :vaultId AND token.id.userId = u.id - WHERE perm.id.vaultId = :vaultId AND token.vault IS NULL AND u.ecdhPublicKey IS NOT NULL AND u.enabled - """) @NamedQuery(name = "User.getEffectiveGroupUsers", query = """ SELECT DISTINCT u FROM User u @@ -270,10 +261,6 @@ public long deleteByIds(Collection ids) { return Batch.of(200).run(ids, 0L, (batch, result) -> result + delete("id IN :ids", Map.of("ids", batch))); } - public Stream findRequiringAccessGrant(UUID vaultId) { - return find("#User.requiringAccessGrant", Map.of("vaultId", vaultId)).stream(); - } - public long countEffectiveGroupUsers(String groupdId) { return count("#User.countEffectiveGroupUsers", Map.of("groupId", groupdId)); } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java index 5ddd802aa..e622b8a01 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Vault.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Vault.java @@ -127,6 +127,12 @@ public class Vault { @Column(name = "emergency_key_share") private Map emergencyKeyShares = new HashMap<>(); + @Column(name = "uvf_metadata_file") + private String uvfMetadataFile; + + @Column(name = "uvf_jwks") + private String uvfKeySet; + public Optional getAuthenticationPublicKeyOptional() { if (authenticationPublicKey == null) { return Optional.empty(); @@ -257,24 +263,33 @@ public void setEmergencyKeyShares(Map emergencyKeyShares) { this.emergencyKeyShares.putAll(emergencyKeyShares); } + public String getUvfMetadataFile() { + return uvfMetadataFile; + } + + public void setUvfMetadataFile(String uvfMetadataFile) { + this.uvfMetadataFile = uvfMetadataFile; + } + + public String getUvfKeySet() { + return uvfKeySet; + } + + public void setUvfKeySet(String uvfKeySet) { + this.uvfKeySet = uvfKeySet; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Vault vault = (Vault) o; - return Objects.equals(id, vault.id) - && Objects.equals(name, vault.name) - && Objects.equals(salt, vault.salt) - && Objects.equals(iterations, vault.iterations) - && Objects.equals(masterkey, vault.masterkey) - && Objects.equals(archived, vault.archived) - && Objects.equals(requiredEmergencyKeyShares, vault.requiredEmergencyKeyShares) - && Objects.equals(emergencyKeyShares, vault.emergencyKeyShares); + return Objects.equals(id, vault.id); } @Override public int hashCode() { - return Objects.hash(id, name, salt, iterations, masterkey, archived, requiredEmergencyKeyShares, emergencyKeyShares); + return Objects.hash(id); } @Override @@ -284,7 +299,6 @@ public String toString() { ", members=" + directMembers.stream().map(Authority::getId).collect(Collectors.joining(", ")) + ", accessToken=" + accessTokens.stream().map(a -> a.getId().toString()).collect(Collectors.joining(", ")) + ", name='" + name + '\'' + - ", archived='" + archived + '\'' + ", salt='" + salt + '\'' + ", iterations='" + iterations + '\'' + ", masterkey='" + masterkey + '\'' + @@ -292,6 +306,9 @@ public String toString() { ", authenticationPrivateKey='" + authenticationPrivateKey + '\'' + ", requiredEmergencyKeyShares='" + requiredEmergencyKeyShares + '\'' + ", emergencyKeyShares=" + emergencyKeyShares.keySet().stream().collect(Collectors.joining(", ")) + + ", archived='" + archived + '\'' + + ", uvfMetadataFile='" + uvfMetadataFile + '\'' + + ", uvfKeySet='" + uvfKeySet + '\'' + '}'; } diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java b/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java index 8e94b75c1..91119a2eb 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/EventLogger.java @@ -79,12 +79,13 @@ public void logUserSetupCodeChanged(String changedBy) { auditEventRepository.persist(event); } - public void logVaultAccessGranted(String grantedBy, UUID vaultId, String authorityId) { + public void logVaultAccessGranted(String grantedBy, UUID vaultId, String authorityId, boolean automatic) { var event = new VaultAccessGrantedEvent(); event.setTimestamp(Instant.now()); event.setGrantedBy(grantedBy); event.setVaultId(vaultId); event.setAuthorityId(authorityId); + event.setAutomatic(automatic); auditEventRepository.persist(event); } @@ -137,6 +138,16 @@ public void logWotSettingUpdated(String updatedBy, int wotIdVerifyLen, int wotMa auditEventRepository.persist(event); } + public void logAutoGrantSettingUpdated(String updatedBy, boolean enabled, int trustThreshold, boolean allowOverride) { + var event = new SettingAutoGrantUpdateEvent(); + event.setTimestamp(Instant.now()); + event.setUpdatedBy(updatedBy); + event.setEnabled(enabled); + event.setTrustThreshold(trustThreshold); + event.setAllowOverride(allowOverride); + auditEventRepository.persist(event); + } + public void logWotIdSigned(String userId, String signerId, String signerKey, String signature) { var event = new SignedWotIdEvent(); event.setTimestamp(Instant.now()); diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/SettingAutoGrantUpdateEvent.java b/backend/src/main/java/org/cryptomator/hub/entities/events/SettingAutoGrantUpdateEvent.java new file mode 100644 index 000000000..5c8778e52 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/SettingAutoGrantUpdateEvent.java @@ -0,0 +1,73 @@ +package org.cryptomator.hub.entities.events; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.util.Objects; + +@Entity +@Table(name = "audit_event_setting_auto_grant_update") +@DiscriminatorValue(SettingAutoGrantUpdateEvent.TYPE) +public class SettingAutoGrantUpdateEvent extends AuditEvent { + + public static final String TYPE = "SETTING_AUTO_GRANT_UPDATE"; + + @Column(name = "updated_by") + private String updatedBy; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "trust_threshold") + private int trustThreshold; + + @Column(name = "allow_override") + private boolean allowOverride; + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getTrustThreshold() { + return trustThreshold; + } + + public void setTrustThreshold(int trustThreshold) { + this.trustThreshold = trustThreshold; + } + + public boolean isAllowOverride() { + return allowOverride; + } + + public void setAllowOverride(boolean allowOverride) { + this.allowOverride = allowOverride; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + SettingAutoGrantUpdateEvent that = (SettingAutoGrantUpdateEvent) o; + return enabled == that.enabled && trustThreshold == that.trustThreshold && allowOverride == that.allowOverride && Objects.equals(updatedBy, that.updatedBy); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), updatedBy, enabled, trustThreshold, allowOverride); + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/VaultAccessGrantedEvent.java b/backend/src/main/java/org/cryptomator/hub/entities/events/VaultAccessGrantedEvent.java index 4e4521548..5c9e07d97 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/events/VaultAccessGrantedEvent.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/VaultAccessGrantedEvent.java @@ -25,6 +25,13 @@ public class VaultAccessGrantedEvent extends AuditEvent { @Column(name = "authority_id") private @Nullable String authorityId; + /** + * Whether this grant was performed by a member's client under the automatic access grant policy (as opposed to a + * deliberate manual grant by a vault owner). The cryptographic actor remains {@link #grantedBy} in both cases. + */ + @Column(name = "automatic", nullable = false) + private boolean automatic; + public @Nullable String getGrantedBy() { return grantedBy; } @@ -49,6 +56,14 @@ public void setAuthorityId(@Nullable String authorityId) { this.authorityId = authorityId; } + public boolean isAutomatic() { + return automatic; + } + + public void setAutomatic(boolean automatic) { + this.automatic = automatic; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -57,12 +72,13 @@ public boolean equals(Object o) { return super.equals(that) // && Objects.equals(grantedBy, that.grantedBy) // && Objects.equals(vaultId, that.vaultId) // - && Objects.equals(authorityId, that.authorityId); + && Objects.equals(authorityId, that.authorityId) // + && automatic == that.automatic; } @Override public int hashCode() { - return Objects.hash(super.getId(), grantedBy, vaultId, authorityId); + return Objects.hash(super.getId(), grantedBy, vaultId, authorityId, automatic); } } diff --git a/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoined.java b/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoined.java new file mode 100644 index 000000000..26713bdf7 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoined.java @@ -0,0 +1,15 @@ +package org.cryptomator.hub.events; + +/** + * CDI event fired (from within a transaction) when one or more users have joined a vault — directly or + * transitively via group membership — and may therefore be awaiting a per-user access token. It is deliberately a + * one-directional "grant work may now exist" signal, not a symmetric change feed: removals and role-only updates never + * create members lacking a token, so they do not fire it. Observers should react after the firing transaction commits — + * see {@link VaultMembersJoinedBroadcaster}. + * + *

The event carries no payload: every observer that cares re-queries the database. This keeps fire sites trivial + * (one line per write path) and avoids the bookkeeping of tracking which vault was affected when group/membership + * additions ripple transitively. + */ +public record VaultMembersJoined() { +} diff --git a/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoinedBroadcaster.java b/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoinedBroadcaster.java new file mode 100644 index 000000000..0bb3afa99 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/events/VaultMembersJoinedBroadcaster.java @@ -0,0 +1,63 @@ +package org.cryptomator.hub.events; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.event.TransactionPhase; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * In-process pub/sub for {@link VaultMembersJoined} events. Long-polling request handlers acquire a {@link Ticket}, + * check the current database state, and then block on the ticket until the next event fires or the timeout elapses. + * + *

The observer is wired with {@link TransactionPhase#AFTER_SUCCESS} so a rolled-back transaction does not produce + * spurious wake-ups. Best-effort by design: events are not persisted and listeners that join after a fire miss it — + * a periodic poll cadence on the client is the intended backstop. + */ +@ApplicationScoped +public class VaultMembersJoinedBroadcaster { + + private final Set> waiters = ConcurrentHashMap.newKeySet(); + + void onMembersJoined(@Observes(during = TransactionPhase.AFTER_SUCCESS) VaultMembersJoined event) { + for (var w : waiters) { + w.complete(null); + } + } + + /** + * @return a {@link Ticket} that wakes on the next {@link VaultMembersJoined} fired after this call. Must be closed. + */ + public Ticket subscribe() { + var future = new CompletableFuture(); + waiters.add(future); + return new Ticket(future, () -> waiters.remove(future)); + } + + public record Ticket(CompletableFuture future, Runnable cleanup) implements AutoCloseable { + + /** + * Blocks the calling (virtual) thread until either an event fires or the timeout elapses. Swallows + * timeout and execution exceptions — the contract is "best effort, return when there's news or after a while". + */ + public void awaitChange(Duration timeout) throws InterruptedException { + try { + future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException | ExecutionException ignored) { + // timeout: no event arrived in time — caller re-queries either way. + // execution: future completed exceptionally — same behavior. + } + } + + @Override + public void close() { + cleanup.run(); + } + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java b/backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java index e8557279c..4d192cfe6 100644 --- a/backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java +++ b/backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java @@ -6,6 +6,7 @@ import io.quarkus.cache.CacheResult; import io.quarkus.scheduler.Scheduled; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; import jakarta.inject.Inject; import jakarta.persistence.PersistenceException; import jakarta.transaction.Transactional; @@ -31,6 +32,7 @@ import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.cryptomator.hub.events.VaultMembersJoined; import java.util.EnumSet; import java.util.HashMap; @@ -54,17 +56,19 @@ public class KeycloakAuthorityPuller { private final KeycloakAuthorityProvider remoteUserProvider; private final EffectiveGroupMembership.Repository effectiveGroupMembershipRepo; private final KeycloakRealmRoles realmRoles; + private final Event vaultMembersJoinedEvent; // visible for testing RealmResource realm; @Inject - KeycloakAuthorityPuller(Keycloak keycloak, User.Repository userRepo, Group.Repository groupRepo, KeycloakAuthorityProvider remoteUserProvider, EffectiveGroupMembership.Repository effectiveGroupMembershipRepo, KeycloakRealmRoles realmRoles, @ConfigProperty(name = "hub.keycloak.realm") String keycloakRealm) { + KeycloakAuthorityPuller(Keycloak keycloak, User.Repository userRepo, Group.Repository groupRepo, KeycloakAuthorityProvider remoteUserProvider, EffectiveGroupMembership.Repository effectiveGroupMembershipRepo, KeycloakRealmRoles realmRoles, Event vaultMembersJoinedEvent, @ConfigProperty(name = "hub.keycloak.realm") String keycloakRealm) { this.userRepo = userRepo; this.groupRepo = groupRepo; this.remoteUserProvider = remoteUserProvider; this.effectiveGroupMembershipRepo = effectiveGroupMembershipRepo; this.realmRoles = realmRoles; + this.vaultMembersJoinedEvent = vaultMembersJoinedEvent; this.realm = keycloak.realm(keycloakRealm); } @@ -99,7 +103,15 @@ void sync(Map keycloakGroups, Map syncDeletedGroups(Map keycloakGroups, Map< } //visible for testing - void syncUpdatedGroups(Map keycloakGroups, Map databaseGroups, Set deletedGroupIds, Map allAuthorities) { + boolean syncUpdatedGroups(Map keycloakGroups, Map databaseGroups, Set deletedGroupIds, Map allAuthorities) { var toUpdateIds = diff(databaseGroups.keySet(), deletedGroupIds); var idsOfGroupsWithChangedMembers = new HashSet(); + var membersJoined = false; for (var id : toUpdateIds) { var databaseGroup = databaseGroups.get(id); var keycloakGroup = keycloakGroups.get(id); @@ -171,8 +184,10 @@ void syncUpdatedGroups(Map keycloakGroups, Map= -1 AND "automatic_access_grant_trust_threshold" < 10); + +CREATE TABLE "audit_event_setting_auto_grant_update" +( + "id" BIGINT NOT NULL, + "updated_by" VARCHAR(255) COLLATE "C" NOT NULL, + "enabled" BOOLEAN NOT NULL, + "trust_threshold" INTEGER NOT NULL, + "allow_override" BOOLEAN NOT NULL, + CONSTRAINT "AUDIT_EVENT_SETTING_AUTO_GRANT_UPDATE_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_SETTING_AUTO_GRANT_UPDATE_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +-- distinguishes automatic access grants (performed by a member's client under the auto-grant policy) from manual owner grants +ALTER TABLE "audit_event_vault_access_grant" ADD "automatic" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java b/backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java index b0cced7fc..c65173168 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java @@ -181,7 +181,7 @@ void testUpdateVaultDespiteLicenseExceeded() { Assumptions.assumeTrue(vaultResourceIT.effectiveVaultAccessRepo.countSeatOccupyingUsers() == 5); var vaultId = "7E57C0DE-0000-4000-8000-000100001111"; - var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault 1", Instant.parse("2222-11-11T11:11:11Z"), "This is a testvault.", false, 0, Map.of(), "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault 1", Instant.parse("2222-11-11T11:11:11Z"), "This is a testvault.", false, 0, Map.of(), "doNotUpdate", "doNotUpdate", "someVaule", -1, "doNotUpdate", "doNotUpdate", "doNotUpdate"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", vaultId) @@ -207,7 +207,7 @@ void testCreateVaultExceedingSeats() throws SQLException { Assumptions.assumeTrue(vaultResourceIT.effectiveVaultAccessRepo.countSeatOccupyingUsers() > 5); var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-0001FFFF3333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 4", false, 0, Map.of(), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 4", false, 0, Map.of(), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-0001FFFF3333") .then().statusCode(402); @@ -235,7 +235,7 @@ void unarchiveVaultExceedingSeats() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-00010000AAAA ignores archived flag in createOrUpdate") void createOrUpdateIgnoresArchivedFlag() { var vaultId = "7E57C0DE-0000-4000-8000-00010000AAAA"; - var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault Archived", Instant.parse("2020-02-20T20:20:20Z"), "This is a archived vault.", false, 0, Map.of(), "masterkey3", 42, "salt3", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(UUID.fromString(vaultId), "Vault Archived", Instant.parse("2020-02-20T20:20:20Z"), "This is a archived vault.", false, 0, Map.of(), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "salt3", "doNotUpdate", "doNotUpdate"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", vaultId) diff --git a/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java index 6cec13f54..2778a68da 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java @@ -47,14 +47,17 @@ void testGetInitial() { .body("wotMaxDepth", is(3)) .body("wotIdVerifyLen", is(2)) .body("defaultRequiredEmergencyKeyShares", is(2)) - .body("allowChoosingEmergencyCouncil", is(false)); + .body("allowChoosingEmergencyCouncil", is(false)) + .body("enableAutomaticAccessGrant", is(false)) + .body("automaticAccessGrantTrustThreshold", is(0)) + .body("allowAutomaticAccessGrantOverride", is(false)); } @Test @Order(2) @DisplayName("PUT /settings returns 204 No Content") void testPut() { - var dto = new SettingsResource.SettingsDto("42", 5, 8, true, 2, 3, false, Set.of()); + var dto = new SettingsResource.SettingsDto("42", 5, 8, true, 2, 3, false, Set.of(), true, 2, true); given().contentType(ContentType.JSON).body(dto) .when().put("/settings") .then().statusCode(204); @@ -67,14 +70,47 @@ void testGetModify() { when().get("/settings") .then().statusCode(200) .body("wotMaxDepth", is(5)) - .body("wotIdVerifyLen", is(8)); + .body("wotIdVerifyLen", is(8)) + .body("enableAutomaticAccessGrant", is(true)) + .body("automaticAccessGrantTrustThreshold", is(2)) + .body("allowAutomaticAccessGrantOverride", is(true)); } @Test @Order(4) + @DisplayName("PUT /settings with trustThreshold=-1 (trust everyone) returns 204 No Content") + void testPutTrustEveryone() { + var dto = new SettingsResource.SettingsDto("42", 3, 2, true, 2, 3, false, Set.of(), true, -1, true); + given().contentType(ContentType.JSON).body(dto) + .when().put("/settings") + .then().statusCode(204); + } + + @Test + @Order(5) + @DisplayName("PUT /settings with trustThreshold=-2 returns 400 Bad Request") + void testPutTrustThresholdTooLow() { + var dto = new SettingsResource.SettingsDto("42", 3, 2, true, 2, 3, false, Set.of(), true, -2, true); + given().contentType(ContentType.JSON).body(dto) + .when().put("/settings") + .then().statusCode(400); + } + + @Test + @Order(6) + @DisplayName("PUT /settings with trustThreshold=10 returns 400 Bad Request") + void testPutTrustThresholdTooHigh() { + var dto = new SettingsResource.SettingsDto("42", 3, 2, true, 2, 3, false, Set.of(), true, 10, true); + given().contentType(ContentType.JSON).body(dto) + .when().put("/settings") + .then().statusCode(400); + } + + @Test + @Order(7) @DisplayName("PUT /settings returns 204 No Content") void testPutBackToDefault() { - var dto = new SettingsResource.SettingsDto("42", 3, 2, true, 2, 3, false, Set.of()); + var dto = new SettingsResource.SettingsDto("42", 3, 2, true, 2, 3, false, Set.of(), false, 0, false); given().contentType(ContentType.JSON).body(dto) .when().put("/settings") .then().statusCode(204); diff --git a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java index 83c2746bd..e07e226d9 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/VaultResourceIT.java @@ -153,10 +153,12 @@ class TestVaultDtoValidation { private static final String VALID_SALT = "base64"; private static final String VALID_AUTH_PUB = "base64"; private static final String VALID_AUTH_PRI = "base64"; + private static final String VALID_UVF_METADATA_FILE = "{json}"; + private static final String VALID_UVF_RECOVERY_KEY = "base64"; @Test void testValidDto() { - var dto = new VaultResource.VaultDto(VALID_ID, VALID_NAME, Instant.parse("2020-02-20T20:20:20Z"), "foobarbaz", false, 0, Map.of(), VALID_MASTERKEY, 8, VALID_SALT, VALID_AUTH_PUB, VALID_AUTH_PRI); + var dto = new VaultResource.VaultDto(VALID_ID, VALID_NAME, Instant.parse("2020-02-20T20:20:20Z"), "foobarbaz", false, 0, Map.of(), VALID_UVF_METADATA_FILE, VALID_UVF_RECOVERY_KEY, VALID_MASTERKEY, 8, VALID_SALT, VALID_AUTH_PUB, VALID_AUTH_PRI); var violations = validator.validate(dto); MatcherAssert.assertThat(violations, Matchers.empty()); } @@ -313,6 +315,29 @@ void testUnlockArchived3() { Mockito.verify(vaultUnlockMetrics).recordSuccess(); } + @Test + @DisplayName("GET /vaults/users-requiring-access-grant?wait=0 returns 200 with decryptable pending user999 on vault2") + void testGetUsersRequiringAccessGrant() { + // user999 was added to group2 (owner of vault2) by @BeforeEach, has a valid ecdh key but no access token yet, + // so they are a pending member of vault2. The endpoint returns them because user1 holds a token for vault2 + // (V9999), i.e. can decrypt and therefore re-share. The Web-of-Trust decision is left to the client, so no + // trust setup is needed here. user998 has no ecdh key, so it must not appear. We don't assert the total vault + // count: other tests may leave vaults around. + given().queryParam("wait", 0) + .when().get("/vaults/users-requiring-access-grant") + .then().statusCode(200) + .body("'7e57c0de-0000-4000-8000-000100002222'", hasItems("user999")) + .body("'7e57c0de-0000-4000-8000-000100002222'", not(hasItems("user998"))); + } + + @Test + @DisplayName("GET /vaults/users-requiring-access-grant?wait=-1 returns 400 (validation)") + void testGetUsersRequiringAccessGrantNegativeWait() { + given().queryParam("wait", -1) + .when().get("/vaults/users-requiring-access-grant") + .then().statusCode(400); + } + @Nested @DisplayName("legacy unlock") @TestSecurity(user = "User Name 1", roles = {"user"}) @@ -383,7 +408,7 @@ void testUnlock() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 403 for missing role") void testCreateVaultWithMissingRole() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 3", false, 0, Map.of(), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 3", false, 0, Map.of(), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -406,7 +431,7 @@ class CreateVaults { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 201") void testCreateVault1() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 3", false, 0, Map.of(), "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 3", false, 0, Map.of(), "uvfMetadata3", "uvfKeySet3", "masterkey3", 42, "NaCl", "authPubKey3", "authPrvKey3"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -431,7 +456,7 @@ void testCreateVault2() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100004444 returns 201 ignoring archived flag") void testCreateVault3() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100004444"); - var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 4", true, 0, Map.of(), "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); + var vaultDto = new VaultResource.VaultDto(uuid, "My Vault", Instant.parse("2112-12-21T21:12:21Z"), "Test vault 4", true, 0, Map.of(), "uvfMetadata4", "uvfKeySet4", "masterkey4", 42, "NaCl", "authPubKey4", "authPrvKey4"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100004444") @@ -447,7 +472,7 @@ void testCreateVault3() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100003333 returns 200, updating only name and description (archived flag ignored)") void testUpdateVault() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100003333"); - var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", Instant.parse("2222-11-11T11:11:11Z"), "Vault updated.", true, 0, Map.of(), "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate"); + var vaultDto = new VaultResource.VaultDto(uuid, "VaultUpdated", Instant.parse("2222-11-11T11:11:11Z"), "Vault updated.", true, 0, Map.of(), "doNotUpdate", "doNotUpdate", "doNotUpdate", 27, "doNotUpdate", "doNotUpdate", "doNotUpdate"); given().contentType(ContentType.JSON) .body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100003333") @@ -528,6 +553,37 @@ void testGrantAccessArchived() { .then().statusCode(200); } + @Test + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100002222/access-tokens (manual) returns 403 for non-owner member user1") + void testManualGrantByNonOwnerForbidden() { + // user1 is only a MEMBER of vault2 (via group1), so the owner-only manual endpoint must reject the grant. + given().contentType(ContentType.JSON).body(Map.of("user999", "jwe.jwe.jwe.vault2.user999")) + .when().post("/vaults/{vaultId}/access-tokens/", "7E57C0DE-0000-4000-8000-000100002222") + .then().statusCode(403); + } + + @Test + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100002222/access-tokens/auto returns 200 for user999 (member-initiated automatic grant to a pending user)") + void testAutoGrantByMember() { + // Same member (user1, non-owner of vault2) and same target as above, but via the auto endpoint, which any + // member may call. user999 is in group2 (owner of vault2), has a public key, and holds no token yet → pending. + given().contentType(ContentType.JSON).body(Map.of("user999", "jwe.jwe.jwe.vault2.user999")) + .when().post("/vaults/{vaultId}/access-tokens/auto", "7E57C0DE-0000-4000-8000-000100002222") + .then().statusCode(200); + + // the grant must be recorded as automatic (true), attributed to the member who performed it + Mockito.verify(eventLogger).logVaultAccessGranted("user1", UUID.fromString("7E57C0DE-0000-4000-8000-000100002222"), "user999", true); + } + + @Test + @DisplayName("POST /vaults/7E57C0DE-0000-4000-8000-000100002222/access-tokens/auto returns 400 for user998 (not awaiting a grant: no public key)") + void testAutoGrantToNonPending() { + // user998 is also in group2 but has no public key, so it is not awaiting a grant and must be rejected. + given().contentType(ContentType.JSON).body(Map.of("user998", "jwe.jwe.jwe.vault2.user998")) + .when().post("/vaults/{vaultId}/access-tokens/auto", "7E57C0DE-0000-4000-8000-000100002222") + .then().statusCode(400); + } + } @Nested @@ -1048,7 +1104,7 @@ void testAdminUnarchiveVault() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100001111 returns 403 for admin updating vault they don't own") void testAdminCannotUpdateVaultTheyDontOwn() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100001111"); - var vaultDto = new VaultResource.VaultDto(uuid, "Vault 1", Instant.parse("2020-02-20T20:20:20Z"), "This is a testvault.", true, 0, Map.of(), "masterkey1", 42, "salt1", "authPubKey1", "authPrvKey1"); + var vaultDto = new VaultResource.VaultDto(uuid, "Vault 1", Instant.parse("2020-02-20T20:20:20Z"), "This is a testvault.", true, 0, Map.of(), "uvfMetadata1", "uvfKeySet1", "masterkey1", 42, "salt1", "authPubKey1", "authPrvKey1"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100001111") @@ -1060,7 +1116,7 @@ void testAdminCannotUpdateVaultTheyDontOwn() { @DisplayName("PUT /vaults/7E57C0DE-0000-4000-8000-000100005555 returns 403 for admin creating vault without create-vaults role") void testAdminCannotCreateVaultWithoutCreateVaultsRole() { var uuid = UUID.fromString("7E57C0DE-0000-4000-8000-000100005555"); - var vaultDto = new VaultResource.VaultDto(uuid, "New Vault", Instant.parse("2112-12-21T21:12:21Z"), "Should not be created", false, 0, Map.of(), "masterkey5", 42, "NaCl", "authPubKey5", "authPrvKey5"); + var vaultDto = new VaultResource.VaultDto(uuid, "New Vault", Instant.parse("2112-12-21T21:12:21Z"), "Should not be created", false, 0, Map.of(), "uvfMetadata5", "uvfKeySet5", "masterkey5", 42, "NaCl", "authPubKey5", "authPrvKey5"); given().contentType(ContentType.JSON).body(vaultDto) .when().put("/vaults/{vaultId}", "7E57C0DE-0000-4000-8000-000100005555") diff --git a/backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPullerTest.java b/backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPullerTest.java index 001d278a0..4a1232b8c 100644 --- a/backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPullerTest.java +++ b/backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPullerTest.java @@ -1,5 +1,6 @@ package org.cryptomator.hub.keycloak; +import jakarta.enterprise.event.Event; import jakarta.persistence.PersistenceException; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.InternalServerErrorException; @@ -10,6 +11,7 @@ import org.cryptomator.hub.entities.EffectiveGroupMembership; import org.cryptomator.hub.entities.Group; import org.cryptomator.hub.entities.User; +import org.cryptomator.hub.events.VaultMembersJoined; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.jspecify.annotations.NonNull; @@ -61,6 +63,7 @@ class KeycloakAuthorityPullerTest { private final Group.Repository groupRepo = Mockito.mock(Group.Repository.class); private final EffectiveGroupMembership.Repository effectiveGroupMembershipRepo = Mockito.mock(EffectiveGroupMembership.Repository.class); private final KeycloakRealmRoles realmRoles = Mockito.mock(KeycloakRealmRoles.class); + private final Event vaultMembersJoinedEvent = Mockito.mock(); private final List persistedUsers = new ArrayList<>(); private final List persistedGroups = new ArrayList<>(); @@ -69,7 +72,7 @@ class KeycloakAuthorityPullerTest { @BeforeEach void setUp() { - remoteUserPuller = new KeycloakAuthorityPuller(keycloak, userRepo, groupRepo, remoteUserProvider, effectiveGroupMembershipRepo, realmRoles, "cryptomator"); + remoteUserPuller = new KeycloakAuthorityPuller(keycloak, userRepo, groupRepo, remoteUserProvider, effectiveGroupMembershipRepo, realmRoles, vaultMembersJoinedEvent, "cryptomator"); persistedUsers.clear(); Mockito.doAnswer(invocation -> { Iterable iterable = invocation.getArgument(0); 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 636e95671..f4bc9efbd 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 @@ -9,7 +9,10 @@ SET "hub_id" = '42', "default_required_emergency_key_shares" = 2, "default_min_members" = 3, "allow_choosing_emergency_council" = FALSE, - "enable_emergency_access" = FALSE + "enable_emergency_access" = FALSE, + "enable_automatic_access_grant" = FALSE, + "automatic_access_grant_trust_threshold" = 0, + "allow_automatic_access_grant_override" = FALSE WHERE "id" = 0; INSERT INTO "authority" ("id", "type", "name") @@ -44,15 +47,18 @@ VALUES ('7E57C0DE-0000-4000-8000-000100001111', 'Vault 1', 'This is a testvault.', '2020-02-20 20:20:20', 'salt1', 42, 'masterkey1', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAElS+JW3VaBvVr9GKZGn1399WDTd61Q9fwQMmZuBGAYPdl/rWk705QY6WhlmbokmEVva/mEHSoNQ98wFm9FBCqzh45IGd/DGwZ04Xhi5ah+1bKbkVhtds8nZtHRdSJokYp', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAa57e0Q/KAqmIVOVcWX7b+Sm5YVNRUx8W7nc4wk1IBj2QJmsj+MeShQRHG4ozTE9KhZANiAASVL4lbdVoG9Wv0YpkafXf31YNN3rVD1/BAyZm4EYBg92X+taTvTlBjpaGWZuiSYRW9r+YQdKg1D3zAWb0UEKrOHjkgZ38MbBnTheGLlqH7VspuRWG12zydm0dF1ImiRik=', - FALSE), + FALSE + ), ('7E57C0DE-0000-4000-8000-000100002222', 'Vault 2', 'This is a testvault.', '2020-02-20 20:20:20', 'salt2', 42, 'masterkey2', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', - FALSE), + FALSE + ), ('7E57C0DE-0000-4000-8000-00010000AAAA', 'Vault Archived', 'This is a archived vault.', '2020-02-20 20:20:20', 'salt3', 42, 'masterkey3', 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEC1uWSXj2czCDwMTLWV5BFmwxdM6PX9p+Pk9Yf9rIf374m5XP1U8q79dBhLSIuaojsvOT39UUcPJROSD1FqYLued0rXiooIii1D3jaW6pmGVJFhodzC31cy5sfOYotrzF', 'MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCAHpFQ62QnGCEvYh/pE9QmR1C9aLcDItRbslbmhen/h1tt8AyMhskeenT+rAyyPhGhZANiAAQLW5ZJePZzMIPAxMtZXkEWbDF0zo9f2n4+T1h/2sh/fviblc/VTyrv10GEtIi5qiOy85Pf1RRw8lE5IPUWpgu553SteKigiKLUPeNpbqmYZUkWGh3MLfVzLmx85ii2vMU=', - TRUE); + TRUE + ); INSERT INTO "vault_access" ("vault_id", "authority_id", "role") VALUES diff --git a/frontend/src/common/auditlog.ts b/frontend/src/common/auditlog.ts index afbe57143..6ec91093b 100644 --- a/frontend/src/common/auditlog.ts +++ b/frontend/src/common/auditlog.ts @@ -28,6 +28,14 @@ export type AuditEventSettingWotUpdateDto = AuditEventDtoBase & { wotIdVerifyLen: number; }; +export type AuditEventSettingAutoGrantUpdateDto = AuditEventDtoBase & { + type: 'SETTING_AUTO_GRANT_UPDATE', + updatedBy: string; + enabled: boolean; + trustThreshold: number; + allowOverride: boolean; +}; + export type AuditEventSignedWotIdDto = AuditEventDtoBase & { type: 'SIGN_WOT_ID', userId: string; @@ -74,6 +82,7 @@ export type AuditEventVaultAccessGrantDto = AuditEventDtoBase & { grantedBy: string; vaultId: string; authorityId: string; + automatic: boolean; }; export type AuditEventVaultKeyRetrieveDto = AuditEventDtoBase & { @@ -162,7 +171,7 @@ export type AuditEventEmergencyAccessRecoveryAbortedDto = AuditEventDtoBase & { ipAddress: string; }; -export type AuditEventDto = AuditEventDeviceRegisterDto | AuditEventDeviceRemoveDto | AuditEventSettingWotUpdateDto | AuditEventSignedWotIdDto | AuditEventUserAccountResetDto | AuditEventUserKeysChangeDto | AuditEventUserSetupCodeChangeDto | AuditEventVaultCreateDto | AuditEventVaultUpdateDto | AuditEventVaultAccessGrantDto | AuditEventVaultKeyRetrieveDto | AuditEventVaultMemberAddDto | AuditEventVaultMemberRemoveDto | AuditEventVaultMemberUpdateDto | AuditEventVaultOwnershipClaimDto | AuditEventEmergencyAccessSetupDto | AuditEventEmergencyAccessSettingsChangedDto | AuditEventEmergencyAccessRecoveryStartedDto | AuditEventEmergencyAccessRecoveryApprovedDto | AuditEventEmergencyAccessRecoveryCompletedDto | AuditEventEmergencyAccessRecoveryAbortedDto; +export type AuditEventDto = AuditEventDeviceRegisterDto | AuditEventDeviceRemoveDto | AuditEventSettingWotUpdateDto | AuditEventSettingAutoGrantUpdateDto | AuditEventSignedWotIdDto | AuditEventUserAccountResetDto | AuditEventUserKeysChangeDto | AuditEventUserSetupCodeChangeDto | AuditEventVaultCreateDto | AuditEventVaultUpdateDto | AuditEventVaultAccessGrantDto | AuditEventVaultKeyRetrieveDto | AuditEventVaultMemberAddDto | AuditEventVaultMemberRemoveDto | AuditEventVaultMemberUpdateDto | AuditEventVaultOwnershipClaimDto | AuditEventEmergencyAccessSetupDto | AuditEventEmergencyAccessSettingsChangedDto | AuditEventEmergencyAccessRecoveryStartedDto | AuditEventEmergencyAccessRecoveryApprovedDto | AuditEventEmergencyAccessRecoveryCompletedDto | AuditEventEmergencyAccessRecoveryAbortedDto; /* Entity Cache */ diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index bbbb454ab..ab47d429f 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -51,8 +51,15 @@ export type VaultDto = { salt?: string; authPublicKey?: string; authPrivateKey?: string; + + uvfMetadataFile?: string; + uvfKeySet?: string; }; +export function isUvfVault(v: VaultDto): v is VaultDto & { uvfMetadataFile: string; uvfKeySet: string } { + return typeof v.uvfMetadataFile === 'string' && typeof v.uvfKeySet === 'string'; +} + export type DeviceDto = { id: string; name: string; @@ -78,6 +85,11 @@ export type AccessGrant = { token: string }; +/** + * Map from vault id to the user ids on that vault who do not yet have a per-user access token. + */ +export type PendingAccessGrants = Record; + export type UserDto = { type: 'USER'; id: string; @@ -188,7 +200,10 @@ export type SettingsDto = { defaultMinMembers: number, allowChoosingEmergencyCouncil: boolean, emergencyCouncilMemberIds: string[], - enableEmergencyAccess: boolean + enableEmergencyAccess: boolean, + enableAutomaticAccessGrant: boolean, + automaticAccessGrantTrustThreshold: number, + allowAutomaticAccessGrantOverride: boolean }; export type RecoveryProcessSetNewOwner = { @@ -360,11 +375,24 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 404, 409)); } - public async getUsersRequiringAccessGrant(vaultId: string, addFallbackPictures: boolean = true): Promise { - const users = await axiosAuth.get(`/vaults/${vaultId}/users-requiring-access-grant`).then(response => response.data).catch(err => rethrowAndConvertIfExpected(err, 403)); + public async getUsersRequiringAccessGrant(vaultId: string, addFallbackPictures: boolean = true): Promise<(MemberDto & UserDto)[]> { + const users = await axiosAuth.get<(MemberDto & UserDto)[]>(`/vaults/${vaultId}/users-requiring-access-grant`).then(response => response.data).catch(err => rethrowAndConvertIfExpected(err, 403)); return addFallbackPictures ? users.map(fillInMissingPicture) : users; } + /** + * Long-polling endpoint used by the automatic access grant flow. Returns pending grants as a map from vault id to + * the user ids on that vault whose access tokens are missing (limited to vaults the caller is a member of). Blocks + * up to `waitSeconds` if there are no pending grants at call time; returns an empty map on timeout. Best effort — + * clients should poll on a coarse cadence too. + */ + public async listPendingAccessGrants(waitSeconds = 25): Promise { + return axiosAuth.get('/vaults/users-requiring-access-grant', { + params: { wait: waitSeconds }, + timeout: (waitSeconds + 10) * 1000 + }).then(response => response.data); + } + public async setArchived(vaultId: string, archived: boolean): Promise { return axiosAuth.put(`/vaults/${vaultId}/archived`, String(archived), { headers: { 'Content-Type': 'text/plain' } }) .then(response => { @@ -374,17 +402,8 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 403, 404)); } - public async createOrUpdateVault(vaultId: string, name: string, archived: boolean, requiredEmergencyKeyShares: number, emergencyKeyShares: Record, description?: string): Promise { - const body: VaultDto = { - id: vaultId, - name: name, - creationTime: new Date(), - description: description, - archived: archived, - requiredEmergencyKeyShares: requiredEmergencyKeyShares, - emergencyKeyShares: emergencyKeyShares - }; - return axiosAuth.put(`/vaults/${vaultId}`, body) + public async createOrUpdateVault(vault: VaultDto): Promise { + return axiosAuth.put(`/vaults/${vault.id}`, vault) .then(response => response.data) .catch((error) => rethrowAndConvertIfExpected(error, 402, 404)); } @@ -415,6 +434,20 @@ class VaultService { .catch((error) => rethrowAndConvertIfExpected(error, 402, 403, 404, 409)); } + /** + * Grants access via the automatic access grant flow. Recorded in the audit log with the automatic flag set. Callable + * by any vault member (not just owners); the backend only accepts tokens for users already awaiting an access grant + * on this vault. Used by the automatic access grant agent; manual grants by owners should use {@link grantAccess}. + */ + public async autoGrantAccess(vaultId: string, ...grants: AccessGrant[]) { + const body = grants.reduce>((accumulator, curr) => { + accumulator[curr.userId] = curr.token; + return accumulator; + }, {}); + await axiosAuth.post(`/vaults/${vaultId}/access-tokens/auto`, body) + .catch((error) => rethrowAndConvertIfExpected(error, 400, 403, 404)); + } + public async removeAuthority(vaultId: string, authorityId: string) { await axiosAuth.delete(`/vaults/${vaultId}/authority/${authorityId}`) .catch((error) => rethrowAndConvertIfExpected(error, 404)); diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index 246774bdd..9eef2d89f 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -1,7 +1,15 @@ -import { aessiv } from '@noble/ciphers/aes.js'; -import { base16, base32, base64, base64nopad, base64urlnopad } from '@scure/base'; -import { JWEBuilder, JWEParser } from './jwe'; -import { CRC32, DB, UTF8, wordEncoder } from './util'; +import { VaultDto } from './backend'; +import { base16, base64, base64urlnopad } from '@scure/base'; +import { JWE, Recipient } from './jwe'; +import { DB, UTF8 } from './util'; + +/** + * Represents a JSON Web Key (JWK) as defined in RFC 7517. + * @see https://datatracker.ietf.org/doc/html/rfc7517#section-5 + */ +export type JsonWebKeySet = { + keys: JsonWebKey & { kid?: string }[] // RFC defines kid, but webcrypto spec does not +}; export class UnwrapKeyError extends Error { @@ -14,29 +22,15 @@ export class UnwrapKeyError extends Error { } -export interface VaultConfigPayload { - jti: string - format: number - cipherCombo: string - shorteningThreshold: number -} - -export interface VaultConfigHeaderHub { - clientId: string - authEndpoint: string - tokenEndpoint: string - authSuccessUrl: string - authErrorUrl: string - apiBaseUrl: string - // deprecated: - devicesResourceUrl: string -} - -interface JWEPayload { - key: string +export interface AccessTokenPayload { + /** + * The vault key (base64-encoded DER-formatted) + */ + key: string, + [key: string]: string | number | boolean | object | undefined } -interface UserKeyPayload { +interface UserKeyPayload extends AccessTokenPayload { /** * @deprecated use `ecdhPrivateKey` instead */ @@ -45,223 +39,72 @@ interface UserKeyPayload { ecdsaPrivateKey: string, } -const GCM_NONCE_LEN = 12; +export const GCM_NONCE_LEN = 12; -export class VaultKeys { +export interface AccessTokenProducing { - // in this browser application, this 512 bit key is used - // as a hmac key to sign the vault config. - // however when used by cryptomator, it gets split into - // a 256 bit encryption key and a 256 bit mac key - private static readonly MASTERKEY_KEY_DESIGNATION: HmacImportParams | HmacKeyGenParams = { - name: 'HMAC', - hash: 'SHA-256', - length: 512 - }; + /** + * Creates a user-specific access token for the given vault. + * @param userPublicKey the public key of the user + * @param isOwner whether to also include owner secrets for this user (UVF only) + */ + encryptForUser(userPublicKey: CryptoKey | BufferSource, isOwner?: boolean): Promise; - readonly masterKey: CryptoKey; +} - protected constructor(masterkey: CryptoKey) { - this.masterKey = masterkey; - } +export interface VaultTemplateProducing { /** - * Creates a new masterkey - * @returns A new masterkey + * Produces a zip file containing the vault template. + * @param apiURL absolute base URL of the API + * @param vault The vault */ - public static async create(): Promise { - const key = crypto.subtle.generateKey( - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - return new VaultKeys(await key); - } + exportTemplate(apiURL: string, vault: VaultDto): Promise; - /** - * Decrypts the vault's masterkey using the user's private key - * @param jwe JWE containing the vault key - * @param userPrivateKey The user's private key - * @returns The masterkey - */ - public static async decryptWithUserKey(jwe: string, userPrivateKey: CryptoKey): Promise { - let rawKey = new Uint8Array(); - try { - const payload: JWEPayload = await JWEParser.parse(jwe).decryptEcdhEs(userPrivateKey); - rawKey = base64.decode(payload.key) as Uint8Array; - const masterkey = crypto.subtle.importKey('raw', rawKey, VaultKeys.MASTERKEY_KEY_DESIGNATION, true, ['sign']); - return new VaultKeys(await masterkey); - } finally { - rawKey.fill(0x00); - } - } +} - /** - * Unwraps keys protected by the legacy "Vault Admin Password". - * @param vaultAdminPassword Vault Admin Password - * @param wrappedMasterkey The wrapped masterkey - * @param wrappedOwnerPrivateKey The wrapped owner private key - * @param ownerPublicKey The owner public key - * @param salt PBKDF2 Salt - * @param iterations PBKDF2 Iterations - * @returns The unwrapped key material. - * @throws WrongPasswordError, if the wrong password is used - * @deprecated Only used during "claim vault ownership" workflow for legacy vaults - */ - public static async decryptWithAdminPassword(vaultAdminPassword: string, wrappedMasterkey: string, wrappedOwnerPrivateKey: string, ownerPublicKey: string, salt: string, iterations: number): Promise<[VaultKeys, CryptoKeyPair]> { - // pbkdf2: - const encodedPw = UTF8.encode(vaultAdminPassword); - const pwKey = crypto.subtle.importKey('raw', encodedPw, 'PBKDF2', false, ['deriveKey']); - const kek = crypto.subtle.deriveKey( - { - name: 'PBKDF2', - hash: 'SHA-256', - salt: base64nopad.decode(salt) as Uint8Array, - iterations: iterations - }, - await pwKey, - { name: 'AES-GCM', length: 256 }, - false, - ['unwrapKey'] - ); - // unwrapping - const decodedMasterKey = base64.decode(wrappedMasterkey); - const decodedPrivateKey = base64.decode(wrappedOwnerPrivateKey,); - const decodedPublicKey = base64.decode(ownerPublicKey); - try { - const masterkey = await crypto.subtle.unwrapKey( - 'raw', - decodedMasterKey.slice(GCM_NONCE_LEN), - await kek, - { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - const privKey = await crypto.subtle.unwrapKey( - 'pkcs8', - decodedPrivateKey.slice(GCM_NONCE_LEN), - await kek, - { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, - { name: 'ECDSA', namedCurve: 'P-384' }, - false, - ['sign'] - ); - const pubKey = await crypto.subtle.importKey( - 'spki', - decodedPublicKey as Uint8Array, - { name: 'ECDSA', namedCurve: 'P-384' }, - true, - ['verify'] - ); - return [new VaultKeys(masterkey), { privateKey: privKey, publicKey: pubKey }]; - } catch (error) { - throw new UnwrapKeyError(error); - } - } +export interface RecoveryKeyProducing { /** - * Restore the master key from a given recovery key, create a new admin signature key pair. - * @param recoveryKey The recovery key - * @returns The recovered master key - * @throws Error, if passing a malformed recovery key + * Creates a recovery key and pads it to a multiple of 3 bytes (if necessary), so it can be safely encoded using both base64 and the word encoder. + * @return The recovery key as a byte array */ - public static async recover(recoveryKey: string): Promise { - // decode and check recovery key: - const decoded = wordEncoder.decode(recoveryKey); - if (decoded.length !== 66) { - throw new Error('Invalid recovery key length.'); - } - const decodedKey = decoded.subarray(0, 64); - const crc32 = CRC32.compute(decodedKey); - if (decoded[64] !== (crc32 & 0xFF) - || decoded[65] !== (crc32 >> 8 & 0xFF)) { - throw new Error('Invalid recovery key checksum.'); - } + createPaddedRecoveryKeyBytes(): Promise; - // construct new VaultKeys from recovered key - const key = crypto.subtle.importKey( - 'raw', - decodedKey, - VaultKeys.MASTERKEY_KEY_DESIGNATION, - true, - ['sign'] - ); - return new VaultKeys(await key); - } +} - public async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { - const header = JSON.stringify({ - kid: kid, - typ: 'jwt', - alg: 'HS256', - hub: hubConfig - }); - const payloadJson = JSON.stringify(payload); - const unsignedToken = base64urlnopad.encode(UTF8.encode(header)) + '.' + base64urlnopad.encode(UTF8.encode(payloadJson)); - const encodedUnsignedToken = UTF8.encode(unsignedToken); - const signature = await crypto.subtle.sign( - 'HMAC', - this.masterKey, - encodedUnsignedToken - ); - return unsignedToken + '.' + base64urlnopad.encode(new Uint8Array(signature)); - } +/** + * Represents a vault member by their public key. + */ +export class OtherVaultMember { - public async hashDirectoryId(cleartextDirectoryId: string): Promise { - const dirHash = UTF8.encode(cleartextDirectoryId); - const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); - try { - // aes-siv requires mac key first and then the enc key: - const encKey = rawkey.subarray(0, Math.trunc(rawkey.length / 2)); - const macKey = rawkey.subarray(Math.trunc(rawkey.length / 2)); - const shiftedRawKey = new Uint8Array([...macKey, ...encKey]); - const ciphertext = aessiv(shiftedRawKey).encrypt(dirHash); - // hash is only used as deterministic scheme for the root dir - const hash = await crypto.subtle.digest('SHA-1', ciphertext); - return base32.encode(new Uint8Array(hash)); - } finally { - rawkey.fill(0x00); - } - } + protected constructor(readonly publicKey: Promise) { } /** - * Encrypts this masterkey using the given public key - * @param userPublicKey The recipient's public key (DER-encoded) - * @returns a JWE containing this Masterkey + * Creates a new vault member with the given public key + * @param publicKey The public key of the vault member + * @returns A vault member with the given public key */ - public async encryptForUser(userPublicKey: CryptoKey | BufferSource): Promise { - const publicKey = await asPublicKey(userPublicKey, UserKeys.ECDH_KEY_DESIGNATION); - const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); - try { - const payload: JWEPayload = { - key: base64.encode(rawkey) - }; - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); - } finally { - rawkey.fill(0x00); - } + public static withPublicKey(publicKey: CryptoKey | BufferSource): OtherVaultMember { + const keyPromise = asPublicKey(publicKey, UserKeys.ECDH_KEY_DESIGNATION); + return new OtherVaultMember(keyPromise); } /** - * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes + * Creates an access token for this vault member. + * @param payload The payload to encrypt + * @return A ECDH-ES encrypted JWE containing the encrypted payload */ - public async createRecoveryKey(): Promise { - const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); - - // add 16 bit checksum: - const crc32 = CRC32.compute(rawkey); - const checksum = new Uint8Array(2); - checksum[0] = crc32 & 0xff; // append the least significant byte of the crc - checksum[1] = crc32 >> 8 & 0xff; // followed by the second-least significant byte - const combined = new Uint8Array([...rawkey, ...checksum]); - - // encode using human-readable words: - return wordEncoder.encodePadded(combined); + public async createAccessToken(payload: AccessTokenPayload): Promise { + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', await this.publicKey)); + return jwe.compactSerialization(); } } +/** + * The current user's key pair. + */ export class UserKeys { public static readonly ECDH_PRIV_KEY_USAGES: KeyUsage[] = ['deriveBits']; @@ -292,7 +135,7 @@ export class UserKeys { * @throws {UnwrapKeyError} when attempting to decrypt the private key using an incorrect setupCode */ public static async recover(privateKeys: string, setupCode: string, userEcdhPublicKey: CryptoKey | BufferSource, userEcdsaPublicKey?: CryptoKey | BufferSource): Promise { - const jwe: UserKeyPayload = await JWEParser.parse(privateKeys).decryptPbes2(setupCode); + const jwe: UserKeyPayload = await JWE.parseCompact(privateKeys).decrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', setupCode)); return UserKeys.createFromJwe(jwe, userEcdhPublicKey, userEcdsaPublicKey); } @@ -305,7 +148,7 @@ export class UserKeys { * @returns The user's key pair */ public static async decryptOnBrowser(jwe: string, browserPrivateKey: CryptoKey, userEcdhPublicKey: CryptoKey | BufferSource, userEcdsaPublicKey?: CryptoKey | BufferSource): Promise { - const payload: UserKeyPayload = await JWEParser.parse(jwe).decryptEcdhEs(browserPrivateKey); + const payload: UserKeyPayload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', browserPrivateKey)); return UserKeys.createFromJwe(payload, userEcdhPublicKey, userEcdsaPublicKey); } @@ -348,24 +191,37 @@ export class UserKeys { /** * Encrypts the user's private key using a key derived from the given setupCode * @param setupCode The password to protect the private key. + * @param p2c Optional number of iterations for PBKDF2. * @returns A JWE holding the encrypted private key - * @see JWEBuilder.pbes2 + * @see Recipient.pbes2 */ - public async encryptWithSetupCode(setupCode: string, iterations?: number): Promise { + public async encryptWithSetupCode(setupCode: string, p2c?: number): Promise { const payload = await this.prepareForEncryption(); - return await JWEBuilder.pbes2(setupCode, iterations).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.pbes2('org.cryptomator.hub.setupCode', setupCode, p2c)); + return jwe.compactSerialization(); } /** * Encrypts the user's private key using the given public key * @param devicePublicKey The device's public key (DER-encoded) * @returns a JWE containing the PKCS#8-encoded private key - * @see JWEBuilder.ecdhEs + * @see Recipient.ecdhEs */ public async encryptForDevice(devicePublicKey: CryptoKey | Uint8Array): Promise { const publicKey = await asPublicKey(devicePublicKey, BrowserKeys.KEY_DESIGNATION); const payload = await this.prepareForEncryption(); - return JWEBuilder.ecdhEs(publicKey).encrypt(payload); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('org.cryptomator.hub.deviceKey', publicKey)); + return jwe.compactSerialization(); + } + + /** + * Decrypts the access token using the user's ECDH private key + * @param jwe The encrypted access token + * @returns The token's payload + */ + public async decryptAccessToken(jwe: string): Promise { + const payload = await JWE.parseCompact(jwe).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', this.ecdhKeyPair.privateKey)); + return payload; } private async prepareForEncryption(): Promise { @@ -471,6 +327,18 @@ export async function asPublicKey(publicKey: CryptoKey | BufferSource, keyDesign /** * Computes the JWK Thumbprint (RFC 7638) using SHA-256. * @param key A key to compute the thumbprint for + * @returns The thumbprint as a base64url-encoded string + * @throws Error if the key is not supported + */ +export async function getJwkThumbprintStr(key: JsonWebKey | CryptoKey): Promise { + const thumbprint = await getJwkThumbprint(key); + return base64urlnopad.encode(new Uint8Array(thumbprint)); +} + +/** + * Computes the JWK Thumbprint (RFC 7638) using SHA-256. + * @param key A key to compute the thumbprint for + * @returns The thumbprint as a Uint8Array * @throws Error if the key is not supported */ export async function getJwkThumbprint(key: JsonWebKey | CryptoKey): Promise { diff --git a/frontend/src/common/emergencyaccess.ts b/frontend/src/common/emergencyaccess.ts index 7bce449a1..ebf53ec2c 100644 --- a/frontend/src/common/emergencyaccess.ts +++ b/frontend/src/common/emergencyaccess.ts @@ -2,7 +2,7 @@ import { base64 } from '@scure/base'; import { combine, split } from 'shamir-secret-sharing'; import { ActivatedUser } from './backend'; import { asPublicKey, UserKeys } from './crypto'; -import { JWEBuilder, JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; type KeySharePayload = { keyShare: string; @@ -53,8 +53,8 @@ export class EmergencyAccess { }; const keyBytes = base64.decode(recipient.ecdhPublicKey) as Uint8Array; const key = await asPublicKey(keyBytes, UserKeys.ECDH_KEY_DESIGNATION); - const jwe = await JWEBuilder.ecdhEs(key).encrypt(payload); - result[recipient.id] = jwe; + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('', key)); + result[recipient.id] = jwe.compactSerialization(); } return result; } @@ -74,8 +74,8 @@ export class EmergencyAccess { for (const member of councilMembers) { const keyBytes = base64.decode(member.ecdhPublicKey) as Uint8Array; const key = await asPublicKey(keyBytes, UserKeys.ECDH_KEY_DESIGNATION); - const jwe = await JWEBuilder.ecdhEs(key).encrypt(payload); - encryptedPrivateKeys.set(member.id, jwe); + const jwe = await JWE.build(payload).encrypt(Recipient.ecdhEs('', key)); + encryptedPrivateKeys.set(member.id, jwe.compactSerialization()); } // return public key and encrypted private keys: const publicKeyJwk = JSON.stringify(await crypto.subtle.exportKey('jwk', processKeyPair.publicKey)); // TODO: JWK? or SPKI? @@ -93,10 +93,10 @@ export class EmergencyAccess { * @returns A new JWE containing the key share, encrypted for the recovery process. */ public static async recoverShare(share: string, userPrivateKey: CryptoKey, recoveryProcessPublicKey: string): Promise { - const decrypted: KeySharePayload = await JWEParser.parse(share).decryptEcdhEs(userPrivateKey); + const decrypted: KeySharePayload = await JWE.parseCompact(share).decrypt(Recipient.ecdhEs('', userPrivateKey)); const processPublicKeyJwk = JSON.parse(recoveryProcessPublicKey); const processPublicKey = await crypto.subtle.importKey('jwk', processPublicKeyJwk, EmergencyAccess.PROCESS_KEY_DESIGNATION, false, []); - return await JWEBuilder.ecdhEs(processPublicKey).encrypt(decrypted); + return (await JWE.build(decrypted).encrypt(Recipient.ecdhEs('', processPublicKey))).compactSerialization(); } /** @@ -107,9 +107,9 @@ export class EmergencyAccess { * @returns The combined secret as a Uint8Array. */ public static async combineRecoveredShares(recoveredShares: string[], recoveryProcessPrivateKeyJwe: string, userPrivateKey: CryptoKey): Promise { - const jwePayload = await JWEParser.parse(recoveryProcessPrivateKeyJwe).decryptEcdhEs(userPrivateKey); + const jwePayload: ProcessPrivateKeyPayload = await JWE.parseCompact(recoveryProcessPrivateKeyJwe).decrypt(Recipient.ecdhEs('', userPrivateKey)); const recoveryProcessPrivateKey = await crypto.subtle.importKey('jwk', jwePayload.privateKey, EmergencyAccess.PROCESS_KEY_DESIGNATION, false, EmergencyAccess.PROCESS_KEY_USAGE); - const decryptedShares = await Promise.all(recoveredShares.map(share => JWEParser.parse(share).decryptEcdhEs(recoveryProcessPrivateKey))); + const decryptedShares: KeySharePayload[] = await Promise.all(recoveredShares.map(share => JWE.parseCompact(share).decrypt(Recipient.ecdhEs('', recoveryProcessPrivateKey)))); const keyShares = decryptedShares.map(share => base64.decode(share.keyShare) as Uint8Array); return combine(keyShares); } diff --git a/frontend/src/common/jwe.ts b/frontend/src/common/jwe.ts index 42cbbf4d4..be1f98669 100644 --- a/frontend/src/common/jwe.ts +++ b/frontend/src/common/jwe.ts @@ -39,13 +39,31 @@ export class ConcatKDF { } export type JWEHeader = { - readonly alg: 'ECDH-ES' | 'PBES2-HS512+A256KW', - readonly enc: 'A256GCM' | 'A128GCM', - readonly apu?: string, - readonly apv?: string, - readonly epk?: JsonWebKey, - readonly p2c?: number, - readonly p2s?: string + kid?: string, + enc?: 'A256GCM' | 'A128GCM', + alg?: 'ECDH-ES' | 'ECDH-ES+A256KW' | 'PBES2-HS512+A256KW' | 'A256KW', + apu?: string, + apv?: string, + epk?: JsonWebKey, + p2c?: number, + p2s?: string, + jku?: string, + cty?: 'json', + crit?: string[], + [other: string]: undefined | string | number | boolean | object; // allow further properties +}; + +export type JsonJWE = { + protected: string, + recipients: PerRecipientProperties[] + iv: string, + ciphertext: string, + tag: string +}; + +type PerRecipientProperties = { + encrypted_key: string; + header: JWEHeader; }; export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { @@ -53,179 +71,366 @@ export const ECDH_P384: EcKeyImportParams | EcKeyGenParams = { namedCurve: 'P-384' }; -export class JWEParser { +// #region Recipients - readonly header: JWEHeader; - readonly encryptedKey: Uint8Array; - readonly iv: Uint8Array; - readonly ciphertext: Uint8Array; - readonly tag: Uint8Array; +export abstract class Recipient { - private constructor(readonly encodedHeader: string, readonly encodedEncryptedKey: string, readonly encodedIv: string, readonly encodedCiphertext: string, readonly encodedTag: string) { - this.header = JSON.parse(UTF8.decode(base64urlnopad.decode(encodedHeader))); - this.encryptedKey = base64urlnopad.decode(encodedEncryptedKey) as Uint8Array; - this.iv = base64urlnopad.decode(encodedIv) as Uint8Array; - this.ciphertext = base64urlnopad.decode(encodedCiphertext) as Uint8Array; - this.tag = base64urlnopad.decode(encodedTag) as Uint8Array; - } + constructor(readonly kid: string) { }; /** - * Decodes the JWE. - * @param jwe The JWE string - * @returns Decoded JWE, ready to decrypt. + * Encryptes a CEK using the recipient-specific `alg`. + * @param cek The CEK that is used to encrypt a JWE payload. + * @param commonHeader The protected and unprotected header (not per-recipient) + * @returns The encrypted CEK and per-recipient header parameters for the used `alg`. */ - public static parse(jwe: string): JWEParser { - const [encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag] = jwe.split('.', 5); - return new JWEParser(encodedHeader, encodedEncryptedKey, encodedIv, encodedCiphertext, encodedTag); - } + abstract encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise; /** - * Decrypts the JWE, assuming alg == ECDH-ES, enc == A256GCM and keys on the P-384 curve. - * @param recipientPrivateKey The recipient's private key - * @returns Decrypted payload + * Decrypts the CEK using the recipient-specific `alg`. + * @param header JOSE header applicable for this recipient + * @param encryptedKey Encrypted CEK for this recipient + * @returns A non-extractable CEK suitable for decryption with AES-GCM. + * @throws {UnwrapKeyError} if decryption failed */ - public async decryptEcdhEs(recipientPrivateKey: CryptoKey): Promise { - if (this.header.alg != 'ECDH-ES' || this.header.enc != 'A256GCM' || !this.header.epk) { - throw new Error('unsupported alg or enc'); + abstract decrypt(header: JWEHeader, encryptedKey: string): Promise; + + /** + * Create a recipient using `alg: ECDH-ES+A256KW`. Also supports `ECDH-ES` with direct key agreement for decryption + * + * @param kid The key ID used to distinguish multiple recipients + * @param recipientKey The recipients static public key for encryption or private key for decryption + * @param apu Optional information about the creator + * @param apv Optional information about the recipient + * @returns A new recipient + */ + public static ecdhEs(kid: string, recipientKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): Recipient { + if (recipientKey?.type === 'secret' || recipientKey?.algorithm.name !== 'ECDH') { + throw new Error('Unsupported recipient key'); } - const ephemeralKey = await crypto.subtle.importKey('jwk', this.header.epk, ECDH_P384, false, []); - const cek = await ECDH_ES.deriveContentKey(ephemeralKey, recipientPrivateKey, 384, 32, this.header); - return this.decrypt(cek); + return new EcdhRecipient(kid, recipientKey, apu, apv); } /** - * Decrypts the JWE, assuming alg == PBES2-HS512+A256KW and enc == A256GCM. + * Create a recipient using `alg: PBES2-HS512+A256KW`. + * + * @param kid The key ID used to distinguish multiple recipients * @param password The password to feed into the KDF - * @returns Decrypted payload - * @throws {UnwrapKeyError} if decryption failed (wrong password?) + * @param iterations The PBKDF2 iteration count (defaults to {@link PBES2.DEFAULT_ITERATION_COUNT}) - ignored and read from the header's `p2c` value during decryption + * @returns A new recipient + */ + public static pbes2(kid: string, password: string, iterations: number = PBES2.DEFAULT_ITERATION_COUNT): Recipient { + return new Pbes2Recipient(kid, password, iterations); + } + + /** + * Create a recipient using `alg: A256KW`. + * + * @param kid The key ID used to distinguish multiple recipients + * @param wrappingKey The key used to wrap the CEK + * @returns A new recipient */ - public async decryptPbes2(password: string): Promise { - if (this.header.alg != 'PBES2-HS512+A256KW' || /* this.header.enc != 'A256GCM' || */ !this.header.p2s || !this.header.p2c) { - throw new Error('unsupported alg or enc'); + public static a256kw(kid: string, wrappingKey: CryptoKey): Recipient { + if (wrappingKey?.type !== 'secret' || wrappingKey?.algorithm.name !== 'AES-KW') { + throw new Error('Unsupported wrapping key'); + } + return new A256kwRecipient(kid, wrappingKey); + } + +} + +class EcdhRecipient extends Recipient { + + constructor(readonly kid: string, private recipientKey: CryptoKey, private apu: Uint8Array = new Uint8Array(), private apv: Uint8Array = new Uint8Array()) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { + if (this.recipientKey.type !== 'public') { + throw new Error('Recipient public key required.'); + } + const ephemeralKey = await crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); + const header: JWEHeader = { + ...commonHeader, + kid: this.kid, + alg: 'ECDH-ES+A256KW', + epk: await crypto.subtle.exportKey('jwk', ephemeralKey.publicKey), + apu: base64urlnopad.encode(this.apu), + apv: base64urlnopad.encode(this.apv) + }; + const wrappingKey = await ECDH_ES.deriveKey(this.recipientKey, ephemeralKey.privateKey, 384, 32, header, false, { name: 'AES-KW', length: 256 }, ['wrapKey']); + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, wrappingKey, 'AES-KW')); + return { + header: header, + encrypted_key: base64urlnopad.encode(encryptedKey) + }; + } + + async decrypt(header: JWEHeader, encryptedKey: string): Promise { + if (this.recipientKey.type !== 'private') { + throw new Error('Recipient private key required.'); + } + if (header.alg === 'ECDH-ES' && header.epk) { + return this.decryptDirect(header, { name: 'AES-GCM', length: 256 }, ['decrypt']); + } else if (header.alg === 'ECDH-ES+A256KW' && header.epk) { + return this.decryptAndUnwrap(header, encryptedKey); + } else { + throw new Error('Missing or invalid header parameters.'); + } + } + + async decryptDirect(header: JWEHeader, keyAlgorithm: AesKeyAlgorithm, keyUsage: KeyUsage[]): Promise { + let keyBits: number; + switch (header.epk!.crv) { + case 'P-256': + keyBits = 256; + break; + case 'P-384': + keyBits = 384; + break; + default: + throw new Error('Unsupported curve'); } - const saltInput = base64urlnopad.decode(this.header.p2s); - const wrappingKey = await PBES2.deriveWrappingKey(password, this.header.alg, saltInput, this.header.p2c); + const epk = await crypto.subtle.importKey('jwk', header.epk!, { name: 'ECDH', namedCurve: header.epk?.crv }, false, []); + return ECDH_ES.deriveKey(epk, this.recipientKey, keyBits, keyAlgorithm.length / 8, header, false, keyAlgorithm, keyUsage); + } + + async decryptAndUnwrap(header: JWEHeader, encryptedKey: string): Promise { + const wrappingKey = await this.decryptDirect(header, { name: 'AES-KW', length: 256 }, ['unwrapKey']); + try { + return await crypto.subtle.unwrapKey('raw', base64urlnopad.decode(encryptedKey) as Uint8Array, wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); + } catch (error) { + throw new UnwrapKeyError(error); + } + } + +} + +class A256kwRecipient extends Recipient { + + constructor(readonly kid: string, private wrappingKey: CryptoKey) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { + const header: JWEHeader = { + ...commonHeader, + kid: this.kid, + alg: 'A256KW' + }; + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, this.wrappingKey, 'AES-KW')); + return { + header: header, + encrypted_key: base64urlnopad.encode(encryptedKey) + }; + } + + async decrypt(header: JWEHeader, encryptedKey: string): Promise { + if (header.alg != 'A256KW') { + throw new Error('unsupported alg'); + } + try { + return await crypto.subtle.unwrapKey('raw', base64urlnopad.decode(encryptedKey) as Uint8Array, this.wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); + } catch (error) { + throw new UnwrapKeyError(error); + } + } + +} + +class Pbes2Recipient extends Recipient { + + constructor(readonly kid: string, private password: string, private iterations: number) { + super(kid); + } + + async encrypt(cek: CryptoKey, commonHeader: JWEHeader): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const header: JWEHeader = { + ...commonHeader, + kid: this.kid, + alg: 'PBES2-HS512+A256KW', + p2c: this.iterations, + p2s: base64urlnopad.encode(salt) + }; + const wrappingKey = PBES2.deriveWrappingKey(this.password, 'PBES2-HS512+A256KW', salt, this.iterations); + const encryptedKey = new Uint8Array(await crypto.subtle.wrapKey('raw', cek, await wrappingKey, 'AES-KW')); + return { + header: header, + encrypted_key: base64urlnopad.encode(encryptedKey) + }; + } + + async decrypt(header: JWEHeader, encryptedKey: string): Promise { + if (header.alg != 'PBES2-HS512+A256KW' || !header.p2s || !header.p2c) { + throw new Error('Missing or invalid header parameters.'); + } + const salt = base64urlnopad.decode(header.p2s); + const wrappingKey = await PBES2.deriveWrappingKey(this.password, 'PBES2-HS512+A256KW', salt, header.p2c); try { - const cek = crypto.subtle.unwrapKey('raw', this.encryptedKey, wrappingKey, 'AES-KW', { name: 'AES-GCM', length: 256 }, false, ['decrypt']); - return this.decrypt(await cek); + return await crypto.subtle.unwrapKey('raw', base64urlnopad.decode(encryptedKey) as Uint8Array, wrappingKey, 'AES-KW', { name: 'AES-GCM' }, false, ['decrypt']); } catch (error) { throw new UnwrapKeyError(error); } } - private async decrypt(cek: CryptoKey): Promise { - const m = new Uint8Array(this.ciphertext.length + this.tag.length); - m.set(this.ciphertext, 0); - m.set(this.tag, this.ciphertext.length); - const payloadJson = new Uint8Array(await crypto.subtle.decrypt( +} + +// #endregion +// #region JWE + +export class JWE { + + private constructor(private payload: object, private protectedHeader: JWEHeader) { } + + public static build(payload: object, protectedHeader: JWEHeader = {}): JWE { + return new JWE(payload, protectedHeader); + } + + public static parseCompact(token: string): EncryptedJWE { + const [protectedHeader, encryptedKey, iv, ciphertext, tag] = token.split('.', 5); + const utf8 = new TextDecoder(); + const header: JWEHeader = JSON.parse(utf8.decode(base64urlnopad.decode(protectedHeader))); + + return new EncryptedJWE(protectedHeader, [{ encrypted_key: encryptedKey, header: header }], iv, ciphertext, tag); + } + + public static parseJson(jwe: JsonJWE): EncryptedJWE { + if (!jwe.protected || !jwe.recipients || !jwe.iv || !jwe.ciphertext || !jwe.tag) { + throw new Error('Malformed JWE'); + } + return new EncryptedJWE(jwe.protected, jwe.recipients, jwe.iv, jwe.ciphertext, jwe.tag); + } + + public async encrypt(recipient: Recipient, ...moreRecipients: Recipient[]): Promise { + let protectedHeader: JWEHeader = { + ...this.protectedHeader, + enc: 'A256GCM' + }; + const cek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const perRecipientData = await Promise.all([recipient, ...moreRecipients].map(r => r.encrypt(cek, protectedHeader))); + + if (perRecipientData.length === 1) { + protectedHeader = { + ...protectedHeader, + ...perRecipientData[0].header + }; + } else { + protectedHeader.enc = perRecipientData[0].header.enc; + for (const key of Object.keys(protectedHeader)) { + perRecipientData.forEach(r => delete r.header[key]); + } + } + + const encodedProtectedHeader = base64urlnopad.encode(UTF8.encode(JSON.stringify(protectedHeader))); + const m = UTF8.encode(JSON.stringify(this.payload)); + const ciphertextAndTag = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', - iv: this.iv, - additionalData: UTF8.encode(this.encodedHeader), + iv: iv, + additionalData: UTF8.encode(encodedProtectedHeader), tagLength: 128 }, cek, m )); - return JSON.parse(UTF8.decode(payloadJson)); + console.assert(m.byteLength > 16, 'result of GCM encryption expected to contain 128bit tag'); + const ciphertext = ciphertextAndTag.slice(0, m.byteLength - 16); + const tag = ciphertextAndTag.slice(m.byteLength - 16); + + const encodedIv = base64urlnopad.encode(iv); + const encodedCiphertext = base64urlnopad.encode(ciphertext); + const encodedTag = base64urlnopad.encode(tag); + return new EncryptedJWE(encodedProtectedHeader, perRecipientData, encodedIv, encodedCiphertext, encodedTag); } } -export class JWEBuilder { +// visible for testing +export class EncryptedJWE { - private constructor(readonly header: Promise, readonly encryptedKey: Promise, readonly cek: Promise) { } + constructor(private protectedHeader: string, private perRecipient: PerRecipientProperties[], private iv: string, private ciphertext: string, private tag: string) { + if (perRecipient.length < 1) { + throw new Error('Expected at least one recipient.'); + } + } - /** - * Prepares a new JWE using alg: ECDH-ES and enc: A256GCM. - * - * @param recipientPublicKey Static public key of the JWE's recipient - * @param apu Optional information about the creator - * @param apv Optional information about the recipient - * @returns A new JWEBuilder ready to encrypt the payload - */ - public static ecdhEs(recipientPublicKey: CryptoKey, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { - /* key agreement and header params described in RFC 7518, Section 4.6: */ - const ephemeralKey = crypto.subtle.generateKey(ECDH_P384, false, ['deriveBits']); - const header = (async () => { - alg: 'ECDH-ES', - enc: 'A256GCM', - epk: await crypto.subtle.exportKey('jwk', (await ephemeralKey).publicKey), - apu: base64urlnopad.encode(apu), - apv: base64urlnopad.encode(apv) - })(); - const encryptedKey = Promise.resolve(Uint8Array.of()); // empty for Direct Key Agreement as per spec - const cek = (async () => ECDH_ES.deriveContentKey(recipientPublicKey, (await ephemeralKey).privateKey, 384, 32, await header))(); - return new JWEBuilder(header, encryptedKey, cek); + public jsonSerialization(): JsonJWE { + if (this.perRecipient.length < 1) { + throw new Error('JWE JSON Serialization requires at least one recipient.'); + } + const recipients = this.perRecipient.map(r => ({ + header: r.header, + encrypted_key: r.encrypted_key + })); + return { + protected: this.protectedHeader, + recipients: recipients, + iv: this.iv, + ciphertext: this.ciphertext, + tag: this.tag + }; } - /** - * Prepares a new JWE using alg: PBES2-HS512+A256KW and enc: A256GCM. - * - * @param password The password to feed into the KDF - * @param iterations The PBKDF2 iteration count (defaults to {@link PBES2.DEFAULT_ITERATION_COUNT} ) - * @param apu Optional information about the creator - * @param apv Optional information about the recipient - * @returns A new JWEBuilder ready to encrypt the payload - */ - public static pbes2(password: string, iterations: number = PBES2.DEFAULT_ITERATION_COUNT, apu: Uint8Array = new Uint8Array(), apv: Uint8Array = new Uint8Array()): JWEBuilder { - const saltInput = crypto.getRandomValues(new Uint8Array(16)); - const header = (async () => { - alg: 'PBES2-HS512+A256KW', - enc: 'A256GCM', - p2s: base64urlnopad.encode(saltInput), - p2c: iterations, - apu: base64urlnopad.encode(apu), - apv: base64urlnopad.encode(apv) - })(); - const wrappingKey = PBES2.deriveWrappingKey(password, 'PBES2-HS512+A256KW', saltInput, iterations); - const cek = crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); - const encryptedKey = (async () => new Uint8Array(await crypto.subtle.wrapKey('raw', await cek, await wrappingKey, 'AES-KW')))(); - return new JWEBuilder(header, encryptedKey, cek); + public compactSerialization(): string { + if (this.perRecipient.length !== 1) { + throw new Error('JWE Compact Serialization requires exactly one recipient.'); + } + return `${this.protectedHeader}.${this.perRecipient[0].encrypted_key}.${this.iv}.${this.ciphertext}.${this.tag}`; } - /** - * Builds the JWE. - * @param payload Payload to be encrypted - * @returns The JWE - */ - public async encrypt(payload: object) { - /* JWE assembly and content encryption described in RFC 7516: */ - const encodedHeader = base64urlnopad.encode(UTF8.encode(JSON.stringify(await this.header))); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encodedIv = base64urlnopad.encode(iv); - const encodedEncryptedKey = base64urlnopad.encode(await this.encryptedKey); - const m = new Uint8Array(await crypto.subtle.encrypt( + public async decrypt(recipient: Recipient): Promise { + const protectedHeader: JWEHeader = JSON.parse(UTF8.decode(base64urlnopad.decode(this.protectedHeader))); + const perRecipientData = (this.perRecipient.length === 1) + ? this.perRecipient[0] + : this.perRecipientWithKid(recipient.kid); + const combinedHeader: JWEHeader = { ...perRecipientData.header, ...protectedHeader }; + const cek = await recipient.decrypt(combinedHeader, perRecipientData.encrypted_key); + const ciphertext = base64urlnopad.decode(this.ciphertext); + const tag = base64urlnopad.decode(this.tag); + const ciphertextAndTag = new Uint8Array([...ciphertext, ...tag]); + const cleartext = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', - iv: iv, - additionalData: UTF8.encode(encodedHeader), + iv: base64urlnopad.decode(this.iv) as Uint8Array, + additionalData: UTF8.encode(this.protectedHeader), tagLength: 128 }, - await this.cek, - UTF8.encode(JSON.stringify(payload)) + cek, + ciphertextAndTag )); - console.assert(m.byteLength > 16, 'result of GCM encryption expected to contain 128bit tag'); - const ciphertext = m.slice(0, m.byteLength - 16); - const tag = m.slice(m.byteLength - 16); - const encodedCiphertext = base64urlnopad.encode(ciphertext); - const encodedTag = base64urlnopad.encode(tag); - return `${encodedHeader}.${encodedEncryptedKey}.${encodedIv}.${encodedCiphertext}.${encodedTag}`; + return JSON.parse(UTF8.decode(cleartext)); + } + + private perRecipientWithKid(kid: string): PerRecipientProperties { + const result = this.perRecipient.find(r => r.header.kid === kid); + if (result) { + return result; + } else { + throw new Error(`JWE does not contain recipient with kid: ${kid}`); + } } } +// #endregion +// #region Utilities + // visible for testing export class ECDH_ES { - public static async deriveContentKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + private static async deriveRawKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader): Promise> { let agreedKey = new Uint8Array(); - let derivedKey = new Uint8Array(); try { - const algorithmId = ECDH_ES.lengthPrefixed(UTF8.encode(header.enc)); + const algOrEnc = header.alg === 'ECDH-ES' ? header.enc : header.alg; // see definition of AlgorithmID in RFC 7518, Section 4.6.2 + if (!algOrEnc) { + throw new Error('Missing alg or enc header parameter'); + } + const algorithmId = ECDH_ES.lengthPrefixed(UTF8.encode(algOrEnc)); const partyUInfo = ECDH_ES.lengthPrefixed(base64urlnopad.decode(header.apu ?? '')); const partyVInfo = ECDH_ES.lengthPrefixed(base64urlnopad.decode(header.apv ?? '')); const suppPubInfo = new ArrayBuffer(4); + const suppPrivInfo = new Uint8Array(); new DataView(suppPubInfo).setUint32(0, desiredKeyBytes * 8, false); agreedKey = new Uint8Array(await crypto.subtle.deriveBits( { @@ -235,16 +440,32 @@ export class ECDH_ES { privateKey, ecdhKeyBits )); - const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo)]); - derivedKey = await ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); - return crypto.subtle.importKey('raw', derivedKey, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, exportable, ['encrypt', 'decrypt']); + const otherInfo = new Uint8Array([...algorithmId, ...partyUInfo, ...partyVInfo, ...new Uint8Array(suppPubInfo), ...suppPrivInfo]); + return ConcatKDF.kdf(new Uint8Array(agreedKey), desiredKeyBytes, otherInfo); } finally { - derivedKey.fill(0x00); agreedKey.fill(0x00); } } - public static lengthPrefixed(data: Uint8Array): Uint8Array { + public static async deriveKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean, algorithm: AesKeyAlgorithm, usage: KeyUsage[]): Promise { + let derivedKey = new Uint8Array(); + try { + derivedKey = await this.deriveRawKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header); + return crypto.subtle.importKey('raw', derivedKey, algorithm, exportable, usage); + } finally { + derivedKey.fill(0x00); + } + } + + public static async deriveContentKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + return this.deriveKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header, exportable, { name: 'AES-GCM', length: desiredKeyBytes * 8 }, ['encrypt', 'decrypt']); + } + + public static async deriveWrappingKey(publicKey: CryptoKey, privateKey: CryptoKey, ecdhKeyBits: number, desiredKeyBytes: number, header: JWEHeader, exportable: boolean = false): Promise { + return this.deriveKey(publicKey, privateKey, ecdhKeyBits, desiredKeyBytes, header, exportable, { name: 'AES-KW', length: desiredKeyBytes * 8 }, ['wrapKey', 'unwrapKey']); + } + + public static lengthPrefixed(data: Uint8Array): Uint8Array { const result = new Uint8Array(4 + data.byteLength); new DataView(result.buffer, 0, 4).setUint32(0, data.byteLength, false); result.set(data, 4); @@ -259,7 +480,6 @@ export class PBES2 { public static readonly DEFAULT_ITERATION_COUNT = 1000000; private static readonly NULL_BYTE = Uint8Array.of(0x00); - // TODO: can we dedup this with crypto.ts's PBKDF2? Or is the latter unused anyway, once we migrate all ciphertext to JWE containers public static async deriveWrappingKey(password: string, alg: 'PBES2-HS512+A256KW' | 'PBES2-HS256+A128KW', salt: Uint8Array, iterations: number, extractable: boolean = false): Promise { let hash, keyLen; if (alg == 'PBES2-HS512+A256KW') { @@ -297,3 +517,5 @@ export class PBES2 { } } + +// #endregion diff --git a/frontend/src/common/jwt.ts b/frontend/src/common/jwt.ts index 755576c29..b9e8e6858 100644 --- a/frontend/src/common/jwt.ts +++ b/frontend/src/common/jwt.ts @@ -15,6 +15,16 @@ export const ECDSA_P384: EcKeyImportParams | EcKeyGenParams = { export class JWT { + public header: any; + public payload: any; + public signature: Uint8Array; + + private constructor(header: any, payload: any, signature: Uint8Array) { + this.header = header; + this.payload = payload; + this.signature = signature; + } + /** * Creates an ES384 JWT (signed with ECDSA using P-384 and SHA-384). * diff --git a/frontend/src/common/universalVaultFormat.ts b/frontend/src/common/universalVaultFormat.ts new file mode 100644 index 000000000..a528e3196 --- /dev/null +++ b/frontend/src/common/universalVaultFormat.ts @@ -0,0 +1,561 @@ +import { base32, base64, base64urlnopad } from '@scure/base'; +import JSZip from 'jszip'; +import { VaultDto } from './backend'; +import { AccessTokenPayload, AccessTokenProducing, JsonWebKeySet, OtherVaultMember, RecoveryKeyProducing, UserKeys, VaultTemplateProducing, getJwkThumbprintStr } from './crypto'; +import { JWE, JWEHeader, JsonJWE, Recipient } from './jwe'; +import { CRC32, UTF8, wordEncoder } from './util'; + +type MetadataPayload = { + fileFormat: 'AES-256-GCM-32k'; + nameFormat: 'AES-SIV-512-B64URL'; + seeds: Record; + initialSeed: string; + latestSeed: string; + kdf: 'HKDF-SHA512'; + kdfSalt: string; + 'org.cryptomator.automaticAccessGrant': VaultMetadataJWEAutomaticAccessGrantDto; +}; + +type VaultMetadataJWEAutomaticAccessGrantDto = { + enabled: boolean, + /** + * Maximum Web of Trust distance (number of signatures in the trust chain from an existing vault member to a new + * member) up to which access may be granted automatically: + * - `0`: self-signed identities only (no practical use case) + * - `1`: direct trust (an existing member has signed the new member's key directly) + * - `>= 2`: transitive trust (a chain of up to N signatures) + * + * A value of `-1` is reserved for downstream products that disable the trust check entirely. This client neither + * produces nor honors it: such vaults simply receive no automatic grants from here. + */ + maxWotDepth: number +}; + +type UvfAccessTokenPayload = AccessTokenPayload & { + /** + * optional private key of the recovery key pair (PKCS8-encoded; only shared with vault owners) + */ + recoveryKey?: string; +}; + +// #region Member Key +/** + * The AES Key Wrap Key used to encapsulate the UVF Vault Metadata CEK for a vault member. + * This key is encrypted for each vault member individually, using the user's public key. + */ +export class MemberKey { + + public static readonly KEY_DESIGNATION: AesKeyGenParams | AesKeyAlgorithm = { name: 'AES-KW', length: 256 }; + + public static readonly KEY_USAGE: KeyUsage[] = ['wrapKey', 'unwrapKey']; + + protected constructor(readonly key: CryptoKey) { } + + /** + * Creates a new vault member key + * @returns new key + */ + public static async create(): Promise { + const key = await crypto.subtle.generateKey(MemberKey.KEY_DESIGNATION, true, MemberKey.KEY_USAGE); + return new MemberKey(key); + } + + /** + * Creates a new vault member key + * @param encodedKey base64-encoded raw 256 bit key (as retrieved from {@link AccessTokenPayload#key}) + * @returns new key + */ + public static async load(encodedKey: string): Promise { + let rawKey: Uint8Array = new Uint8Array(); + try { + rawKey = base64.decode(encodedKey) as Uint8Array; + const memberKey = await crypto.subtle.importKey('raw', rawKey, MemberKey.KEY_DESIGNATION, true, MemberKey.KEY_USAGE); + return new MemberKey(memberKey); + } finally { + rawKey.fill(0x00); + } + } + + /** + * Encodes the key + * @returns member key in base64-encoded raw format + */ + public async serializeKey(): Promise { + const bytes = await crypto.subtle.exportKey('raw', this.key); + return base64.encode(new Uint8Array(bytes)); + } + +} +// #endregion + +// #region Recovery Key +/** + * The Recovery Key Pair used to encapsulate the UVF Vault Metadata CEK for recovery purposes. + */ +export class RecoveryKey { + + public static readonly KEY_DESIGNATION: EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; + + public static readonly KEY_USAGES: KeyUsage[] = ['deriveKey', 'deriveBits']; + + protected constructor(readonly publicKey: CryptoKey, readonly privateKey?: CryptoKey) { } + + /** + * Creates a new vault member key + * @returns new key + */ + public static async create(): Promise { + const keypair = await crypto.subtle.generateKey(RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + return new RecoveryKey(keypair.publicKey, keypair.privateKey); + } + + /** + * Imports the public key of the recovery key pair. + * @param publicKey the DER-encoded public key + * @param publicKey the PKCS8-encoded private key + * @returns recovery key for encrypting vault metadata + */ + public static async import(publicKey: CryptoKey | Uint8Array, privateKey?: CryptoKey | Uint8Array): Promise { + if (publicKey instanceof Uint8Array) { + publicKey = await crypto.subtle.importKey('spki', publicKey, RecoveryKey.KEY_DESIGNATION, true, []); + } + if (privateKey instanceof Uint8Array) { + privateKey = await crypto.subtle.importKey('pkcs8', privateKey, RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + } + return new RecoveryKey(publicKey, privateKey); + } + + /** + * Restores the Recovery Key Pair + * @param recoveryKey the encoded recovery key + * @returns complete recovery key for decrypting vault metadata + * @throws DecodeUvfRecoveryKeyError, if passing a malformed recovery key + */ + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + let decoded; + try { + decoded = wordEncoder.decode(recoveryKey); + } catch (error) { + throw new DecodeUvfRecoveryKeyError(error instanceof Error ? error.message : 'Internal error. See console log for more info.'); + } + + const paddingLength = decoded[decoded.length - 1]; + if (paddingLength > 0x03) { + throw new DecodeUvfRecoveryKeyError('Invalid padding'); + } + const unpadded = decoded.subarray(0, -paddingLength); + const checksum = unpadded.subarray(-2); + const rawkey = unpadded.slice(0, -2); + const crc32 = CRC32.compute(rawkey); + if (checksum[0] !== (crc32 & 0xFF) + || checksum[1] !== (crc32 >> 8 & 0xFF)) { + throw new DecodeUvfRecoveryKeyError('Invalid recovery key checksum.'); + } + + // construct new RecoveryKey from recovered key + const privateKey = await crypto.subtle.importKey('pkcs8', rawkey, RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + delete jwk.d; // remove private part + const publicKey = await crypto.subtle.importKey('jwk', jwk, RecoveryKey.KEY_DESIGNATION, true, []); + return new RecoveryKey(publicKey, privateKey); + } + + public async createRawRecoveryKey(): Promise { + if (!this.privateKey) { + throw new Error('Private key not available'); + } + const rawkey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey)); + + // add 16 bit checksum: + const crc32 = CRC32.compute(rawkey); + const checksum = new Uint8Array(2); + checksum[0] = crc32 & 0xff; // append the least significant byte of the crc + checksum[1] = crc32 >> 8 & 0xff; // followed by the second-least significant byte + const combined = new Uint8Array([...rawkey, ...checksum]); + + // add 1-3 bytes of padding: + const numPaddingBytes = 3 - (combined.length % 3); + const padding = new Uint8Array(numPaddingBytes); + padding.fill(numPaddingBytes & 0xFF); // 01 or 02 02 or 03 03 03 + + return new Uint8Array([...combined, ...padding]); + } + + /** + * Encodes the private key as a list of words + * @returns private key in a human-readable encoding + */ + public async createRecoveryKey(): Promise { + const recoveryKeyBytes = await this.createRawRecoveryKey(); + + // encode using human-readable words: + return wordEncoder.encodePadded(recoveryKeyBytes); + } + + /** + * Encodes the private key + * @returns private key in base64-encoded DER format + */ + public async serializePrivateKey(): Promise { + if (!this.privateKey) { + throw new Error('Private key not available'); + } + const bytes = await crypto.subtle.exportKey('pkcs8', this.privateKey); + return base64.encode(new Uint8Array(bytes)); + } + + /** + * Encodes the public key + * @returns public key in JWK format + */ + public async serializePublicKey(): Promise { + const jwk = await crypto.subtle.exportKey('jwk', this.publicKey); + const thumbprint = await getJwkThumbprintStr(jwk); + return JSON.stringify({ + kid: `org.cryptomator.hub.recoverykey.${thumbprint}`, + kty: jwk.kty, + crv: jwk.crv, + x: jwk.x, + y: jwk.y + }); + } + +} + +export class DecodeUvfRecoveryKeyError extends Error { + + constructor(message: string) { + super(message); + } + +} + +// #endregion + +// #region Vault metadata +/** + * The UVF Metadata file + */ +export class VaultMetadata { + + private constructor( + public automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto, + readonly seeds: Map>, + readonly initialSeedId: number, + readonly latestSeedId: number, + readonly kdfSalt: Uint8Array) { + if (!seeds.has(initialSeedId)) { + throw new Error('Initial seed is missing'); + } + if (!seeds.has(latestSeedId)) { + throw new Error('Latest seed is missing'); + } + } + + /** + * Creates a new UVF vault + * @param automaticAccessGrant Configuration instructing the client how to automatically deal with permission requests + * @returns new vault + */ + public static async create(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto): Promise { + const initialSeedId = new Uint8Array(4); + const initialSeedValue = new Uint8Array(32); + const kdfSalt = new Uint8Array(32); + crypto.getRandomValues(initialSeedId); + crypto.getRandomValues(initialSeedValue); + crypto.getRandomValues(kdfSalt); + const initialSeedNo = new DataView(initialSeedId.buffer).getInt32(0, false); + const seeds: Map> = new Map>(); + seeds.set(initialSeedNo, initialSeedValue); + return new VaultMetadata(automaticAccessGrant, seeds, initialSeedNo, initialSeedNo, kdfSalt); + } + + public get initialSeed(): Uint8Array { + if (!this.seeds.has(this.initialSeedId)) { + throw new Error('Illegal State'); + } + return this.seeds.get(this.initialSeedId)!; + } + + public get latestSeed(): Uint8Array { + if (!this.seeds.has(this.latestSeedId)) { + throw new Error('Illegal State'); + } + return this.seeds.get(this.latestSeedId)!; + } + + /** + * Decrypts the vault metadata using the members' vault key + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param memberKey the vault members' wrapping key + * @returns Decrypted vault metadata + */ + public static async decryptWithMemberKey(uvfMetadataFile: string, memberKey: MemberKey): Promise { + const json: JsonJWE = JSON.parse(uvfMetadataFile); + const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey.key)); + return VaultMetadata.createFromJson(payload); + } + + /** + * Decrypts the vault metadata using the recovery key + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param recoveryKey the vault's recovery key + * @returns Decrypted vault metadata + */ + public static async decryptWithRecoveryKey(uvfMetadataFile: string, recoveryKey: RecoveryKey): Promise { + if (!recoveryKey.privateKey) { + throw new Error('Recovery key does not have a private key'); + } + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprintStr(recoveryKey.publicKey)}`; + const json: JsonJWE = JSON.parse(uvfMetadataFile); + const payload: MetadataPayload = await JWE.parseJson(json).decrypt(Recipient.ecdhEs(recoveryKeyID, recoveryKey.privateKey)); + return VaultMetadata.createFromJson(payload); + } + + public static async createFromJson(payload: MetadataPayload): Promise { + const seeds = new Map>(); + for (const key in payload.seeds) { + const num = parseSeedId(key); + const value = base64urlnopad.decode(payload.seeds[key]) as Uint8Array; + seeds.set(num, value); + } + const initialSeedId = parseSeedId(payload['initialSeed']); + const latestSeedId = parseSeedId(payload['latestSeed']); + const kdfSalt = base64urlnopad.decode(payload['kdfSalt']) as Uint8Array; + return new VaultMetadata( + payload['org.cryptomator.automaticAccessGrant'], + seeds, + initialSeedId, + latestSeedId, + kdfSalt + ); + } + + /** + * Encrypts the vault metadata + * @param apiURL absolute base URL of the API + * @param vault the corresponding vault + * @param memberKey the vault members' AES wrapping key + * @param recoveryKey the public part of the recovery EC key pair + * @returns `vault.uvf` file contents + */ + public async encrypt(apiURL: string, vault: VaultDto, memberKey: MemberKey, recoveryKey: RecoveryKey): Promise { + const recoveryKeyID = `org.cryptomator.hub.recoverykey.${await getJwkThumbprintStr(recoveryKey.publicKey)}`; + // see https://github.com/encryption-alliance/unified-vault-format/tree/develop/vault%20metadata#jose-header + const protectedHeader: JWEHeader = { + // enc: 'A256GCM', // will be set by JWE.build() + cty: 'json', + crit: ['uvf.spec.version'], + 'uvf.spec.version': 1, + 'cloud.katta.origin': `${apiURL}/vaults/${vault.id}/uvf/vault.uvf`, // single source of truth for this vault + jku: 'jwks.json', // URL relative to cloud.katta.origin + }; + const jwe = await JWE.build(this.payload(), protectedHeader).encrypt(Recipient.a256kw('org.cryptomator.hub.memberkey', memberKey.key), Recipient.ecdhEs(recoveryKeyID, recoveryKey.publicKey)); + const json = jwe.jsonSerialization(); + return JSON.stringify(json); + } + + public payload(): MetadataPayload { + const encodedSeeds: Record = {}; + for (const [key, value] of this.seeds) { + const seedId = stringifySeedId(key); + encodedSeeds[seedId] = base64urlnopad.encode(value); + } + return { + fileFormat: 'AES-256-GCM-32k', + nameFormat: 'AES-SIV-512-B64URL', + seeds: encodedSeeds, + initialSeed: stringifySeedId(this.initialSeedId), + latestSeed: stringifySeedId(this.latestSeedId), + kdf: 'HKDF-SHA512', + kdfSalt: base64urlnopad.encode(this.kdfSalt), + 'org.cryptomator.automaticAccessGrant': this.automaticAccessGrant + }; + } + +} +// #endregion +// #region UVF + +/** + * A UVF-formatted Vault + */ +export class UniversalVaultFormat implements AccessTokenProducing, VaultTemplateProducing, RecoveryKeyProducing { + + private constructor(readonly metadata: VaultMetadata, readonly memberKey: MemberKey, readonly recoveryKey: RecoveryKey) { } + + public static async create(automaticAccessGrant: VaultMetadataJWEAutomaticAccessGrantDto): Promise { + const metadata = await VaultMetadata.create(automaticAccessGrant); + const memberKey = await MemberKey.create(); + const recoveryKey = await RecoveryKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + public static async forTesting(metadata: VaultMetadata) { + const memberKey = await MemberKey.create(); + const recoveryKey = await RecoveryKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + /** + * Decrypts a UVF vault. + * @param vault The vault to decrypt + * @param accessToken The vault member's access token + * @param userKeyPair THe vault member's key pair + * @returns The decrypted vault + */ + public static async decrypt(vault: VaultDto, accessToken: string, userKeyPair: UserKeys): Promise { + if (!vault.uvfMetadataFile || !vault.uvfKeySet) { + throw new Error('Not a UVF vault.'); + } + const jwks = JSON.parse(vault.uvfKeySet) as JsonWebKeySet; + const recoveryPublicKey = await this.getRecoveryPublicKeyFromJwks(jwks); + const payload = await userKeyPair.decryptAccessToken(accessToken) as UvfAccessTokenPayload; + const memberKey = await MemberKey.load(payload.key); + const metadata = await VaultMetadata.decryptWithMemberKey(vault.uvfMetadataFile, memberKey); + let recoveryKey: RecoveryKey; + if (payload.recoveryKey) { + recoveryKey = await RecoveryKey.import(recoveryPublicKey, base64.decode(payload.recoveryKey) as Uint8Array); + } else { + recoveryKey = await RecoveryKey.import(recoveryPublicKey); + } + return new UniversalVaultFormat(metadata, memberKey, recoveryKey); + } + + /** @inheritdoc */ + public async createPaddedRecoveryKeyBytes(): Promise { + return this.recoveryKey.createRawRecoveryKey(); + } + + /** + * Recover the `vault.uvf` file using the recovery key. After recovery, all access tokens need to be re-issued. + * @param uvfMetadataFile contents of the `vault.uvf` file + * @param recoveryKey the vault's recovery key encoded into human-readable words + * @returns The recovered vault + */ + public static async recover(uvfMetadataFile: string, recoveryKey: string): Promise { + const recoveryKeyPair = await RecoveryKey.recover(recoveryKey); + const metadata = await VaultMetadata.decryptWithRecoveryKey(uvfMetadataFile, recoveryKeyPair); + const memberKey = await MemberKey.create(); + return new UniversalVaultFormat(metadata, memberKey, recoveryKeyPair); + } + + private static async getRecoveryPublicKeyFromJwks(jwks: JsonWebKeySet): Promise { + for (const key of jwks.keys) { + if (key.kid?.startsWith('org.cryptomator.hub.recoverykey.')) { + const thumbprint = await getJwkThumbprintStr(key as JsonWebKey); + if (key.kid === `org.cryptomator.hub.recoverykey.${thumbprint}`) { + return await crypto.subtle.importKey('jwk', key as JsonWebKey, RecoveryKey.KEY_DESIGNATION, true, []); + } + } + } + throw new Error('Recovery key not found in JWKS'); + } + + /** + * Creates the `vault.uvf` file + * @param apiURL absolute base URL of the API + * @param vault the vault + * @returns `vault.uvf` file contents + */ + public async createMetadataFile(apiURL: string, vault: VaultDto): Promise { + return this.metadata.encrypt(apiURL, vault, this.memberKey, this.recoveryKey); + } + + public async computeRootDirId(): Promise> { + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveBits']); + const rootDirId = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('rootDirId') }, initialSeed, 256); + return new Uint8Array(rootDirId); + } + + public async computeRootDirIdHash(rootDirId: Uint8Array): Promise { + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveKey']); + const hmacKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('hmac') }, initialSeed, { name: 'HMAC', hash: 'SHA-256', length: 512 }, false, ['sign']); + const rootDirHash = await crypto.subtle.sign('HMAC', hmacKey, rootDirId); + return base32.encode(new Uint8Array(rootDirHash).slice(0, 20)); + } + + public async encryptFile(content: Uint8Array, seedId: number): Promise { + const seed = this.metadata.seeds.get(seedId); + if (!seed) { + throw new Error('Seed not found'); + } + if (content.length > 32 * 1024) { + throw new Error('Only files up to 32k are supported.'); + } + const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); + + // general header: + const generalHeader = new ArrayBuffer(8); + const view = new DataView(generalHeader); + view.setUint32(0, 0x75766601); // magic bytes "uvf1" + view.setUint32(4, seedId); + + // format-specific header: + const initialSeed = await crypto.subtle.importKey('raw', this.metadata.initialSeed, { name: 'HKDF' }, false, ['deriveKey']); + const headerKey = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-512', salt: this.metadata.kdfSalt, info: UTF8.encode('fileHeader') }, initialSeed, { name: 'AES-GCM', length: 256 }, false, ['wrapKey']); + const headerNonce = new Uint8Array(12); + crypto.getRandomValues(headerNonce); + const encryptedFileKeyAndTag = await crypto.subtle.wrapKey('raw', fileKey, headerKey, { name: 'AES-GCM', iv: headerNonce, additionalData: generalHeader }); + + // complete header: + const header = new Uint8Array([...new Uint8Array(generalHeader), ...headerNonce, ...new Uint8Array(encryptedFileKeyAndTag)]); + + // encrypt chunk 0: + const blockNonce = new Uint8Array(12); + crypto.getRandomValues(blockNonce); + const blockAd = new Uint8Array([0x00, 0x00, 0x00, 0x00, ...headerNonce]); + const blockCiphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: blockNonce, additionalData: blockAd }, fileKey, content); + + // result: + return new Uint8Array([...header, ...blockNonce, ...new Uint8Array(blockCiphertext)]); + } + + /** @inheritdoc */ + public async exportTemplate(apiURL: string, vault: VaultDto): Promise { + const rootDirId = await this.computeRootDirId(); + const rootDirHash = await this.computeRootDirIdHash(rootDirId); + const dirFile = await this.encryptFile(rootDirId, this.metadata.initialSeedId); + const zip = new JSZip(); + zip.file('vault.uvf', this.createMetadataFile(apiURL, vault)); + const rootDir = zip.folder('d')?.folder(rootDirHash.substring(0, 2))?.folder(rootDirHash.substring(2)); // TODO verify after merging https://github.com/encryption-alliance/unified-vault-format/pull/24 + rootDir?.file('dir.uvf', dirFile); + return zip.generateAsync({ type: 'blob' }); + } + + /** @inheritdoc */ + public async encryptForUser(userPublicKey: CryptoKey | Uint8Array, isOwner?: boolean): Promise { + const payload: UvfAccessTokenPayload = { + key: await this.memberKey.serializeKey(), + recoveryKey: isOwner && this.recoveryKey.privateKey ? await this.recoveryKey.serializePrivateKey() : undefined + }; + return OtherVaultMember.withPublicKey(userPublicKey).createAccessToken(payload); + } + +} + +/** + * Decodes a base64url-encoded 32 bit big endian number. + * @param encoded base64url-encoded seed ID + * @returns a 32 bit number + * @throws Error if the input is invalid + */ +function parseSeedId(encoded: string): number { + const bytes = base64urlnopad.decode(encoded); + if (bytes.length != 4) { + throw new Error('Malformed seed ID'); + } + return new DataView(bytes.buffer).getInt32(0, false); +} + +/** + * Encodes the seed ID as a 32 bit big endian integer and base64url-encodes it. + * @param id numeric seed ID + * @returns a base6url-encoded seed ID + */ +function stringifySeedId(id: number): string { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, id, false); + return base64urlnopad.encode(bytes); +} diff --git a/frontend/src/common/userdata.ts b/frontend/src/common/userdata.ts index 2112754c5..1649eea24 100644 --- a/frontend/src/common/userdata.ts +++ b/frontend/src/common/userdata.ts @@ -1,7 +1,7 @@ import { base64 } from '@scure/base'; import backend, { DeviceDto, UserDto } from './backend'; import { BrowserKeys, UserKeys } from './crypto'; -import { JWEParser } from './jwe'; +import { JWE, Recipient } from './jwe'; class UserData { @@ -139,6 +139,16 @@ class UserData { return userKeys; } + public async decryptSetupCode(userKeys: UserKeys): Promise { + const me = await this.me; + if (me.setupCode) { + const payload: { setupCode: string } = await JWE.parseCompact(me.setupCode).decrypt(Recipient.ecdhEs('org.cryptomator.hub.userkey', userKeys.ecdhKeyPair.privateKey)); + return payload.setupCode; + } else { + throw new Error('User not set up yet.'); + } + } + /** * Updates the stored user keys, if the ECDSA key was missing before (added in 1.4.0) * @param userKeys The user keys that contain the ECDSA key @@ -146,9 +156,9 @@ class UserData { private async addEcdsaKeyIfMissing(userKeys: UserKeys) { const me = await this.me; if (me.setupCode && !me.ecdsaPublicKey) { - const payload: { setupCode: string } = await JWEParser.parse(me.setupCode).decryptEcdhEs(userKeys.ecdhKeyPair.privateKey); + const setupCode = await this.decryptSetupCode(userKeys); me.ecdsaPublicKey = await userKeys.encodedEcdsaPublicKey(); - me.privateKeys = await userKeys.encryptWithSetupCode(payload.setupCode); + me.privateKeys = await userKeys.encryptWithSetupCode(setupCode); for (const device of me.devices) { device.userPrivateKey = await userKeys.encryptForDevice(base64.decode(device.publicKey) as Uint8Array); } diff --git a/frontend/src/common/vaultFormat8.ts b/frontend/src/common/vaultFormat8.ts new file mode 100644 index 000000000..0edfe8767 --- /dev/null +++ b/frontend/src/common/vaultFormat8.ts @@ -0,0 +1,318 @@ +import { aessiv } from '@noble/ciphers/aes.js'; +import { base32, base64, base64nopad, base64urlnopad } from '@scure/base'; +import JSZip from 'jszip'; +import { VaultDto } from './backend'; +import config, { absFrontendBaseURL } from './config'; +import { AccessTokenProducing, GCM_NONCE_LEN, OtherVaultMember, RecoveryKeyProducing, UnwrapKeyError, UserKeys, VaultTemplateProducing } from './crypto'; +import { CRC32, UTF8, wordEncoder } from './util'; + +interface VaultConfigPayload { + jti: string + format: number + cipherCombo: string + shorteningThreshold: number +} + +interface VaultConfigHeaderHub { + clientId: string + authEndpoint: string + tokenEndpoint: string + authSuccessUrl: string + authErrorUrl: string + apiBaseUrl: string + // deprecated: + devicesResourceUrl: string +} + +export class VaultFormat8 implements AccessTokenProducing, VaultTemplateProducing, RecoveryKeyProducing { + + // in this browser application, this 512 bit key is used + // as a hmac key to sign the vault config. + // however when used by cryptomator, it gets split into + // a 256 bit encryption key and a 256 bit mac key + private static readonly MASTERKEY_KEY_DESIGNATION: HmacImportParams | HmacKeyGenParams = { + name: 'HMAC', + hash: 'SHA-256', + length: 512 + }; + + readonly masterKey: CryptoKey; + + protected constructor(masterKey: CryptoKey) { + this.masterKey = masterKey; + } + + /** + * Creates a new masterkey + * @returns A new masterkey + */ + public static async create(): Promise { + const key = crypto.subtle.generateKey( + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultFormat8(await key); + } + + /** + * Decrypts the vault's masterkey using the user's private key + * @param jwe JWE containing the vault key + * @param userKeyPair The current user's key pair + * @returns The masterkey + */ + public static async decryptWithUserKey(jwe: string, userKeyPair: UserKeys): Promise { + let rawKey = new Uint8Array(); + try { + const payload = await userKeyPair.decryptAccessToken(jwe); + rawKey = base64.decode(payload.key) as Uint8Array; + const masterKey = crypto.subtle.importKey('raw', rawKey, VaultFormat8.MASTERKEY_KEY_DESIGNATION, true, ['sign']); + return new VaultFormat8(await masterKey); + } finally { + rawKey.fill(0x00); + } + } + + /** + * Unwraps keys protected by the legacy "Vault Admin Password". + * @param vaultAdminPassword Vault Admin Password + * @param wrappedMasterkey The wrapped masterkey + * @param wrappedOwnerPrivateKey The wrapped owner private key + * @param ownerPublicKey The owner public key + * @param salt PBKDF2 Salt + * @param iterations PBKDF2 Iterations + * @returns The unwrapped key material. + * @throws WrongPasswordError, if the wrong password is used + * @deprecated Only used during "claim vault ownership" workflow for legacy vaults + */ + public static async decryptWithAdminPassword(vaultAdminPassword: string, wrappedMasterkey: string, wrappedOwnerPrivateKey: string, ownerPublicKey: string, salt: string, iterations: number): Promise<[VaultFormat8, CryptoKeyPair]> { + // pbkdf2: + const encodedPw = UTF8.encode(vaultAdminPassword); + const pwKey = crypto.subtle.importKey('raw', encodedPw, 'PBKDF2', false, ['deriveKey']); + const kek = crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: base64nopad.decode(salt) as Uint8Array, + iterations: iterations + }, + await pwKey, + { name: 'AES-GCM', length: 256 }, + false, + ['unwrapKey'] + ); + // unwrapping + const decodedMasterKey = base64.decode(wrappedMasterkey); + const decodedPrivateKey = base64.decode(wrappedOwnerPrivateKey); + const decodedPublicKey = base64.decode(ownerPublicKey); + try { + const masterkey = await crypto.subtle.unwrapKey( + 'raw', + decodedMasterKey.slice(GCM_NONCE_LEN), + await kek, + { name: 'AES-GCM', iv: decodedMasterKey.slice(0, GCM_NONCE_LEN) }, + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + const privKey = await crypto.subtle.unwrapKey( + 'pkcs8', + decodedPrivateKey.slice(GCM_NONCE_LEN), + await kek, + { name: 'AES-GCM', iv: decodedPrivateKey.slice(0, GCM_NONCE_LEN) }, + { name: 'ECDSA', namedCurve: 'P-384' }, + false, + ['sign'] + ); + const pubKey = await crypto.subtle.importKey( + 'spki', + decodedPublicKey as Uint8Array, + { name: 'ECDSA', namedCurve: 'P-384' }, + true, + ['verify'] + ); + return [new VaultFormat8(masterkey), { privateKey: privKey, publicKey: pubKey }]; + } catch (error) { + throw new UnwrapKeyError(error); + } + } + + /** + * Restore the master key from a given recovery key and verify the masterkey matches with the given vault config. On success, creates a new admin signature key pair. + * @param vaultMetadataToken Content of the alleged matching vault metadata file vault.cryptomator + * @param recoveryKey The recovery key + * @returns The recovered master key + * @throws Error, if passing a malformed recovery key or the recovery key does not match with the vault metadata + */ + public static async recoverAndVerify(vaultMetadataToken: string, recoveryKey: string) { + const vault = await this.recover(recoveryKey); + + const sigSeparatorIndex = vaultMetadataToken.lastIndexOf('.'); + const headerPlusPayload = vaultMetadataToken.slice(0, sigSeparatorIndex); + const signature = vaultMetadataToken.slice(sigSeparatorIndex + 1, vaultMetadataToken.length); + const message = UTF8.encode(headerPlusPayload); + const digest = await crypto.subtle.sign( + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + vault.masterKey, + message + ); + const base64urlDigest = base64urlnopad.encode(new Uint8Array(digest)); + if (!(signature === base64urlDigest)) { + throw new Error('Recovery key does not match vault file.'); + } + + return vault; + } + + /** + * Restore the master key from a given recovery key, create a new admin signature key pair. + * @param recoveryKey The recovery key + * @returns The recovered master key + * @throws DecodeVf8RecoveryKeyError, if passing a malformed recovery key + */ + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + let decoded; + try { + decoded = wordEncoder.decode(recoveryKey); + } catch (error) { + throw new DecodeVf8RecoveryKeyError(error instanceof Error ? error.message : 'Internal error. See console log for more info.'); + } + + if (decoded.length !== 66) { + throw new DecodeVf8RecoveryKeyError('Invalid recovery key length.'); + } + const decodedKey = decoded.subarray(0, 64); + const crc32 = CRC32.compute(decodedKey); + if (decoded[64] !== (crc32 & 0xFF) + || decoded[65] !== (crc32 >> 8 & 0xFF)) { + throw new DecodeVf8RecoveryKeyError('Invalid recovery key checksum.'); + } + + // construct new VaultKeys from recovered key + const key = crypto.subtle.importKey( + 'raw', + decodedKey, + VaultFormat8.MASTERKEY_KEY_DESIGNATION, + true, + ['sign'] + ); + return new VaultFormat8(await key); + } + + /** @inheritdoc */ + public async exportTemplate(apiURL: string, vault: VaultDto): Promise { + const cfg = config.get(); + + const kid = `hub+${apiURL}vaults/${vault.id}`; + + const hubConfig: VaultConfigHeaderHub = { + clientId: cfg.keycloakClientIdCryptomator, + authEndpoint: cfg.keycloakAuthEndpoint, + tokenEndpoint: cfg.keycloakTokenEndpoint, + authSuccessUrl: `${absFrontendBaseURL}unlock-success?vault=${vault.id}`, + authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vault.id}`, + apiBaseUrl: apiURL, + devicesResourceUrl: `${apiURL}devices/`, + }; + + const jwtPayload: VaultConfigPayload = { + jti: vault.id, + format: 8, + cipherCombo: 'SIV_GCM', + shorteningThreshold: 220 + }; + + const vaultConfigToken = await this.createVaultConfig(kid, hubConfig, jwtPayload); + const rootDirHash = await this.hashDirectoryId(''); + + const zip = new JSZip(); + zip.file('vault.cryptomator', vaultConfigToken); + zip.folder('d')?.folder(rootDirHash.substring(0, 2))?.folder(rootDirHash.substring(2)); + return zip.generateAsync({ type: 'blob' }); + } + + private async createVaultConfig(kid: string, hubConfig: VaultConfigHeaderHub, payload: VaultConfigPayload): Promise { + const header = JSON.stringify({ + kid: kid, + typ: 'jwt', + alg: 'HS256', + hub: hubConfig + }); + const payloadJson = JSON.stringify(payload); + const unsignedToken = base64urlnopad.encode(UTF8.encode(header)) + '.' + base64urlnopad.encode(UTF8.encode(payloadJson)); + const encodedUnsignedToken = UTF8.encode(unsignedToken); + const signature = await crypto.subtle.sign( + 'HMAC', + this.masterKey, + encodedUnsignedToken + ); + return unsignedToken + '.' + base64urlnopad.encode(new Uint8Array(signature)); + } + + // visible for testing + public async hashDirectoryId(cleartextDirectoryId: string): Promise { + const dirHash = UTF8.encode(cleartextDirectoryId); + const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey)); + try { + // aes-siv requires mac key first and then the enc key: + const encKey = rawkey.subarray(0, rawkey.length / 2 | 0); + const macKey = rawkey.subarray(rawkey.length / 2 | 0); + const shiftedRawKey = new Uint8Array([...macKey, ...encKey]); + const ciphertext = aessiv(shiftedRawKey).encrypt(dirHash) as Uint8Array; + // hash is only used as deterministic scheme for the root dir + const hash = await crypto.subtle.digest('SHA-1', ciphertext); + return base32.encode(new Uint8Array(hash)); + } finally { + rawkey.fill(0x00); + } + } + + /** + * Encodes the key masterkey + * @returns master key in base64-encoded raw format + */ + public async serializeMasterKey(): Promise { + const bytes = await crypto.subtle.exportKey('raw', this.masterKey); + return base64.encode(new Uint8Array(bytes)); + } + + /** @inheritdoc */ + public async encryptForUser(userPublicKey: CryptoKey | BufferSource): Promise { + return OtherVaultMember.withPublicKey(userPublicKey).createAccessToken({ + key: await this.serializeMasterKey(), + }); + } + + /** @inheritdoc */ + public async createPaddedRecoveryKeyBytes(): Promise { + const rawkey = new Uint8Array(await crypto.subtle.exportKey('raw', this.masterKey));; + + // add 16 bit checksum: + const crc32 = CRC32.compute(rawkey); + const checksum = new Uint8Array(2); + checksum[0] = crc32 & 0xff; // append the least significant byte of the crc + checksum[1] = crc32 >> 8 & 0xff; // followed by the second-least significant byte + + return new Uint8Array([...rawkey, ...checksum]); + } + + /** + * Encode masterkey for offline backup purposes, allowing re-importing the key for recovery purposes + */ + public async createRecoveryKey(): Promise { + const recoveryKeyBytes = await this.createPaddedRecoveryKeyBytes(); + + // encode using human-readable words: + return wordEncoder.encodePadded(recoveryKeyBytes); + } + +} + +export class DecodeVf8RecoveryKeyError extends Error { + + constructor(message: string) { + super(message); + } + +} diff --git a/frontend/src/common/vaultKeys.ts b/frontend/src/common/vaultKeys.ts new file mode 100644 index 000000000..7e1bb836f --- /dev/null +++ b/frontend/src/common/vaultKeys.ts @@ -0,0 +1,31 @@ +import backend, { VaultDto } from './backend'; +import { AccessTokenProducing } from './crypto'; +import { UniversalVaultFormat } from './universalVaultFormat'; +import { VaultFormat8 } from './vaultFormat8'; +import userdata from './userdata'; + +/** + * Fetches the current user's access token for the given vault and decrypts it into the vault keys, transparently + * handling both the Universal Vault Format and the legacy Vault Format 8 (chosen by the presence of a UVF metadata + * file on the vault). The returned object can wrap the vault key for other users via + * {@link AccessTokenProducing#encryptForUser}. + * + * Requires the current user to be a member of the vault (otherwise the access token request is rejected) and to have + * unlocked their user keys in this browser. + * + * @param vault the vault whose keys to unwrap + * @returns the decrypted vault keys + */ +export async function unwrapVaultKeys(vault: VaultDto & { uvfMetadataFile: string }): Promise; +export async function unwrapVaultKeys(vault: VaultDto & { uvfMetadataFile: undefined }): Promise; +export async function unwrapVaultKeys(vault: VaultDto): Promise; +export async function unwrapVaultKeys(vault: VaultDto): Promise { + const deviceId = await (await userdata.browserKeys)?.id(); + const accessToken = await backend.vaults.accessToken(vault.id, deviceId, true); + const userKeys = await userdata.decryptUserKeysWithBrowser(); + if (vault.uvfMetadataFile) { + return UniversalVaultFormat.decrypt(vault, accessToken, userKeys); + } else { + return VaultFormat8.decryptWithUserKey(accessToken, userKeys); + } +} diff --git a/frontend/src/common/vaultconfig.ts b/frontend/src/common/vaultconfig.ts deleted file mode 100644 index d266be46d..000000000 --- a/frontend/src/common/vaultconfig.ts +++ /dev/null @@ -1,49 +0,0 @@ -import JSZip from 'jszip'; -import config, { absBackendBaseURL, absFrontendBaseURL } from '../common/config'; -import { VaultConfigHeaderHub, VaultConfigPayload, VaultKeys } from '../common/crypto'; - -export class VaultConfig { - - readonly vaultConfigToken: string; - private readonly rootDirHash: string; - - private constructor(vaultConfigToken: string, rootDirHash: string) { - this.vaultConfigToken = vaultConfigToken; - this.rootDirHash = rootDirHash; - } - - public static async create(vaultId: string, vaultKeys: VaultKeys): Promise { - const cfg = config.get(); - - const kid = `hub+${absBackendBaseURL}vaults/${vaultId}`; - - const hubConfig: VaultConfigHeaderHub = { - clientId: cfg.keycloakClientIdCryptomator, - authEndpoint: cfg.keycloakAuthEndpoint, - tokenEndpoint: cfg.keycloakTokenEndpoint, - authSuccessUrl: `${absFrontendBaseURL}unlock-success?vault=${vaultId}`, - authErrorUrl: `${absFrontendBaseURL}unlock-error?vault=${vaultId}`, - apiBaseUrl: absBackendBaseURL, - devicesResourceUrl: `${absBackendBaseURL}devices/`, - }; - - const jwtPayload: VaultConfigPayload = { - jti: vaultId, - format: 8, - cipherCombo: 'SIV_GCM', - shorteningThreshold: 220 - }; - - const vaultConfigToken = await vaultKeys.createVaultConfig(kid, hubConfig, jwtPayload); - const rootDirHash = await vaultKeys.hashDirectoryId(''); - return new VaultConfig(vaultConfigToken, rootDirHash); - } - - public async exportTemplate(): Promise { - const zip = new JSZip(); - zip.file('vault.cryptomator', this.vaultConfigToken); - zip.folder('d')?.folder(this.rootDirHash.substring(0, 2))?.folder(this.rootDirHash.substring(2)); - return zip.generateAsync({ type: 'blob' }); - } - -} diff --git a/frontend/src/components/AdminSettings.vue b/frontend/src/components/AdminSettings.vue index 1c4edfc11..10040c9cb 100644 --- a/frontend/src/components/AdminSettings.vue +++ b/frontend/src/components/AdminSettings.vue @@ -1,5 +1,5 @@