336 lines
14 KiB
C#
336 lines
14 KiB
C#
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.SmRemote.Service.Security;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Cryptography;
|
|
using StellaOps.Cryptography.Plugin.SmSoft;
|
|
using System.Linq;
|
|
using System.Text.Json.Serialization;
|
|
|
|
using StellaOps.Router.AspNet;
|
|
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>(_ =>
|
|
{
|
|
var smOpts = Options.Create(new StellaOps.Cryptography.Plugin.SmSoft.SmSoftProviderOptions
|
|
{
|
|
RequireEnvironmentGate = false
|
|
});
|
|
var smProvider = new SmSoftCryptoProvider(smOpts);
|
|
var providers = new ICryptoProvider[] { smProvider };
|
|
var preferred = new[] { "cn.sm.soft" };
|
|
return new CryptoProviderRegistry(providers, preferred);
|
|
});
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
|
|
// Authentication and authorization
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddStellaOpsScopePolicy(SmRemotePolicies.Sign, StellaOpsScopes.SmRemoteSign);
|
|
options.AddStellaOpsScopePolicy(SmRemotePolicies.Verify, StellaOpsScopes.SmRemoteVerify);
|
|
});
|
|
|
|
builder.Services.AddStellaOpsTenantServices();
|
|
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
|
|
|
// Stella Router integration
|
|
var routerEnabled = builder.Services.AddRouterMicroservice(
|
|
builder.Configuration,
|
|
serviceName: "smremote",
|
|
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
|
routerOptionsSection: "Router");
|
|
|
|
if (builder.Environment.IsDevelopment())
|
|
{
|
|
builder.TryAddStellaOpsLocalBinding("smremote");
|
|
}
|
|
var app = builder.Build();
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.LogStellaOpsLocalHostname("smremote");
|
|
}
|
|
|
|
app.UseStellaOpsCors();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseStellaOpsTenantMiddleware();
|
|
app.TryUseStellaRouter(routerEnabled);
|
|
|
|
app.MapGet("/health", () => Results.Ok(new SmHealthResponse("ok")))
|
|
.WithName("SmRemoteHealth")
|
|
.WithDescription("Returns the liveness status of the SM Remote crypto service. Always returns 200 OK with status 'ok' when the service is running. Used by infrastructure health probes.")
|
|
.AllowAnonymous();
|
|
|
|
app.MapGet("/status", (ICryptoProviderRegistry registry) =>
|
|
{
|
|
var algorithms = new[] { SignatureAlgorithms.Sm2 };
|
|
return Results.Ok(new SmStatusResponse(true, "cn.sm.soft", algorithms));
|
|
})
|
|
.WithName("SmRemoteStatus")
|
|
.WithDescription("Returns the availability status and supported algorithms of the SM Remote crypto provider. Reports the active provider name (cn.sm.soft or cn.sm.remote.http) and the list of supported signature algorithms.")
|
|
.AllowAnonymous();
|
|
|
|
app.MapPost("/hash", (HashRequest req) =>
|
|
{
|
|
if (req is null ||
|
|
!TryGetSupportedHashAlgorithm(req.AlgorithmId, out var algorithmId) ||
|
|
!TryDecodeBase64(req.PayloadBase64, out var payload))
|
|
{
|
|
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()));
|
|
})
|
|
.WithName("SmRemoteHash")
|
|
.WithDescription("Computes an SM3 hash of the provided base64-encoded payload. Returns the hash as both base64 and lowercase hex. Defaults to SM3 if algorithmId is omitted. Returns 400 if the payload is missing, invalid base64, or an unsupported algorithm is requested.")
|
|
.RequireAuthorization(SmRemotePolicies.Sign)
|
|
.RequireTenant();
|
|
|
|
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)));
|
|
})
|
|
.WithName("SmRemoteEncrypt")
|
|
.WithDescription("Encrypts the provided base64-encoded payload using SM4-ECB with PKCS7 padding and the supplied 128-bit (16-byte) base64-encoded key. Returns the ciphertext as base64. Returns 400 if the key, payload, or algorithm is missing, invalid, or the key length is not 16 bytes.")
|
|
.RequireTenant();
|
|
|
|
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");
|
|
}
|
|
})
|
|
.WithName("SmRemoteDecrypt")
|
|
.WithDescription("Decrypts the provided base64-encoded SM4-ECB ciphertext using the supplied 128-bit (16-byte) base64-encoded key with PKCS7 unpadding. Returns the plaintext payload as base64. Returns 400 if the key, ciphertext, or algorithm is invalid, or if the ciphertext padding is corrupt.")
|
|
.RequireTenant();
|
|
|
|
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, timeProvider);
|
|
|
|
var resolution = registry.ResolveSigner(CryptoCapability.Signing, req.AlgorithmId, new CryptoKeyReference(req.KeyId, provider.Name), provider.Name);
|
|
var signer = resolution.Signer;
|
|
var signature = await signer.SignAsync(payload, ct);
|
|
return Results.Ok(new SignResponse(Convert.ToBase64String(signature)));
|
|
})
|
|
.WithName("SmRemoteSign")
|
|
.WithDescription("Signs the provided base64-encoded payload using the SM2 algorithm and the specified key ID. Seeds the key from an ephemeral EC key pair if not already present. Returns the base64-encoded SM2 signature. Returns 400 if the key ID, algorithm, or payload is missing or invalid.")
|
|
.RequireTenant();
|
|
|
|
app.MapPost("/verify", async (VerifyRequest 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) ||
|
|
!TryDecodeBase64(req.Signature, out var signature))
|
|
{
|
|
return Results.BadRequest("missing or invalid fields");
|
|
}
|
|
|
|
var provider = ResolveProvider(registry);
|
|
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 ok = await signer.VerifyAsync(payload, signature, ct);
|
|
return Results.Ok(new VerifyResponse(ok));
|
|
})
|
|
.WithName("SmRemoteVerify")
|
|
.WithDescription("Verifies an SM2 signature against the provided base64-encoded payload using the specified key ID. Returns a boolean valid field indicating whether the signature matches. Returns 400 if the key ID, algorithm, payload, or signature is missing or invalid base64.")
|
|
.RequireTenant();
|
|
|
|
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
|
app.Run();
|
|
|
|
static ICryptoProvider ResolveProvider(ICryptoProviderRegistry registry)
|
|
{
|
|
if (registry.TryResolve("cn.sm.remote.http", out var remote) && remote is not null)
|
|
{
|
|
return remote;
|
|
}
|
|
|
|
if (registry.TryResolve("cn.sm.soft", out var soft) && soft is not null)
|
|
{
|
|
return soft;
|
|
}
|
|
|
|
return registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Sm2);
|
|
}
|
|
|
|
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 &&
|
|
diag.DescribeKeys().Any(k => k.KeyId.Equals(keyId, StringComparison.OrdinalIgnoreCase))) return;
|
|
|
|
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
|
|
var domain = new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
|
|
var generator = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator("EC");
|
|
generator.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(domain, new Org.BouncyCastle.Security.SecureRandom()));
|
|
var pair = generator.GenerateKeyPair();
|
|
var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(pair.Private).GetDerEncoded();
|
|
|
|
provider.UpsertSigningKey(new CryptoSigningKey(
|
|
new CryptoKeyReference(keyId, provider.Name),
|
|
SignatureAlgorithms.Sm2,
|
|
privateDer,
|
|
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);
|
|
public sealed record SignResponse([property: JsonPropertyName("signature")] string Signature);
|
|
public sealed record VerifyRequest([property: JsonPropertyName("keyId")] string KeyId,
|
|
[property: JsonPropertyName("algorithmId")] string AlgorithmId,
|
|
[property: JsonPropertyName("payloadBase64")] string PayloadBase64,
|
|
[property: JsonPropertyName("signature")] string Signature);
|
|
public sealed record VerifyResponse([property: JsonPropertyName("valid")] bool Valid);
|
|
|
|
// Expose Program class for WebApplicationFactory in tests
|
|
public partial class Program;
|
|
|