feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Policy;
|
||||
|
||||
public sealed record OfflineVerificationPolicy
|
||||
{
|
||||
[JsonPropertyName("keys")]
|
||||
public IReadOnlyList<string> Keys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("tlog")]
|
||||
public OfflineTlogPolicy? Tlog { get; init; }
|
||||
|
||||
[JsonPropertyName("attestations")]
|
||||
public OfflineAttestationsPolicy? Attestations { get; init; }
|
||||
|
||||
[JsonPropertyName("constraints")]
|
||||
public OfflineConstraintsPolicy? Constraints { get; init; }
|
||||
|
||||
public OfflineVerificationPolicy Canonicalize()
|
||||
{
|
||||
var tlog = (Tlog ?? new OfflineTlogPolicy()).Canonicalize();
|
||||
var attestations = (Attestations ?? new OfflineAttestationsPolicy()).Canonicalize();
|
||||
var constraints = (Constraints ?? new OfflineConstraintsPolicy()).Canonicalize();
|
||||
|
||||
var keys = CanonicalizeStrings(Keys);
|
||||
|
||||
return this with
|
||||
{
|
||||
Keys = keys,
|
||||
Tlog = tlog,
|
||||
Attestations = attestations,
|
||||
Constraints = constraints
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CanonicalizeStrings(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Select(static value => value?.Trim())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineTlogPolicy
|
||||
{
|
||||
[JsonPropertyName("mode")]
|
||||
public string? Mode { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public string? Checkpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("entry_pack")]
|
||||
public string? EntryPack { get; init; }
|
||||
|
||||
public OfflineTlogPolicy Canonicalize()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
Mode = NormalizeToken(Mode),
|
||||
Checkpoint = NormalizePathToken(Checkpoint),
|
||||
EntryPack = NormalizePathToken(EntryPack)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeToken(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizePathToken(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineAttestationsPolicy
|
||||
{
|
||||
[JsonPropertyName("required")]
|
||||
public IReadOnlyList<OfflineAttestationRequirement> Required { get; init; } = Array.Empty<OfflineAttestationRequirement>();
|
||||
|
||||
[JsonPropertyName("optional")]
|
||||
public IReadOnlyList<OfflineAttestationRequirement> Optional { get; init; } = Array.Empty<OfflineAttestationRequirement>();
|
||||
|
||||
public OfflineAttestationsPolicy Canonicalize()
|
||||
{
|
||||
var required = CanonicalizeRequirements(Required);
|
||||
var optional = CanonicalizeRequirements(Optional);
|
||||
|
||||
return this with
|
||||
{
|
||||
Required = required,
|
||||
Optional = optional
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OfflineAttestationRequirement> CanonicalizeRequirements(IReadOnlyList<OfflineAttestationRequirement>? requirements)
|
||||
{
|
||||
if (requirements is null || requirements.Count == 0)
|
||||
{
|
||||
return Array.Empty<OfflineAttestationRequirement>();
|
||||
}
|
||||
|
||||
return requirements
|
||||
.Select(static requirement => requirement.Canonicalize())
|
||||
.Where(static requirement => !string.IsNullOrWhiteSpace(requirement.Type))
|
||||
.DistinctBy(static requirement => requirement.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static requirement => requirement.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineAttestationRequirement
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
public OfflineAttestationRequirement Canonicalize()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Type))
|
||||
{
|
||||
return this with { Type = null };
|
||||
}
|
||||
|
||||
return this with { Type = Type.Trim().ToLowerInvariant() };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineConstraintsPolicy
|
||||
{
|
||||
[JsonPropertyName("subjects")]
|
||||
public OfflineSubjectsConstraints? Subjects { get; init; }
|
||||
|
||||
[JsonPropertyName("certs")]
|
||||
public OfflineCertConstraints? Certs { get; init; }
|
||||
|
||||
public OfflineConstraintsPolicy Canonicalize()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
Subjects = (Subjects ?? new OfflineSubjectsConstraints()).Canonicalize(),
|
||||
Certs = (Certs ?? new OfflineCertConstraints()).Canonicalize()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineSubjectsConstraints
|
||||
{
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
public OfflineSubjectsConstraints Canonicalize()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
{
|
||||
return this with { Algorithm = null };
|
||||
}
|
||||
|
||||
return this with { Algorithm = Algorithm.Trim().ToLowerInvariant() };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OfflineCertConstraints
|
||||
{
|
||||
[JsonPropertyName("allowed_issuers")]
|
||||
public IReadOnlyList<string> AllowedIssuers { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("allow_expired_if_timepinned")]
|
||||
public bool? AllowExpiredIfTimePinned { get; init; }
|
||||
|
||||
public OfflineCertConstraints Canonicalize()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
AllowedIssuers = CanonicalizeIssuers(AllowedIssuers)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> CanonicalizeIssuers(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Select(static value => value?.Trim())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Policy;
|
||||
|
||||
public static class OfflineVerificationPolicyLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter()
|
||||
}
|
||||
};
|
||||
|
||||
public static async Task<OfflineVerificationPolicy> LoadAsync(string policyPath, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyPath);
|
||||
|
||||
var content = await File.ReadAllTextAsync(policyPath, ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
throw new InvalidDataException("Offline verification policy is empty.");
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(policyPath);
|
||||
var isYaml = extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
extension.Equals(".yml", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var node = isYaml
|
||||
? ParseYamlToJsonNode(content)
|
||||
: JsonNode.Parse(content, documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
|
||||
var policy = node?.Deserialize<OfflineVerificationPolicy>(SerializerOptions);
|
||||
if (policy is null)
|
||||
{
|
||||
throw new InvalidDataException("Offline verification policy did not deserialize to an object.");
|
||||
}
|
||||
|
||||
return policy.Canonicalize();
|
||||
}
|
||||
|
||||
private static JsonNode? ParseYamlToJsonNode(string content)
|
||||
{
|
||||
var yaml = new YamlStream();
|
||||
using var reader = new StringReader(content);
|
||||
yaml.Load(reader);
|
||||
|
||||
if (yaml.Documents.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConvertYamlNode(yaml.Documents[0].RootNode);
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertYamlNode(YamlNode node)
|
||||
{
|
||||
return node switch
|
||||
{
|
||||
YamlMappingNode mapping => ConvertMapping(mapping),
|
||||
YamlSequenceNode sequence => ConvertSequence(sequence),
|
||||
YamlScalarNode scalar => ConvertScalar(scalar),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject ConvertMapping(YamlMappingNode mapping)
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
var entries = mapping.Children
|
||||
.Select(static kvp => (Key: kvp.Key as YamlScalarNode, Value: kvp.Value))
|
||||
.Where(static entry => entry.Key?.Value is not null)
|
||||
.OrderBy(static entry => entry.Key!.Value, StringComparer.Ordinal);
|
||||
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
obj[key!.Value!] = ConvertYamlNode(value);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static JsonArray ConvertSequence(YamlSequenceNode sequence)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var child in sequence.Children)
|
||||
{
|
||||
array.Add(ConvertYamlNode(child));
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertScalar(YamlScalarNode scalar)
|
||||
{
|
||||
if (scalar.Value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bool.TryParse(scalar.Value, out var boolean))
|
||||
{
|
||||
return JsonValue.Create(boolean);
|
||||
}
|
||||
|
||||
if (long.TryParse(scalar.Value, out var integer))
|
||||
{
|
||||
return JsonValue.Create(integer);
|
||||
}
|
||||
|
||||
if (decimal.TryParse(scalar.Value, out var decimalValue))
|
||||
{
|
||||
return JsonValue.Create(decimalValue);
|
||||
}
|
||||
|
||||
return JsonValue.Create(scalar.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Org.BouncyCastle.Asn1;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
@@ -95,8 +94,8 @@ internal sealed class EvidenceGraphDsseSigner
|
||||
var rs = signer.GenerateSignature(digest);
|
||||
var r = rs[0];
|
||||
var s = rs[1];
|
||||
var sequence = new DerSequence(new DerInteger(r), new DerInteger(s));
|
||||
return sequence.GetDerEncoded();
|
||||
|
||||
return CreateP1363Signature(r, s, algorithmId);
|
||||
}
|
||||
|
||||
private static (byte[] Digest, IDigest CalculatorDigest) CreateSignatureDigest(ReadOnlySpan<byte> message, string algorithmId)
|
||||
@@ -110,6 +109,30 @@ internal sealed class EvidenceGraphDsseSigner
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateP1363Signature(Org.BouncyCastle.Math.BigInteger r, Org.BouncyCastle.Math.BigInteger s, string algorithmId)
|
||||
{
|
||||
var componentLength = algorithmId?.ToUpperInvariant() switch
|
||||
{
|
||||
"ES256" => 32,
|
||||
"ES384" => 48,
|
||||
"ES512" => 66,
|
||||
_ => throw new NotSupportedException($"Unsupported ECDSA algorithm '{algorithmId}'.")
|
||||
};
|
||||
|
||||
var rBytes = r.ToByteArrayUnsigned();
|
||||
var sBytes = s.ToByteArrayUnsigned();
|
||||
|
||||
if (rBytes.Length > componentLength || sBytes.Length > componentLength)
|
||||
{
|
||||
throw new CryptographicException("Generated ECDSA signature component exceeded expected length.");
|
||||
}
|
||||
|
||||
var signature = new byte[componentLength * 2];
|
||||
rBytes.CopyTo(signature.AsSpan(componentLength - rBytes.Length, rBytes.Length));
|
||||
sBytes.CopyTo(signature.AsSpan(componentLength + (componentLength - sBytes.Length), sBytes.Length));
|
||||
return signature;
|
||||
}
|
||||
|
||||
private static ECPrivateKeyParameters LoadEcPrivateKey(string pemPath)
|
||||
{
|
||||
using var reader = File.OpenText(pemPath);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user