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:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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>