Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled

- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners.
- Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns.
- Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures.
- Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
StellaOps Bot
2025-12-07 13:44:13 +02:00
parent af30fc322f
commit 965cbf9574
49 changed files with 11935 additions and 152 deletions

View File

@@ -11,6 +11,7 @@ using StellaOps.Attestor.Core.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using StellaOps.Cryptography.Plugin.SmSoft;
namespace StellaOps.Attestor.Infrastructure.Signing;
@@ -44,6 +45,21 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
var edProvider = new BouncyCastleEd25519CryptoProvider();
RegisterProvider(edProvider);
// SM2 software provider (non-certified). Requires SM_SOFT_ALLOWED env to be enabled.
SmSoftCryptoProvider? smProvider = null;
if (RequiresSm2(signingOptions))
{
smProvider = new SmSoftCryptoProvider();
if (smProvider.Supports(CryptoCapability.Signing, SignatureAlgorithms.Sm2))
{
RegisterProvider(smProvider);
}
else
{
_logger.LogWarning("SM2 requested but SM_SOFT_ALLOWED is not enabled; SM provider not registered.");
}
}
KmsCryptoProvider? kmsProvider = null;
if (RequiresKms(signingOptions))
{
@@ -86,6 +102,7 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
providerMap,
defaultProvider,
edProvider,
smProvider,
kmsProvider,
_kmsClient,
timeProvider);
@@ -126,11 +143,16 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
=> signingOptions.Keys?.Any(static key =>
string.Equals(key?.Mode, "kms", StringComparison.OrdinalIgnoreCase)) == true;
private static bool RequiresSm2(AttestorOptions.SigningOptions signingOptions)
=> signingOptions.Keys?.Any(static key =>
string.Equals(key?.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase)) == true;
private SigningKeyEntry CreateEntry(
AttestorOptions.SigningKeyOptions key,
IReadOnlyDictionary<string, ICryptoProvider> providers,
DefaultCryptoProvider defaultProvider,
BouncyCastleEd25519CryptoProvider edProvider,
SmSoftCryptoProvider? smProvider,
KmsCryptoProvider? kmsProvider,
FileKmsClient? kmsClient,
TimeProvider timeProvider)
@@ -205,6 +227,22 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
edProvider.UpsertSigningKey(signingKey);
}
else if (string.Equals(providerName, "cn.sm.soft", StringComparison.OrdinalIgnoreCase))
{
if (smProvider is null)
{
throw new InvalidOperationException($"SM2 signing provider is not configured but signing key '{key.KeyId}' requests algorithm 'SM2'.");
}
var privateKeyBytes = LoadSm2KeyBytes(key);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference(providerKeyId, providerName),
normalizedAlgorithm,
privateKeyBytes,
now);
smProvider.UpsertSigningKey(signingKey);
}
else
{
var parameters = LoadEcParameters(key);
@@ -252,6 +290,11 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
return "bouncycastle.ed25519";
}
if (string.Equals(key.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase))
{
return "cn.sm.soft";
}
return "default";
}
@@ -311,6 +354,20 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
return ecdsa.ExportParameters(true);
}
private static byte[] LoadSm2KeyBytes(AttestorOptions.SigningKeyOptions key)
{
var material = ReadMaterial(key);
// SM2 provider accepts PEM or PKCS#8 DER bytes
return key.MaterialFormat?.ToLowerInvariant() switch
{
null or "pem" => System.Text.Encoding.UTF8.GetBytes(material),
"base64" => Convert.FromBase64String(material),
"hex" => Convert.FromHexString(material),
_ => throw new InvalidOperationException($"Unsupported materialFormat '{key.MaterialFormat}' for SM2 signing key '{key.KeyId}'. Supported formats: pem, base64, hex.")
};
}
private static string ReadMaterial(AttestorOptions.SigningKeyOptions key)
{
if (!string.IsNullOrWhiteSpace(key.MaterialPassphrase))

View File

@@ -12,6 +12,7 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
@@ -23,6 +24,6 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
<PackageReference Include="AWSSDK.S3" Version="4.0.2" />
</ItemGroup>
</Project>

View File

@@ -15,6 +15,10 @@ using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Tests;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Security;
using Xunit;
namespace StellaOps.Attestor.Tests;
@@ -210,6 +214,147 @@ public sealed class AttestorSigningServiceTests : IDisposable
Assert.Equal("signed", auditSink.Records[0].Result);
}
[Fact]
public async Task SignAsync_Sm2Key_ReturnsValidSignature_WhenGateEnabled()
{
var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
try
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
// Generate SM2 key pair
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
var generator = new ECKeyPairGenerator("EC");
generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
var keyPair = generator.GenerateKeyPair();
var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
var options = Options.Create(new AttestorOptions
{
Signing = new AttestorOptions.SigningOptions
{
Keys =
{
new AttestorOptions.SigningKeyOptions
{
KeyId = "sm2-1",
Algorithm = SignatureAlgorithms.Sm2,
Mode = "keyful",
Material = Convert.ToBase64String(privateDer),
MaterialFormat = "base64"
}
}
}
});
using var metrics = new AttestorMetrics();
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
var auditSink = new InMemoryAttestorAuditSink();
var service = new AttestorSigningService(
registry,
new DefaultDsseCanonicalizer(),
auditSink,
metrics,
NullLogger<AttestorSigningService>.Instance,
TimeProvider.System);
var payloadBytes = Encoding.UTF8.GetBytes("{}");
var request = new AttestationSignRequest
{
KeyId = "sm2-1",
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(payloadBytes),
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('c', 64),
Kind = "sbom"
}
};
var context = new SubmissionContext
{
CallerSubject = "urn:subject",
CallerAudience = "attestor",
CallerClientId = "client",
CallerTenant = "tenant",
MtlsThumbprint = "thumbprint"
};
var result = await service.SignAsync(request, context);
Assert.NotNull(result);
Assert.Equal("sm2-1", result.KeyId);
Assert.Equal("keyful", result.Mode);
Assert.Equal("cn.sm.soft", result.Provider);
Assert.Equal(SignatureAlgorithms.Sm2, result.Algorithm);
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
Assert.Single(result.Bundle.Dsse.Signatures);
// Verify the signature
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
var verifier = new SM2Signer();
var userId = Encoding.ASCII.GetBytes("1234567812345678");
verifier.Init(false, new ParametersWithID(keyPair.Public, userId));
verifier.BlockUpdate(preAuth, 0, preAuth.Length);
Assert.True(verifier.VerifySignature(signature));
Assert.Single(auditSink.Records);
Assert.Equal("sign", auditSink.Records[0].Action);
Assert.Equal("signed", auditSink.Records[0].Result);
}
finally
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
}
}
[Fact]
public void Sm2Registry_Fails_WhenGateDisabled()
{
var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
try
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", null);
// Generate SM2 key pair
var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
var generator = new ECKeyPairGenerator("EC");
generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
var keyPair = generator.GenerateKeyPair();
var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
var options = Options.Create(new AttestorOptions
{
Signing = new AttestorOptions.SigningOptions
{
Keys =
{
new AttestorOptions.SigningKeyOptions
{
KeyId = "sm2-fail",
Algorithm = SignatureAlgorithms.Sm2,
Mode = "keyful",
Material = Convert.ToBase64String(privateDer),
MaterialFormat = "base64"
}
}
}
});
// Creating registry should throw because SM_SOFT_ALLOWED is not set
Assert.Throws<InvalidOperationException>(() =>
new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance));
}
finally
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
}
}
private string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "attestor-signing-tests", Guid.NewGuid().ToString("N"));

