save checkpoint: save features
This commit is contained in:
@@ -12,6 +12,7 @@ using System.Text.Json.Serialization;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddLogging();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
// Minimal crypto registry: only SM2 soft provider, no remote/http probing
|
||||
builder.Services.AddSingleton<ICryptoProviderRegistry>(_ =>
|
||||
{
|
||||
@@ -30,50 +31,133 @@ builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("smremote");
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.TryAddStellaOpsLocalBinding("smremote");
|
||||
}
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("smremote");
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.LogStellaOpsLocalHostname("smremote");
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new SmHealthResponse("ok")));
|
||||
|
||||
app.MapGet("/status", (ICryptoProviderRegistry registry) =>
|
||||
{
|
||||
var algorithms = new[] { SignatureAlgorithms.Sm2 };
|
||||
return Results.Ok(new SmStatusResponse(true, "cn.sm.soft", algorithms));
|
||||
});
|
||||
|
||||
app.MapPost("/sign", async (SignRequest req, ICryptoProviderRegistry registry, CancellationToken ct) =>
|
||||
app.MapPost("/hash", (HashRequest req) =>
|
||||
{
|
||||
if (req is null || string.IsNullOrWhiteSpace(req.KeyId) || string.IsNullOrWhiteSpace(req.AlgorithmId) || string.IsNullOrWhiteSpace(req.PayloadBase64))
|
||||
if (req is null ||
|
||||
!TryGetSupportedHashAlgorithm(req.AlgorithmId, out var algorithmId) ||
|
||||
!TryDecodeBase64(req.PayloadBase64, out var payload))
|
||||
{
|
||||
return Results.BadRequest("missing fields");
|
||||
return Results.BadRequest("missing or invalid fields");
|
||||
}
|
||||
|
||||
var digest = new Org.BouncyCastle.Crypto.Digests.SM3Digest();
|
||||
digest.BlockUpdate(payload, 0, payload.Length);
|
||||
var hash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(hash, 0);
|
||||
|
||||
return Results.Ok(new HashResponse(
|
||||
algorithmId,
|
||||
Convert.ToBase64String(hash),
|
||||
Convert.ToHexString(hash).ToLowerInvariant()));
|
||||
});
|
||||
|
||||
app.MapPost("/encrypt", (EncryptRequest req) =>
|
||||
{
|
||||
if (req is null ||
|
||||
!TryGetSupportedSm4Algorithm(req.AlgorithmId, out var algorithmId) ||
|
||||
!TryDecodeBase64(req.KeyBase64, out var keyBytes) ||
|
||||
!TryDecodeBase64(req.PayloadBase64, out var payload))
|
||||
{
|
||||
return Results.BadRequest("missing or invalid fields");
|
||||
}
|
||||
|
||||
if (keyBytes.Length != 16)
|
||||
{
|
||||
return Results.BadRequest("invalid sm4 key length");
|
||||
}
|
||||
|
||||
var cipher = new Org.BouncyCastle.Crypto.Paddings.PaddedBufferedBlockCipher(
|
||||
new Org.BouncyCastle.Crypto.Engines.SM4Engine(),
|
||||
new Org.BouncyCastle.Crypto.Paddings.Pkcs7Padding());
|
||||
cipher.Init(true, new Org.BouncyCastle.Crypto.Parameters.KeyParameter(keyBytes));
|
||||
var ciphertext = ProcessCipher(cipher, payload);
|
||||
|
||||
return Results.Ok(new EncryptResponse(algorithmId, Convert.ToBase64String(ciphertext)));
|
||||
});
|
||||
|
||||
app.MapPost("/decrypt", (DecryptRequest req) =>
|
||||
{
|
||||
if (req is null ||
|
||||
!TryGetSupportedSm4Algorithm(req.AlgorithmId, out var algorithmId) ||
|
||||
!TryDecodeBase64(req.KeyBase64, out var keyBytes) ||
|
||||
!TryDecodeBase64(req.CiphertextBase64, out var ciphertext))
|
||||
{
|
||||
return Results.BadRequest("missing or invalid fields");
|
||||
}
|
||||
|
||||
if (keyBytes.Length != 16)
|
||||
{
|
||||
return Results.BadRequest("invalid sm4 key length");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cipher = new Org.BouncyCastle.Crypto.Paddings.PaddedBufferedBlockCipher(
|
||||
new Org.BouncyCastle.Crypto.Engines.SM4Engine(),
|
||||
new Org.BouncyCastle.Crypto.Paddings.Pkcs7Padding());
|
||||
cipher.Init(false, new Org.BouncyCastle.Crypto.Parameters.KeyParameter(keyBytes));
|
||||
var payload = ProcessCipher(cipher, ciphertext);
|
||||
return Results.Ok(new DecryptResponse(algorithmId, Convert.ToBase64String(payload)));
|
||||
}
|
||||
catch (Org.BouncyCastle.Crypto.InvalidCipherTextException)
|
||||
{
|
||||
return Results.BadRequest("invalid ciphertext");
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/sign", async (SignRequest req, ICryptoProviderRegistry registry, TimeProvider timeProvider, CancellationToken ct) =>
|
||||
{
|
||||
if (req is null ||
|
||||
string.IsNullOrWhiteSpace(req.KeyId) ||
|
||||
string.IsNullOrWhiteSpace(req.AlgorithmId) ||
|
||||
!TryDecodeBase64(req.PayloadBase64, out var payload))
|
||||
{
|
||||
return Results.BadRequest("missing or invalid fields");
|
||||
}
|
||||
|
||||
var provider = ResolveProvider(registry);
|
||||
EnsureKeySeeded(provider, req.KeyId);
|
||||
EnsureKeySeeded(provider, req.KeyId, timeProvider);
|
||||
|
||||
var resolution = registry.ResolveSigner(CryptoCapability.Signing, req.AlgorithmId, new CryptoKeyReference(req.KeyId, provider.Name), provider.Name);
|
||||
var signer = resolution.Signer;
|
||||
var payload = Convert.FromBase64String(req.PayloadBase64);
|
||||
var signature = await signer.SignAsync(payload, ct);
|
||||
return Results.Ok(new SignResponse(Convert.ToBase64String(signature)));
|
||||
});
|
||||
|
||||
app.MapPost("/verify", async (VerifyRequest req, ICryptoProviderRegistry registry, CancellationToken ct) =>
|
||||
app.MapPost("/verify", async (VerifyRequest req, ICryptoProviderRegistry registry, TimeProvider timeProvider, CancellationToken ct) =>
|
||||
{
|
||||
if (req is null || string.IsNullOrWhiteSpace(req.KeyId) || string.IsNullOrWhiteSpace(req.AlgorithmId) ||
|
||||
string.IsNullOrWhiteSpace(req.PayloadBase64) || string.IsNullOrWhiteSpace(req.Signature))
|
||||
!TryDecodeBase64(req.PayloadBase64, out var payload) ||
|
||||
!TryDecodeBase64(req.Signature, out var signature))
|
||||
{
|
||||
return Results.BadRequest("missing fields");
|
||||
return Results.BadRequest("missing or invalid fields");
|
||||
}
|
||||
|
||||
var provider = ResolveProvider(registry);
|
||||
EnsureKeySeeded(provider, req.KeyId);
|
||||
EnsureKeySeeded(provider, req.KeyId, timeProvider);
|
||||
|
||||
var resolution = registry.ResolveSigner(CryptoCapability.Signing, req.AlgorithmId, new CryptoKeyReference(req.KeyId, provider.Name), provider.Name);
|
||||
var signer = resolution.Signer;
|
||||
var payload = Convert.FromBase64String(req.PayloadBase64);
|
||||
var signature = Convert.FromBase64String(req.Signature);
|
||||
var ok = await signer.VerifyAsync(payload, signature, ct);
|
||||
return Results.Ok(new VerifyResponse(ok));
|
||||
});
|
||||
@@ -95,7 +179,7 @@ static ICryptoProvider ResolveProvider(ICryptoProviderRegistry registry)
|
||||
return registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Sm2);
|
||||
}
|
||||
|
||||
static void EnsureKeySeeded(ICryptoProvider provider, string keyId)
|
||||
static void EnsureKeySeeded(ICryptoProvider provider, string keyId, TimeProvider timeProvider)
|
||||
{
|
||||
// The soft provider hides private material via GetSigningKeys(), so rely on diagnostics DescribeKeys() to detect presence.
|
||||
if (provider is ICryptoProviderDiagnostics diag &&
|
||||
@@ -112,10 +196,83 @@ static void EnsureKeySeeded(ICryptoProvider provider, string keyId)
|
||||
new CryptoKeyReference(keyId, provider.Name),
|
||||
SignatureAlgorithms.Sm2,
|
||||
privateDer,
|
||||
DateTimeOffset.UtcNow));
|
||||
timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
static bool TryDecodeBase64(string value, out byte[] result)
|
||||
{
|
||||
result = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
result = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryGetSupportedHashAlgorithm(string? value, out string algorithmId)
|
||||
{
|
||||
algorithmId = string.IsNullOrWhiteSpace(value) ? HashAlgorithms.Sm3 : value.Trim();
|
||||
if (algorithmId.Equals(HashAlgorithms.Sm3, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
algorithmId = HashAlgorithms.Sm3;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetSupportedSm4Algorithm(string? value, out string algorithmId)
|
||||
{
|
||||
algorithmId = string.IsNullOrWhiteSpace(value) ? "SM4-ECB" : value.Trim().ToUpperInvariant();
|
||||
if (algorithmId is "SM4" or "SM4-ECB")
|
||||
{
|
||||
algorithmId = "SM4-ECB";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static byte[] ProcessCipher(Org.BouncyCastle.Crypto.BufferedBlockCipher cipher, byte[] input)
|
||||
{
|
||||
var output = new byte[cipher.GetOutputSize(input.Length)];
|
||||
var written = cipher.ProcessBytes(input, 0, input.Length, output, 0);
|
||||
written += cipher.DoFinal(output, written);
|
||||
return output[..written];
|
||||
}
|
||||
|
||||
public sealed record SmHealthResponse([property: JsonPropertyName("status")] string Status);
|
||||
public sealed record SmStatusResponse(bool Available, string Provider, IEnumerable<string> Algorithms);
|
||||
public sealed record HashRequest(
|
||||
[property: JsonPropertyName("algorithmId")] string? AlgorithmId,
|
||||
[property: JsonPropertyName("payloadBase64")] string PayloadBase64);
|
||||
public sealed record HashResponse(
|
||||
[property: JsonPropertyName("algorithmId")] string AlgorithmId,
|
||||
[property: JsonPropertyName("hashBase64")] string HashBase64,
|
||||
[property: JsonPropertyName("hashHex")] string HashHex);
|
||||
public sealed record EncryptRequest(
|
||||
[property: JsonPropertyName("algorithmId")] string? AlgorithmId,
|
||||
[property: JsonPropertyName("keyBase64")] string KeyBase64,
|
||||
[property: JsonPropertyName("payloadBase64")] string PayloadBase64);
|
||||
public sealed record EncryptResponse(
|
||||
[property: JsonPropertyName("algorithmId")] string AlgorithmId,
|
||||
[property: JsonPropertyName("ciphertextBase64")] string CiphertextBase64);
|
||||
public sealed record DecryptRequest(
|
||||
[property: JsonPropertyName("algorithmId")] string? AlgorithmId,
|
||||
[property: JsonPropertyName("keyBase64")] string KeyBase64,
|
||||
[property: JsonPropertyName("ciphertextBase64")] string CiphertextBase64);
|
||||
public sealed record DecryptResponse(
|
||||
[property: JsonPropertyName("algorithmId")] string AlgorithmId,
|
||||
[property: JsonPropertyName("payloadBase64")] string PayloadBase64);
|
||||
public sealed record SignRequest([property: JsonPropertyName("keyId")] string KeyId,
|
||||
[property: JsonPropertyName("algorithmId")] string AlgorithmId,
|
||||
[property: JsonPropertyName("payloadBase64")] string PayloadBase64);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.SmRemote.Service.Tests;
|
||||
|
||||
public sealed class SmRemoteServiceApiTests
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task HealthAndStatusEndpoints_ReturnSuccess()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var healthResponse = await client.GetAsync("/health");
|
||||
Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode);
|
||||
|
||||
var status = await client.GetFromJsonAsync<SmStatusResponse>("/status");
|
||||
Assert.NotNull(status);
|
||||
Assert.True(status!.Available);
|
||||
Assert.Equal("cn.sm.soft", status.Provider);
|
||||
Assert.Contains(SignatureAlgorithms.Sm2, status.Algorithms);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task HashEndpoint_ComputesExpectedSm3()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("smremote-sm3-payload");
|
||||
var request = new HashRequest(
|
||||
"SM3",
|
||||
Convert.ToBase64String(payloadBytes));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/hash", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<HashResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("SM3", body!.AlgorithmId);
|
||||
|
||||
var digest = new SM3Digest();
|
||||
digest.BlockUpdate(payloadBytes, 0, payloadBytes.Length);
|
||||
var expectedHash = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(expectedHash, 0);
|
||||
|
||||
Assert.Equal(Convert.ToBase64String(expectedHash), body.HashBase64);
|
||||
Assert.Equal(Convert.ToHexString(expectedHash).ToLowerInvariant(), body.HashHex);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task EncryptThenDecrypt_RoundTripsPayload()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var keyBytes = Enumerable.Range(1, 16).Select(static i => (byte)i).ToArray();
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("smremote-sm4-roundtrip");
|
||||
var encryptRequest = new EncryptRequest(
|
||||
"SM4-ECB",
|
||||
Convert.ToBase64String(keyBytes),
|
||||
Convert.ToBase64String(payloadBytes));
|
||||
|
||||
var encryptResponse = await client.PostAsJsonAsync("/encrypt", encryptRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, encryptResponse.StatusCode);
|
||||
|
||||
var encrypted = await encryptResponse.Content.ReadFromJsonAsync<EncryptResponse>();
|
||||
Assert.NotNull(encrypted);
|
||||
Assert.Equal("SM4-ECB", encrypted!.AlgorithmId);
|
||||
Assert.NotEqual(Convert.ToBase64String(payloadBytes), encrypted.CiphertextBase64);
|
||||
|
||||
var decryptRequest = new DecryptRequest("SM4-ECB", Convert.ToBase64String(keyBytes), encrypted.CiphertextBase64);
|
||||
var decryptResponse = await client.PostAsJsonAsync("/decrypt", decryptRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, decryptResponse.StatusCode);
|
||||
|
||||
var decrypted = await decryptResponse.Content.ReadFromJsonAsync<DecryptResponse>();
|
||||
Assert.NotNull(decrypted);
|
||||
Assert.Equal(Convert.ToBase64String(payloadBytes), decrypted!.PayloadBase64);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task HashEndpoint_WithInvalidBase64_ReturnsBadRequest()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/hash", new HashRequest("SM3", "not-base64"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task SignThenVerify_ReportsValidAndInvalidOutcomes()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("smremote-sm2-payload");
|
||||
var signRequest = new SignRequest(
|
||||
"qa-signing-key",
|
||||
SignatureAlgorithms.Sm2,
|
||||
Convert.ToBase64String(payloadBytes));
|
||||
|
||||
var signResponse = await client.PostAsJsonAsync("/sign", signRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, signResponse.StatusCode);
|
||||
|
||||
var signed = await signResponse.Content.ReadFromJsonAsync<SignResponse>();
|
||||
Assert.NotNull(signed);
|
||||
Assert.False(string.IsNullOrWhiteSpace(signed!.Signature));
|
||||
|
||||
var verifyRequest = new VerifyRequest(
|
||||
"qa-signing-key",
|
||||
SignatureAlgorithms.Sm2,
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
signed.Signature);
|
||||
var verifyResponse = await client.PostAsJsonAsync("/verify", verifyRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResponse.StatusCode);
|
||||
|
||||
var verified = await verifyResponse.Content.ReadFromJsonAsync<VerifyResponse>();
|
||||
Assert.NotNull(verified);
|
||||
Assert.True(verified!.Valid);
|
||||
|
||||
var tamperedRequest = verifyRequest with
|
||||
{
|
||||
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("smremote-sm2-payload-tampered"))
|
||||
};
|
||||
var tamperedVerifyResponse = await client.PostAsJsonAsync("/verify", tamperedRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, tamperedVerifyResponse.StatusCode);
|
||||
|
||||
var tamperedResult = await tamperedVerifyResponse.Content.ReadFromJsonAsync<VerifyResponse>();
|
||||
Assert.NotNull(tamperedResult);
|
||||
Assert.False(tamperedResult!.Valid);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task EncryptEndpoint_WithInvalidKeyLength_ReturnsBadRequest()
|
||||
{
|
||||
using var app = new WebApplicationFactory<Program>();
|
||||
using var client = app.CreateClient();
|
||||
|
||||
var shortKey = Convert.ToBase64String(Enumerable.Range(1, 8).Select(static i => (byte)i).ToArray());
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("smremote-invalid-key"));
|
||||
var response = await client.PostAsJsonAsync("/encrypt", new EncryptRequest("SM4-ECB", shortKey, payload));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.SmRemote.Service\StellaOps.SmRemote.Service.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user