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 f9d85a90..0d9afd5c 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java @@ -17,6 +17,7 @@ import org.cryptomator.hub.entities.events.AuditEvent; import org.cryptomator.hub.entities.events.DeviceRegisteredEvent; import org.cryptomator.hub.entities.events.DeviceRemovedEvent; +import org.cryptomator.hub.entities.events.SignedWotIdEvent; import org.cryptomator.hub.entities.events.VaultAccessGrantedEvent; import org.cryptomator.hub.entities.events.VaultCreatedEvent; import org.cryptomator.hub.entities.events.VaultKeyRetrievedEvent; @@ -80,6 +81,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 = SignedWotIdEvent.class, name = SignedWotIdEvent.TYPE), // @JsonSubTypes.Type(value = VaultCreatedEventDto.class, name = VaultCreatedEvent.TYPE), // @JsonSubTypes.Type(value = VaultUpdatedEventDto.class, name = VaultUpdatedEvent.TYPE), // @JsonSubTypes.Type(value = VaultAccessGrantedEventDto.class, name = VaultAccessGrantedEvent.TYPE), // @@ -101,6 +103,7 @@ static AuditEventDto fromEntity(AuditEvent entity) { return switch (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 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()); @@ -121,6 +124,9 @@ record DeviceRegisteredEventDto(long id, Instant timestamp, String type, @JsonPr record DeviceRemovedEventDto(long id, Instant timestamp, String type, @JsonProperty("removedBy") String removedBy, @JsonProperty("deviceId") String deviceId) implements AuditEventDto { } + record SignedWotIdEventDto(long id, Instant timestamp, String type, @JsonProperty("userId") String userId, @JsonProperty("signerId") String signerId, @JsonProperty("signerKey") String signerKey, @JsonProperty("signature") String signature) implements AuditEventDto { + } + record VaultCreatedEventDto(long id, Instant timestamp, String type, @JsonProperty("createdBy") String createdBy, @JsonProperty("vaultId") UUID vaultId, @JsonProperty("vaultName") String vaultName, @JsonProperty("vaultDescription") String vaultDescription) implements AuditEventDto { } diff --git a/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java b/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java index bcae87d4..98ae5883 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/MemberDto.java @@ -7,20 +7,26 @@ public final class MemberDto extends AuthorityDto { + @JsonProperty("ecdhPublicKey") + public final String ecdhPublicKey; + @JsonProperty("ecdsaPublicKey") + public final String ecdsaPublicKey; @JsonProperty("role") public final VaultAccess.Role role; - MemberDto(@JsonProperty("id") String id, @JsonProperty("type") AuthorityDto.Type type, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("role") VaultAccess.Role role) { + MemberDto(@JsonProperty("id") String id, @JsonProperty("type") Type type, @JsonProperty("name") String name, @JsonProperty("pictureUrl") String pictureUrl, @JsonProperty("ecdhPublicKey") String ecdhPublicKey, @JsonProperty("ecdsaPublicKey") String ecdsaPublicKey, @JsonProperty("role") VaultAccess.Role role) { super(id, type, name, pictureUrl); + this.ecdhPublicKey = ecdhPublicKey; + this.ecdsaPublicKey = ecdsaPublicKey; this.role = role; } public static MemberDto fromEntity(User user, VaultAccess.Role role) { - return new MemberDto(user.getId(), Type.USER, user.getName(), user.getPictureUrl(), role); + return new MemberDto(user.getId(), Type.USER, user.getName(), user.getPictureUrl(), user.getEcdhPublicKey(), user.getEcdsaPublicKey(), role); } public static MemberDto fromEntity(Group group, VaultAccess.Role role) { - return new MemberDto(group.getId(), Type.GROUP, group.getName(), null, role); + return new MemberDto(group.getId(), Type.GROUP, group.getName(), null, null, null, role); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java b/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java new file mode 100644 index 00000000..2e5735c0 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/api/SettingsResource.java @@ -0,0 +1,62 @@ +package org.cryptomator.hub.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.cryptomator.hub.entities.Settings; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; + +@Path("/settings") +public class SettingsResource { + + @Inject + Settings.Repository settingsRepo; + + @GET + @RolesAllowed("user") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get the billing information") + @APIResponse(responseCode = "200") + @Transactional + public SettingsDto get() { + return SettingsDto.fromEntity(settingsRepo.get()); + } + + @PUT + @RolesAllowed("admin") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "update settings") + @APIResponse(responseCode = "204", description = "token set") + @APIResponse(responseCode = "400", description = "invalid settings") + @APIResponse(responseCode = "403", description = "only admins are allowed to update settings") + @Transactional + public Response put(@NotNull @Valid SettingsDto dto) { + var settings = settingsRepo.get(); + settings.setWotMaxDepth(dto.wotMaxDepth); + settings.setWotIdVerifyLen(dto.wotIdVerifyLen); + settingsRepo.persist(settings); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + public record SettingsDto(@JsonProperty("hubId") String hubId, @JsonProperty("wotMaxDepth") @Min(0) @Max(9) int wotMaxDepth, @JsonProperty("wotIdVerifyLen") @Min(0) int wotIdVerifyLen) { + + public static SettingsDto fromEntity(Settings entity) { + return new SettingsDto(entity.getHubId(), entity.getWotMaxDepth(), entity.getWotIdVerifyLen()); + } + + } + +} diff --git a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java index 14542f00..5ede84d2 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UserDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UserDto.java @@ -23,7 +23,11 @@ public final class UserDto extends AuthorityDto { @JsonProperty("setupCode") public final String setupCode; - @Deprecated + /** + * Same as {@link #ecdhPublicKey}, kept for compatibility purposes + * @deprecated to be removed when all clients moved to the new DTO field names + */ + @Deprecated(forRemoval = true) @JsonProperty("publicKey") public final String legacyEcdhPublicKey; 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 0b200057..d42e73c3 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/UsersResource.java @@ -1,5 +1,6 @@ package org.cryptomator.hub.api; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.Nullable; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; @@ -8,17 +9,21 @@ import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.cryptomator.hub.entities.AccessToken; import org.cryptomator.hub.entities.Device; +import org.cryptomator.hub.entities.EffectiveWot; import org.cryptomator.hub.entities.User; import org.cryptomator.hub.entities.Vault; +import org.cryptomator.hub.entities.WotEntry; import org.cryptomator.hub.entities.events.EventLogger; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -48,6 +53,10 @@ public class UsersResource { Device.Repository deviceRepo; @Inject Vault.Repository vaultRepo; + @Inject + WotEntry.Repository wotRepo; + @Inject + EffectiveWot.Repository effectiveWotRepo; @Inject JsonWebToken jwt; @@ -168,7 +177,63 @@ public Response resetMe() { @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "list all users") public List getAll() { - return userRepo.findAll().stream().map(UserDto::justPublicInfo).toList(); + return userRepo.findAll().stream().map(UserDto::justPublicInfo).toList(); } + @PUT + @Path("/trusted/{userId}") + @RolesAllowed("user") + @Transactional + @Consumes(MediaType.TEXT_PLAIN) + @Operation(summary = "adds/updates trust", description = "Stores a signature for the given user.") + @APIResponse(responseCode = "204", description = "signature stored") + public Response putSignature(@PathParam("userId") String userId, @NotNull String signature) { + var signer = userRepo.findById(jwt.getSubject()); + var id = new WotEntry.Id(); + id.setUserId(userId); + id.setSignerId(signer.getId()); + var entry = wotRepo.findById(id); + if (entry == null) { + entry = new WotEntry(); + entry.setId(id); + } + entry.setSignature(signature); + wotRepo.persist(entry); + eventLogger.logWotIdSigned(userId, signer.getId(), signer.getEcdsaPublicKey(), signature); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @GET + @Path("/trusted/{userId}") + @RolesAllowed("user") + @NoCache + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get trust detail for given user", description = "returns the shortest found signature chain for the given user") + @APIResponse(responseCode = "200") + @APIResponse(responseCode = "404", description = "if no sufficiently short trust chain between the invoking user and the user with the given id has been found") + public TrustedUserDto getTrustedUser(@PathParam("userId") String trustedUserId) { + var trustingUserId = jwt.getSubject(); + return effectiveWotRepo.findTrusted(trustingUserId, trustedUserId).singleResultOptional().map(TrustedUserDto::fromEntity).orElseThrow(NotFoundException::new); + } + + @GET + @Path("/trusted") + @RolesAllowed("user") + @NoCache + @Transactional + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "get trusted users", description = "returns a list of users trusted by the currently logged-in user") + @APIResponse(responseCode = "200") + public List getTrustedUsers() { + var trustingUserId = jwt.getSubject(); + return effectiveWotRepo.findTrusted(trustingUserId).stream().map(TrustedUserDto::fromEntity).toList(); + } + + public record TrustedUserDto(@JsonProperty("trustedUserId") String trustedUserId, @JsonProperty("signatureChain") List signatureChain) { + + public static TrustedUserDto fromEntity(EffectiveWot entity) { + return new TrustedUserDto(entity.getId().getTrustedUserId(), List.of(entity.getSignatureChain())); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/org/cryptomator/hub/entities/EffectiveWot.java b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveWot.java new file mode 100644 index 00000000..977e21a7 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/EffectiveWot.java @@ -0,0 +1,108 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.Table; +import org.hibernate.annotations.Immutable; +import org.hibernate.annotations.Type; + +import java.io.Serializable; +import java.util.Objects; + +@Entity +@Immutable +@Table(name = "effective_wot") +@NamedQuery(name = "EffectiveWot.findTrustedUsers", query = """ + SELECT wot + FROM EffectiveWot wot + WHERE wot.id.trustingUserId = :trustingUserId + """) +@NamedQuery(name = "EffectiveWot.findTrustedUser", query = """ + SELECT wot + FROM EffectiveWot wot + WHERE wot.id.trustingUserId = :trustingUserId AND wot.id.trustedUserId = :trustedUserId + """) +public class EffectiveWot { + + @EmbeddedId + private Id id; + + @Column(name = "signature_chain") + @Type(StringArrayType.class) + private String[] signatureChain; + + public Id getId() { + return id; + } + + public void setId(Id id) { + this.id = id; + } + + public String[] getSignatureChain() { + return signatureChain; + } + + public void setSignatureChain(String[] signatureChain) { + this.signatureChain = signatureChain; + } + + @Embeddable + public static class Id implements Serializable { + + @Column(name = "trusting_user_id") + private String trustingUserId; + + @Column(name = "trusted_user_id") + private String trustedUserId; + + public String getTrustingUserId() { + return trustingUserId; + } + + public String getTrustedUserId() { + return trustedUserId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof Id other) { + return Objects.equals(trustingUserId, other.trustingUserId) // + && Objects.equals(trustedUserId, other.trustedUserId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(trustingUserId, trustedUserId); + } + + @Override + public String toString() { + return "EffectiveWotId{" + + "trustingUserId='" + trustingUserId + '\'' + + ", trustedUserId='" + trustedUserId + '\'' + + '}'; + } + } + + @ApplicationScoped + public static class Repository implements PanacheRepositoryBase { + public PanacheQuery findTrusted(String trustingUserId) { + return find("#EffectiveWot.findTrustedUsers", Parameters.with("trustingUserId", trustingUserId)); + } + + public PanacheQuery findTrusted(String trustingUserId, String trustedUserId) { + return find("#EffectiveWot.findTrustedUser", Parameters.with("trustingUserId", trustingUserId).and("trustedUserId", trustedUserId)); + } + } +} 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 d521e548..48f8373d 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Settings.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Settings.java @@ -25,6 +25,12 @@ public class Settings { @Column(name = "license_key") private String licenseKey; + @Column(name = "wot_max_depth", nullable = false) + private int wotMaxDepth; + + @Column(name = "wot_id_verify_len", nullable = false) + private int wotIdVerifyLen; + public int getId() { return id; } @@ -49,12 +55,30 @@ public void setLicenseKey(String licenseKey) { this.licenseKey = licenseKey; } + public int getWotMaxDepth() { + return wotMaxDepth; + } + + public void setWotMaxDepth(int wotMaxDepth) { + this.wotMaxDepth = wotMaxDepth; + } + + public int getWotIdVerifyLen() { + return wotIdVerifyLen; + } + + public void setWotIdVerifyLen(int wotIdVerifyLen) { + this.wotIdVerifyLen = wotIdVerifyLen; + } + @Override public String toString() { return "Settings{" + "id=" + id + ", hubId='" + hubId + '\'' + ", licenseKey='" + licenseKey + '\'' + + ", wotMaxDepth='" + wotMaxDepth + '\'' + + ", wotIdVerifyLen='" + wotIdVerifyLen + '\'' + '}'; } @@ -65,12 +89,14 @@ public boolean equals(Object o) { Settings settings = (Settings) o; return id == settings.id && Objects.equals(hubId, settings.hubId) - && Objects.equals(licenseKey, settings.licenseKey); + && Objects.equals(licenseKey, settings.licenseKey) + && Objects.equals(wotMaxDepth, settings.wotMaxDepth) + && Objects.equals(wotIdVerifyLen, settings.wotIdVerifyLen); } @Override public int hashCode() { - return Objects.hash(id, hubId, licenseKey); + return Objects.hash(id, hubId, licenseKey, wotMaxDepth, wotIdVerifyLen); } @ApplicationScoped diff --git a/backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java b/backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java new file mode 100644 index 00000000..07e63552 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java @@ -0,0 +1,66 @@ +package org.cryptomator.hub.entities; + +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.usertype.UserType; + +import java.io.Serializable; +import java.sql.Array; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Arrays; + +public class StringArrayType implements UserType { + + @Override + public int getSqlType() { + return Types.ARRAY; + } + + @Override + public Class returnedClass() { + return String[].class; + } + + @Override + public boolean equals(String[] x, String[] y) { + return Arrays.equals(x, y); + } + + @Override + public int hashCode(String[] x) { + return Arrays.hashCode(x); + } + + @Override + public String[] nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) throws SQLException { + Array array = rs.getArray(position); + return array != null ? (String[]) array.getArray() : null; + } + + @Override + public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) { + throw new UnsupportedOperationException("Read Only"); + } + + @Override + public boolean isMutable() { + return false; + } + + @Override + public String[] deepCopy(String[] value) { + return value; // value is immutable + } + + @Override + public Serializable disassemble(String[] value) { + return value; // value is immutable + } + + @Override + public String[] assemble(Serializable cached, Object owner) { + return (String[]) cached; // value is immutable + } +} diff --git a/backend/src/main/java/org/cryptomator/hub/entities/WotEntry.java b/backend/src/main/java/org/cryptomator/hub/entities/WotEntry.java new file mode 100644 index 00000000..e451831b --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/WotEntry.java @@ -0,0 +1,92 @@ +package org.cryptomator.hub.entities; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.io.Serializable; +import java.util.Objects; + +@Entity +@Table(name = "wot") +public class WotEntry { + + @EmbeddedId + private Id id; + + @Column(name = "signature", nullable = false) + private String signature; + + public Id getId() { + return id; + } + + public void setId(Id id) { + this.id = id; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } + + @Embeddable + public static class Id implements Serializable { + + @Column(name = "user_id") + private String userId; + + @Column(name = "signer_id") + private String signerId; + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getSignerId() { + return signerId; + } + + public void setSignerId(String signerId) { + this.signerId = signerId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof Id other) { + return Objects.equals(userId, other.userId) // + && Objects.equals(signerId, other.signerId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(userId, signerId); + } + + @Override + public String toString() { + return "WotEntryId{" + + "userId='" + userId + '\'' + + ", signerId='" + signerId + '\'' + + '}'; + } + } + + @ApplicationScoped + public static class Repository implements PanacheRepositoryBase { + } +} 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 d2839e10..d4cee866 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 @@ -100,6 +100,16 @@ public void logVaultMemberUpdated(String updatedBy, UUID vaultId, String authori auditEventRepository.persist(event); } + public void logWotIdSigned(String userId, String signerId, String signerKey, String signature) { + var event = new SignedWotIdEvent(); + event.setTimestamp(Instant.now()); + event.setUserId(userId); + event.setSignerId(signerId); + event.setSignerKey(signerKey); + event.setSignature(signature); + auditEventRepository.persist(event); + } + //legacy public void logVaultOwnershipClaimed(String claimedBy, UUID vaultId) { var event = new VaultOwnershipClaimedEvent(); diff --git a/backend/src/main/java/org/cryptomator/hub/entities/events/SignedWotIdEvent.java b/backend/src/main/java/org/cryptomator/hub/entities/events/SignedWotIdEvent.java new file mode 100644 index 00000000..e87a4161 --- /dev/null +++ b/backend/src/main/java/org/cryptomator/hub/entities/events/SignedWotIdEvent.java @@ -0,0 +1,81 @@ +package org.cryptomator.hub.entities.events; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "audit_event_sign_wot_id") +@DiscriminatorValue(SignedWotIdEvent.TYPE) +public class SignedWotIdEvent extends AuditEvent { + + public static final String TYPE = "SIGN_WOT_ID"; + + @Column(name = "user_id") + private String userId; + + @Column(name = "signer_id") + private String signerId; + + @Column(name = "signer_key") + private String signerKey; + + @Column(name = "signature") + private String signature; + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getSignerId() { + return signerId; + } + + public void setSignerId(String signerId) { + this.signerId = signerId; + } + + public String getSignerKey() { + return signerKey; + } + + public void setSignerKey(String signerKey) { + this.signerKey = signerKey; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SignedWotIdEvent that = (SignedWotIdEvent) o; + return super.equals(that) // + && Objects.equals(userId, that.userId) // + && Objects.equals(signerId, that.signerId) // + && Objects.equals(signerKey, that.signerKey) // + && Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(super.getId(), userId, signerId, signerKey, signature); + } + +} diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png index 19bce5b4..dbc96863 100644 Binary files a/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png and b/backend/src/main/resources/org/cryptomator/hub/flyway/ERM.png differ diff --git a/backend/src/main/resources/org/cryptomator/hub/flyway/V16__WoT.sql b/backend/src/main/resources/org/cryptomator/hub/flyway/V16__WoT.sql new file mode 100644 index 00000000..dbd5b1ed --- /dev/null +++ b/backend/src/main/resources/org/cryptomator/hub/flyway/V16__WoT.sql @@ -0,0 +1,48 @@ +ALTER TABLE "settings" ADD "wot_max_depth" INTEGER NOT NULL DEFAULT 3; +ALTER TABLE "settings" ADD "wot_id_verify_len" INTEGER NOT NULL DEFAULT 2; +ALTER TABLE "settings" ADD CONSTRAINT "check_wot_max_depth" CHECK ("wot_max_depth" >= 0 AND "wot_max_depth" < 10); + +CREATE TABLE "wot" ( + "user_id" VARCHAR(255) COLLATE "C" NOT NULL, + "signer_id" VARCHAR(255) COLLATE "C" NOT NULL, + "signature" VARCHAR NOT NULL, + PRIMARY KEY ("user_id", "signer_id"), + CONSTRAINT "fk_user_id" FOREIGN KEY ("user_id") REFERENCES "user_details" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_signer_id" FOREIGN KEY ("signer_id") REFERENCES "user_details" ("id") ON DELETE CASCADE +); + +CREATE TABLE "audit_event_sign_wot_id" +( + "id" BIGINT NOT NULL, + "user_id" VARCHAR(255) COLLATE "C" NOT NULL, + "signer_id" VARCHAR(255) COLLATE "C" NOT NULL, + "signer_key" VARCHAR NOT NULL, + "signature" VARCHAR NOT NULL, + CONSTRAINT "AUDIT_EVENT_SIGN_WOT_ID_PK" PRIMARY KEY ("id"), + CONSTRAINT "AUDIT_EVENT_SIGN_WOT_ID_FK_AUDIT_EVENT" FOREIGN KEY ("id") REFERENCES "audit_event" ("id") ON DELETE CASCADE +); + +-- @formatter:off +CREATE VIEW "effective_wot" ("trusting_user_id", "trusted_user_id", "signature_chain") AS +WITH RECURSIVE "r" ("trusting_user_id", "trusted_user_id", "depth", "signer_chain", "signature_chain") AS ( + -- Anchor member: Directly trusted users + SELECT "signer_id", "user_id", 0, array["signer_id"]::varchar[], array["signature"]::varchar[] + FROM "wot" + + UNION ALL + + -- Recursive member: Transitive trust + SELECT "r"."trusting_user_id", "wot"."user_id", "r"."depth" + 1, ("r"."signer_chain" || "wot"."signer_id")::varchar[], ("r"."signature_chain" || "wot"."signature")::varchar[] + FROM "wot" + INNER JOIN "r" + ON "wot"."signer_id" = "r"."trusted_user_id" -- primary recursion criteria + AND "wot"."user_id" <> ALL("r"."signer_chain") -- only if user isn't part of signature chain already (avoid loops) + INNER JOIN "settings" ON "settings"."id" = 0 + WHERE "r"."depth" < "settings"."wot_max_depth" +) +SELECT + DISTINCT ON ("trusting_user_id", "trusted_user_id") -- keep only one relation (ordered by depth), i.e. the shortest path + "trusting_user_id", "trusted_user_id", "signature_chain" + FROM "r" + ORDER BY "trusting_user_id", "trusted_user_id", "depth"; +-- @formatter:on \ No newline at end of file diff --git a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java index 43db2f14..4a75e9a4 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/AuditLogResourceIT.java @@ -1,12 +1,16 @@ package org.cryptomator.hub.api; +import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.restassured.RestAssured; +import org.cryptomator.hub.license.LicenseHolder; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; @@ -16,11 +20,20 @@ @DisplayName("Resource /auditlog") public class AuditLogResourceIT { + @InjectMock + LicenseHolder licenseHolder; + @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); } + @BeforeEach + public void beforeEach() { + Mockito.doReturn(true).when(licenseHolder).isSet(); + Mockito.doReturn(false).when(licenseHolder).isExpired(); + } + @Test @TestSecurity(user = "Admin", roles = {"admin"}) @DisplayName("As admin, GET /auditlog?startDate=2020-02-20T00:00:00.000Z&endDate=2020-02-20T23:59:59.999Z&paginationId=9999 returns 200 with 20 entries") diff --git a/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java new file mode 100644 index 00000000..cdb7243e --- /dev/null +++ b/backend/src/test/java/org/cryptomator/hub/api/SettingsResourceIT.java @@ -0,0 +1,127 @@ +package org.cryptomator.hub.api; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.oidc.Claim; +import io.quarkus.test.security.oidc.OidcSecurity; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.is; + +@QuarkusTest +@DisplayName("Resource /settings") +public class SettingsResourceIT { + + @BeforeAll + public static void beforeAll() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Nested + @DisplayName("As admin") + @TestSecurity(user = "Admin", roles = {"user", "admin"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "admin") + }) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public class AsAdmin { + + @Test + @Order(1) + @DisplayName("GET /settings returns 200") + public void testGetInitial() { + when().get("/settings") + .then().statusCode(200) + .body("wotMaxDepth", is(3)) + .body("wotIdVerifyLen", is(2)); + } + + @Test + @Order(2) + @DisplayName("PUT /settings returns 204 No Content") + public void testPut() { + var dto = new SettingsResource.SettingsDto("42", 5, 8); + given().contentType(ContentType.JSON).body(dto) + .when().put("/settings") + .then().statusCode(204); + } + + @Test + @Order(3) + @DisplayName("GET /settings returns 200") + public void testGetModify() { + when().get("/settings") + .then().statusCode(200) + .body("wotMaxDepth", is(5)) + .body("wotIdVerifyLen", is(8)); + } + + @Test + @Order(4) + @DisplayName("PUT /settings returns 204 No Content") + public void testPutBackToDefault() { + var dto = new SettingsResource.SettingsDto("42", 3, 2); + given().contentType(ContentType.JSON).body(dto) + .when().put("/settings") + .then().statusCode(204); + } + + + } + + @Nested + @DisplayName("As normal user") + @TestSecurity(user = "User Name 1", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user1") + }) + public class AsNormalUser { + + @Test + @DisplayName("GET /settings returns 200") + public void testGet() { + when().get("/settings") + .then().statusCode(200); + } + + @Test + @DisplayName("PUT /settings returns 403 Forbidden") + public void testPut() { + given().contentType(ContentType.JSON).body("") + .when().put("/settings") + .then().statusCode(403); + } + + } + + @Nested + @DisplayName("As unauthenticated user") + public class AsAnonymous { + + @Test + @DisplayName("GET /billing returns 401 Unauthorized") + public void testGet() { + when().get("/settings") + .then().statusCode(401); + } + + @Test + @DisplayName("PUT /settings returns 401 Unauthorized") + public void testPut() { + given().contentType(ContentType.JSON).body("") + .when().put("/settings") + .then().statusCode(401); + } + + } +} diff --git a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java index 4e79b629..4778b818 100644 --- a/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java +++ b/backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java @@ -1,28 +1,52 @@ package org.cryptomator.hub.api; +import io.agroal.api.AgroalDataSource; +import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import io.quarkus.test.security.oidc.Claim; import io.quarkus.test.security.oidc.OidcSecurity; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import jakarta.inject.Inject; +import org.cryptomator.hub.license.LicenseHolder; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mockito; + +import java.sql.SQLException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.comparesEqualTo; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; @QuarkusTest @DisplayName("Resource /users") public class UsersResourceIT { + @Inject + AgroalDataSource dataSource; + + @InjectMock + LicenseHolder licenseHolder; + @BeforeAll public static void beforeAll() { RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); @@ -119,4 +143,177 @@ public void testGet(String method, String path) { } + @Nested + @DisplayName("Test Web of Trust") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + public class WebOfTrust { + + private Instant testStart; + + @BeforeAll + public void setup() throws SQLException { + testStart = Instant.now(); + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + INSERT INTO "authority" ("id", "type", "name") VALUES ('user997', 'USER', 'User 997'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user998', 'USER', 'User 998'); + INSERT INTO "authority" ("id", "type", "name") VALUES ('user999', 'USER', 'User 999'); + INSERT INTO "user_details" ("id", "ecdsa_publickey") VALUES ('user997', 'ecdsa_public997'); + INSERT INTO "user_details" ("id", "ecdsa_publickey") VALUES ('user998', 'ecdsa_public998'); + INSERT INTO "user_details" ("id", "ecdsa_publickey") VALUES ('user999', 'ecdsa_public999'); + """); + } + } + + @Test + @Order(1) + @DisplayName("PUT /users/trusted/user998 as user 997") + @TestSecurity(user = "User 997", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user997") + }) + public void test997Trusts998() { + given().contentType(ContentType.TEXT).body("997 trusts 998") + .when().put("/users/trusted/user998") + .then().statusCode(204); + } + + @Test + @Order(1) + @DisplayName("PUT /users/trusted/user999 as user 998") + @TestSecurity(user = "User 998", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user998") + }) + public void test998Trusts999() { + given().contentType(ContentType.TEXT).body("998 trusts 999") + .when().put("/users/trusted/user999") + .then().statusCode(204); + } + + @Test + @Order(1) + @DisplayName("PUT /users/trusted/user997 as user 998") + @TestSecurity(user = "User 998", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user998") + }) + public void test998Trusts997() { + given().contentType(ContentType.TEXT).body("998 trusts 997") + .when().put("/users/trusted/user997") + .then().statusCode(204); + } + + @Test + @Order(2) + @DisplayName("GET /users/trusted as user 997") + @TestSecurity(user = "User 997", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user997") + }) + public void testGetTrustedBy997() { + given().when().get("/users/trusted") + .then().statusCode(200) + .body("$", hasSize(2)) + .body("trustedUserId", hasItems("user998", "user999")) + .body("find{it.trustedUserId==\"user998\"}.signatureChain", hasItems("997 trusts 998")) + .body("find{it.trustedUserId==\"user999\"}.signatureChain", hasItems("997 trusts 998", "998 trusts 999")); + } + + @Test + @Order(2) + @DisplayName("GET /users/trusted as user 998") + @TestSecurity(user = "User 998", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user998") + }) + public void testGetTrustedBy998() { + given().when().get("/users/trusted") + .then().statusCode(200) + .body("$", hasSize(2)) + .body("trustedUserId", hasItems("user997", "user999")) + .body("find{it.trustedUserId==\"user997\"}.signatureChain", hasItems("998 trusts 997")) + .body("find{it.trustedUserId==\"user999\"}.signatureChain", hasItems("998 trusts 999")); + } + + @Test + @Order(2) + @DisplayName("GET /users/trusted as user 999") + @TestSecurity(user = "User 999", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user999") + }) + public void testGetTrustedBy999() { + given().when().get("/users/trusted") + .then().statusCode(200) + .body("$", hasSize(0)); + } + + @Test + @Order(3) + @DisplayName("GET /users/trusted/user998 as user 997") + @TestSecurity(user = "User 997", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user997") + }) + public void test997Gets998() { + given().when().get("/users/trusted/user998") + .then().statusCode(200) + .body("signatureChain", hasItems("997 trusts 998")); + } + + @Test + @Order(3) + @DisplayName("GET /users/trusted/user999 as user 997") + @TestSecurity(user = "User 997", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user997") + }) + public void test997Gets999() { + given().when().get("/users/trusted/user999") + .then().statusCode(200) + .body("signatureChain", hasItems("997 trusts 998", "998 trusts 999")); + } + + @Test + @Order(3) + @DisplayName("GET /users/trusted/user998 as user 999") + @TestSecurity(user = "User 999", roles = {"user"}) + @OidcSecurity(claims = { + @Claim(key = "sub", value = "user999") + }) + public void test999Gets998() { + given().when().get("/users/trusted/user998") + .then().statusCode(404); + } + + @Test + @Order(4) + @TestSecurity(user = "Admin", roles = {"admin"}) + @DisplayName("As admin, GET /auditlog contains signature events") + public void testGetAuditLogEntries() { + Mockito.doReturn(true).when(licenseHolder).isSet(); + Mockito.doReturn(false).when(licenseHolder).isExpired(); + + given().param("startDate", DateTimeFormatter.ISO_INSTANT.format(testStart)) + .param("endDate", DateTimeFormatter.ISO_INSTANT.format(Instant.now())) + .param("paginationId", 9999L) + .when().get("/auditlog") + .then().statusCode(200) + .body("signature", contains("997 trusts 998", "998 trusts 999", "998 trusts 997")); + } + + + @AfterAll + public void tearDown() throws SQLException { + try (var c = dataSource.getConnection(); var s = c.createStatement()) { + s.execute(""" + DELETE FROM "authority" WHERE "id" IN ('user997', 'user998', 'user999'); + """); + } + } + + } + } \ No newline at end of file diff --git a/frontend/src/common/auditlog.ts b/frontend/src/common/auditlog.ts index e6c83dcc..5ccdbdd9 100644 --- a/frontend/src/common/auditlog.ts +++ b/frontend/src/common/auditlog.ts @@ -3,32 +3,42 @@ import { Deferred, debounce } from './util'; /* DTOs */ -export type AuditEventDto = { +type AuditEventDtoBase = { id: number; timestamp: Date; - type: 'DEVICE_REGISTER' | 'DEVICE_REMOVE' | 'VAULT_CREATE' | 'VAULT_UPDATE' | 'VAULT_ACCESS_GRANT' | 'VAULT_KEY_RETRIEVE' | 'VAULT_MEMBER_ADD' | 'VAULT_MEMBER_REMOVE' | 'VAULT_MEMBER_UPDATE' | 'VAULT_OWNERSHIP_CLAIM'; } - -export type AuditEventDeviceRegisterDto = AuditEventDto & { +export type AuditEventDeviceRegisterDto = AuditEventDtoBase & { + type: 'DEVICE_REGISTER', registeredBy: string; deviceId: string; deviceName: string; deviceType: 'BROWSER' | 'DESKTOP' | 'MOBILE'; } -export type AuditEventDeviceRemoveDto = AuditEventDto & { +export type AuditEventDeviceRemoveDto = AuditEventDtoBase & { + type: 'DEVICE_REMOVE', removedBy: string; deviceId: string; } -export type AuditEventVaultCreateDto = AuditEventDto & { +export type AuditEventSignedWotIdDto = AuditEventDtoBase & { + type: 'SIGN_WOT_ID', + userId: string; + signerId: string; + signerKey: string; + signature: string; +} + +export type AuditEventVaultCreateDto = AuditEventDtoBase & { + type: 'VAULT_CREATE', createdBy: string; vaultId: string; vaultName: string; vaultDescription: string; } -export type AuditEventVaultUpdateDto = AuditEventDto & { +export type AuditEventVaultUpdateDto = AuditEventDtoBase & { + type: 'VAULT_UPDATE', updatedBy: string; vaultId: string; vaultName: string; @@ -36,43 +46,51 @@ export type AuditEventVaultUpdateDto = AuditEventDto & { vaultArchived: boolean; } -export type AuditEventVaultAccessGrantDto = AuditEventDto & { +export type AuditEventVaultAccessGrantDto = AuditEventDtoBase & { + type: 'VAULT_ACCESS_GRANT', grantedBy: string; vaultId: string; authorityId: string; } -export type AuditEventVaultKeyRetrieveDto = AuditEventDto & { +export type AuditEventVaultKeyRetrieveDto = AuditEventDtoBase & { + type: 'VAULT_KEY_RETRIEVE', retrievedBy: string; vaultId: string; result: 'SUCCESS' | 'UNAUTHORIZED'; } -export type AuditEventVaultMemberAddDto = AuditEventDto & { +export type AuditEventVaultMemberAddDto = AuditEventDtoBase & { + type: 'VAULT_MEMBER_ADD', addedBy: string; vaultId: string; authorityId: string; role: 'MEMBER' | 'OWNER'; } -export type AuditEventVaultMemberRemoveDto = AuditEventDto & { +export type AuditEventVaultMemberRemoveDto = AuditEventDtoBase & { + type: 'VAULT_MEMBER_REMOVE', removedBy: string; vaultId: string; authorityId: string; } -export type AuditEventVaultMemberUpdateDto = AuditEventDto & { +export type AuditEventVaultMemberUpdateDto = AuditEventDtoBase & { + type: 'VAULT_MEMBER_UPDATE', updatedBy: string; vaultId: string; authorityId: string; role: 'MEMBER' | 'OWNER'; } -export type AuditEventVaultOwnershipClaimDto = AuditEventDto & { +export type AuditEventVaultOwnershipClaimDto = AuditEventDtoBase & { + type: 'VAULT_OWNERSHIP_CLAIM', claimedBy: string; vaultId: string; } +export type AuditEventDto = AuditEventDeviceRegisterDto | AuditEventDeviceRemoveDto | AuditEventSignedWotIdDto | AuditEventVaultCreateDto | AuditEventVaultUpdateDto | AuditEventVaultAccessGrantDto | AuditEventVaultKeyRetrieveDto | AuditEventVaultMemberAddDto | AuditEventVaultMemberRemoveDto | AuditEventVaultMemberUpdateDto | AuditEventVaultOwnershipClaimDto; + /* Entity Cache */ export class AuditLogEntityCache { diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index fb2072a4..b833e754 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -91,6 +91,11 @@ export type MemberDto = AuthorityDto & { role: VaultRole } +export type TrustDto = { + trustedUserId: string, + signatureChain: string[] +} + export type BillingDto = { hubId: string; hasLicense: boolean; @@ -107,6 +112,12 @@ export type VersionDto = { keycloakVersion: string; } +export type SettingsDto = { + hubId: string, + wotMaxDepth: number, + wotIdVerifyLen: number +} + export class LicenseUserInfoDto { constructor( public licensedSeats: number, @@ -245,6 +256,23 @@ class UserService { } } +class TrustService { + public async trustUser(userId: string, signature: string): Promise { + return axiosAuth.put(`/users/trusted/${userId}`, signature, { headers: { 'Content-Type': 'text/plain' } }); + } + public async get(userId: string): Promise { + return axiosAuth.get(`/users/trusted/${userId}`).then(response => response.data) + .catch(e => { + if (e.response.status === 404) return undefined; + else throw e; + }); + } + + public async listTrusted(): Promise { + return axiosAuth.get('/users/trusted').then(response => response.data); + } +} + class AuthorityService { public async search(query: string): Promise { return axiosAuth.get(`/authorities/search?query=${query}`).then(response => response.data.map(AuthorityService.fillInMissingPicture)); @@ -333,17 +361,25 @@ class VersionService { } } +class SettingsService { + public async get(): Promise { + return axiosAuth.get('/settings').then(response => response.data); + } +} + /** * Note: Each service can thrown an {@link UnauthorizedError} when the access token is expired! */ const services = { vaults: new VaultService(), users: new UserService(), + trust: new TrustService(), authorities: new AuthorityService(), devices: new DeviceService(), billing: new BillingService(), version: new VersionService(), - license: new LicenseService() + license: new LicenseService(), + settings: new SettingsService() }; export default services; diff --git a/frontend/src/common/crypto.ts b/frontend/src/common/crypto.ts index a2cc0d6f..351741ff 100644 --- a/frontend/src/common/crypto.ts +++ b/frontend/src/common/crypto.ts @@ -260,11 +260,13 @@ export class VaultKeys { } export class UserKeys { - public static readonly ECDH_KEY_USAGES: KeyUsage[] = ['deriveBits']; + public static readonly ECDH_PRIV_KEY_USAGES: KeyUsage[] = ['deriveBits']; public static readonly ECDH_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { name: 'ECDH', namedCurve: 'P-384' }; - public static readonly ECDSA_KEY_USAGES: KeyUsage[] = ['sign']; + public static readonly ECDSA_PRIV_KEY_USAGES: KeyUsage[] = ['sign']; + + public static readonly ECDSA_PUB_KEY_USAGES: KeyUsage[] = ['verify']; public static readonly ECDSA_KEY_DESIGNATION: EcKeyImportParams | EcKeyGenParams = { name: 'ECDSA', namedCurve: 'P-384' }; @@ -276,8 +278,8 @@ export class UserKeys { * @returns A set of new user key pairs */ public static async create(): Promise { - const ecdhKeyPair = crypto.subtle.generateKey(UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_KEY_USAGES); - const ecdsaKeyPair = crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES); + const ecdhKeyPair = crypto.subtle.generateKey(UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_PRIV_KEY_USAGES); + const ecdsaKeyPair = crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_PRIV_KEY_USAGES); return new UserKeys(await ecdhKeyPair, await ecdsaKeyPair); } @@ -311,17 +313,17 @@ export class UserKeys { private static async createFromJwe(jwe: UserKeyPayload, ecdhPublicKey: CryptoKey | BufferSource, ecdsaPublicKey?: CryptoKey | BufferSource): Promise { const ecdhKeyPair: CryptoKeyPair = { publicKey: await asPublicKey(ecdhPublicKey, UserKeys.ECDH_KEY_DESIGNATION), - privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdhPrivateKey ?? jwe.key, { loose: true }), UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_KEY_USAGES) + privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdhPrivateKey ?? jwe.key, { loose: true }), UserKeys.ECDH_KEY_DESIGNATION, true, UserKeys.ECDH_PRIV_KEY_USAGES) }; let ecdsaKeyPair: CryptoKeyPair; if (jwe.ecdsaPrivateKey && ecdsaPublicKey) { ecdsaKeyPair = { - publicKey: await asPublicKey(ecdsaPublicKey, UserKeys.ECDSA_KEY_DESIGNATION), - privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdsaPrivateKey, { loose: true }), UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES) + publicKey: await asPublicKey(ecdsaPublicKey, UserKeys.ECDSA_KEY_DESIGNATION, UserKeys.ECDSA_PUB_KEY_USAGES), + privateKey: await crypto.subtle.importKey('pkcs8', base64.parse(jwe.ecdsaPrivateKey, { loose: true }), UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_PRIV_KEY_USAGES) }; } else { // ECDSA key was added in Hub 1.4.0. If it's missing, we generate a new one. - ecdsaKeyPair = await crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_KEY_USAGES); + ecdsaKeyPair = await crypto.subtle.generateKey(UserKeys.ECDSA_KEY_DESIGNATION, true, UserKeys.ECDSA_PRIV_KEY_USAGES); } return new UserKeys(ecdhKeyPair, ecdsaKeyPair); } @@ -456,22 +458,40 @@ export class BrowserKeys { } } -async function asPublicKey(publicKey: CryptoKey | BufferSource, keyDesignation: EcKeyImportParams): Promise { +export async function asPublicKey(publicKey: CryptoKey | BufferSource, keyDesignation: EcKeyImportParams, keyUsages: KeyUsage[] = []): Promise { if (publicKey instanceof CryptoKey) { return publicKey; } else { - return await crypto.subtle.importKey('spki', publicKey, keyDesignation, true, []); + return await crypto.subtle.importKey('spki', publicKey, keyDesignation, true, keyUsages); } } -export async function getFingerprint(key: string | undefined) { - if (key) { - const encodedKey = new TextEncoder().encode(key); - const hashBuffer = await crypto.subtle.digest('SHA-256', encodedKey); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, '0').toUpperCase()) - .join(''); - return hashHex; +/** + * Computes the JWK Thumbprint (RFC 7638) using SHA-256. + * @param key A key to compute the thumbprint for + * @throws Error if the key is not supported + */ +export async function getJwkThumbprint(key: JsonWebKey | CryptoKey): Promise { + let jwk: JsonWebKey; + if (key instanceof CryptoKey) { + jwk = await crypto.subtle.exportKey('jwk', key); + } else { + jwk = key; + } + // see https://datatracker.ietf.org/doc/html/rfc7638#section-3.2 + let orderedJson: string; + switch (jwk.kty) { + case 'EC': + orderedJson = `{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}","y":"${jwk.y}"}`; + break; + case 'RSA': + orderedJson = `{"e":"${jwk.e}","kty":"${jwk.kty}","n":"${jwk.n}"}`; + break; + case 'oct': + orderedJson = `{"k":"${jwk.k}","kty":"${jwk.kty}"}`; + break; + default: throw new Error('Unsupported key type'); } + const bytes = new TextEncoder().encode(orderedJson); + return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); } diff --git a/frontend/src/common/jwt.ts b/frontend/src/common/jwt.ts index 16979d94..f7a7d00a 100644 --- a/frontend/src/common/jwt.ts +++ b/frontend/src/common/jwt.ts @@ -4,11 +4,12 @@ export type JWTHeader = { alg: 'ES384'; typ: 'JWT'; b64: true; + [other: string]: undefined | string | number | boolean | object; // allow further properties } export class JWT { /** - * Creates a ES384 JWT (signed with ECDSA using P-384 and SHA-384). + * Creates an ES384 JWT (signed with ECDSA using P-384 and SHA-384). * * See RFC 7519, * RFC 7515 and @@ -37,4 +38,41 @@ export class JWT { ); return base64url.stringify(new Uint8Array(signature), { pad: false }); } + + /** + * Decodes and verifies an ES384 JWT (signed with ECDSA using P-384 and SHA-384). + * @param jwt + * @param signerPublicKey + * @returns header and payload + * @throws Error if the JWT is invalid + */ + public static async parse(jwt: string, signerPublicKey: CryptoKey): Promise<[JWTHeader, any]> { + const [encodedHeader, encodedPayload, encodedSignature] = jwt.split('.'); + const header: JWTHeader = JSON.parse(new TextDecoder().decode(base64url.parse(encodedHeader, { loose: true }))); + if (header.alg !== 'ES384') { + throw new Error('Unsupported algorithm'); + } + const validSignature = await this.es384verify(jwt, signerPublicKey); + if (!validSignature) { + throw new Error('Invalid signature'); + } + const payload = JSON.parse(new TextDecoder().decode(base64url.parse(encodedPayload, { loose: true }))); + return [header, payload]; + } + + // visible for testing + public static async es384verify(jwt: string, signerPublicKey: CryptoKey): Promise { + const [encodedHeader, encodedPayload, encodedSignature] = jwt.split('.'); + const headerAndPayload = new TextEncoder().encode(encodedHeader + '.' + encodedPayload); + const signature = base64url.parse(encodedSignature); + return window.crypto.subtle.verify( + { + name: 'ECDSA', + hash: { name: 'SHA-384' }, + }, + signerPublicKey, + signature, + headerAndPayload + ); + } } diff --git a/frontend/src/common/wot.ts b/frontend/src/common/wot.ts new file mode 100644 index 00000000..dcf5fb67 --- /dev/null +++ b/frontend/src/common/wot.ts @@ -0,0 +1,101 @@ +import { base64 } from 'rfc4648'; +import backend, { TrustDto, UserDto } from './backend'; +import { UserKeys, asPublicKey, getJwkThumbprint } from './crypto'; +import { JWT, JWTHeader } from './jwt'; +import userdata from './userdata'; + +export type SignedKeys = { + ecdhPublicKey: string; + ecdsaPublicKey: string; +} + +function deeplyEqual(a: SignedKeys, b: SignedKeys) { + return a.ecdhPublicKey === b.ecdhPublicKey + && a.ecdsaPublicKey === b.ecdsaPublicKey; +} + +/** + * Signs the public key of a user with my private key and sends the signature to the backend. + * @param user The user whose keys to sign + * @returns The new trust object created during the signing process + */ +async function sign(user: UserDto): Promise { + if (!user.ecdhPublicKey || !user.ecdsaPublicKey) { + throw new Error('No public key to sign'); + } + const toSign: SignedKeys = { + ecdhPublicKey: user.ecdhPublicKey, + ecdsaPublicKey: user.ecdsaPublicKey + }; + const me = await userdata.me; + const userKeys = await userdata.decryptUserKeysWithBrowser(); + const signature = await JWT.build({ + alg: 'ES384', + typ: 'JWT', + b64: true, + iss: me.id, + sub: user.id, + iat: Math.floor(Date.now() / 1000) + }, toSign, userKeys.ecdsaKeyPair.privateKey); + await backend.trust.trustUser(user.id, signature); + const trust = await backend.trust.get(user.id); + return trust!; +} + +/** + * Verifies a chain of signatures, where each signature signs the public key of the next signature. + * @param signatureChain The signature chain, where the first element is signed by me + * @param allegedSignedKey The public key that should be signed by the last signature in the chain + */ +async function verify(signatureChain: string[], allegedSignedKey: SignedKeys) { + let signerPublicKey = await userdata.decryptUserKeysWithBrowser().then(keys => keys.ecdsaKeyPair.publicKey); + await verifyRescursive(signatureChain, signerPublicKey, allegedSignedKey); +} + +/** + * Recursively verifies a chain of signatures, where each signature signs the public key of the next signature. + * @param signatureChain The chain of signatures to verify + * @param signerPublicKey A trusted public key to verify the first signature in the chain + * @param allegedSignedKey The public key that should be signed by the last signature in the chain + * @throws Error if the signature chain is invalid + */ +async function verifyRescursive(signatureChain: string[], signerPublicKey: CryptoKey, allegedSignedKey: SignedKeys) { + // get first element of signature chain: + const [signature, ...remainingChain] = signatureChain; + const [_, signedKeys] = await JWT.parse(signature, signerPublicKey) as [JWTHeader, SignedKeys]; + if (remainingChain.length === 0) { + // last element in chain should match signed public key + if (!deeplyEqual(signedKeys, allegedSignedKey)) { + throw new Error('Alleged public key does not match signed public key'); + } + } else { + // otherwise, the payload is an intermediate public key used to sign the next element + const nextTrustedPublicKey = await asPublicKey(base64.parse(signedKeys.ecdsaPublicKey), UserKeys.ECDSA_KEY_DESIGNATION, UserKeys.ECDSA_PUB_KEY_USAGES); + await verifyRescursive(remainingChain, nextTrustedPublicKey, allegedSignedKey); + } +} + +/** + * Creates a unique fingerprint for a user by hashing the concatenated thumbprints of their public keys. + * @param user The user whose fingerprint to compute + * @returns Hexadecimal representation of the fingerprint + */ +async function computeFingerprint(user: { ecdhPublicKey?: string; ecdsaPublicKey?: string }) { + if (!user.ecdhPublicKey || !user.ecdsaPublicKey) { + throw new Error('User has no public keys'); + } + const ecdhPublicKey = await asPublicKey(base64.parse(user.ecdhPublicKey), UserKeys.ECDH_KEY_DESIGNATION); + const ecdsaPublicKey = await asPublicKey(base64.parse(user.ecdsaPublicKey), UserKeys.ECDSA_KEY_DESIGNATION, UserKeys.ECDSA_PUB_KEY_USAGES); + const concatenatedThumbprints = new Uint8Array([ + ...await getJwkThumbprint(ecdhPublicKey), + ...await getJwkThumbprint(ecdsaPublicKey) + ]); + const digest = await crypto.subtle.digest('SHA-256', concatenatedThumbprints); + const digestBytes = Array.from(new Uint8Array(digest)); + const digestHexStr = digestBytes + .map((b) => b.toString(16).padStart(2, '0').toUpperCase()) + .join(''); + return digestHexStr; +} + +export default { sign, verify, computeFingerprint }; diff --git a/frontend/src/components/AuditLog.vue b/frontend/src/components/AuditLog.vue index 22337126..1f1ed11f 100644 --- a/frontend/src/components/AuditLog.vue +++ b/frontend/src/components/AuditLog.vue @@ -101,16 +101,17 @@ {{ auditEvent.timestamp.toLocaleString('sv') }} - - - - - - - - - - + + + + + + + + + + + @@ -164,10 +165,11 @@ import { ChevronDownIcon } from '@heroicons/vue/20/solid'; import { CheckIcon, ChevronUpDownIcon, WrenchIcon } from '@heroicons/vue/24/solid'; import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import auditlog, { AuditEventDeviceRegisterDto, AuditEventDeviceRemoveDto, AuditEventDto, AuditEventVaultAccessGrantDto, AuditEventVaultCreateDto, AuditEventVaultKeyRetrieveDto, AuditEventVaultMemberAddDto, AuditEventVaultMemberRemoveDto, AuditEventVaultMemberUpdateDto, AuditEventVaultOwnershipClaimDto, AuditEventVaultUpdateDto } from '../common/auditlog'; +import auditlog, { AuditEventDto } from '../common/auditlog'; import { PaymentRequiredError } from '../common/backend'; import AuditLogDetailsDeviceRegister from './AuditLogDetailsDeviceRegister.vue'; import AuditLogDetailsDeviceRemove from './AuditLogDetailsDeviceRemove.vue'; +import AuditLogDetailsSignedWotId from './AuditLogDetailsSignedWotId.vue'; import AuditLogDetailsVaultAccessGrant from './AuditLogDetailsVaultAccessGrant.vue'; import AuditLogDetailsVaultCreate from './AuditLogDetailsVaultCreate.vue'; import AuditLogDetailsVaultKeyRetrieve from './AuditLogDetailsVaultKeyRetrieve.vue'; diff --git a/frontend/src/components/AuditLogDetailsSignedWotId.vue b/frontend/src/components/AuditLogDetailsSignedWotId.vue new file mode 100644 index 00000000..a537e6fa --- /dev/null +++ b/frontend/src/components/AuditLogDetailsSignedWotId.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/GrantPermissionDialog.vue b/frontend/src/components/GrantPermissionDialog.vue index a5967f10..224d7189 100644 --- a/frontend/src/components/GrantPermissionDialog.vue +++ b/frontend/src/components/GrantPermissionDialog.vue @@ -9,39 +9,37 @@
-
-
-
-
-