|
|
|
|
@@ -0,0 +1,398 @@
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Serialization;
|
|
|
|
|
using FluentAssertions;
|
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using StellaOps.Attestor.Core.Options;
|
|
|
|
|
using StellaOps.Attestor.Core.Signing;
|
|
|
|
|
using StellaOps.Attestor.Core.Storage;
|
|
|
|
|
using StellaOps.Attestor.Core.Submission;
|
|
|
|
|
using StellaOps.Attestor.Verify;
|
|
|
|
|
using StellaOps.Cryptography;
|
|
|
|
|
using StellaOps.TestKit;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Attestor.Verify.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed class AttestorVerificationEngineTests
|
|
|
|
|
{
|
|
|
|
|
private static readonly DateTimeOffset FixedTime = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
|
|
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task VerifyAsync_KmsSignaturesCountedOncePerSignature()
|
|
|
|
|
{
|
|
|
|
|
var canonicalizer = new TestDsseCanonicalizer();
|
|
|
|
|
var cryptoHash = new TestCryptoHash();
|
|
|
|
|
var options = Options.Create(new AttestorOptions
|
|
|
|
|
{
|
|
|
|
|
Verification =
|
|
|
|
|
{
|
|
|
|
|
RequireTransparencyInclusion = false,
|
|
|
|
|
RequireCheckpoint = false
|
|
|
|
|
},
|
|
|
|
|
Security =
|
|
|
|
|
{
|
|
|
|
|
SignerIdentity =
|
|
|
|
|
{
|
|
|
|
|
KmsKeys = new List<string>
|
|
|
|
|
{
|
|
|
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes("kms-secret-1")),
|
|
|
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes("kms-secret-2"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
|
|
|
|
|
var payloadType = "application/vnd.in-toto+json";
|
|
|
|
|
var signatureBytes = ComputeHmacSignature(payload, payloadType, Encoding.UTF8.GetBytes("kms-secret-1"));
|
|
|
|
|
|
|
|
|
|
var bundle = new AttestorSubmissionRequest.SubmissionBundle
|
|
|
|
|
{
|
|
|
|
|
Mode = "kms",
|
|
|
|
|
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
|
|
|
|
{
|
|
|
|
|
PayloadType = payloadType,
|
|
|
|
|
PayloadBase64 = Convert.ToBase64String(payload),
|
|
|
|
|
Signatures =
|
|
|
|
|
{
|
|
|
|
|
new AttestorSubmissionRequest.DsseSignature
|
|
|
|
|
{
|
|
|
|
|
Signature = Convert.ToBase64String(signatureBytes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "kms");
|
|
|
|
|
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
|
|
|
|
|
|
|
|
|
|
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
|
|
|
|
|
|
|
|
|
|
report.Signatures.VerifiedSignatures.Should().Be(1);
|
|
|
|
|
report.Signatures.TotalSignatures.Should().Be(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task VerifyAsync_KeylessUsesAsnSanParsing()
|
|
|
|
|
{
|
|
|
|
|
using var key = ECDsa.Create();
|
|
|
|
|
using var cert = CreateSelfSignedCertificate(key, "CN=Leaf", "leaf.example.test");
|
|
|
|
|
|
|
|
|
|
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
|
|
|
|
|
var payloadType = "application/vnd.in-toto+json";
|
|
|
|
|
var signatureBytes = key.SignData(DssePreAuthenticationEncoding.Compute(payloadType, payload), HashAlgorithmName.SHA256);
|
|
|
|
|
|
|
|
|
|
var bundle = new AttestorSubmissionRequest.SubmissionBundle
|
|
|
|
|
{
|
|
|
|
|
Mode = "keyless",
|
|
|
|
|
CertificateChain = { ToPem(cert) },
|
|
|
|
|
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
|
|
|
|
{
|
|
|
|
|
PayloadType = payloadType,
|
|
|
|
|
PayloadBase64 = Convert.ToBase64String(payload),
|
|
|
|
|
Signatures =
|
|
|
|
|
{
|
|
|
|
|
new AttestorSubmissionRequest.DsseSignature
|
|
|
|
|
{
|
|
|
|
|
Signature = Convert.ToBase64String(signatureBytes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var canonicalizer = new TestDsseCanonicalizer();
|
|
|
|
|
var cryptoHash = new TestCryptoHash();
|
|
|
|
|
var options = Options.Create(new AttestorOptions
|
|
|
|
|
{
|
|
|
|
|
Verification =
|
|
|
|
|
{
|
|
|
|
|
RequireTransparencyInclusion = false,
|
|
|
|
|
RequireCheckpoint = false
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "keyless");
|
|
|
|
|
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
|
|
|
|
|
|
|
|
|
|
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
|
|
|
|
|
|
|
|
|
|
report.Issuer.SubjectAlternativeName.Should().Be("leaf.example.test");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task VerifyAsync_KeylessChainBuildUsesIntermediateStore()
|
|
|
|
|
{
|
|
|
|
|
using var rootKey = ECDsa.Create();
|
|
|
|
|
using var root = CreateCertificateAuthority(rootKey, "CN=Root");
|
|
|
|
|
|
|
|
|
|
using var intermediateKey = ECDsa.Create();
|
|
|
|
|
using var intermediate = CreateIntermediateCertificate(intermediateKey, root, "CN=Intermediate");
|
|
|
|
|
|
|
|
|
|
using var leafKey = ECDsa.Create();
|
|
|
|
|
using var leaf = CreateLeafCertificate(leafKey, intermediate, "CN=Leaf", "leaf-chain.example");
|
|
|
|
|
|
|
|
|
|
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
|
|
|
|
|
var payloadType = "application/vnd.in-toto+json";
|
|
|
|
|
var signatureBytes = leafKey.SignData(DssePreAuthenticationEncoding.Compute(payloadType, payload), HashAlgorithmName.SHA256);
|
|
|
|
|
|
|
|
|
|
var bundle = new AttestorSubmissionRequest.SubmissionBundle
|
|
|
|
|
{
|
|
|
|
|
Mode = "keyless",
|
|
|
|
|
CertificateChain =
|
|
|
|
|
{
|
|
|
|
|
ToPem(leaf),
|
|
|
|
|
ToPem(intermediate)
|
|
|
|
|
},
|
|
|
|
|
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
|
|
|
|
{
|
|
|
|
|
PayloadType = payloadType,
|
|
|
|
|
PayloadBase64 = Convert.ToBase64String(payload),
|
|
|
|
|
Signatures =
|
|
|
|
|
{
|
|
|
|
|
new AttestorSubmissionRequest.DsseSignature
|
|
|
|
|
{
|
|
|
|
|
Signature = Convert.ToBase64String(signatureBytes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var rootPath = Path.GetTempFileName();
|
|
|
|
|
File.WriteAllBytes(rootPath, root.Export(X509ContentType.Cert));
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var canonicalizer = new TestDsseCanonicalizer();
|
|
|
|
|
var cryptoHash = new TestCryptoHash();
|
|
|
|
|
var options = Options.Create(new AttestorOptions
|
|
|
|
|
{
|
|
|
|
|
Verification =
|
|
|
|
|
{
|
|
|
|
|
RequireTransparencyInclusion = false,
|
|
|
|
|
RequireCheckpoint = false
|
|
|
|
|
},
|
|
|
|
|
Security =
|
|
|
|
|
{
|
|
|
|
|
SignerIdentity =
|
|
|
|
|
{
|
|
|
|
|
FulcioRoots = { rootPath }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "keyless");
|
|
|
|
|
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
|
|
|
|
|
|
|
|
|
|
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
|
|
|
|
|
|
|
|
|
|
report.Issuer.Issues.Should().NotContain(issue => issue.StartsWith("certificate_chain_untrusted", StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
File.Delete(rootPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
|
|
|
[Fact]
|
|
|
|
|
public void DssePreAuthenticationEncoding_FollowsSpec()
|
|
|
|
|
{
|
|
|
|
|
var payload = Encoding.UTF8.GetBytes("{}");
|
|
|
|
|
var payloadType = "application/test";
|
|
|
|
|
|
|
|
|
|
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
|
|
|
|
var expected = $"DSSEv1 {Encoding.UTF8.GetByteCount(payloadType)} {payloadType} {payload.Length} {{}}";
|
|
|
|
|
|
|
|
|
|
Encoding.UTF8.GetString(pae).Should().Be(expected);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static AttestorEntry BuildEntry(
|
|
|
|
|
AttestorSubmissionRequest.SubmissionBundle bundle,
|
|
|
|
|
IDsseCanonicalizer canonicalizer,
|
|
|
|
|
ICryptoHash cryptoHash,
|
|
|
|
|
string mode)
|
|
|
|
|
{
|
|
|
|
|
var request = new AttestorSubmissionRequest
|
|
|
|
|
{
|
|
|
|
|
Bundle = bundle,
|
|
|
|
|
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
|
|
|
|
{
|
|
|
|
|
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
|
|
|
|
{
|
|
|
|
|
Sha256 = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
|
|
|
Kind = "container",
|
|
|
|
|
ImageDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
|
|
|
SubjectUri = "oci://registry.example.test/example"
|
|
|
|
|
},
|
|
|
|
|
BundleSha256 = string.Empty
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var canonicalBytes = canonicalizer.CanonicalizeAsync(request, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
|
var bundleHash = cryptoHash.ComputeHashHexForPurpose(canonicalBytes, HashPurpose.Attestation);
|
|
|
|
|
|
|
|
|
|
return new AttestorEntry
|
|
|
|
|
{
|
|
|
|
|
RekorUuid = "rekor-test",
|
|
|
|
|
BundleSha256 = bundleHash,
|
|
|
|
|
Status = "included",
|
|
|
|
|
Artifact = new AttestorEntry.ArtifactDescriptor
|
|
|
|
|
{
|
|
|
|
|
Sha256 = request.Meta.Artifact.Sha256,
|
|
|
|
|
Kind = request.Meta.Artifact.Kind,
|
|
|
|
|
ImageDigest = request.Meta.Artifact.ImageDigest,
|
|
|
|
|
SubjectUri = request.Meta.Artifact.SubjectUri
|
|
|
|
|
},
|
|
|
|
|
Log = new AttestorEntry.LogDescriptor
|
|
|
|
|
{
|
|
|
|
|
Backend = "primary",
|
|
|
|
|
Url = "https://rekor.example.test"
|
|
|
|
|
},
|
|
|
|
|
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
|
|
|
|
{
|
|
|
|
|
Mode = mode
|
|
|
|
|
},
|
|
|
|
|
CreatedAt = FixedTime
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static byte[] ComputeHmacSignature(byte[] payload, string payloadType, byte[] secret)
|
|
|
|
|
{
|
|
|
|
|
using var hmac = new HMACSHA256(secret);
|
|
|
|
|
return hmac.ComputeHash(DssePreAuthenticationEncoding.Compute(payloadType, payload));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static X509Certificate2 CreateSelfSignedCertificate(ECDsa key, string subject, string dnsName)
|
|
|
|
|
{
|
|
|
|
|
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
|
|
|
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
|
|
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
|
|
|
|
|
var sanBuilder = new SubjectAlternativeNameBuilder();
|
|
|
|
|
sanBuilder.AddDnsName(dnsName);
|
|
|
|
|
request.CertificateExtensions.Add(sanBuilder.Build());
|
|
|
|
|
|
|
|
|
|
var cert = request.CreateSelfSigned(FixedTime.AddDays(-1), FixedTime.AddDays(1));
|
|
|
|
|
return EnsurePrivateKey(cert, key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static X509Certificate2 CreateCertificateAuthority(ECDsa key, string subject)
|
|
|
|
|
{
|
|
|
|
|
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
|
|
|
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
|
|
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true));
|
|
|
|
|
|
|
|
|
|
var cert = request.CreateSelfSigned(FixedTime.AddDays(-1), FixedTime.AddDays(1));
|
|
|
|
|
return EnsurePrivateKey(cert, key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static X509Certificate2 CreateIntermediateCertificate(ECDsa key, X509Certificate2 issuer, string subject)
|
|
|
|
|
{
|
|
|
|
|
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
|
|
|
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
|
|
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true));
|
|
|
|
|
|
|
|
|
|
var serial = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
|
|
|
|
var cert = request.Create(issuer, FixedTime.AddDays(-1), FixedTime.AddDays(1), serial);
|
|
|
|
|
return EnsurePrivateKey(cert, key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static X509Certificate2 CreateLeafCertificate(ECDsa key, X509Certificate2 issuer, string subject, string dnsName)
|
|
|
|
|
{
|
|
|
|
|
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
|
|
|
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
|
|
|
|
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
|
|
|
|
|
|
|
|
|
var sanBuilder = new SubjectAlternativeNameBuilder();
|
|
|
|
|
sanBuilder.AddDnsName(dnsName);
|
|
|
|
|
request.CertificateExtensions.Add(sanBuilder.Build());
|
|
|
|
|
|
|
|
|
|
var serial = new byte[] { 0x05, 0x06, 0x07, 0x08 };
|
|
|
|
|
var cert = request.Create(issuer, FixedTime.AddDays(-1), FixedTime.AddDays(1), serial);
|
|
|
|
|
return EnsurePrivateKey(cert, key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static X509Certificate2 EnsurePrivateKey(X509Certificate2 certificate, ECDsa key)
|
|
|
|
|
=> certificate.HasPrivateKey ? certificate : certificate.CopyWithPrivateKey(key);
|
|
|
|
|
|
|
|
|
|
private static string ToPem(X509Certificate2 certificate)
|
|
|
|
|
{
|
|
|
|
|
var builder = new StringBuilder();
|
|
|
|
|
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
|
|
|
|
builder.AppendLine(Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
|
|
|
|
|
builder.AppendLine("-----END CERTIFICATE-----");
|
|
|
|
|
return builder.ToString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestDsseCanonicalizer : IDsseCanonicalizer
|
|
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions Options = new()
|
|
|
|
|
{
|
|
|
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
|
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
|
|
|
WriteIndented = false
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
|
|
|
|
=> Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(request, Options));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestCryptoHash : ICryptoHash
|
|
|
|
|
{
|
|
|
|
|
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
|
|
|
|
{
|
|
|
|
|
using var algorithm = SHA256.Create();
|
|
|
|
|
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 = SHA256.Create();
|
|
|
|
|
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)}";
|
|
|
|
|
}
|
|
|
|
|
}
|