up
This commit is contained in:
		@@ -0,0 +1,125 @@
 | 
			
		||||
using System;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
 | 
			
		||||
 | 
			
		||||
public class CryptoPasswordHasherTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Hash_EmitsArgon2idByDefault()
 | 
			
		||||
    {
 | 
			
		||||
        var options = CreateOptions();
 | 
			
		||||
        var hasher = new CryptoPasswordHasher(options, new DefaultCryptoProvider());
 | 
			
		||||
 | 
			
		||||
        var encoded = hasher.Hash("Secr3t!");
 | 
			
		||||
 | 
			
		||||
        Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Verify_ReturnsSuccess_ForCurrentAlgorithm()
 | 
			
		||||
    {
 | 
			
		||||
        var options = CreateOptions();
 | 
			
		||||
        var provider = new DefaultCryptoProvider();
 | 
			
		||||
        var hasher = new CryptoPasswordHasher(options, provider);
 | 
			
		||||
        var encoded = hasher.Hash("Passw0rd!");
 | 
			
		||||
 | 
			
		||||
        var result = hasher.Verify("Passw0rd!", encoded);
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(PasswordVerificationResult.Success, result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Verify_FlagsLegacyPbkdf2_ForRehash()
 | 
			
		||||
    {
 | 
			
		||||
        var options = CreateOptions();
 | 
			
		||||
        var provider = new DefaultCryptoProvider();
 | 
			
		||||
        var hasher = new CryptoPasswordHasher(options, provider);
 | 
			
		||||
 | 
			
		||||
        var legacy = new Pbkdf2PasswordHasher().Hash(
 | 
			
		||||
            "Passw0rd!",
 | 
			
		||||
            new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Algorithm = PasswordHashAlgorithm.Pbkdf2,
 | 
			
		||||
                Iterations = 150_000
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        var result = hasher.Verify("Passw0rd!", legacy);
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(PasswordVerificationResult.SuccessRehashNeeded, result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Verify_RejectsTamperedPayload()
 | 
			
		||||
    {
 | 
			
		||||
        var options = CreateOptions();
 | 
			
		||||
        var provider = new DefaultCryptoProvider();
 | 
			
		||||
        var hasher = new CryptoPasswordHasher(options, provider);
 | 
			
		||||
 | 
			
		||||
        var legacy = new Pbkdf2PasswordHasher().Hash(
 | 
			
		||||
            "Passw0rd!",
 | 
			
		||||
            new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Algorithm = PasswordHashAlgorithm.Pbkdf2,
 | 
			
		||||
                Iterations = 160_000
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        var tampered = legacy + "corrupted";
 | 
			
		||||
 | 
			
		||||
        var result = hasher.Verify("Passw0rd!", tampered);
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(PasswordVerificationResult.Failed, result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Verify_AllowsLegacyAlgorithmWhenConfigured()
 | 
			
		||||
    {
 | 
			
		||||
        var options = CreateOptions();
 | 
			
		||||
        options.PasswordHashing = options.PasswordHashing with
 | 
			
		||||
        {
 | 
			
		||||
            Algorithm = PasswordHashAlgorithm.Pbkdf2,
 | 
			
		||||
            Iterations = 200_000
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var provider = new DefaultCryptoProvider();
 | 
			
		||||
        var hasher = new CryptoPasswordHasher(options, provider);
 | 
			
		||||
 | 
			
		||||
        var legacy = new Pbkdf2PasswordHasher().Hash(
 | 
			
		||||
            "Passw0rd!",
 | 
			
		||||
            new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Algorithm = PasswordHashAlgorithm.Pbkdf2,
 | 
			
		||||
                Iterations = 200_000
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        var result = hasher.Verify("Passw0rd!", legacy);
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(PasswordVerificationResult.Success, result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static StandardPluginOptions CreateOptions() => new()
 | 
			
		||||
    {
 | 
			
		||||
        PasswordPolicy = new PasswordPolicyOptions
 | 
			
		||||
        {
 | 
			
		||||
            MinimumLength = 8,
 | 
			
		||||
            RequireDigit = true,
 | 
			
		||||
            RequireLowercase = true,
 | 
			
		||||
            RequireUppercase = true,
 | 
			
		||||
            RequireSymbol = false
 | 
			
		||||
        },
 | 
			
		||||
        Lockout = new LockoutOptions
 | 
			
		||||
        {
 | 
			
		||||
            Enabled = true,
 | 
			
		||||
            MaxAttempts = 5,
 | 
			
		||||
            WindowMinutes = 15
 | 
			
		||||
        },
 | 
			
		||||
        PasswordHashing = new PasswordHashOptions
 | 
			
		||||
        {
 | 
			
		||||
            Algorithm = PasswordHashAlgorithm.Argon2id,
 | 
			
		||||
            MemorySizeInKib = 8 * 1024,
 | 
			
		||||
            Iterations = 2,
 | 
			
		||||
            Parallelism = 1
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
 | 
			
		||||
 | 
			
		||||
@@ -96,4 +97,49 @@ public class StandardPluginOptionsTests
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_WhenPasswordHashingMemoryInvalid()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            PasswordHashing = new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                MemorySizeInKib = 0
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
 | 
			
		||||
        Assert.Contains("memory", ex.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_WhenPasswordHashingIterationsInvalid()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            PasswordHashing = new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Iterations = 0
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
 | 
			
		||||
        Assert.Contains("iteration", ex.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_WhenPasswordHashingParallelismInvalid()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            PasswordHashing = new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Parallelism = 0
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
 | 
			
		||||
        Assert.Contains("parallelism", ex.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,6 +34,9 @@ public class StandardPluginRegistrarTests
 | 
			
		||||
                ["passwordPolicy:requireDigit"] = "false",
 | 
			
		||||
                ["passwordPolicy:requireSymbol"] = "false",
 | 
			
		||||
                ["lockout:enabled"] = "false",
 | 
			
		||||
                ["passwordHashing:memorySizeInKib"] = "8192",
 | 
			
		||||
                ["passwordHashing:iterations"] = "2",
 | 
			
		||||
                ["passwordHashing:parallelism"] = "1",
 | 
			
		||||
                ["bootstrapUser:username"] = "bootstrap",
 | 
			
		||||
                ["bootstrapUser:password"] = "Bootstrap1!",
 | 
			
		||||
                ["bootstrapUser:requirePasswordReset"] = "true"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging.Abstractions;
 | 
			
		||||
@@ -7,6 +9,7 @@ using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
 | 
			
		||||
 | 
			
		||||
@@ -37,13 +40,21 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
 | 
			
		||||
                Enabled = true,
 | 
			
		||||
                MaxAttempts = 2,
 | 
			
		||||
                WindowMinutes = 1
 | 
			
		||||
            },
 | 
			
		||||
            PasswordHashing = new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Algorithm = PasswordHashAlgorithm.Argon2id,
 | 
			
		||||
                MemorySizeInKib = 8 * 1024,
 | 
			
		||||
                Iterations = 2,
 | 
			
		||||
                Parallelism = 1
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        var cryptoProvider = new DefaultCryptoProvider();
 | 
			
		||||
        store = new StandardUserCredentialStore(
 | 
			
		||||
            "standard",
 | 
			
		||||
            database,
 | 
			
		||||
            options,
 | 
			
		||||
            new Pbkdf2PasswordHasher(),
 | 
			
		||||
            new CryptoPasswordHasher(options, cryptoProvider),
 | 
			
		||||
            NullLogger<StandardUserCredentialStore>.Instance);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -65,6 +76,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
 | 
			
		||||
        var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None);
 | 
			
		||||
        Assert.True(result.Succeeded);
 | 
			
		||||
        Assert.Equal("alice", result.User?.Username);
 | 
			
		||||
        Assert.Empty(result.AuditProperties);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
@@ -90,6 +102,46 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
 | 
			
		||||
        Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode);
 | 
			
		||||
        Assert.NotNull(second.RetryAfter);
 | 
			
		||||
        Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
 | 
			
		||||
        Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
 | 
			
		||||
    {
 | 
			
		||||
        var legacyHash = new Pbkdf2PasswordHasher().Hash(
 | 
			
		||||
            "Legacy1!",
 | 
			
		||||
            new PasswordHashOptions
 | 
			
		||||
            {
 | 
			
		||||
                Algorithm = PasswordHashAlgorithm.Pbkdf2,
 | 
			
		||||
                Iterations = 160_000
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        var document = new StandardUserDocument
 | 
			
		||||
        {
 | 
			
		||||
            Username = "legacy",
 | 
			
		||||
            NormalizedUsername = "legacy",
 | 
			
		||||
            PasswordHash = legacyHash,
 | 
			
		||||
            Roles = new List<string>(),
 | 
			
		||||
            Attributes = new Dictionary<string, string?>(),
 | 
			
		||||
            CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
 | 
			
		||||
            UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await database.GetCollection<StandardUserDocument>("authority_users_standard")
 | 
			
		||||
            .InsertOneAsync(document);
 | 
			
		||||
 | 
			
		||||
        var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
 | 
			
		||||
 | 
			
		||||
        Assert.True(result.Succeeded);
 | 
			
		||||
        Assert.Equal("legacy", result.User?.Username);
 | 
			
		||||
        Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed");
 | 
			
		||||
 | 
			
		||||
        var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
 | 
			
		||||
            .Find(u => u.NormalizedUsername == "legacy")
 | 
			
		||||
            .FirstOrDefaultAsync();
 | 
			
		||||
 | 
			
		||||
        Assert.NotNull(updated);
 | 
			
		||||
        Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task InitializeAsync() => Task.CompletedTask;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user