View File

@@ -61,7 +61,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
@@ -149,7 +149,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
@@ -325,7 +325,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
var repository = new InMemoryAttestorEntryRepository();
var rekorClient = new RecordingRekorClient();
@@ -388,7 +388,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
var repository = new InMemoryAttestorEntryRepository();
var rekorClient = new RecordingRekorClient();
@@ -498,7 +498,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());

View File

@@ -21,5 +21,6 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,12 +1,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Cryptography;
namespace StellaOps.Attestor.Tests;
@@ -210,3 +213,66 @@ internal sealed class InMemoryAttestorArchiveStore : IAttestorArchiveStore
return Task.FromResult<AttestorArchiveBundle?>(null);
}
}
internal sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var algorithm = CreateAlgorithm(algorithmId);
return algorithm.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var algorithm = CreateAlgorithm(algorithmId);
await using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return algorithm.ComputeHash(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data, HashAlgorithms.Sha256);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data, HashAlgorithms.Sha256);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data, HashAlgorithms.Sha256);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken);
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken);
public string GetAlgorithmForPurpose(string purpose)
=> HashAlgorithms.Sha256;
public string GetHashPrefix(string purpose)
=> "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> $"{GetHashPrefix(purpose)}{ComputeHashHexForPurpose(data, purpose)}";
private static HashAlgorithm CreateAlgorithm(string? algorithmId)
{
return algorithmId?.ToUpperInvariant() switch
{
null or "" or HashAlgorithms.Sha256 => SHA256.Create(),
HashAlgorithms.Sha512 => SHA512.Create(),
_ => throw new NotSupportedException($"Test crypto hash does not support algorithm {algorithmId}.")
};
}
}