diff --git a/SecureSend.Application/Commands/CreateSecureUpload.cs b/SecureSend.Application/Commands/CreateSecureUpload.cs index 35cae12..27e2ed2 100644 --- a/SecureSend.Application/Commands/CreateSecureUpload.cs +++ b/SecureSend.Application/Commands/CreateSecureUpload.cs @@ -3,5 +3,5 @@ namespace SecureSend.Application.Commands { - public record CreateSecureUpload(Guid uploadId, DateTime? expiryDate): ICommand; + public record CreateSecureUpload(Guid uploadId, DateTime? expiryDate, string? password): ICommand; } diff --git a/SecureSend.Application/Commands/Handlers/CreateSecureUploadHandler.cs b/SecureSend.Application/Commands/Handlers/CreateSecureUploadHandler.cs index 8246303..f5f8a47 100644 --- a/SecureSend.Application/Commands/Handlers/CreateSecureUploadHandler.cs +++ b/SecureSend.Application/Commands/Handlers/CreateSecureUploadHandler.cs @@ -25,8 +25,8 @@ public CreateSecureUploadHandler(ISecureSendUploadRepository secureSendUploadRep public async Task Handle (CreateSecureUpload command, CancellationToken cancellationToken) { var persisted = await _secureUploadReadService.GetUploadId(command.uploadId, cancellationToken); - if (persisted != Guid.Empty) throw new UploadAlreadyExistsException(persisted.Value); - var secureUpload = _secureSendUploadFactory.CreateSecureSendUpload(command.uploadId, new SecureSendUploadDate(), command.expiryDate, false); + if (persisted is not null && persisted != Guid.Empty) throw new UploadAlreadyExistsException(persisted.Value); + var secureUpload = _secureSendUploadFactory.CreateSecureSendUpload(command.uploadId, new SecureSendUploadDate(), command.expiryDate, false, command.password); await _secureSendUploadRepository.AddAsync(secureUpload, cancellationToken); return Unit.Value; diff --git a/SecureSend.Application/Commands/Handlers/ViewSecureUploadHandler.cs b/SecureSend.Application/Commands/Handlers/ViewSecureUploadHandler.cs index 4afdc29..621240b 100644 --- a/SecureSend.Application/Commands/Handlers/ViewSecureUploadHandler.cs +++ b/SecureSend.Application/Commands/Handlers/ViewSecureUploadHandler.cs @@ -15,9 +15,11 @@ public ViewSecureUploadHandler(ISecureSendUploadRepository repository) public async Task Handle(ViewSecureUpload request, CancellationToken cancellationToken) { - var upload = await _repository.GetAsync(request.id, cancellationToken); - if (upload is null) throw new UploadDoesNotExistException(request.id); - if (upload.ExpiryDate is not null && upload.ExpiryDate < DateTime.UtcNow) throw new UploadExpiredException(upload.ExpiryDate); + var upload = await _repository.GetAsync(request.id, cancellationToken) ?? throw new UploadDoesNotExistException(request.id); + if (upload.PasswordHash is not null) + { + upload.PasswordHash.VerifyHash(request.password); + } upload.MarkAsViewed(); await _repository.SaveChanges(cancellationToken); diff --git a/SecureSend.Application/Commands/ViewSecureUpload.cs b/SecureSend.Application/Commands/ViewSecureUpload.cs index cd2d79f..24c3d44 100644 --- a/SecureSend.Application/Commands/ViewSecureUpload.cs +++ b/SecureSend.Application/Commands/ViewSecureUpload.cs @@ -2,7 +2,5 @@ namespace SecureSend.Application.Commands { - public record ViewSecureUpload(Guid id) : ICommand - { - } + public record ViewSecureUpload(Guid id, string? password) : ICommand; } diff --git a/SecureSend.Application/DTO/UploadVerifyResponseDTO.cs b/SecureSend.Application/DTO/UploadVerifyResponseDTO.cs new file mode 100644 index 0000000..3893933 --- /dev/null +++ b/SecureSend.Application/DTO/UploadVerifyResponseDTO.cs @@ -0,0 +1,7 @@ +namespace SecureSend.Application.DTO; + +public class UploadVerifyResponseDTO +{ + public Guid SecureUploadId { get; set; } + public bool IsProtected { get; set; } +} \ No newline at end of file diff --git a/SecureSend.Application/Queries/Handlers/VerifyUploadHandler.cs b/SecureSend.Application/Queries/Handlers/VerifyUploadHandler.cs new file mode 100644 index 0000000..ad08723 --- /dev/null +++ b/SecureSend.Application/Queries/Handlers/VerifyUploadHandler.cs @@ -0,0 +1,31 @@ +using SecureSend.Application.DTO; +using SecureSend.Application.Exceptions; +using SecureSend.Application.Services; + +namespace SecureSend.Application.Queries.Handlers; + +public sealed class VerifyUploadHandler: IQueryHandler +{ + private readonly ISecureUploadReadService _readService; + + public VerifyUploadHandler(ISecureUploadReadService readService) + { + _readService = readService; + } + + public async Task Handle(VerifyUpload request, CancellationToken cancellationToken) + { + var upload = await _readService.GetSecureUpload(request.id, cancellationToken); + if (upload is null) throw new UploadDoesNotExistException(request.id); + if (upload.ExpiryDate is not null && upload.ExpiryDate < DateTime.UtcNow) throw new UploadExpiredException(upload.ExpiryDate); + + var uploadVerifyDto = new UploadVerifyResponseDTO() + { + SecureUploadId = upload.Id, + IsProtected = upload.PasswordHash is not null, + + }; + + return uploadVerifyDto; + } +} \ No newline at end of file diff --git a/SecureSend.Application/Queries/VerifyUpload.cs b/SecureSend.Application/Queries/VerifyUpload.cs new file mode 100644 index 0000000..6c56a9f --- /dev/null +++ b/SecureSend.Application/Queries/VerifyUpload.cs @@ -0,0 +1,5 @@ +using SecureSend.Application.DTO; + +namespace SecureSend.Application.Queries; + +public record VerifyUpload(Guid id): IQuery; \ No newline at end of file diff --git a/SecureSend.Application/Services/ISecureUploadReadService.cs b/SecureSend.Application/Services/ISecureUploadReadService.cs index f7f042b..5c0240c 100644 --- a/SecureSend.Application/Services/ISecureUploadReadService.cs +++ b/SecureSend.Application/Services/ISecureUploadReadService.cs @@ -6,5 +6,6 @@ public interface ISecureUploadReadService { Task GetUploadedFile(string fileName, Guid id, CancellationToken cancellationToken); Task GetUploadId(Guid id, CancellationToken cancellationToken); + Task GetSecureUpload(Guid id, CancellationToken cancellationToken); } } diff --git a/SecureSend.Domain/Entities/SecureSendUpload.cs b/SecureSend.Domain/Entities/SecureSendUpload.cs index e7abd18..2f5ec00 100644 --- a/SecureSend.Domain/Entities/SecureSendUpload.cs +++ b/SecureSend.Domain/Entities/SecureSendUpload.cs @@ -8,16 +8,19 @@ public class SecureSendUpload { public SecureSendUploadId Id {get; private set;} public SecureSendUploadDate UploadDate { get; private set; } - public SecureSendExpiryDate ExpiryDate { get; private set; } + public SecureSendExpiryDate? ExpiryDate { get; private set; } public SecureSendIsViewed IsViewed { get; private set; } + public SecureSendPasswordHash? PasswordHash { get; private set; } + public List Files { get; private set; } = new(); - public SecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate expiryDate, SecureSendIsViewed isViewedl) + public SecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate? expiryDate, SecureSendIsViewed isViewedl, SecureSendPasswordHash? passwordHash) { Id = id; UploadDate = uploadDate; ExpiryDate = expiryDate; IsViewed = isViewedl; + PasswordHash = passwordHash; } public SecureSendUpload() diff --git a/SecureSend.Domain/Exceptions/InvalidPasswordException.cs b/SecureSend.Domain/Exceptions/InvalidPasswordException.cs new file mode 100644 index 0000000..47e0428 --- /dev/null +++ b/SecureSend.Domain/Exceptions/InvalidPasswordException.cs @@ -0,0 +1,10 @@ +using SecureSend.Domain.Exceptions; + +namespace SecureSend.Domain.Entities; + +public class InvalidPasswordException : SecureSendException +{ + public InvalidPasswordException() : base("Provided password is invalid.") + { + } +} \ No newline at end of file diff --git a/SecureSend.Domain/Factories/ISecureSendUploadFactory.cs b/SecureSend.Domain/Factories/ISecureSendUploadFactory.cs index 5d38a59..d2b54fb 100644 --- a/SecureSend.Domain/Factories/ISecureSendUploadFactory.cs +++ b/SecureSend.Domain/Factories/ISecureSendUploadFactory.cs @@ -5,6 +5,6 @@ namespace SecureSend.Domain.Factories { public interface ISecureSendUploadFactory { - SecureSendUpload CreateSecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate expiryDate, SecureSendIsViewed isViewedl); + SecureSendUpload CreateSecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate? expiryDate, SecureSendIsViewed isViewedl, SecureSendPasswordHash? password); } } \ No newline at end of file diff --git a/SecureSend.Domain/Factories/SecureSendUploadFactory.cs b/SecureSend.Domain/Factories/SecureSendUploadFactory.cs index 6e05fb6..c8d7efc 100644 --- a/SecureSend.Domain/Factories/SecureSendUploadFactory.cs +++ b/SecureSend.Domain/Factories/SecureSendUploadFactory.cs @@ -10,9 +10,9 @@ public SecureSendUploadFactory() } - public SecureSendUpload CreateSecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate expiryDate, SecureSendIsViewed isViewedl) + public SecureSendUpload CreateSecureSendUpload(SecureSendUploadId id, SecureSendUploadDate uploadDate, SecureSendExpiryDate? expiryDate, SecureSendIsViewed isViewedl, SecureSendPasswordHash? password) { - return new SecureSendUpload(id, uploadDate, expiryDate, isViewedl); + return new SecureSendUpload(id, uploadDate, expiryDate, isViewedl, password); } } } diff --git a/SecureSend.Domain/ReadModels/SecureUploadsReadModel.cs b/SecureSend.Domain/ReadModels/SecureUploadsReadModel.cs index 95a9ee2..8d3a1e3 100644 --- a/SecureSend.Domain/ReadModels/SecureUploadsReadModel.cs +++ b/SecureSend.Domain/ReadModels/SecureUploadsReadModel.cs @@ -6,6 +6,7 @@ public class SecureUploadsReadModel public DateTime? ExpiryDate { get; set; } public bool IsViewed { get; set; } public DateTime UploadDate { get; set; } + public byte[]? PasswordHash { get; set; } public ICollection Files { get; set; } } } diff --git a/SecureSend.Domain/Repositories/ISecureSendUploadRepository.cs b/SecureSend.Domain/Repositories/ISecureSendUploadRepository.cs index a9f7c08..735f923 100644 --- a/SecureSend.Domain/Repositories/ISecureSendUploadRepository.cs +++ b/SecureSend.Domain/Repositories/ISecureSendUploadRepository.cs @@ -5,7 +5,7 @@ namespace SecureSend.Domain.Repositories { public interface ISecureSendUploadRepository { - Task GetAsync(SecureSendUploadId id, CancellationToken cancellationToken); + Task GetAsync(SecureSendUploadId id, CancellationToken cancellationToken); Task AddAsync(SecureSendUpload upload, CancellationToken cancellationToken); Task DeleteAsync(SecureSendUpload id, CancellationToken cancellationToken); Task UpdateAsync(SecureSendUpload upload, CancellationToken cancellationToken); diff --git a/SecureSend.Domain/SecureSend.Domain.csproj b/SecureSend.Domain/SecureSend.Domain.csproj index 8184cb6..3cd045a 100644 --- a/SecureSend.Domain/SecureSend.Domain.csproj +++ b/SecureSend.Domain/SecureSend.Domain.csproj @@ -7,6 +7,7 @@ + diff --git a/SecureSend.Domain/Services/HashingService.cs b/SecureSend.Domain/Services/HashingService.cs new file mode 100644 index 0000000..bce514b --- /dev/null +++ b/SecureSend.Domain/Services/HashingService.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; + +namespace SecureSend.Domain.Services; + +public class HashingService +{ + private const int DefaultIterations = 10000; + + /// + /// Provides Information about a specific Hash Version + /// + private class HashVersion + { + public short Version { get; set; } + public int SaltSize { get; set; } + public int HashSize { get; set; } + public KeyDerivationPrf KeyDerivation { get; set; } + } + + /// + /// Holds all possible Hash Versions + /// + private readonly Dictionary _versions = new Dictionary + { + { + 1, new HashVersion + { + Version = 1, + KeyDerivation = KeyDerivationPrf.HMACSHA512, + HashSize = 256 / 8, + SaltSize = 128 / 8 + } + } + }; + + /// + /// The default Hash Version, which should be used, if a new Hash is Created + /// + private HashVersion DefaultVersion => _versions[1]; + + /// + /// Checks if a given hash uses the latest version + /// + /// The hash + /// Is the hash of the latest version? + public bool IsLatestHashVersion(byte[] data) + { + var version = BitConverter.ToInt16(data, 0); + return version == DefaultVersion.Version; + } + + /// + /// Checks if a given hash uses the latest version + /// + /// The hash + /// Is the hash of the latest version? + public bool IsLatestHashVersion(string data) + { + var dataBytes = Convert.FromBase64String(data); + return IsLatestHashVersion(dataBytes); + } + + /// + /// Gets a random byte array + /// + /// The length of the byte array + /// The random byte array + public byte[] GetRandomBytes(int length) + { + var data = new byte[length]; + using (var randomNumberGenerator = RandomNumberGenerator.Create()) + { + randomNumberGenerator.GetBytes(data); + } + return data; + } + + /// + /// Creates a Hash of a clear text + /// + /// the clear text + /// the number of iteration the hash alogrythm should run + /// the Hash + public byte[] Hash(string clearText, int iterations = DefaultIterations) + { + //get current version + var currentVersion = DefaultVersion; + + //get the byte arrays of the hash and meta information + var saltBytes = GetRandomBytes(currentVersion.SaltSize); + var versionBytes = BitConverter.GetBytes(currentVersion.Version); + var iterationBytes = BitConverter.GetBytes(iterations); + var hashBytes = KeyDerivation.Pbkdf2(clearText, saltBytes, currentVersion.KeyDerivation, iterations, currentVersion.HashSize); + + //calculate the indexes for the combined hash + var indexVersion = 0; + var indexIteration = indexVersion + 2; + var indexSalt = indexIteration + 4; + var indexHash = indexSalt + currentVersion.SaltSize; + + //combine all data to one result hash + var resultBytes = new byte[2 + 4 + currentVersion.SaltSize + currentVersion.HashSize]; + Array.Copy(versionBytes, 0, resultBytes, indexVersion, 2); + Array.Copy(iterationBytes, 0, resultBytes, indexIteration, 4); + Array.Copy(saltBytes, 0, resultBytes, indexSalt, currentVersion.SaltSize); + Array.Copy(hashBytes, 0, resultBytes, indexHash, currentVersion.HashSize); + return resultBytes; + } + + /// + /// Creates a Hash of a clear text and convert it to a Base64 String representation + /// + /// the clear text + /// the number of iteration the hash alogrythm should run + /// the Hash + public string HashToString(string clearText, int iterations = DefaultIterations) + { + var data = Hash(clearText, iterations); + return Convert.ToBase64String(data); + } + + /// + /// Verifies a given clear Text against a hash + /// + /// The clear text + /// The hash + /// Is the hash equal to the clear text? + public bool Verify(string clearText, byte[] data) + { + //Get the current version and number of iterations + var currentVersion = _versions[BitConverter.ToInt16(data, 0)]; + var iteration = BitConverter.ToInt32(data, 2); + + //Create the byte arrays for the salt and hash + var saltBytes = new byte[currentVersion.SaltSize]; + var hashBytes = new byte[currentVersion.HashSize]; + + //Calculate the indexes of the salt and the hash + var indexSalt = 2 + 4; // Int16 (Version) and Int32 (Iteration) + var indexHash = indexSalt + currentVersion.SaltSize; + + //Fill the byte arrays with salt and hash + Array.Copy(data, indexSalt, saltBytes, 0, currentVersion.SaltSize); + Array.Copy(data, indexHash, hashBytes, 0, currentVersion.HashSize); + + //Hash the current clearText with the parameters given via the data + var verificationHashBytes = KeyDerivation.Pbkdf2(clearText, saltBytes, currentVersion.KeyDerivation, iteration, currentVersion.HashSize); + + //Check if generated hashes are equal + return hashBytes.SequenceEqual(verificationHashBytes); + } + + /// + /// Verifies a given clear Text against a hash + /// + /// The clear text + /// The hash + /// Is the hash equal to the clear text? + public bool Verify(string clearText, string data) + { + var dataBytes = Convert.FromBase64String(data); + return Verify(clearText, dataBytes); + } +} \ No newline at end of file diff --git a/SecureSend.Domain/ValueObjects/SecureSendExpiryDate.cs b/SecureSend.Domain/ValueObjects/SecureSendExpiryDate.cs index ff0f2cb..2d7b4b6 100644 --- a/SecureSend.Domain/ValueObjects/SecureSendExpiryDate.cs +++ b/SecureSend.Domain/ValueObjects/SecureSendExpiryDate.cs @@ -6,11 +6,11 @@ public record SecureSendExpiryDate public SecureSendExpiryDate(DateTime? value = null) { - Value = value is not null ? value?.ToUniversalTime() : null; + Value = value?.ToUniversalTime(); } - public static implicit operator DateTime?(SecureSendExpiryDate secureSendExpiryDate) => secureSendExpiryDate?.Value; + public static implicit operator DateTime?(SecureSendExpiryDate? secureSendExpiryDate) => secureSendExpiryDate?.Value; public static implicit operator SecureSendExpiryDate(DateTime? secureSendExpiryDate) => new(secureSendExpiryDate); } diff --git a/SecureSend.Domain/ValueObjects/SecureSendPasswordHash.cs b/SecureSend.Domain/ValueObjects/SecureSendPasswordHash.cs new file mode 100644 index 0000000..c7f6314 --- /dev/null +++ b/SecureSend.Domain/ValueObjects/SecureSendPasswordHash.cs @@ -0,0 +1,31 @@ +using SecureSend.Domain.Services; + +namespace SecureSend.Domain.Entities; + +public class SecureSendPasswordHash +{ + public byte[]? Value { get;} + private readonly HashingService _hashingService; + public SecureSendPasswordHash(string? password) + { + _hashingService = new HashingService(); + Value = String.IsNullOrEmpty(password) ? null : _hashingService.Hash(password); + } + + public SecureSendPasswordHash(byte[]? hash) + { + _hashingService = new HashingService(); + Value = hash; + } + + public void VerifyHash(string? password) + { + if (Value is null || String.IsNullOrEmpty(password) || !_hashingService.Verify(password, Value)) throw new InvalidPasswordException(); + } + + public static implicit operator byte[]?(SecureSendPasswordHash value) => value.Value; + + public static implicit operator SecureSendPasswordHash(byte[] hash) => new(hash); + + public static implicit operator SecureSendPasswordHash(string? password) => new(password); +} \ No newline at end of file diff --git a/SecureSend.Infrastructure/EF/Config/SecureSendUploadWriteConfiguration.cs b/SecureSend.Infrastructure/EF/Config/SecureSendUploadWriteConfiguration.cs index 9f537f2..d11fe9b 100644 --- a/SecureSend.Infrastructure/EF/Config/SecureSendUploadWriteConfiguration.cs +++ b/SecureSend.Infrastructure/EF/Config/SecureSendUploadWriteConfiguration.cs @@ -22,6 +22,8 @@ public void Configure(EntityTypeBuilder builder) var uploadDateConverter = new ValueConverter(d => d.Value, d => new SecureSendUploadDate()); var expiryDateConverter = new ValueConverter(d => d.Value, d => new SecureSendExpiryDate(d)); var isViewedConverter = new ValueConverter(v => v.Value, v => new SecureSendIsViewed(v)); + var passwordHashConverter = + new ValueConverter(v => v.Value, v => new SecureSendPasswordHash(v)); builder.Property(p => p.Id).HasConversion(id => id.Value, id => new SecureSendUploadId(id)); @@ -39,6 +41,11 @@ public void Configure(EntityTypeBuilder builder) .HasConversion(isViewedConverter) .HasColumnName("IsViewed"); + builder.Property(p => p.PasswordHash) + .HasConversion(passwordHashConverter) + .HasColumnName("PasswordHash") + .IsRequired(false); + builder.OwnsMany(p => p.Files, fileBuilder => { fileBuilder.Property("Id"); diff --git a/SecureSend.Infrastructure/Middlewares/ExceptionMiddleware.cs b/SecureSend.Infrastructure/Middlewares/ExceptionMiddleware.cs index 6e98d55..ce50447 100644 --- a/SecureSend.Infrastructure/Middlewares/ExceptionMiddleware.cs +++ b/SecureSend.Infrastructure/Middlewares/ExceptionMiddleware.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using SecureSend.Domain.Exceptions; using System.Text.Json; +using SecureSend.Domain.Entities; namespace SecureSend.Infrastructure.Middlewares { @@ -16,7 +17,12 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) catch (SecureSendNotFoundException ex) { context.Response.StatusCode = 404; - await WriteToResponse(context, ex); + await WriteToResponse(context, ex); + } + catch (InvalidPasswordException ex) + { + context.Response.StatusCode = 401; + await WriteToResponse(context, ex); } catch (SecureSendException ex) { diff --git a/SecureSend.Infrastructure/Repositories/SecureSendRepository.cs b/SecureSend.Infrastructure/Repositories/SecureSendRepository.cs index e0c485a..c36f6c8 100644 --- a/SecureSend.Infrastructure/Repositories/SecureSendRepository.cs +++ b/SecureSend.Infrastructure/Repositories/SecureSendRepository.cs @@ -20,7 +20,7 @@ public SecureSendRepository(SecureSendDbWriteContext context) public async Task AddAsync(SecureSendUpload upload, CancellationToken cancellationToken) { - await _uploads.AddAsync(upload); + await _uploads.AddAsync(upload, cancellationToken); await SaveChanges(cancellationToken); } diff --git a/SecureSend.Infrastructure/Services/SecureUploadReadService.cs b/SecureSend.Infrastructure/Services/SecureUploadReadService.cs index 15ffa67..d8a2bbd 100644 --- a/SecureSend.Infrastructure/Services/SecureUploadReadService.cs +++ b/SecureSend.Infrastructure/Services/SecureUploadReadService.cs @@ -31,6 +31,11 @@ public SecureUploadReadService(SecureSendDbReadContext context) .FirstOrDefaultAsync(cancellationToken); } + public async Task GetSecureUpload(Guid id, CancellationToken cancellationToken) + { + return await _uploads.Where(x => x.Id == id).AsNoTracking().FirstOrDefaultAsync(cancellationToken); + } + } } diff --git a/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.Designer.cs b/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.Designer.cs new file mode 100644 index 0000000..0188373 --- /dev/null +++ b/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SecureSend.Infrastructure.EF.Context; + +#nullable disable + +namespace SecureSend.PostgresMigrations.Migrations +{ + [DbContext(typeof(SecureSendDbWriteContext))] + [Migration("20231118145732_PasswordHash")] + partial class PasswordHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("upload") + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SecureSend.Domain.Entities.SecureSendUpload", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("ExpiryDate"); + + b.Property("IsViewed") + .HasColumnType("boolean") + .HasColumnName("IsViewed"); + + b.Property("PasswordHash") + .HasColumnType("bytea") + .HasColumnName("PasswordHash"); + + b.Property("UploadDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("UploadDate"); + + b.HasKey("Id"); + + b.ToTable("SecureUploads", "upload"); + }); + + modelBuilder.Entity("SecureSend.Domain.Entities.SecureSendUpload", b => + { + b.OwnsMany("SecureSend.Domain.ValueObjects.SecureSendFile", "Files", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b1.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b1.Property("FileSize") + .HasColumnType("bigint"); + + b1.Property("SecureSendUploadId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("SecureSendUploadId"); + + b1.ToTable("UploadedFiles", "upload"); + + b1.WithOwner() + .HasForeignKey("SecureSendUploadId"); + }); + + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.cs b/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.cs new file mode 100644 index 0000000..ccadf5b --- /dev/null +++ b/SecureSend.PostgresMigrations/Migrations/20231118145732_PasswordHash.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecureSend.PostgresMigrations.Migrations +{ + /// + public partial class PasswordHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + schema: "upload", + table: "SecureUploads", + type: "bytea", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + schema: "upload", + table: "SecureUploads"); + } + } +} diff --git a/SecureSend.PostgresMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs b/SecureSend.PostgresMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs index 19ce743..12fa192 100644 --- a/SecureSend.PostgresMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs +++ b/SecureSend.PostgresMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs @@ -36,6 +36,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("IsViewed"); + b.Property("PasswordHash") + .HasColumnType("bytea") + .HasColumnName("PasswordHash"); + b.Property("UploadDate") .HasColumnType("timestamp with time zone") .HasColumnName("UploadDate"); diff --git a/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.Designer.cs b/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.Designer.cs new file mode 100644 index 0000000..d0777c1 --- /dev/null +++ b/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SecureSend.Infrastructure.EF.Context; + +#nullable disable + +namespace SecureSend.SqlServerMigration.Migrations +{ + [DbContext(typeof(SecureSendDbWriteContext))] + [Migration("20231118151742_PasswordHash")] + partial class PasswordHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("upload") + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SecureSend.Domain.Entities.SecureSendUpload", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiryDate") + .HasColumnType("datetime2") + .HasColumnName("ExpiryDate"); + + b.Property("IsViewed") + .HasColumnType("bit") + .HasColumnName("IsViewed"); + + b.Property("PasswordHash") + .HasColumnType("varbinary(max)") + .HasColumnName("PasswordHash"); + + b.Property("UploadDate") + .HasColumnType("datetime2") + .HasColumnName("UploadDate"); + + b.HasKey("Id"); + + b.ToTable("SecureUploads", "upload"); + }); + + modelBuilder.Entity("SecureSend.Domain.Entities.SecureSendUpload", b => + { + b.OwnsMany("SecureSend.Domain.ValueObjects.SecureSendFile", "Files", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("FileSize") + .HasColumnType("bigint"); + + b1.Property("SecureSendUploadId") + .HasColumnType("uniqueidentifier"); + + b1.HasKey("Id"); + + b1.HasIndex("SecureSendUploadId"); + + b1.ToTable("UploadedFiles", "upload"); + + b1.WithOwner() + .HasForeignKey("SecureSendUploadId"); + }); + + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.cs b/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.cs new file mode 100644 index 0000000..7b84bd8 --- /dev/null +++ b/SecureSend.SqlServerMigrations/Migrations/20231118151742_PasswordHash.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecureSend.SqlServerMigration.Migrations +{ + /// + public partial class PasswordHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + schema: "upload", + table: "SecureUploads", + type: "varbinary(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + schema: "upload", + table: "SecureUploads"); + } + } +} diff --git a/SecureSend.SqlServerMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs b/SecureSend.SqlServerMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs index d8d4506..bcf574b 100644 --- a/SecureSend.SqlServerMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs +++ b/SecureSend.SqlServerMigrations/Migrations/SecureSendDbWriteContextModelSnapshot.cs @@ -36,6 +36,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasColumnName("IsViewed"); + b.Property("PasswordHash") + .HasColumnType("varbinary(max)") + .HasColumnName("PasswordHash"); + b.Property("UploadDate") .HasColumnType("datetime2") .HasColumnName("UploadDate"); diff --git a/SecureSend.Test/Application/Handlers/CancelUploadHandlerTests.cs b/SecureSend.Test/Application/Handlers/CancelUploadHandlerTests.cs index 62d65b0..89ac9e7 100644 --- a/SecureSend.Test/Application/Handlers/CancelUploadHandlerTests.cs +++ b/SecureSend.Test/Application/Handlers/CancelUploadHandlerTests.cs @@ -24,7 +24,7 @@ public CancelUploadHandlerTests() _factory = new SecureSendUploadFactory(); _fileService = new Mock(); _commandHandler = new CancelUploadHandler(_fileService.Object, _repository.Object); - upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false); + upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false, String.Empty); } [Fact] diff --git a/SecureSend.Test/Application/Handlers/CreateSecureUploadHandlerTests.cs b/SecureSend.Test/Application/Handlers/CreateSecureUploadHandlerTests.cs index 829d162..63d2dc2 100644 --- a/SecureSend.Test/Application/Handlers/CreateSecureUploadHandlerTests.cs +++ b/SecureSend.Test/Application/Handlers/CreateSecureUploadHandlerTests.cs @@ -34,7 +34,7 @@ public CreateSecureUploadHandlerTests() [Fact] public async void Handle_Succeeds() { - var command = new CreateSecureUpload(Guid.NewGuid(), DateTime.Now.AddDays(5)); + var command = new CreateSecureUpload(Guid.NewGuid(), DateTime.Now.AddDays(5), String.Empty); _secureUploadReadService.Setup(x => x.GetUploadId(command.uploadId, It.IsAny())) .ReturnsAsync(() => Guid.Empty); @@ -45,7 +45,7 @@ public async void Handle_Succeeds() [Fact] public async void Handle_Throws_UploadAlreadyExistsException() { - var command = new CreateSecureUpload(Guid.NewGuid(), DateTime.Now.AddDays(5)); + var command = new CreateSecureUpload(Guid.NewGuid(), DateTime.Now.AddDays(5), String.Empty); _secureUploadReadService.Setup(x => x.GetUploadId(It.IsAny(), It.IsAny())) .ReturnsAsync(() => Guid.NewGuid()); diff --git a/SecureSend.Test/Application/Handlers/UploadChunksHandlerTests.cs b/SecureSend.Test/Application/Handlers/UploadChunksHandlerTests.cs index 7696038..564bfd8 100644 --- a/SecureSend.Test/Application/Handlers/UploadChunksHandlerTests.cs +++ b/SecureSend.Test/Application/Handlers/UploadChunksHandlerTests.cs @@ -28,7 +28,7 @@ public UploadChunksHandlerTests() _fileService = new Mock(); _commandHandler = new UploadChunksHandler(_fileService.Object, _repository.Object); _factory = new SecureSendUploadFactory(); - upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false); + upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false, String.Empty); _file = new Mock(); _file.Setup(x => x.FileName).Returns("file.txt"); _file.Setup(x => x.ContentType).Returns("text/plain"); diff --git a/SecureSend.Test/Application/Handlers/VerifySecureUploadHandlerTests.cs b/SecureSend.Test/Application/Handlers/VerifySecureUploadHandlerTests.cs new file mode 100644 index 0000000..e72fcd6 --- /dev/null +++ b/SecureSend.Test/Application/Handlers/VerifySecureUploadHandlerTests.cs @@ -0,0 +1,91 @@ +using Moq; +using SecureSend.Application.DTO; +using SecureSend.Application.Exceptions; +using SecureSend.Application.Queries; +using SecureSend.Application.Queries.Handlers; +using SecureSend.Application.Services; +using SecureSend.Domain.ReadModels; + +namespace SecureSend.Test.Application.Handlers; + +public class VerifySecureUploadHandlerTests +{ + #region ARRANGE + + private readonly Mock _readService; + private readonly IQueryHandler _commandHandler; + + public VerifySecureUploadHandlerTests() + { + _readService = new Mock(); + _commandHandler = new VerifyUploadHandler(_readService.Object); + } + + #endregion + + [Fact] + public async void Handle_Throws_UploadDoesNotExistException() + { + var query = new VerifyUpload(Guid.NewGuid()); + _readService.Setup(x => x.GetSecureUpload(query.id, It.IsAny()))! + .ReturnsAsync(default(SecureUploadsReadModel)); + + var exception = await Record.ExceptionAsync(() => _commandHandler.Handle(query, It.IsAny())); + Assert.NotNull(exception); + Assert.IsType(exception); + + } + + [Fact] + public async void Handle_Throws_UploadExpiredException() + { + var upload = new SecureUploadsReadModel() + { + Id = Guid.NewGuid(), UploadDate = DateTime.Now, ExpiryDate = DateTime.Now.AddDays(-5), IsViewed = false + }; + var query = new VerifyUpload(Guid.NewGuid()); + _readService.Setup(x => x.GetSecureUpload(query.id, It.IsAny()))! + .ReturnsAsync(upload); + + var exception = await Record.ExceptionAsync(() => _commandHandler.Handle(query, It.IsAny())); + Assert.NotNull(exception); + Assert.IsType(exception); + + } + + [Fact] + public async void Handle_Returns_Protected() + { + var upload = new SecureUploadsReadModel() + { + Id = Guid.NewGuid(), UploadDate = DateTime.Now, ExpiryDate = DateTime.Now.AddDays(5), IsViewed = false, PasswordHash = Array.Empty() + }; + var query = new VerifyUpload(Guid.NewGuid()); + _readService.Setup(x => x.GetSecureUpload(query.id, It.IsAny()))! + .ReturnsAsync(upload); + + var result = await _commandHandler.Handle(query, It.IsAny()); + Assert.NotNull(result); + Assert.IsType(result); + Assert.True(result.IsProtected); + + } + + [Fact] + public async void Handle_Returns_NotProtected() + { + var upload = new SecureUploadsReadModel() + { + Id = Guid.NewGuid(), UploadDate = DateTime.Now, ExpiryDate = DateTime.Now.AddDays(5), IsViewed = false + }; + var query = new VerifyUpload(Guid.NewGuid()); + _readService.Setup(x => x.GetSecureUpload(query.id, It.IsAny()))! + .ReturnsAsync(upload); + + var result = await _commandHandler.Handle(query, It.IsAny()); + Assert.NotNull(result); + Assert.IsType(result); + Assert.False(result.IsProtected); + + } +} \ No newline at end of file diff --git a/SecureSend.Test/Application/Handlers/ViewSecureUploadHandlerTests.cs b/SecureSend.Test/Application/Handlers/ViewSecureUploadHandlerTests.cs index 1d945b1..15fa1f2 100644 --- a/SecureSend.Test/Application/Handlers/ViewSecureUploadHandlerTests.cs +++ b/SecureSend.Test/Application/Handlers/ViewSecureUploadHandlerTests.cs @@ -27,37 +27,37 @@ public ViewSecureUploadHandlerTests() #endregion [Fact] - public async void Handle_Throws_UploadDoesNotExistException() + public async void Handle_Throws_InvalidPasswordException() { - var command = new ViewSecureUpload(Guid.NewGuid()); + var upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(-5), false, "testing"); + var command = new ViewSecureUpload(Guid.NewGuid(), "wrong password"); _repository.Setup(x => x.GetAsync(command.id, It.IsAny()))! - .ReturnsAsync(default(SecureSendUpload)); + .ReturnsAsync(upload); var exception = await Record.ExceptionAsync(() => _commandHandler.Handle(command, It.IsAny())); Assert.NotNull(exception); - Assert.IsType(exception); - + Assert.IsType(exception); } [Fact] - public async void Handle_Throws_UploadExpiredException() + public async void Handle_Succeeds_EmptyPassword() { - var upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(-5), false); - var command = new ViewSecureUpload(Guid.NewGuid()); + var upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(-5), false, null); + var command = new ViewSecureUpload(Guid.NewGuid(), "wrong password"); _repository.Setup(x => x.GetAsync(command.id, It.IsAny()))! .ReturnsAsync(upload); - var exception = await Record.ExceptionAsync(() => _commandHandler.Handle(command, It.IsAny())); - Assert.NotNull(exception); - Assert.IsType(exception); - + var result = await _commandHandler.Handle(command, It.IsAny()); + Assert.True(upload.IsViewed); + Assert.NotNull(result); + Assert.IsType(result); } [Fact] - public async void Handle_Succedes() + public async void Handle_Succeeds_Protected() { - var upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false); - var command = new ViewSecureUpload(Guid.NewGuid()); + var upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false, "testing"); + var command = new ViewSecureUpload(Guid.NewGuid(), "testing"); _repository.Setup(x => x.GetAsync(command.id, It.IsAny()))! .ReturnsAsync(upload); @@ -65,6 +65,5 @@ public async void Handle_Succedes() Assert.True(upload.IsViewed); Assert.NotNull(result); Assert.IsType(result); - } } \ No newline at end of file diff --git a/SecureSend.Test/Domain/SecureSendUploadTests.cs b/SecureSend.Test/Domain/SecureSendUploadTests.cs index ece60a6..b9e9948 100644 --- a/SecureSend.Test/Domain/SecureSendUploadTests.cs +++ b/SecureSend.Test/Domain/SecureSendUploadTests.cs @@ -15,7 +15,7 @@ public class SecureSendUploadTests public SecureSendUploadTests() { _factory = new SecureSendUploadFactory(); - upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false); + upload = _factory.CreateSecureSendUpload(Guid.NewGuid(), DateTime.Now, DateTime.Now.AddDays(5), false, "testing"); } #endregion @@ -63,4 +63,18 @@ public void RemoveFile_Succeeds() upload.RemoveFile("test_file"); Assert.Empty(upload.Files); } + + [Fact] + public void VerifyHash_Throws_InvalidPasswordException() + { + var exception = Record.Exception(() => upload.PasswordHash.VerifyHash("wrong password")); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void VerifyHash_Succeeds() + { + upload.PasswordHash.VerifyHash("testing"); + } } \ No newline at end of file diff --git a/SecureSend/ClientApp/serviceWorker.ts b/SecureSend/ClientApp/serviceWorker.ts index 5ad119f..4ab6f5d 100644 --- a/SecureSend/ClientApp/serviceWorker.ts +++ b/SecureSend/ClientApp/serviceWorker.ts @@ -26,8 +26,8 @@ const decrypt = async (id: string, url: string) => { const body = fileResponse.body!; const decryptedResponse = decryptStream( body, - fileData.salt, - fileData.masterKey + fileData.b64key, + fileData.password ); const headers = { "Content-Disposition": diff --git a/SecureSend/ClientApp/src/App.vue b/SecureSend/ClientApp/src/App.vue index cbaa6a9..a9d17b7 100644 --- a/SecureSend/ClientApp/src/App.vue +++ b/SecureSend/ClientApp/src/App.vue @@ -9,7 +9,9 @@ import { RouterView } from "vue-router"; > - + + + diff --git a/SecureSend/ClientApp/src/models/CreateSecureUpload.ts b/SecureSend/ClientApp/src/models/CreateSecureUpload.ts index 596cba7..33e2ea2 100644 --- a/SecureSend/ClientApp/src/models/CreateSecureUpload.ts +++ b/SecureSend/ClientApp/src/models/CreateSecureUpload.ts @@ -1,4 +1,5 @@ export interface CreateSecureUpload { - uploadId: Uint8Array; - expiryDate?: string; + uploadId: string; + expiryDate: string | null; + password: string | null; } diff --git a/SecureSend/ClientApp/src/models/VerifyUploadResponseDTO.ts b/SecureSend/ClientApp/src/models/VerifyUploadResponseDTO.ts new file mode 100644 index 0000000..26f5e6f --- /dev/null +++ b/SecureSend/ClientApp/src/models/VerifyUploadResponseDTO.ts @@ -0,0 +1,4 @@ +export interface UploadVerifyResponseDTO { + secureUploadId: string; + isProtected: boolean; +} diff --git a/SecureSend/ClientApp/src/models/ViewSecureUpload.ts b/SecureSend/ClientApp/src/models/ViewSecureUpload.ts index 841d069..f68b5e9 100644 --- a/SecureSend/ClientApp/src/models/ViewSecureUpload.ts +++ b/SecureSend/ClientApp/src/models/ViewSecureUpload.ts @@ -1,3 +1,4 @@ export interface ViewSecureUpload { id: string; + password: string | null; } diff --git a/SecureSend/ClientApp/src/models/WorkerInit.ts b/SecureSend/ClientApp/src/models/WorkerInit.ts index 7668b5a..fe13bc2 100644 --- a/SecureSend/ClientApp/src/models/WorkerInit.ts +++ b/SecureSend/ClientApp/src/models/WorkerInit.ts @@ -1,8 +1,6 @@ -import type { encryptionKey } from "@/models/utilityTypes/encryptionKey"; - export interface IWorkerInit { request: string; - salt: Uint8Array; - masterKey: encryptionKey; + b64key: string; + password?: string; id: string; } diff --git a/SecureSend/ClientApp/src/models/enums/ErrorTypes.ts b/SecureSend/ClientApp/src/models/enums/ErrorTypes.ts index 7273d93..36330d8 100644 --- a/SecureSend/ClientApp/src/models/enums/ErrorTypes.ts +++ b/SecureSend/ClientApp/src/models/enums/ErrorTypes.ts @@ -1,4 +1,5 @@ export enum ErrorTypes { upload_does_not_exist = "upload_does_not_exist", upload_expired = "upload_expired", + invalid_password = "invalid_password", } diff --git a/SecureSend/ClientApp/src/models/errors/ResponseErrors.ts b/SecureSend/ClientApp/src/models/errors/ResponseErrors.ts index ce2568d..f06e4c0 100644 --- a/SecureSend/ClientApp/src/models/errors/ResponseErrors.ts +++ b/SecureSend/ClientApp/src/models/errors/ResponseErrors.ts @@ -11,3 +11,10 @@ export class UploadDoesNotExistError extends Error { Object.setPrototypeOf(this, UploadDoesNotExistError.prototype); } } + +export class InvalidPasswordError extends Error { + constructor(msg: string) { + super(msg); + Object.setPrototypeOf(this, InvalidPasswordError.prototype); + } +} diff --git a/SecureSend/ClientApp/src/models/utilityTypes/encryptionKey.ts b/SecureSend/ClientApp/src/models/utilityTypes/encryptionKey.ts deleted file mode 100644 index f829ed4..0000000 --- a/SecureSend/ClientApp/src/models/utilityTypes/encryptionKey.ts +++ /dev/null @@ -1 +0,0 @@ -export type encryptionKey = Uint8Array | string; diff --git a/SecureSend/ClientApp/src/router/index.ts b/SecureSend/ClientApp/src/router/index.ts index 108e4b0..8b16c46 100644 --- a/SecureSend/ClientApp/src/router/index.ts +++ b/SecureSend/ClientApp/src/router/index.ts @@ -1,7 +1,7 @@ -import type { SecureUploadDto } from "@/models/SecureUploadDto"; import { UploadExpiredError } from "@/models/errors/ResponseErrors"; import { SecureSendService } from "@/services/SecureSendService"; import { createRouter, createWebHistory } from "vue-router"; +import type { UploadVerifyResponseDTO } from "@/models/VerifyUploadResponseDTO"; const FileUploadView = () => import("@/views/FileUploadView.vue"); const FileDownloadView = () => import("@/views/FileDownloadView.vue"); @@ -34,25 +34,11 @@ const router = createRouter({ props: true, beforeEnter: async (to) => { try { - (to.params.secureUpload as unknown as SecureUploadDto) = - await SecureSendService.viewSecureUpload({ - id: to.params.id as string, - }); + (to.params + .verifyUploadResponse as unknown as UploadVerifyResponseDTO) = + await SecureSendService.verifySecureUpload(to.params.id as string); const keys = to.hash.split("_"); - (to.params.salt as unknown as Uint8Array) = new Uint8Array( - atob(keys[0].slice(1)) - .split("") - .map((c) => c.charCodeAt(0)) - ); - const isProtected = to.query["pass"] === "true"; - (to.params.isPasswordProtected as unknown as boolean) = isProtected; - (to.params.masterKey as unknown as string | Uint8Array) = isProtected - ? keys[1] - : new Uint8Array( - atob(keys[1]) - .split("") - .map((c) => c.charCodeAt(0)) - ); + to.params.b64Key = keys[0].slice(1); } catch (error) { if (error instanceof UploadExpiredError) { return { diff --git a/SecureSend/ClientApp/src/services/SecureSendService.ts b/SecureSend/ClientApp/src/services/SecureSendService.ts index 9f6647e..5b43d09 100644 --- a/SecureSend/ClientApp/src/services/SecureSendService.ts +++ b/SecureSend/ClientApp/src/services/SecureSendService.ts @@ -3,14 +3,16 @@ import type { CancelSecureUpload } from "@/models/CancelSecureUpload"; import type { SecureUploadDto } from "@/models/SecureUploadDto"; import type { ViewSecureUpload } from "@/models/ViewSecureUpload"; import { fetchWrapper } from "@/utils/fetchWrapper"; +import type { CreateSecureUpload } from "@/models/CreateSecureUpload"; +import type { UploadVerifyResponseDTO } from "@/models/VerifyUploadResponseDTO"; export abstract class SecureSendService { static createSecureUpload = async ( - uuid: string, - expiryDate: string + secureUpload: CreateSecureUpload ): Promise => { - return await fetchWrapper.post( - `${endpoints.secureSend}?uploadId=${uuid}&expiryDate=${expiryDate}` + return await fetchWrapper.post( + endpoints.secureSend, + secureUpload ); }; @@ -41,8 +43,9 @@ export abstract class SecureSendService { static viewSecureUpload = async ( viewSecureUpload: ViewSecureUpload ): Promise => { - return await fetchWrapper.put( - `${endpoints.secureSend}?id=${viewSecureUpload.id}` + return await fetchWrapper.put( + `${endpoints.secureSend}`, + viewSecureUpload ); }; @@ -53,4 +56,10 @@ export abstract class SecureSendService { `${endpoints.cancelUpload}?id=${cancelSecureUpload.id}&fileName=${cancelSecureUpload.fileName}` ); }; + + static verifySecureUpload = async ( + id: string + ): Promise => { + return await fetchWrapper.get(`${endpoints.secureSend}?id=${id}`); + }; } diff --git a/SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptography.ts b/SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptographyService.ts similarity index 57% rename from SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptography.ts rename to SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptographyService.ts index 2b2644d..c209938 100644 --- a/SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptography.ts +++ b/SecureSend/ClientApp/src/utils/AuthenticatedSecretKeyCryptographyService.ts @@ -1,41 +1,74 @@ -import { generateHash } from "./pbkdfHash"; -import type { encryptionKey } from "@/models/utilityTypes/encryptionKey"; - -export default class AuthenticatedSecretKeyCryptography { - public static readonly KEY_LENGTH_IN_BYTES = 16; - public static readonly IV_LENGTH_IN_BYTES = 16; +export default class AuthenticatedSecretKeyCryptographyService { + public static readonly KEY_LENGTH_IN_BYTES = 32; + public static readonly SALT_LENGTH_IN_BYTES = 16; public static readonly TAG_LENGTH_IN_BYTES = 16; + private readonly NONCE_LENGTH = 12; + private readonly tagLengthInBytes: number; private readonly ALGORITHM = "AES-GCM"; - private secretKey!: CryptoKey; - private keyData!: ArrayBuffer; - private readonly tagLengthInBytes: number; + + private cryptoKey!: CryptoKey; + private derivedKey!: ArrayBuffer; + private readonly masterKey: string | Uint8Array; private readonly salt: Uint8Array; private nonceBase!: ArrayBuffer; public seq: number; - private hash?: string; - private readonly masterKey: encryptionKey; private readonly requirePassword: boolean; constructor( - password?: encryptionKey, - salt = crypto.getRandomValues(new Uint8Array(16)), - tagLengthInBytes = AuthenticatedSecretKeyCryptography.TAG_LENGTH_IN_BYTES + password?: string, + b64Key?: string, + tagLengthInBytes = AuthenticatedSecretKeyCryptographyService.TAG_LENGTH_IN_BYTES ) { this.tagLengthInBytes = tagLengthInBytes; - this.salt = salt; - this.masterKey = password - ? password - : crypto.getRandomValues(new Uint8Array(32)); this.seq = 0; - this.requirePassword = typeof this.masterKey === "string"; + + if (b64Key) { + const arrayKey = this.base64ToArray(b64Key); + if (!password) { + this.salt = arrayKey.slice( + 0, + AuthenticatedSecretKeyCryptographyService.SALT_LENGTH_IN_BYTES + ); + this.masterKey = arrayKey.slice( + AuthenticatedSecretKeyCryptographyService.SALT_LENGTH_IN_BYTES + ); + } else { + this.salt = arrayKey; + this.masterKey = password; + } + } else { + this.salt = crypto.getRandomValues( + new Uint8Array( + AuthenticatedSecretKeyCryptographyService.SALT_LENGTH_IN_BYTES + ) + ); + this.masterKey = password + ? password + : crypto.getRandomValues( + new Uint8Array( + AuthenticatedSecretKeyCryptographyService.KEY_LENGTH_IN_BYTES + ) + ); + } + this.requirePassword = password ? true : false; + } + + private base64ToArray(b64Key: string): Uint8Array { + return new Uint8Array( + atob(b64Key) + .split("") + .map((c) => c.charCodeAt(0)) + ); } async start() { - this.secretKey = await this.getCryptoKeyFromRawKey(this.masterKey); + this.cryptoKey = await this.getCryptoKeyFromRawKey( + this.masterKey as string + ); this.nonceBase = await this.generateNonceBase(); } @@ -43,7 +76,7 @@ export default class AuthenticatedSecretKeyCryptography { const encoder = new TextEncoder(); const inputKey = await crypto.subtle.importKey( "raw", - this.keyData, + this.derivedKey, "HKDF", false, ["deriveKey"] @@ -82,13 +115,13 @@ export default class AuthenticatedSecretKeyCryptography { return new Uint8Array(nonce.buffer); } - public async getCryptoKeyFromRawKey(masterKey: encryptionKey) { - const keyData = this.requirePassword - ? await this.derivePbkdfKeyMaterial(masterKey as string, this.salt) + public async getCryptoKeyFromRawKey(password: string) { + this.derivedKey = this.requirePassword + ? await this.derivePbkdfKeyMaterial(password) : await this.deriveHkdfKeyMaterial(); return await crypto.subtle.importKey( "raw", - keyData, + this.derivedKey, { name: this.ALGORITHM, }, @@ -105,7 +138,7 @@ export default class AuthenticatedSecretKeyCryptography { iv: nonce, tagLength: this.tagLengthInBytes * 8, }, - this.secretKey, + this.cryptoKey, data.buffer ); } @@ -118,15 +151,12 @@ export default class AuthenticatedSecretKeyCryptography { iv: nonce, tagLength: this.tagLengthInBytes * 8, }, - this.secretKey, + this.cryptoKey, data ); } - private async derivePbkdfKeyMaterial( - password: string, - salt: Uint8Array - ): Promise { + private async derivePbkdfKeyMaterial(password: string): Promise { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( "raw", @@ -136,11 +166,10 @@ export default class AuthenticatedSecretKeyCryptography { ["deriveBits"] ); const keyBuffer = await crypto.subtle.deriveBits( - { name: "PBKDF2", salt, iterations: 1e6, hash: "SHA-256" }, + { name: "PBKDF2", salt: this.salt, iterations: 1e6, hash: "SHA-256" }, keyMaterial, 256 ); - this.keyData = keyBuffer; return keyBuffer; } @@ -153,7 +182,7 @@ export default class AuthenticatedSecretKeyCryptography { ["deriveBits"] ); const info = new TextEncoder().encode("info"); - this.keyData = await crypto.subtle.deriveBits( + return await crypto.subtle.deriveBits( { name: "HKDF", salt: this.salt, @@ -163,20 +192,17 @@ export default class AuthenticatedSecretKeyCryptography { keyMaterial, 256 // 256 bits key length ); - return this.keyData; - } - - getMasterKey() { - return this.masterKey as Uint8Array; } - getHash() { - if (this.hash) return this.hash; - this.hash = generateHash(this.keyData, this.salt); - return this.hash; - } - - getSalt() { - return this.salt; + getSecret() { + if (this.requirePassword) return btoa(String.fromCharCode(...this.salt)); + return btoa( + String.fromCharCode( + ...new Uint8Array([ + ...this.salt, + ...new Uint8Array(this.masterKey as Uint8Array), + ]) + ) + ); } } diff --git a/SecureSend/ClientApp/src/utils/fetchWrapper.ts b/SecureSend/ClientApp/src/utils/fetchWrapper.ts index 0df6b11..fb72cca 100644 --- a/SecureSend/ClientApp/src/utils/fetchWrapper.ts +++ b/SecureSend/ClientApp/src/utils/fetchWrapper.ts @@ -1,5 +1,6 @@ import { ErrorTypes } from "@/models/enums/ErrorTypes"; import { + InvalidPasswordError, UploadDoesNotExistError, UploadExpiredError, } from "@/models/errors/ResponseErrors"; @@ -22,7 +23,11 @@ function get(url: string): Promise { return fetch(url, requestOptions).then(handleResponse); } -function post(url: string, body?: Body, options?: RequestInit): Promise { +function post( + url: string, + body?: Y, + options?: RequestInit +): Promise { const requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, @@ -31,7 +36,7 @@ function post(url: string, body?: Body, options?: RequestInit): Promise { return fetch(url, options ?? requestOptions).then(handleResponse); } -function put(url: string, body?: Body): Promise { +function put(url: string, body?: Y): Promise { const requestOptions = { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -63,6 +68,8 @@ async function handleResponse(response: Response): Promise { ErrorTypes.upload_does_not_exist ) return Promise.reject(new UploadDoesNotExistError(data.Message)); + if ((data as IErrorResponse).ErrorCode === ErrorTypes.invalid_password) + return Promise.reject(new InvalidPasswordError(data.Message)); } return Promise.reject(new Error(response.statusText)); } diff --git a/SecureSend/ClientApp/src/utils/pbkdfHash.ts b/SecureSend/ClientApp/src/utils/pbkdfHash.ts deleted file mode 100644 index e8ae8f3..0000000 --- a/SecureSend/ClientApp/src/utils/pbkdfHash.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function generateHash(keyBuffer: ArrayBuffer, salt: Uint8Array) { - const keyArray = Array.from(new Uint8Array(keyBuffer)); - const saltArray = Array.from(new Uint8Array(salt)); - - const iterHex = ("000000" + (1e6).toString(16)).slice(-6) as string; - const iterArray = iterHex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)); - - const compositeArray: number[] = new Array().concat( - saltArray, - iterArray, - keyArray - ); - const compositeStr = compositeArray - .map((byte) => String.fromCharCode(byte)) - .join(""); - const compositeBase64 = btoa("v01" + compositeStr); - return compositeBase64; -} - -export async function verifyHash(key: string, password: string) { - let compositeStr = null; - try { - compositeStr = atob(key); - } catch (e) { - throw new Error("Invalid key"); - } - - const version = compositeStr.slice(0, 3); - const saltStr = compositeStr.slice(3, 19); - const iterStr = compositeStr.slice(19, 22); - const keyStr = compositeStr.slice(22, 54); - - if (version != "v01") throw new Error("Invalid key"); - - // -- recover salt & iterations from stored (composite) key - - const saltUint8 = new Uint8Array( - saltStr.match(/./g)!.map((ch) => ch.charCodeAt(0)) - ); - - const iterHex = iterStr - .match(/./g)! - .map((ch) => ch.charCodeAt(0).toString(16)) - .join(""); - const iterations = parseInt(iterHex, 16); - - const pwUtf8 = new TextEncoder().encode(password); - const pwKey = await crypto.subtle.importKey("raw", pwUtf8, "PBKDF2", false, [ - "deriveBits", - ]); - - const params = { - name: "PBKDF2", - hash: "SHA-256", - salt: saltUint8, - iterations: iterations, - }; - const keyBuffer = await crypto.subtle.deriveBits(params, pwKey, 256); - const keyArray = Array.from(new Uint8Array(keyBuffer)); - const keyStrNew = keyArray.map((byte) => String.fromCharCode(byte)).join(""); - - return keyStrNew == keyStr; -} diff --git a/SecureSend/ClientApp/src/utils/streams/StreamDecryptor.ts b/SecureSend/ClientApp/src/utils/streams/StreamDecryptor.ts index 64022cf..c9a667e 100644 --- a/SecureSend/ClientApp/src/utils/streams/StreamDecryptor.ts +++ b/SecureSend/ClientApp/src/utils/streams/StreamDecryptor.ts @@ -1,11 +1,13 @@ -import AuthenticatedSecretKeyCryptography from "../AuthenticatedSecretKeyCryptography"; -import type { encryptionKey } from "@/models/utilityTypes/encryptionKey"; +import AuthenticatedSecretKeyCryptographyService from "../AuthenticatedSecretKeyCryptographyService"; export default class StreamDecryptor { - private readonly keychain: AuthenticatedSecretKeyCryptography; + private readonly keychain: AuthenticatedSecretKeyCryptographyService; - constructor(masterKey: encryptionKey, salt: Uint8Array) { - this.keychain = new AuthenticatedSecretKeyCryptography(masterKey, salt); + constructor(b64Key: string, password?: string) { + this.keychain = new AuthenticatedSecretKeyCryptographyService( + password, + b64Key + ); } public async transform( diff --git a/SecureSend/ClientApp/src/utils/streams/decryptionStream.ts b/SecureSend/ClientApp/src/utils/streams/decryptionStream.ts index 1a86320..8cf3a6a 100644 --- a/SecureSend/ClientApp/src/utils/streams/decryptionStream.ts +++ b/SecureSend/ClientApp/src/utils/streams/decryptionStream.ts @@ -1,16 +1,15 @@ import StreamSlicer from "./StreamSlicer"; import StreamDecryptor from "./StreamDecryptor"; -import type { encryptionKey } from "@/models/utilityTypes/encryptionKey"; export default function decryptStream( input: ReadableStream, - salt: Uint8Array, - masterKey: encryptionKey + b64Key: string, + password?: string ) { const inputStream = input.pipeThrough( new TransformStream(new StreamSlicer(5 * 1024 * 1024 + 16)) ); return inputStream.pipeThrough( - new TransformStream(new StreamDecryptor(masterKey, salt)) + new TransformStream(new StreamDecryptor(b64Key, password)) ); } diff --git a/SecureSend/ClientApp/src/views/FileDownloadView.vue b/SecureSend/ClientApp/src/views/FileDownloadView.vue index 31750bb..7387092 100644 --- a/SecureSend/ClientApp/src/views/FileDownloadView.vue +++ b/SecureSend/ClientApp/src/views/FileDownloadView.vue @@ -3,41 +3,63 @@ import StyledButton from "@/components/StyledButton.vue"; import endpoints from "@/config/endpoints"; import type { SecureUploadDto } from "@/models/SecureUploadDto"; import { ButtonType } from "@/models/enums/ButtonType"; -import { verifyHash } from "@/utils/pbkdfHash"; -import { ref } from "vue"; +import { inject, ref } from "vue"; import SimpleInput from "@/components/SimpleInput.vue"; import { computed } from "vue"; +import type { Ref } from "vue"; import FileCard from "@/components/FileCard.vue"; import type { IWorkerInit } from "@/models/WorkerInit"; +import type { UploadVerifyResponseDTO } from "@/models/VerifyUploadResponseDTO"; +import { SecureSendService } from "@/services/SecureSendService"; +import { InvalidPasswordError } from "@/models/errors/ResponseErrors"; +import LoadingIndicator from "@/components/LoadingIndicator.vue"; const props = defineProps<{ - secureUpload: SecureUploadDto; - salt: Uint8Array; - masterKey: string | Uint8Array; - isPasswordProtected: boolean; + verifyUploadResponse: UploadVerifyResponseDTO; + b64Key: string; }>(); +const isLoading = inject>("isLoading"); + +const secureUpload = ref(null); + const password = ref(""); -const setUpWorker = () => { +const setUpWorker = async () => { navigator.serviceWorker.controller?.postMessage({ request: "init", - id: props.secureUpload.secureUploadId, - salt: props.salt, - masterKey: props.isPasswordProtected ? password.value : props.masterKey, + id: secureUpload.value!.secureUploadId, + b64key: props.b64Key, + password: props.verifyUploadResponse.isProtected + ? password.value + : undefined, } as IWorkerInit); }; -if (!props.isPasswordProtected) setUpWorker(); +if (!props.verifyUploadResponse.isProtected) { + secureUpload.value = await SecureSendService.viewSecureUpload({ + id: props.verifyUploadResponse.secureUploadId, + password: password.value, + }); + await setUpWorker(); +} const isPasswordValid = ref(); const verifyPassword = async () => { - isPasswordValid.value = await verifyHash( - props.masterKey as string, - password.value - ); - if (isPasswordValid.value) setUpWorker(); + isLoading!.value = true; + try { + secureUpload.value = await SecureSendService.viewSecureUpload({ + id: props.verifyUploadResponse.secureUploadId, + password: password.value, + }); + isPasswordValid.value = true; + await setUpWorker(); + } catch (err: unknown) { + if (err instanceof InvalidPasswordError) isPasswordValid.value = false; + else throw err; + } + isLoading!.value = false; }; const isPasswordValidComputed = computed( @@ -48,14 +70,14 @@ const isPasswordValidComputed = computed(

Download files

Download @@ -102,9 +124,18 @@ const isPasswordValidComputed = computed( errorMessage="Invalid password" >
- Unlock + + Unlock + + +
diff --git a/SecureSend/ClientApp/src/views/FileUploadView.vue b/SecureSend/ClientApp/src/views/FileUploadView.vue index b02a2cb..c0e599f 100644 --- a/SecureSend/ClientApp/src/views/FileUploadView.vue +++ b/SecureSend/ClientApp/src/views/FileUploadView.vue @@ -117,7 +117,7 @@