From d4abf061d5dbf5a69689e1f283f175891a5db146 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:20:12 +0200
Subject: [PATCH 01/77] build(crypto): scaffold SquidStd.Crypto module over
PgpCore
---
SquidStd.slnx | 1 +
src/SquidStd.Crypto/SquidStd.Crypto.csproj | 19 +++++++++++++++++++
tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 +
3 files changed, 21 insertions(+)
create mode 100644 src/SquidStd.Crypto/SquidStd.Crypto.csproj
diff --git a/SquidStd.slnx b/SquidStd.slnx
index c970f273..55f1af76 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -37,6 +37,7 @@
+
diff --git a/src/SquidStd.Crypto/SquidStd.Crypto.csproj b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
new file mode 100644
index 00000000..f7054b8e
--- /dev/null
+++ b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index 810f8e0c..82c89143 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -70,6 +70,7 @@
+
From fd3abe8f25dc709e606c56f481b94b42c226cda9 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:21:02 +0200
Subject: [PATCH 02/77] feat(crypto): add PGP key model, options, and result
DTOs
---
.../Pgp/Data/PgpDecryptionResult.cs | 7 +++++++
src/SquidStd.Crypto/Pgp/Data/PgpKey.cs | 21 +++++++++++++++++++
src/SquidStd.Crypto/Pgp/Data/PgpKeyOptions.cs | 18 ++++++++++++++++
.../Pgp/Data/PgpVerificationResult.cs | 7 +++++++
.../Pgp/Types/PgpKeyAlgorithm.cs | 10 +++++++++
5 files changed, 63 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Data/PgpDecryptionResult.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Data/PgpKeyOptions.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Data/PgpVerificationResult.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Types/PgpKeyAlgorithm.cs
diff --git a/src/SquidStd.Crypto/Pgp/Data/PgpDecryptionResult.cs b/src/SquidStd.Crypto/Pgp/Data/PgpDecryptionResult.cs
new file mode 100644
index 00000000..417836b4
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Data/PgpDecryptionResult.cs
@@ -0,0 +1,7 @@
+namespace SquidStd.Crypto.Pgp.Data;
+
+///
+/// Outcome of decrypt-and-verify: the recovered plaintext, whether the message carried a signature, and
+/// whether that signature validated against a keyring public key.
+///
+public sealed record PgpDecryptionResult(byte[] Data, bool IsSigned, bool IsValid);
diff --git a/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs b/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
new file mode 100644
index 00000000..467f18dd
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
@@ -0,0 +1,21 @@
+using SquidStd.Crypto.Pgp.Types;
+
+namespace SquidStd.Crypto.Pgp.Data;
+
+///
+/// An OpenPGP key as held in the keyring: identity, key id, fingerprint, the armored public block, and
+/// optionally the armored secret block. Metadata is derived from the public key material.
+///
+public sealed record PgpKey(
+ string Identity,
+ string KeyId,
+ string Fingerprint,
+ string PublicArmored,
+ string? PrivateArmored,
+ DateTimeOffset CreatedUtc,
+ DateTimeOffset? ExpiresUtc,
+ PgpKeyAlgorithm Algorithm)
+{
+ /// Whether this key carries secret material (can decrypt and sign).
+ public bool HasSecret => PrivateArmored is not null;
+}
diff --git a/src/SquidStd.Crypto/Pgp/Data/PgpKeyOptions.cs b/src/SquidStd.Crypto/Pgp/Data/PgpKeyOptions.cs
new file mode 100644
index 00000000..69f62e86
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Data/PgpKeyOptions.cs
@@ -0,0 +1,18 @@
+using SquidStd.Crypto.Pgp.Types;
+
+namespace SquidStd.Crypto.Pgp.Data;
+
+///
+/// Options controlling key generation: algorithm, key size, and an optional validity period.
+///
+public sealed class PgpKeyOptions
+{
+ /// Public-key algorithm family. Defaults to RSA.
+ public PgpKeyAlgorithm Algorithm { get; init; } = PgpKeyAlgorithm.Rsa;
+
+ /// RSA key size in bits. Defaults to 2048.
+ public int KeySizeBits { get; init; } = 2048;
+
+ /// Optional validity period from creation; null means the key does not expire.
+ public TimeSpan? ExpiresAfter { get; init; }
+}
diff --git a/src/SquidStd.Crypto/Pgp/Data/PgpVerificationResult.cs b/src/SquidStd.Crypto/Pgp/Data/PgpVerificationResult.cs
new file mode 100644
index 00000000..7fe6fc3d
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Data/PgpVerificationResult.cs
@@ -0,0 +1,7 @@
+namespace SquidStd.Crypto.Pgp.Data;
+
+///
+/// Outcome of verifying a signed message: whether the signature validated against a keyring public key,
+/// plus the recovered content. PgpCore reports pass/fail only — no signer attribution is exposed.
+///
+public sealed record PgpVerificationResult(bool IsValid, byte[] Data);
diff --git a/src/SquidStd.Crypto/Pgp/Types/PgpKeyAlgorithm.cs b/src/SquidStd.Crypto/Pgp/Types/PgpKeyAlgorithm.cs
new file mode 100644
index 00000000..f98a5288
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Types/PgpKeyAlgorithm.cs
@@ -0,0 +1,10 @@
+namespace SquidStd.Crypto.Pgp.Types;
+
+///
+/// OpenPGP public-key algorithm family for a generated key. Extensible (ECC may be added later).
+///
+public enum PgpKeyAlgorithm
+{
+ /// RSA key pair.
+ Rsa
+}
From 22bc121c6a0da19c108f2a70b1b687ba912c3578 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:21:02 +0200
Subject: [PATCH 03/77] feat(crypto): add PGP service, keyring, and key store
contracts
---
.../Pgp/Interfaces/IPgpKeyStore.cs | 15 +++++++
.../Pgp/Interfaces/IPgpKeyring.cs | 31 ++++++++++++++
.../Pgp/Interfaces/IPgpService.cs | 40 +++++++++++++++++++
3 files changed, 86 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyStore.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyring.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
diff --git a/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyStore.cs b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyStore.cs
new file mode 100644
index 00000000..b5f4ebbb
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyStore.cs
@@ -0,0 +1,15 @@
+using SquidStd.Crypto.Pgp.Data;
+
+namespace SquidStd.Crypto.Pgp.Interfaces;
+
+///
+/// Persistence backend for a set of PGP keys. Implementations decide the on-disk representation.
+///
+public interface IPgpKeyStore
+{
+ /// Persists the supplied keys, replacing any previously stored set.
+ Task SaveAsync(IReadOnlyCollection keys, CancellationToken cancellationToken = default);
+
+ /// Loads all persisted keys. Returns an empty list when nothing is stored.
+ Task> LoadAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyring.cs b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyring.cs
new file mode 100644
index 00000000..bd2917f6
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpKeyring.cs
@@ -0,0 +1,31 @@
+using SquidStd.Crypto.Pgp.Data;
+
+namespace SquidStd.Crypto.Pgp.Interfaces;
+
+///
+/// In-memory collection of PGP keys, indexed by identity, key id, and fingerprint. Crypto operations look
+/// recipients and signers up here by identity.
+///
+public interface IPgpKeyring
+{
+ /// All keys currently held.
+ IReadOnlyCollection Keys { get; }
+
+ /// Imports an armored public or secret key block (auto-detected) and returns the parsed key.
+ PgpKey Import(string armored);
+
+ /// Removes a key matched by identity, key id, or fingerprint. Returns whether one was removed.
+ bool Remove(string identityOrKeyIdOrFingerprint);
+
+ /// Finds a key by identity, key id, or fingerprint; null when absent.
+ PgpKey? Find(string identityOrKeyIdOrFingerprint);
+
+ /// Whether a key matching identity, key id, or fingerprint is present.
+ bool Contains(string identityOrKeyIdOrFingerprint);
+
+ /// Replaces the keyring contents with the key store's persisted keys.
+ Task LoadAsync(IPgpKeyStore store, CancellationToken cancellationToken = default);
+
+ /// Persists the keyring contents to the key store.
+ Task SaveAsync(IPgpKeyStore store, CancellationToken cancellationToken = default);
+}
diff --git a/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
new file mode 100644
index 00000000..a3ffa871
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
@@ -0,0 +1,40 @@
+using SquidStd.Crypto.Pgp.Data;
+
+namespace SquidStd.Crypto.Pgp.Interfaces;
+
+///
+/// OpenPGP operations over the keyring: key generation, encrypt/decrypt, sign/verify, and the combined
+/// encrypt+sign / decrypt+verify flows. Recipients and signers are resolved from the keyring by identity.
+///
+public interface IPgpService
+{
+ /// Generates a new key pair for protected by .
+ PgpKey GenerateKey(string identity, string passphrase, PgpKeyOptions? options = null);
+
+ /// Encrypts for the recipient, returning an armored message.
+ Task EncryptForAsync(string recipientIdentity, byte[] data, CancellationToken cancellationToken = default);
+
+ /// Encrypts a stream for the recipient, writing an armored message to .
+ Task EncryptForAsync(string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default);
+
+ /// Decrypts an armored message using the matching keyring secret key and passphrase.
+ Task DecryptAsync(string armored, string passphrase, CancellationToken cancellationToken = default);
+
+ /// Decrypts a stream using the matching keyring secret key and passphrase.
+ Task DecryptAsync(Stream input, Stream output, string passphrase, CancellationToken cancellationToken = default);
+
+ /// Encrypts for the recipient and signs it with the signer's secret key.
+ Task EncryptAndSignForAsync(string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default);
+
+ /// Encrypts and signs a stream for the recipient.
+ Task EncryptAndSignForAsync(string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default);
+
+ /// Decrypts an armored message and reports whether it was signed and whether the signature validated.
+ Task DecryptAndVerifyAsync(string armored, string passphrase, CancellationToken cancellationToken = default);
+
+ /// Produces an armored signed message that embeds .
+ Task SignAsync(byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default);
+
+ /// Verifies an armored signed message against the keyring, recovering the embedded content.
+ Task VerifyAsync(string signedMessage, CancellationToken cancellationToken = default);
+}
From d0ff4b3f9626d4c8678ade6ed3d43c4278230e47 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:22:31 +0200
Subject: [PATCH 04/77] feat(crypto): parse PGP key metadata from armored
material
---
.../Pgp/Internal/PgpKeyFactory.cs | 62 +++++++++++++++++++
src/SquidStd.Crypto/SquidStd.Crypto.csproj | 4 ++
.../Crypto/Pgp/PgpKeyFactoryTests.cs | 37 +++++++++++
.../Crypto/Pgp/Support/PgpKeysCollection.cs | 7 +++
.../Crypto/Pgp/Support/PgpTestKeys.cs | 44 +++++++++++++
5 files changed, 154 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Internal/PgpKeyFactory.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/PgpKeyFactoryTests.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/Support/PgpKeysCollection.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
diff --git a/src/SquidStd.Crypto/Pgp/Internal/PgpKeyFactory.cs b/src/SquidStd.Crypto/Pgp/Internal/PgpKeyFactory.cs
new file mode 100644
index 00000000..5838b962
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Internal/PgpKeyFactory.cs
@@ -0,0 +1,62 @@
+using System.Globalization;
+using Org.BouncyCastle.Bcpg;
+using Org.BouncyCastle.Bcpg.OpenPgp;
+using PgpCore;
+using SquidStd.Crypto.Pgp.Data;
+using SquidStd.Crypto.Pgp.Types;
+
+namespace SquidStd.Crypto.Pgp.Internal;
+
+///
+/// Builds a from armored key material by reading metadata off the public master key.
+/// Shared by key generation, keyring import, and key-store loading.
+///
+internal static class PgpKeyFactory
+{
+ public static PgpKey FromArmored(string publicArmored, string? privateArmored)
+ {
+ var master = new EncryptionKeys(publicArmored).MasterKey;
+
+ var identity = FirstUserId(master);
+ var keyId = master.KeyId.ToString("X16", CultureInfo.InvariantCulture);
+ var fingerprint = Convert.ToHexString(master.GetFingerprint());
+ var createdUtc = new DateTimeOffset(DateTime.SpecifyKind(master.CreationTime, DateTimeKind.Utc));
+
+ var validSeconds = master.GetValidSeconds();
+ DateTimeOffset? expiresUtc = validSeconds > 0 ? createdUtc.AddSeconds(validSeconds) : null;
+
+ return new PgpKey(
+ identity,
+ keyId,
+ fingerprint,
+ publicArmored,
+ privateArmored,
+ createdUtc,
+ expiresUtc,
+ MapAlgorithm(master.Algorithm)
+ );
+ }
+
+ private static string FirstUserId(PgpPublicKey master)
+ {
+ foreach (var userId in master.GetUserIds())
+ {
+ if (userId is string text && !string.IsNullOrWhiteSpace(text))
+ {
+ return text;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private static PgpKeyAlgorithm MapAlgorithm(PublicKeyAlgorithmTag tag)
+ {
+ return tag switch
+ {
+ PublicKeyAlgorithmTag.RsaGeneral or PublicKeyAlgorithmTag.RsaEncrypt or PublicKeyAlgorithmTag.RsaSign =>
+ PgpKeyAlgorithm.Rsa,
+ _ => PgpKeyAlgorithm.Rsa
+ };
+ }
+}
diff --git a/src/SquidStd.Crypto/SquidStd.Crypto.csproj b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
index f7054b8e..f81170ff 100644
--- a/src/SquidStd.Crypto/SquidStd.Crypto.csproj
+++ b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
@@ -11,6 +11,10 @@
+
+
+
+
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyFactoryTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyFactoryTests.cs
new file mode 100644
index 00000000..765f2ea8
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyFactoryTests.cs
@@ -0,0 +1,37 @@
+using SquidStd.Crypto.Pgp.Internal;
+using SquidStd.Crypto.Pgp.Types;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class PgpKeyFactoryTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public PgpKeyFactoryTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public void FromArmored_PublicOnly_ParsesMetadataWithoutSecret()
+ {
+ var key = PgpKeyFactory.FromArmored(_keys.AlicePublic, null);
+
+ Assert.Equal(PgpTestKeys.AliceIdentity, key.Identity);
+ Assert.False(string.IsNullOrWhiteSpace(key.KeyId));
+ Assert.False(string.IsNullOrWhiteSpace(key.Fingerprint));
+ Assert.Equal(PgpKeyAlgorithm.Rsa, key.Algorithm);
+ Assert.False(key.HasSecret);
+ }
+
+ [Fact]
+ public void FromArmored_WithSecret_SetsHasSecret()
+ {
+ var key = PgpKeyFactory.FromArmored(_keys.AlicePublic, _keys.AlicePrivate);
+
+ Assert.True(key.HasSecret);
+ Assert.Equal(_keys.AlicePrivate, key.PrivateArmored);
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpKeysCollection.cs b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpKeysCollection.cs
new file mode 100644
index 00000000..93185bd8
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpKeysCollection.cs
@@ -0,0 +1,7 @@
+namespace SquidStd.Tests.Crypto.Pgp.Support;
+
+/// Shares one instance across the PGP test classes.
+[CollectionDefinition("PgpKeys")]
+public sealed class PgpKeysCollection : ICollectionFixture
+{
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
new file mode 100644
index 00000000..996b6a0d
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
@@ -0,0 +1,44 @@
+using PgpCore;
+
+namespace SquidStd.Tests.Crypto.Pgp.Support;
+
+///
+/// Generates two throwaway armored RSA-2048 key pairs once for the whole assembly. Key generation is
+/// expensive, so tests share this fixture via .
+///
+public sealed class PgpTestKeys
+{
+ public const string AlicePassphrase = "alice-pw";
+ public const string BobPassphrase = "bob-pw";
+ public const string AliceIdentity = "alice@squidstd.test";
+ public const string BobIdentity = "bob@squidstd.test";
+
+ public PgpTestKeys()
+ {
+ (AlicePublic, AlicePrivate) = Generate(AliceIdentity, AlicePassphrase);
+ (BobPublic, BobPrivate) = Generate(BobIdentity, BobPassphrase);
+ }
+
+ public string AlicePublic { get; }
+
+ public string AlicePrivate { get; }
+
+ public string BobPublic { get; }
+
+ public string BobPrivate { get; }
+
+ private static (string Public, string Private) Generate(string identity, string passphrase)
+ {
+ var pgp = new PGP();
+ using var pub = new MemoryStream();
+ using var priv = new MemoryStream();
+ pgp.GenerateKey(pub, priv, identity, passphrase, 2048);
+
+ return (ReadAll(pub), ReadAll(priv));
+ }
+
+ private static string ReadAll(MemoryStream stream)
+ {
+ return System.Text.Encoding.UTF8.GetString(stream.ToArray());
+ }
+}
From 2799b415fc50ed0e34ee96ab4a7e66ddd9f5d5d0 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:24:12 +0200
Subject: [PATCH 05/77] feat(crypto): add in-memory PGP keyring indexed by
identity, key id, fingerprint
---
.../Pgp/Services/PgpKeyring.cs | 114 ++++++++++++++++++
.../Crypto/Pgp/PgpKeyringTests.cs | 49 ++++++++
2 files changed, 163 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Services/PgpKeyring.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/PgpKeyringTests.cs
diff --git a/src/SquidStd.Crypto/Pgp/Services/PgpKeyring.cs b/src/SquidStd.Crypto/Pgp/Services/PgpKeyring.cs
new file mode 100644
index 00000000..8d258635
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Services/PgpKeyring.cs
@@ -0,0 +1,114 @@
+using System.Collections.Concurrent;
+using System.Text;
+using Org.BouncyCastle.Bcpg;
+using Org.BouncyCastle.Bcpg.OpenPgp;
+using SquidStd.Crypto.Pgp.Data;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Internal;
+
+namespace SquidStd.Crypto.Pgp.Services;
+
+///
+/// Thread-safe in-memory keyring indexed by identity, key id, and fingerprint.
+///
+public sealed class PgpKeyring : IPgpKeyring
+{
+ private const string SecretHeader = "BEGIN PGP PRIVATE KEY BLOCK";
+ private readonly ConcurrentDictionary _byKeyId = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ public IReadOnlyCollection Keys => _byKeyId.Values.ToArray();
+
+ ///
+ public PgpKey Import(string armored)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(armored);
+
+ var key = armored.Contains(SecretHeader, StringComparison.Ordinal)
+ ? PgpKeyFactory.FromArmored(ExportPublic(armored), armored)
+ : PgpKeyFactory.FromArmored(armored, null);
+
+ _byKeyId[key.KeyId] = key;
+
+ return key;
+ }
+
+ ///
+ public bool Remove(string identityOrKeyIdOrFingerprint)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(identityOrKeyIdOrFingerprint);
+
+ var match = Find(identityOrKeyIdOrFingerprint);
+
+ return match is not null && _byKeyId.TryRemove(match.KeyId, out _);
+ }
+
+ ///
+ public PgpKey? Find(string identityOrKeyIdOrFingerprint)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(identityOrKeyIdOrFingerprint);
+
+ if (_byKeyId.TryGetValue(identityOrKeyIdOrFingerprint, out var byId))
+ {
+ return byId;
+ }
+
+ foreach (var key in _byKeyId.Values)
+ {
+ if (string.Equals(key.Identity, identityOrKeyIdOrFingerprint, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key.Fingerprint, identityOrKeyIdOrFingerprint, StringComparison.OrdinalIgnoreCase))
+ {
+ return key;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ public bool Contains(string identityOrKeyIdOrFingerprint)
+ {
+ return Find(identityOrKeyIdOrFingerprint) is not null;
+ }
+
+ ///
+ public async Task LoadAsync(IPgpKeyStore store, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+
+ var loaded = await store.LoadAsync(cancellationToken).ConfigureAwait(false);
+ _byKeyId.Clear();
+
+ foreach (var key in loaded)
+ {
+ _byKeyId[key.KeyId] = key;
+ }
+ }
+
+ ///
+ public Task SaveAsync(IPgpKeyStore store, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+
+ return store.SaveAsync(Keys, cancellationToken);
+ }
+
+ private static string ExportPublic(string secretArmored)
+ {
+ using var input = PgpUtilities.GetDecoderStream(
+ new MemoryStream(Encoding.UTF8.GetBytes(secretArmored))
+ );
+ var ring = new PgpSecretKeyRingBundle(input).GetKeyRings().Cast().First();
+
+ using var output = new MemoryStream();
+ using (var armor = new ArmoredOutputStream(output))
+ {
+ foreach (PgpSecretKey secretKey in ring.GetSecretKeys())
+ {
+ secretKey.PublicKey.Encode(armor);
+ }
+ }
+
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyringTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyringTests.cs
new file mode 100644
index 00000000..b85859ca
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpKeyringTests.cs
@@ -0,0 +1,49 @@
+using SquidStd.Crypto.Pgp.Services;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class PgpKeyringTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public PgpKeyringTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public void Import_PublicBlock_FindableByIdentityKeyIdAndFingerprint()
+ {
+ var keyring = new PgpKeyring();
+ var key = keyring.Import(_keys.AlicePublic);
+
+ Assert.Same(key, keyring.Find(PgpTestKeys.AliceIdentity));
+ Assert.Same(key, keyring.Find(key.KeyId));
+ Assert.Same(key, keyring.Find(key.Fingerprint));
+ Assert.True(keyring.Contains(PgpTestKeys.AliceIdentity));
+ Assert.False(key.HasSecret);
+ }
+
+ [Fact]
+ public void Import_SecretBlock_SetsHasSecret()
+ {
+ var keyring = new PgpKeyring();
+
+ var key = keyring.Import(_keys.AlicePrivate);
+
+ Assert.True(key.HasSecret);
+ }
+
+ [Fact]
+ public void Remove_ByIdentity_RemovesKey()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePublic);
+
+ Assert.True(keyring.Remove(PgpTestKeys.AliceIdentity));
+ Assert.Null(keyring.Find(PgpTestKeys.AliceIdentity));
+ Assert.Empty(keyring.Keys);
+ }
+}
From 2b4c75459c3d596ea3175f29a94efbc747e48580 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:26:11 +0200
Subject: [PATCH 06/77] feat(crypto): PGP key generation and encrypt/decrypt
over the keyring
---
.../Pgp/Services/PgpService.cs | 270 ++++++++++++++++++
.../Crypto/Pgp/PgpServiceEncryptionTests.cs | 61 ++++
.../Pgp/PgpServiceKeyGenerationTests.cs | 20 ++
3 files changed, 351 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Services/PgpService.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/PgpServiceKeyGenerationTests.cs
diff --git a/src/SquidStd.Crypto/Pgp/Services/PgpService.cs b/src/SquidStd.Crypto/Pgp/Services/PgpService.cs
new file mode 100644
index 00000000..abe90dd7
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Services/PgpService.cs
@@ -0,0 +1,270 @@
+using System.Globalization;
+using System.Text;
+using PgpCore;
+using SquidStd.Crypto.Pgp.Data;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Internal;
+using BcPgpException = Org.BouncyCastle.Bcpg.OpenPgp.PgpException;
+
+namespace SquidStd.Crypto.Pgp.Services;
+
+///
+/// OpenPGP operations over a keyring, implemented with PgpCore. Every byte/armored-string operation
+/// round-trips through so binary payloads survive intact.
+///
+public sealed class PgpService : IPgpService
+{
+ private readonly IPgpKeyring _keyring;
+
+ public PgpService(IPgpKeyring keyring)
+ {
+ _keyring = keyring;
+ }
+
+ ///
+ public PgpKey GenerateKey(string identity, string passphrase, PgpKeyOptions? options = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(identity);
+ ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
+
+ var settings = options ?? new PgpKeyOptions();
+ var expirationSeconds = settings.ExpiresAfter is { } span ? (long)span.TotalSeconds : 0L;
+
+ var pgp = new PGP();
+ using var publicStream = new MemoryStream();
+ using var privateStream = new MemoryStream();
+ pgp.GenerateKey(
+ publicStream,
+ privateStream,
+ identity,
+ passphrase,
+ settings.KeySizeBits,
+ armor: true,
+ keyExpirationInSeconds: expirationSeconds
+ );
+
+ var publicArmored = ReadAll(publicStream);
+ var privateArmored = ReadAll(privateStream);
+
+ return PgpKeyFactory.FromArmored(publicArmored, privateArmored);
+ }
+
+ ///
+ public async Task EncryptForAsync(string recipientIdentity, byte[] data, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var recipient = RequireKey(recipientIdentity);
+ var pgp = new PGP(new EncryptionKeys(recipient.PublicArmored));
+
+ using var input = new MemoryStream(data);
+ using var output = new MemoryStream();
+ await pgp.EncryptAsync(input, output).ConfigureAwait(false);
+
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ ///
+ public async Task EncryptForAsync(string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+
+ var recipient = RequireKey(recipientIdentity);
+ var pgp = new PGP(new EncryptionKeys(recipient.PublicArmored));
+ await pgp.EncryptAsync(input, output).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task DecryptAsync(string armored, string passphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(armored);
+
+ var secret = RequireSecretFor(armored);
+ var pgp = new PGP(new EncryptionKeys(secret.PrivateArmored!, passphrase));
+
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(armored));
+ using var output = new MemoryStream();
+ await pgp.DecryptAsync(input, output).ConfigureAwait(false);
+
+ return output.ToArray();
+ }
+
+ ///
+ public async Task DecryptAsync(Stream input, Stream output, string passphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+
+ // Buffer to a seekable copy so the recipient key id can be read before decrypting.
+ using var buffer = new MemoryStream();
+ await input.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
+ var armored = Encoding.UTF8.GetString(buffer.ToArray());
+
+ var secret = RequireSecretFor(armored);
+ var pgp = new PGP(new EncryptionKeys(secret.PrivateArmored!, passphrase));
+
+ buffer.Position = 0;
+ await pgp.DecryptAsync(buffer, output).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task EncryptAndSignForAsync(string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var pgp = BuildEncryptAndSign(recipientIdentity, signerIdentity, signerPassphrase);
+
+ using var input = new MemoryStream(data);
+ using var output = new MemoryStream();
+ await pgp.EncryptAndSignAsync(input, output).ConfigureAwait(false);
+
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ ///
+ public async Task EncryptAndSignForAsync(string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(output);
+
+ var pgp = BuildEncryptAndSign(recipientIdentity, signerIdentity, signerPassphrase);
+ await pgp.EncryptAndSignAsync(input, output).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task DecryptAndVerifyAsync(string armored, string passphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(armored);
+
+ var secret = RequireSecretFor(armored);
+ var publicKeys = _keyring.Keys.Select(key => key.PublicArmored).ToArray();
+ var pgp = new PGP(new EncryptionKeys(publicKeys, secret.PrivateArmored!, passphrase));
+
+ var isSigned = pgp.Inspect(armored).IsSigned;
+
+ if (!isSigned)
+ {
+ var plain = await DecryptWith(pgp, armored).ConfigureAwait(false);
+
+ return new PgpDecryptionResult(plain, false, false);
+ }
+
+ try
+ {
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(armored));
+ using var output = new MemoryStream();
+ await pgp.DecryptAndVerifyAsync(input, output).ConfigureAwait(false);
+
+ return new PgpDecryptionResult(output.ToArray(), true, true);
+ }
+ catch (Exception ex) when (ex is BcPgpException or InvalidOperationException or ArgumentException or IOException)
+ {
+ var plain = await DecryptWith(pgp, armored).ConfigureAwait(false);
+
+ return new PgpDecryptionResult(plain, true, false);
+ }
+ }
+
+ ///
+ public async Task SignAsync(byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var signer = RequireKey(signerIdentity);
+ var pgp = new PGP(new EncryptionKeys(signer.PrivateArmored!, passphrase));
+
+ using var input = new MemoryStream(data);
+ using var output = new MemoryStream();
+ await pgp.SignAsync(input, output).ConfigureAwait(false);
+
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ ///
+ public async Task VerifyAsync(string signedMessage, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(signedMessage);
+
+ var recovered = Array.Empty();
+
+ foreach (var key in _keyring.Keys)
+ {
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(signedMessage));
+ using var output = new MemoryStream();
+ var pgp = new PGP(new EncryptionKeys(key.PublicArmored));
+
+ bool ok;
+ try
+ {
+ ok = await pgp.VerifyAsync(input, output).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is BcPgpException or InvalidOperationException or ArgumentException or IOException)
+ {
+ ok = false;
+ }
+
+ if (output.Length > 0)
+ {
+ recovered = output.ToArray();
+ }
+
+ if (ok)
+ {
+ return new PgpVerificationResult(true, recovered);
+ }
+ }
+
+ return new PgpVerificationResult(false, recovered);
+ }
+
+ private PGP BuildEncryptAndSign(string recipientIdentity, string signerIdentity, string signerPassphrase)
+ {
+ var recipient = RequireKey(recipientIdentity);
+ var signer = RequireKey(signerIdentity);
+
+ return new PGP(new EncryptionKeys(recipient.PublicArmored, signer.PrivateArmored!, signerPassphrase));
+ }
+
+ private static async Task DecryptWith(PGP pgp, string armored)
+ {
+ using var input = new MemoryStream(Encoding.UTF8.GetBytes(armored));
+ using var output = new MemoryStream();
+ await pgp.DecryptAsync(input, output).ConfigureAwait(false);
+
+ return output.ToArray();
+ }
+
+ private PgpKey RequireKey(string identity)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(identity);
+
+ return _keyring.Find(identity)
+ ?? throw new KeyNotFoundException($"No key for identity '{identity}' in the keyring.");
+ }
+
+ private PgpKey RequireSecretFor(string armored)
+ {
+ var recipientIds = new PGP().GetRecipients(armored).ToHashSet();
+
+ foreach (var key in _keyring.Keys)
+ {
+ if (key.HasSecret && recipientIds.Contains(ParseKeyId(key.KeyId)))
+ {
+ return key;
+ }
+ }
+
+ throw new KeyNotFoundException("No secret key in the keyring matches the message recipients.");
+ }
+
+ private static long ParseKeyId(string keyId)
+ {
+ return long.Parse(keyId, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ }
+
+ private static string ReadAll(MemoryStream stream)
+ {
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
new file mode 100644
index 00000000..069d9a1d
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
@@ -0,0 +1,61 @@
+using System.Text;
+using SquidStd.Crypto.Pgp.Services;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class PgpServiceEncryptionTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public PgpServiceEncryptionTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public async Task EncryptFor_ThenDecrypt_RoundTripsBytes()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePublic); // recipient public only for encryption
+ keyring.Import(_keys.AlicePrivate); // secret for decryption (replaces same key id, now HasSecret)
+ var service = new PgpService(keyring);
+ var payload = Encoding.UTF8.GetBytes("squid secret message");
+
+ var armored = await service.EncryptForAsync(PgpTestKeys.AliceIdentity, payload);
+ var plain = await service.DecryptAsync(armored, PgpTestKeys.AlicePassphrase);
+
+ Assert.StartsWith("-----BEGIN PGP MESSAGE-----", armored);
+ Assert.Equal(payload, plain);
+ }
+
+ [Fact]
+ public async Task EncryptFor_UnknownRecipient_ThrowsKeyNotFound()
+ {
+ var service = new PgpService(new PgpKeyring());
+
+ await Assert.ThrowsAsync(
+ () => service.EncryptForAsync("nobody@squidstd.test", Encoding.UTF8.GetBytes("x"))
+ );
+ }
+
+ [Fact]
+ public async Task EncryptFor_ThenDecrypt_StreamOverloadsRoundTripLargePayload()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePrivate);
+ var service = new PgpService(keyring);
+ var payload = Encoding.UTF8.GetBytes(new string('z', 8192));
+
+ using var plaintext = new MemoryStream(payload);
+ using var ciphertext = new MemoryStream();
+ await service.EncryptForAsync(PgpTestKeys.AliceIdentity, plaintext, ciphertext);
+
+ ciphertext.Position = 0;
+ using var recovered = new MemoryStream();
+ await service.DecryptAsync(ciphertext, recovered, PgpTestKeys.AlicePassphrase);
+
+ Assert.Equal(payload, recovered.ToArray());
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceKeyGenerationTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceKeyGenerationTests.cs
new file mode 100644
index 00000000..5b7d63cf
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceKeyGenerationTests.cs
@@ -0,0 +1,20 @@
+using SquidStd.Crypto.Pgp.Services;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+public class PgpServiceKeyGenerationTests
+{
+ [Fact]
+ public void GenerateKey_ReturnsKeyWithMetadataAndSecret()
+ {
+ var keyring = new PgpKeyring();
+ var service = new PgpService(keyring);
+
+ var key = service.GenerateKey("carol@squidstd.test", "carol-pw");
+
+ Assert.Equal("carol@squidstd.test", key.Identity);
+ Assert.False(string.IsNullOrWhiteSpace(key.KeyId));
+ Assert.False(string.IsNullOrWhiteSpace(key.Fingerprint));
+ Assert.True(key.HasSecret);
+ }
+}
From 164e76190ca73d8bac9f37b7d5b4692dab742552 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:26:11 +0200
Subject: [PATCH 07/77] feat(crypto): PGP sign/verify and
encrypt-and-sign/decrypt-and-verify
---
.../Crypto/Pgp/PgpServiceSigningTests.cs | 105 ++++++++++++++++++
1 file changed, 105 insertions(+)
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
new file mode 100644
index 00000000..c6a9f9ae
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
@@ -0,0 +1,105 @@
+using System.Text;
+using SquidStd.Crypto.Pgp.Services;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class PgpServiceSigningTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public PgpServiceSigningTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public async Task Sign_ThenVerify_ValidAndRecoversData()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePrivate);
+ var service = new PgpService(keyring);
+ var payload = Encoding.UTF8.GetBytes("signed by alice");
+
+ var signed = await service.SignAsync(payload, PgpTestKeys.AliceIdentity, PgpTestKeys.AlicePassphrase);
+ var result = await service.VerifyAsync(signed);
+
+ Assert.True(result.IsValid);
+ Assert.Equal(payload, result.Data);
+ }
+
+ [Fact]
+ public async Task Verify_TamperedMessage_IsInvalid()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePrivate);
+ var service = new PgpService(keyring);
+
+ var signed = await service.SignAsync(Encoding.UTF8.GetBytes("original"), PgpTestKeys.AliceIdentity, PgpTestKeys.AlicePassphrase);
+ var tampered = MutateBody(signed);
+
+ var result = await service.VerifyAsync(tampered);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public async Task EncryptAndSign_ThenDecryptAndVerify_RoundTripsAndValidates()
+ {
+ var keyring = new PgpKeyring();
+ keyring.Import(_keys.AlicePrivate); // recipient (has secret to decrypt)
+ keyring.Import(_keys.BobPrivate); // signer
+ var service = new PgpService(keyring);
+ var payload = Encoding.UTF8.GetBytes("confidential and signed");
+
+ var armored = await service.EncryptAndSignForAsync(
+ PgpTestKeys.AliceIdentity, payload, PgpTestKeys.BobIdentity, PgpTestKeys.BobPassphrase
+ );
+ var result = await service.DecryptAndVerifyAsync(armored, PgpTestKeys.AlicePassphrase);
+
+ Assert.Equal(payload, result.Data);
+ Assert.True(result.IsSigned);
+ Assert.True(result.IsValid);
+ }
+
+ [Fact]
+ public async Task DecryptAndVerify_SignerNotInKeyring_RecoversDataButInvalid()
+ {
+ // Sign with Bob, then decrypt on a keyring that lacks Bob's public key.
+ var signingKeyring = new PgpKeyring();
+ signingKeyring.Import(_keys.AlicePrivate);
+ signingKeyring.Import(_keys.BobPrivate);
+ var signingService = new PgpService(signingKeyring);
+ var payload = Encoding.UTF8.GetBytes("signed by an unknown party");
+ var armored = await signingService.EncryptAndSignForAsync(
+ PgpTestKeys.AliceIdentity, payload, PgpTestKeys.BobIdentity, PgpTestKeys.BobPassphrase
+ );
+
+ var recipientOnly = new PgpKeyring();
+ recipientOnly.Import(_keys.AlicePrivate); // no Bob public key
+ var service = new PgpService(recipientOnly);
+
+ var result = await service.DecryptAndVerifyAsync(armored, PgpTestKeys.AlicePassphrase);
+
+ Assert.Equal(payload, result.Data);
+ Assert.True(result.IsSigned);
+ Assert.False(result.IsValid);
+ }
+
+ private static string MutateBody(string signed)
+ {
+ // Flip a character in the middle of the armored body to corrupt the signed payload.
+ var lines = signed.Split('\n');
+ var mid = lines.Length / 2;
+
+ if (lines[mid].Length > 4)
+ {
+ var chars = lines[mid].ToCharArray();
+ chars[2] = chars[2] == 'A' ? 'B' : 'A';
+ lines[mid] = new string(chars);
+ }
+
+ return string.Join('\n', lines);
+ }
+}
From 33a5e94851e16d5e8d2901b96384275035a5dbad Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:26:54 +0200
Subject: [PATCH 08/77] feat(crypto): add file-backed PGP key store (armored
.asc files)
---
.../Pgp/Services/FilePgpKeyStore.cs | 82 +++++++++++++++++++
.../Crypto/Pgp/FilePgpKeyStoreTests.cs | 45 ++++++++++
2 files changed, 127 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/FilePgpKeyStoreTests.cs
diff --git a/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs b/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
new file mode 100644
index 00000000..6a1c3041
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
@@ -0,0 +1,82 @@
+using System.Text;
+using SquidStd.Crypto.Pgp.Data;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Internal;
+
+namespace SquidStd.Crypto.Pgp.Services;
+
+///
+/// Key store backed by a directory of armored .asc files (one public, optionally one secret, per
+/// key). gpg-interoperable.
+///
+public sealed class FilePgpKeyStore : IPgpKeyStore
+{
+ private const string PublicSuffix = ".pub.asc";
+ private const string SecretSuffix = ".sec.asc";
+ private readonly string _directory;
+
+ public FilePgpKeyStore(string directory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ _directory = directory;
+ }
+
+ ///
+ public async Task SaveAsync(IReadOnlyCollection keys, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+ Directory.CreateDirectory(_directory);
+
+ foreach (var key in keys)
+ {
+ var stem = Stem(key);
+ await File.WriteAllTextAsync(
+ Path.Combine(_directory, stem + PublicSuffix), key.PublicArmored, cancellationToken
+ ).ConfigureAwait(false);
+
+ if (key.PrivateArmored is not null)
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(_directory, stem + SecretSuffix), key.PrivateArmored, cancellationToken
+ ).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ public async Task> LoadAsync(CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(_directory))
+ {
+ return [];
+ }
+
+ var result = new List();
+
+ foreach (var publicPath in Directory.EnumerateFiles(_directory, "*" + PublicSuffix))
+ {
+ var stem = Path.GetFileName(publicPath)[..^PublicSuffix.Length];
+ var publicArmored = await File.ReadAllTextAsync(publicPath, cancellationToken).ConfigureAwait(false);
+
+ var secretPath = Path.Combine(_directory, stem + SecretSuffix);
+ string? secretArmored = File.Exists(secretPath)
+ ? await File.ReadAllTextAsync(secretPath, cancellationToken).ConfigureAwait(false)
+ : null;
+
+ result.Add(PgpKeyFactory.FromArmored(publicArmored, secretArmored));
+ }
+
+ return result;
+ }
+
+ private static string Stem(PgpKey key)
+ {
+ var safeIdentity = new StringBuilder(key.Identity.Length);
+ foreach (var ch in key.Identity)
+ {
+ safeIdentity.Append(char.IsLetterOrDigit(ch) ? ch : '_');
+ }
+
+ return safeIdentity + "." + key.KeyId;
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/FilePgpKeyStoreTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/FilePgpKeyStoreTests.cs
new file mode 100644
index 00000000..f905de9a
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/FilePgpKeyStoreTests.cs
@@ -0,0 +1,45 @@
+using SquidStd.Crypto.Pgp.Services;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class FilePgpKeyStoreTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public FilePgpKeyStoreTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public async Task SaveThenLoad_RestoresPublicAndSecretKeys()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "squidstd-pgp-" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var source = new PgpKeyring();
+ source.Import(_keys.AlicePrivate); // has secret
+ source.Import(_keys.BobPublic); // public only
+
+ var store = new FilePgpKeyStore(dir);
+ await source.SaveAsync(store);
+
+ var restored = new PgpKeyring();
+ await restored.LoadAsync(store);
+
+ Assert.Equal(2, restored.Keys.Count);
+ Assert.True(restored.Find(PgpTestKeys.AliceIdentity)!.HasSecret);
+ Assert.False(restored.Find(PgpTestKeys.BobIdentity)!.HasSecret);
+ }
+ finally
+ {
+ if (Directory.Exists(dir))
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+ }
+}
From a560a698ce89a633eeca8181ea523842ac7892d4 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:27:48 +0200
Subject: [PATCH 09/77] feat(crypto): add AES-GCM encrypted PGP key store via
ISecretProtector
---
.../Pgp/Internal/PgpKeyStoreCodec.cs | 46 ++++++++++++++++
.../Pgp/Services/AesGcmPgpKeyStore.cs | 54 +++++++++++++++++++
.../Crypto/Pgp/AesGcmPgpKeyStoreTests.cs | 48 +++++++++++++++++
3 files changed, 148 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Internal/PgpKeyStoreCodec.cs
create mode 100644 src/SquidStd.Crypto/Pgp/Services/AesGcmPgpKeyStore.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/AesGcmPgpKeyStoreTests.cs
diff --git a/src/SquidStd.Crypto/Pgp/Internal/PgpKeyStoreCodec.cs b/src/SquidStd.Crypto/Pgp/Internal/PgpKeyStoreCodec.cs
new file mode 100644
index 00000000..24d3fcad
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Internal/PgpKeyStoreCodec.cs
@@ -0,0 +1,46 @@
+using System.Text;
+using SquidStd.Crypto.Pgp.Data;
+
+namespace SquidStd.Crypto.Pgp.Internal;
+
+///
+/// Encodes a set of keys into a single byte blob (one base64 record per line) and back. Only armored
+/// material is stored; metadata is re-derived on load.
+///
+internal static class PgpKeyStoreCodec
+{
+ public static byte[] Encode(IReadOnlyCollection keys)
+ {
+ var builder = new StringBuilder();
+
+ foreach (var key in keys)
+ {
+ var pub = Convert.ToBase64String(Encoding.UTF8.GetBytes(key.PublicArmored));
+ var sec = key.PrivateArmored is null
+ ? string.Empty
+ : Convert.ToBase64String(Encoding.UTF8.GetBytes(key.PrivateArmored));
+ builder.Append(pub).Append('\t').Append(sec).Append('\n');
+ }
+
+ return Encoding.UTF8.GetBytes(builder.ToString());
+ }
+
+ public static IReadOnlyList Decode(byte[] blob)
+ {
+ var text = Encoding.UTF8.GetString(blob);
+ var result = new List();
+
+ foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var parts = line.Split('\t');
+ var publicArmored = Encoding.UTF8.GetString(Convert.FromBase64String(parts[0]));
+ string? privateArmored = parts.Length > 1 && parts[1].Length > 0
+ ? Encoding.UTF8.GetString(Convert.FromBase64String(parts[1]))
+ : null;
+
+ result.Add(PgpKeyFactory.FromArmored(publicArmored, privateArmored));
+ }
+
+ return result;
+ }
+}
diff --git a/src/SquidStd.Crypto/Pgp/Services/AesGcmPgpKeyStore.cs b/src/SquidStd.Crypto/Pgp/Services/AesGcmPgpKeyStore.cs
new file mode 100644
index 00000000..f595a789
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Services/AesGcmPgpKeyStore.cs
@@ -0,0 +1,54 @@
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Crypto.Pgp.Data;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Internal;
+
+namespace SquidStd.Crypto.Pgp.Services;
+
+///
+/// Key store that serializes the keyring to a single file encrypted at rest with the application key via
+/// .
+///
+public sealed class AesGcmPgpKeyStore : IPgpKeyStore
+{
+ private readonly ISecretProtector _protector;
+ private readonly string _path;
+
+ public AesGcmPgpKeyStore(ISecretProtector protector, string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _protector = protector;
+ _path = path;
+ }
+
+ ///
+ public async Task SaveAsync(IReadOnlyCollection keys, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+
+ var plaintext = PgpKeyStoreCodec.Encode(keys);
+ var protectedBytes = _protector.Protect(plaintext);
+
+ var directory = Path.GetDirectoryName(_path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllBytesAsync(_path, protectedBytes, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> LoadAsync(CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(_path))
+ {
+ return [];
+ }
+
+ var protectedBytes = await File.ReadAllBytesAsync(_path, cancellationToken).ConfigureAwait(false);
+ var plaintext = _protector.Unprotect(protectedBytes);
+
+ return PgpKeyStoreCodec.Decode(plaintext);
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/AesGcmPgpKeyStoreTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/AesGcmPgpKeyStoreTests.cs
new file mode 100644
index 00000000..d4e820db
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/AesGcmPgpKeyStoreTests.cs
@@ -0,0 +1,48 @@
+using System.Text;
+using SquidStd.Core.Data.Storage;
+using SquidStd.Crypto.Pgp.Services;
+using SquidStd.Services.Core.Services.Storage;
+using SquidStd.Tests.Crypto.Pgp.Support;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+[Collection("PgpKeys")]
+public class AesGcmPgpKeyStoreTests
+{
+ private readonly PgpTestKeys _keys;
+
+ public AesGcmPgpKeyStoreTests(PgpTestKeys keys)
+ {
+ _keys = keys;
+ }
+
+ [Fact]
+ public async Task SaveThenLoad_RoundTripsAndBlobIsNotPlaintext()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "squidstd-pgp-" + Guid.NewGuid().ToString("N") + ".bin");
+ var protector = new AesGcmSecretProtector(new SecretsConfig());
+
+ try
+ {
+ var source = new PgpKeyring();
+ source.Import(_keys.AlicePrivate);
+ var store = new AesGcmPgpKeyStore(protector, path);
+ await source.SaveAsync(store);
+
+ var bytes = await File.ReadAllBytesAsync(path);
+ var asText = Encoding.UTF8.GetString(bytes);
+ Assert.DoesNotContain("BEGIN PGP", asText, StringComparison.Ordinal);
+
+ var restored = new PgpKeyring();
+ await restored.LoadAsync(store);
+ Assert.True(restored.Find(PgpTestKeys.AliceIdentity)!.HasSecret);
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+}
From ee197da992bb2d0be8caf1555ff7ae1b47ecbe62 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:28:24 +0200
Subject: [PATCH 10/77] feat(crypto): add RegisterPgp DI extension
---
.../Pgp/Extensions/RegisterPgpExtensions.cs | 30 +++++++++++++++++++
.../Crypto/Pgp/RegisterPgpExtensionsTests.cs | 26 ++++++++++++++++
2 files changed, 56 insertions(+)
create mode 100644 src/SquidStd.Crypto/Pgp/Extensions/RegisterPgpExtensions.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Pgp/RegisterPgpExtensionsTests.cs
diff --git a/src/SquidStd.Crypto/Pgp/Extensions/RegisterPgpExtensions.cs b/src/SquidStd.Crypto/Pgp/Extensions/RegisterPgpExtensions.cs
new file mode 100644
index 00000000..56153d0b
--- /dev/null
+++ b/src/SquidStd.Crypto/Pgp/Extensions/RegisterPgpExtensions.cs
@@ -0,0 +1,30 @@
+using DryIoc;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Services;
+
+namespace SquidStd.Crypto.Pgp.Extensions;
+
+/// DryIoc registration helpers for the PGP module.
+public static class RegisterPgpExtensions
+{
+ /// Container that receives the PGP registrations.
+ extension(IContainer container)
+ {
+ ///
+ /// Registers the PGP keyring, service, and the chosen key store (all singletons). The keyring is not
+ /// auto-loaded; call at startup if persistence is desired.
+ ///
+ /// Builds the from the resolver.
+ /// The same container for chaining.
+ public IContainer RegisterPgp(Func keyStoreFactory)
+ {
+ ArgumentNullException.ThrowIfNull(keyStoreFactory);
+
+ container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
+ container.RegisterDelegate(keyStoreFactory, Reuse.Singleton);
+
+ return container;
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/RegisterPgpExtensionsTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/RegisterPgpExtensionsTests.cs
new file mode 100644
index 00000000..47fd51b9
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Pgp/RegisterPgpExtensionsTests.cs
@@ -0,0 +1,26 @@
+using DryIoc;
+using SquidStd.Crypto.Pgp.Extensions;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Services;
+
+namespace SquidStd.Tests.Crypto.Pgp;
+
+public class RegisterPgpExtensionsTests
+{
+ [Fact]
+ public void RegisterPgp_RegistersKeyringServiceAndStoreAsSingletons()
+ {
+ using var container = new Container();
+ var dir = Path.Combine(Path.GetTempPath(), "squidstd-pgp-di-" + Guid.NewGuid().ToString("N"));
+
+ container.RegisterPgp(_ => new FilePgpKeyStore(dir));
+
+ var keyring = container.Resolve();
+ var service = container.Resolve();
+ var store = container.Resolve();
+
+ Assert.Same(keyring, container.Resolve());
+ Assert.NotNull(service);
+ Assert.IsType(store);
+ }
+}
From 62fab4000269eedbf2fcf437680c2704c125eef5 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 14:30:23 +0200
Subject: [PATCH 11/77] docs(crypto): add SquidStd.Crypto README and format
module
---
src/SquidStd.Crypto/Pgp/Data/PgpKey.cs | 3 +-
.../Pgp/Interfaces/IPgpService.cs | 22 ++++--
.../Pgp/Services/FilePgpKeyStore.cs | 14 ++--
.../Pgp/Services/PgpService.cs | 30 ++++++--
src/SquidStd.Crypto/README.md | 69 +++++++++++++++++++
.../Crypto/Pgp/PgpServiceEncryptionTests.cs | 10 +--
.../Crypto/Pgp/PgpServiceSigningTests.cs | 20 ++++--
.../Crypto/Pgp/Support/PgpTestKeys.cs | 12 ++--
8 files changed, 148 insertions(+), 32 deletions(-)
create mode 100644 src/SquidStd.Crypto/README.md
diff --git a/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs b/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
index 467f18dd..2fd65dcd 100644
--- a/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
+++ b/src/SquidStd.Crypto/Pgp/Data/PgpKey.cs
@@ -14,7 +14,8 @@ public sealed record PgpKey(
string? PrivateArmored,
DateTimeOffset CreatedUtc,
DateTimeOffset? ExpiresUtc,
- PgpKeyAlgorithm Algorithm)
+ PgpKeyAlgorithm Algorithm
+)
{
/// Whether this key carries secret material (can decrypt and sign).
public bool HasSecret => PrivateArmored is not null;
diff --git a/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
index a3ffa871..cdf07fcc 100644
--- a/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
+++ b/src/SquidStd.Crypto/Pgp/Interfaces/IPgpService.cs
@@ -15,7 +15,9 @@ public interface IPgpService
Task EncryptForAsync(string recipientIdentity, byte[] data, CancellationToken cancellationToken = default);
/// Encrypts a stream for the recipient, writing an armored message to .
- Task EncryptForAsync(string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default);
+ Task EncryptForAsync(
+ string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default
+ );
/// Decrypts an armored message using the matching keyring secret key and passphrase.
Task DecryptAsync(string armored, string passphrase, CancellationToken cancellationToken = default);
@@ -24,16 +26,26 @@ public interface IPgpService
Task DecryptAsync(Stream input, Stream output, string passphrase, CancellationToken cancellationToken = default);
/// Encrypts for the recipient and signs it with the signer's secret key.
- Task EncryptAndSignForAsync(string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default);
+ Task EncryptAndSignForAsync(
+ string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase,
+ CancellationToken cancellationToken = default
+ );
/// Encrypts and signs a stream for the recipient.
- Task EncryptAndSignForAsync(string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default);
+ Task EncryptAndSignForAsync(
+ string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase,
+ CancellationToken cancellationToken = default
+ );
/// Decrypts an armored message and reports whether it was signed and whether the signature validated.
- Task DecryptAndVerifyAsync(string armored, string passphrase, CancellationToken cancellationToken = default);
+ Task DecryptAndVerifyAsync(
+ string armored, string passphrase, CancellationToken cancellationToken = default
+ );
/// Produces an armored signed message that embeds .
- Task SignAsync(byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default);
+ Task SignAsync(
+ byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default
+ );
/// Verifies an armored signed message against the keyring, recovering the embedded content.
Task VerifyAsync(string signedMessage, CancellationToken cancellationToken = default);
diff --git a/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs b/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
index 6a1c3041..e71d2c1c 100644
--- a/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
+++ b/src/SquidStd.Crypto/Pgp/Services/FilePgpKeyStore.cs
@@ -31,14 +31,20 @@ public async Task SaveAsync(IReadOnlyCollection keys, CancellationToken
{
var stem = Stem(key);
await File.WriteAllTextAsync(
- Path.Combine(_directory, stem + PublicSuffix), key.PublicArmored, cancellationToken
- ).ConfigureAwait(false);
+ Path.Combine(_directory, stem + PublicSuffix),
+ key.PublicArmored,
+ cancellationToken
+ )
+ .ConfigureAwait(false);
if (key.PrivateArmored is not null)
{
await File.WriteAllTextAsync(
- Path.Combine(_directory, stem + SecretSuffix), key.PrivateArmored, cancellationToken
- ).ConfigureAwait(false);
+ Path.Combine(_directory, stem + SecretSuffix),
+ key.PrivateArmored,
+ cancellationToken
+ )
+ .ConfigureAwait(false);
}
}
}
diff --git a/src/SquidStd.Crypto/Pgp/Services/PgpService.cs b/src/SquidStd.Crypto/Pgp/Services/PgpService.cs
index abe90dd7..6838551f 100644
--- a/src/SquidStd.Crypto/Pgp/Services/PgpService.cs
+++ b/src/SquidStd.Crypto/Pgp/Services/PgpService.cs
@@ -50,7 +50,9 @@ public PgpKey GenerateKey(string identity, string passphrase, PgpKeyOptions? opt
}
///
- public async Task EncryptForAsync(string recipientIdentity, byte[] data, CancellationToken cancellationToken = default)
+ public async Task EncryptForAsync(
+ string recipientIdentity, byte[] data, CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(data);
@@ -65,7 +67,9 @@ public async Task EncryptForAsync(string recipientIdentity, byte[] data,
}
///
- public async Task EncryptForAsync(string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default)
+ public async Task EncryptForAsync(
+ string recipientIdentity, Stream input, Stream output, CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(output);
@@ -91,7 +95,9 @@ public async Task DecryptAsync(string armored, string passphrase, Cancel
}
///
- public async Task DecryptAsync(Stream input, Stream output, string passphrase, CancellationToken cancellationToken = default)
+ public async Task DecryptAsync(
+ Stream input, Stream output, string passphrase, CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(output);
@@ -109,7 +115,10 @@ public async Task DecryptAsync(Stream input, Stream output, string passphrase, C
}
///
- public async Task EncryptAndSignForAsync(string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default)
+ public async Task EncryptAndSignForAsync(
+ string recipientIdentity, byte[] data, string signerIdentity, string signerPassphrase,
+ CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(data);
@@ -123,7 +132,10 @@ public async Task EncryptAndSignForAsync(string recipientIdentity, byte[
}
///
- public async Task EncryptAndSignForAsync(string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase, CancellationToken cancellationToken = default)
+ public async Task EncryptAndSignForAsync(
+ string recipientIdentity, Stream input, Stream output, string signerIdentity, string signerPassphrase,
+ CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(output);
@@ -133,7 +145,9 @@ public async Task EncryptAndSignForAsync(string recipientIdentity, Stream input,
}
///
- public async Task DecryptAndVerifyAsync(string armored, string passphrase, CancellationToken cancellationToken = default)
+ public async Task DecryptAndVerifyAsync(
+ string armored, string passphrase, CancellationToken cancellationToken = default
+ )
{
ArgumentException.ThrowIfNullOrWhiteSpace(armored);
@@ -167,7 +181,9 @@ public async Task DecryptAndVerifyAsync(string armored, str
}
///
- public async Task SignAsync(byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default)
+ public async Task SignAsync(
+ byte[] data, string signerIdentity, string passphrase, CancellationToken cancellationToken = default
+ )
{
ArgumentNullException.ThrowIfNull(data);
diff --git a/src/SquidStd.Crypto/README.md b/src/SquidStd.Crypto/README.md
new file mode 100644
index 00000000..69baf78c
--- /dev/null
+++ b/src/SquidStd.Crypto/README.md
@@ -0,0 +1,69 @@
+
SquidStd.Crypto
+
+OpenPGP key management and operations for SquidStd, built on [PgpCore](https://github.com/mattosaurus/PgpCore)
+(a maintained MIT wrapper over BouncyCastle). Provides key generation, encrypt/decrypt, sign/verify, and the
+combined encrypt+sign / decrypt+verify flows over a stateful, indexed **keyring** with a pluggable persistence
+backend. `SquidStd.Core` stays dependency-free; this module is the home for higher-level crypto features, with
+PGP namespaced under `SquidStd.Crypto.Pgp` so future areas can coexist.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Crypto
+```
+
+## Usage
+
+```csharp
+using DryIoc;
+using SquidStd.Crypto.Pgp.Extensions;
+using SquidStd.Crypto.Pgp.Interfaces;
+using SquidStd.Crypto.Pgp.Services;
+
+// Register the keyring, service, and a key store (all singletons).
+container.RegisterPgp(_ => new FilePgpKeyStore("/var/lib/app/pgp"));
+// or encrypted at rest with the app key:
+// container.RegisterPgp(r => new AesGcmPgpKeyStore(r.Resolve(), "/var/lib/app/pgp.bin"));
+
+var keyring = container.Resolve();
+var pgp = container.Resolve();
+
+// Generate a key and import it into the keyring.
+var alice = pgp.GenerateKey("alice@example.com", "passphrase");
+keyring.Import(alice.PrivateArmored!);
+keyring.Import(bobPublicArmored); // a correspondent's public key
+
+// Encrypt for a recipient, decrypt with the held secret key.
+string armored = await pgp.EncryptForAsync("bob@example.com", payloadBytes);
+byte[] plaintext = await pgp.DecryptAsync(armoredFromBob, "passphrase");
+
+// Sign and verify (signed message — the data is embedded in the armored block).
+string signed = await pgp.SignAsync(payloadBytes, "alice@example.com", "passphrase");
+var verification = await pgp.VerifyAsync(signed); // verification.IsValid, verification.Data
+
+// Combined: encrypt + sign, then decrypt + verify.
+string sealed = await pgp.EncryptAndSignForAsync("bob@example.com", payloadBytes, "alice@example.com", "passphrase");
+var result = await pgp.DecryptAndVerifyAsync(sealedFromBob, "passphrase"); // result.Data, result.IsSigned, result.IsValid
+
+// Persist / restore the keyring.
+await keyring.SaveAsync(container.Resolve());
+await keyring.LoadAsync(container.Resolve());
+```
+
+## Key stores
+
+- **`FilePgpKeyStore(directory)`** — one armored `.asc` per key (public, plus secret when held). gpg-interoperable.
+- **`AesGcmPgpKeyStore(ISecretProtector, path)`** — the whole keyring serialized to a single file, encrypted at
+ rest with the application key via `SquidStd`'s `ISecretProtector`.
+
+## Notes
+
+- **Signatures are signed messages**, not detached signatures: `SignAsync` embeds the data in the armored block
+ and `VerifyAsync` recovers it. Verification is **pass/fail** — PgpCore does not expose the signer's key id or
+ identity, so the results carry no signer attribution.
+- `DecryptAndVerifyAsync` never throws on a bad/absent signature when the ciphertext itself is valid: it always
+ recovers `Data` and reports `IsSigned` / `IsValid`.
+- The streaming `DecryptAsync(Stream, Stream, …)` buffers its input internally so the recipient key id can be
+ read before decrypting; the encrypt and sign stream paths flow straight through.
+- Passphrases are supplied per operation and never persisted (only passphrase-protected secret blocks are
+ stored).
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
index 069d9a1d..0a1904c8 100644
--- a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceEncryptionTests.cs
@@ -18,8 +18,8 @@ public PgpServiceEncryptionTests(PgpTestKeys keys)
public async Task EncryptFor_ThenDecrypt_RoundTripsBytes()
{
var keyring = new PgpKeyring();
- keyring.Import(_keys.AlicePublic); // recipient public only for encryption
- keyring.Import(_keys.AlicePrivate); // secret for decryption (replaces same key id, now HasSecret)
+ keyring.Import(_keys.AlicePublic); // recipient public only for encryption
+ keyring.Import(_keys.AlicePrivate); // secret for decryption (replaces same key id, now HasSecret)
var service = new PgpService(keyring);
var payload = Encoding.UTF8.GetBytes("squid secret message");
@@ -35,8 +35,10 @@ public async Task EncryptFor_UnknownRecipient_ThrowsKeyNotFound()
{
var service = new PgpService(new PgpKeyring());
- await Assert.ThrowsAsync(
- () => service.EncryptForAsync("nobody@squidstd.test", Encoding.UTF8.GetBytes("x"))
+ await Assert.ThrowsAsync(() => service.EncryptForAsync(
+ "nobody@squidstd.test",
+ Encoding.UTF8.GetBytes("x")
+ )
);
}
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
index c6a9f9ae..cad70f8d 100644
--- a/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Pgp/PgpServiceSigningTests.cs
@@ -36,7 +36,11 @@ public async Task Verify_TamperedMessage_IsInvalid()
keyring.Import(_keys.AlicePrivate);
var service = new PgpService(keyring);
- var signed = await service.SignAsync(Encoding.UTF8.GetBytes("original"), PgpTestKeys.AliceIdentity, PgpTestKeys.AlicePassphrase);
+ var signed = await service.SignAsync(
+ Encoding.UTF8.GetBytes("original"),
+ PgpTestKeys.AliceIdentity,
+ PgpTestKeys.AlicePassphrase
+ );
var tampered = MutateBody(signed);
var result = await service.VerifyAsync(tampered);
@@ -48,13 +52,16 @@ public async Task Verify_TamperedMessage_IsInvalid()
public async Task EncryptAndSign_ThenDecryptAndVerify_RoundTripsAndValidates()
{
var keyring = new PgpKeyring();
- keyring.Import(_keys.AlicePrivate); // recipient (has secret to decrypt)
- keyring.Import(_keys.BobPrivate); // signer
+ keyring.Import(_keys.AlicePrivate); // recipient (has secret to decrypt)
+ keyring.Import(_keys.BobPrivate); // signer
var service = new PgpService(keyring);
var payload = Encoding.UTF8.GetBytes("confidential and signed");
var armored = await service.EncryptAndSignForAsync(
- PgpTestKeys.AliceIdentity, payload, PgpTestKeys.BobIdentity, PgpTestKeys.BobPassphrase
+ PgpTestKeys.AliceIdentity,
+ payload,
+ PgpTestKeys.BobIdentity,
+ PgpTestKeys.BobPassphrase
);
var result = await service.DecryptAndVerifyAsync(armored, PgpTestKeys.AlicePassphrase);
@@ -73,7 +80,10 @@ public async Task DecryptAndVerify_SignerNotInKeyring_RecoversDataButInvalid()
var signingService = new PgpService(signingKeyring);
var payload = Encoding.UTF8.GetBytes("signed by an unknown party");
var armored = await signingService.EncryptAndSignForAsync(
- PgpTestKeys.AliceIdentity, payload, PgpTestKeys.BobIdentity, PgpTestKeys.BobPassphrase
+ PgpTestKeys.AliceIdentity,
+ payload,
+ PgpTestKeys.BobIdentity,
+ PgpTestKeys.BobPassphrase
);
var recipientOnly = new PgpKeyring();
diff --git a/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
index 996b6a0d..16b6e9ce 100644
--- a/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
+++ b/tests/SquidStd.Tests/Crypto/Pgp/Support/PgpTestKeys.cs
@@ -13,12 +13,6 @@ public sealed class PgpTestKeys
public const string AliceIdentity = "alice@squidstd.test";
public const string BobIdentity = "bob@squidstd.test";
- public PgpTestKeys()
- {
- (AlicePublic, AlicePrivate) = Generate(AliceIdentity, AlicePassphrase);
- (BobPublic, BobPrivate) = Generate(BobIdentity, BobPassphrase);
- }
-
public string AlicePublic { get; }
public string AlicePrivate { get; }
@@ -27,6 +21,12 @@ public PgpTestKeys()
public string BobPrivate { get; }
+ public PgpTestKeys()
+ {
+ (AlicePublic, AlicePrivate) = Generate(AliceIdentity, AlicePassphrase);
+ (BobPublic, BobPrivate) = Generate(BobIdentity, BobPassphrase);
+ }
+
private static (string Public, string Private) Generate(string identity, string passphrase)
{
var pgp = new PGP();
From 00ace0480501daf2fed8b5e3e0e5faee3671d88f Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:38:12 +0200
Subject: [PATCH 12/77] build(vfs): scaffold SquidStd.Vfs.Abstractions and
SquidStd.Vfs modules
---
SquidStd.slnx | 2 ++
src/SquidStd.Crypto/SquidStd.Crypto.csproj | 1 +
.../SquidStd.Vfs.Abstractions.csproj | 10 ++++++++++
src/SquidStd.Vfs/SquidStd.Vfs.csproj | 19 +++++++++++++++++++
tests/SquidStd.Tests/SquidStd.Tests.csproj | 2 ++
5 files changed, 34 insertions(+)
create mode 100644 src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj
create mode 100644 src/SquidStd.Vfs/SquidStd.Vfs.csproj
diff --git a/SquidStd.slnx b/SquidStd.slnx
index 55f1af76..a850a11c 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -38,6 +38,8 @@
+
+
diff --git a/src/SquidStd.Crypto/SquidStd.Crypto.csproj b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
index f81170ff..fa0605cd 100644
--- a/src/SquidStd.Crypto/SquidStd.Crypto.csproj
+++ b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj b/src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj
new file mode 100644
index 00000000..2f0e9b5b
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/SquidStd.Vfs.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
diff --git a/src/SquidStd.Vfs/SquidStd.Vfs.csproj b/src/SquidStd.Vfs/SquidStd.Vfs.csproj
new file mode 100644
index 00000000..34c96321
--- /dev/null
+++ b/src/SquidStd.Vfs/SquidStd.Vfs.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index 82c89143..0c7805d4 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -71,6 +71,8 @@
+
+
From 0efecc5c860d0c222b06910f561609bc583fdfa6 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:38:40 +0200
Subject: [PATCH 13/77] feat(vfs): add virtual filesystem abstraction and
lockable contract
---
.../Data/VfsEntry.cs | 4 +++
.../Interfaces/ILockableFileSystem.cs | 14 ++++++++++
.../Interfaces/IVirtualFileSystem.cs | 28 +++++++++++++++++++
3 files changed, 46 insertions(+)
create mode 100644 src/SquidStd.Vfs.Abstractions/Data/VfsEntry.cs
create mode 100644 src/SquidStd.Vfs.Abstractions/Interfaces/ILockableFileSystem.cs
create mode 100644 src/SquidStd.Vfs.Abstractions/Interfaces/IVirtualFileSystem.cs
diff --git a/src/SquidStd.Vfs.Abstractions/Data/VfsEntry.cs b/src/SquidStd.Vfs.Abstractions/Data/VfsEntry.cs
new file mode 100644
index 00000000..b8c6f45a
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/Data/VfsEntry.cs
@@ -0,0 +1,4 @@
+namespace SquidStd.Vfs.Abstractions.Data;
+
+/// An entry listed in a virtual filesystem: its logical path, byte size, and last-modified time.
+public sealed record VfsEntry(string Path, long Size, DateTimeOffset ModifiedUtc);
diff --git a/src/SquidStd.Vfs.Abstractions/Interfaces/ILockableFileSystem.cs b/src/SquidStd.Vfs.Abstractions/Interfaces/ILockableFileSystem.cs
new file mode 100644
index 00000000..2edc5365
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/Interfaces/ILockableFileSystem.cs
@@ -0,0 +1,14 @@
+namespace SquidStd.Vfs.Abstractions.Interfaces;
+
+/// A virtual filesystem that is locked until unlocked with a passphrase (e.g. an encrypted vault).
+public interface ILockableFileSystem : IVirtualFileSystem
+{
+ /// Whether the filesystem is currently unlocked.
+ bool IsUnlocked { get; }
+
+ /// Derives the key from the passphrase and unlocks the filesystem.
+ void Unlock(string passphrase);
+
+ /// Zeroes and drops the key, locking the filesystem.
+ void Lock();
+}
diff --git a/src/SquidStd.Vfs.Abstractions/Interfaces/IVirtualFileSystem.cs b/src/SquidStd.Vfs.Abstractions/Interfaces/IVirtualFileSystem.cs
new file mode 100644
index 00000000..5422b506
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/Interfaces/IVirtualFileSystem.cs
@@ -0,0 +1,28 @@
+using SquidStd.Vfs.Abstractions.Data;
+
+namespace SquidStd.Vfs.Abstractions.Interfaces;
+
+/// A path-based virtual filesystem over a pluggable backend (directory, zip, encrypted container).
+public interface IVirtualFileSystem
+{
+ /// Whether a file exists at the logical path.
+ ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default);
+
+ /// Reads the whole file, or null when it does not exist.
+ ValueTask ReadAllBytesAsync(string path, CancellationToken cancellationToken = default);
+
+ /// Writes the whole file, creating or overwriting it.
+ ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default);
+
+ /// Opens a readable stream over the file; throws when absent.
+ Task OpenReadAsync(string path, CancellationToken cancellationToken = default);
+
+ /// Opens a writable stream that creates or overwrites the file on disposal.
+ Task OpenWriteAsync(string path, CancellationToken cancellationToken = default);
+
+ /// Deletes the file. Returns whether one was removed.
+ ValueTask DeleteAsync(string path, CancellationToken cancellationToken = default);
+
+ /// Lists entries, optionally filtered by logical path prefix.
+ IAsyncEnumerable ListAsync(string? prefix = null, CancellationToken cancellationToken = default);
+}
From e4f4e9e91e5dd30139251ce2fc2668bbfe0569e6 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:39:46 +0200
Subject: [PATCH 14/77] feat(vfs): add path normalization and physical
filesystem provider
---
src/SquidStd.Vfs/Internal/VfsPath.cs | 23 ++++
.../Services/PhysicalFileSystem.cs | 106 ++++++++++++++++++
.../Vfs/PhysicalFileSystemTests.cs | 41 +++++++
tests/SquidStd.Tests/Vfs/VfsPathTests.cs | 23 ++++
4 files changed, 193 insertions(+)
create mode 100644 src/SquidStd.Vfs/Internal/VfsPath.cs
create mode 100644 src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
create mode 100644 tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
create mode 100644 tests/SquidStd.Tests/Vfs/VfsPathTests.cs
diff --git a/src/SquidStd.Vfs/Internal/VfsPath.cs b/src/SquidStd.Vfs/Internal/VfsPath.cs
new file mode 100644
index 00000000..a9803ad6
--- /dev/null
+++ b/src/SquidStd.Vfs/Internal/VfsPath.cs
@@ -0,0 +1,23 @@
+namespace SquidStd.Vfs.Internal;
+
+/// Normalizes logical VFS paths to forward-slash, root-relative form and rejects traversal.
+internal static class VfsPath
+{
+ public static string Normalize(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var segments = path.Replace('\\', '/')
+ .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var segment in segments)
+ {
+ if (segment is "." or "..")
+ {
+ throw new ArgumentException($"Path '{path}' must not contain relative segments.", nameof(path));
+ }
+ }
+
+ return string.Join('/', segments);
+ }
+}
diff --git a/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
new file mode 100644
index 00000000..f0514586
--- /dev/null
+++ b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
@@ -0,0 +1,106 @@
+using System.Runtime.CompilerServices;
+using SquidStd.Vfs.Abstractions.Data;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Internal;
+
+namespace SquidStd.Vfs.Services;
+
+/// A virtual filesystem mapped onto a real directory tree.
+public sealed class PhysicalFileSystem : IVirtualFileSystem
+{
+ private readonly string _root;
+
+ public PhysicalFileSystem(string root)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(root);
+ _root = Path.GetFullPath(root);
+ Directory.CreateDirectory(_root);
+ }
+
+ public ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(File.Exists(Resolve(path)));
+ }
+
+ public async ValueTask ReadAllBytesAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var full = Resolve(path);
+
+ return File.Exists(full)
+ ? await File.ReadAllBytesAsync(full, cancellationToken).ConfigureAwait(false)
+ : null;
+ }
+
+ public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ {
+ var full = Resolve(path);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+ await File.WriteAllBytesAsync(full, data, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task OpenReadAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var full = Resolve(path);
+
+ if (!File.Exists(full))
+ {
+ throw new FileNotFoundException($"No file at '{path}'.", path);
+ }
+
+ return Task.FromResult(File.OpenRead(full));
+ }
+
+ public Task OpenWriteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var full = Resolve(path);
+ Directory.CreateDirectory(Path.GetDirectoryName(full)!);
+
+ return Task.FromResult(File.Create(full));
+ }
+
+ public ValueTask DeleteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var full = Resolve(path);
+
+ if (!File.Exists(full))
+ {
+ return ValueTask.FromResult(false);
+ }
+
+ File.Delete(full);
+
+ return ValueTask.FromResult(true);
+ }
+
+ public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(_root))
+ {
+ yield break;
+ }
+
+ var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
+
+ foreach (var file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var logical = Path.GetRelativePath(_root, file).Replace('\\', '/');
+
+ if (normalizedPrefix is not null && !logical.StartsWith(normalizedPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var info = new FileInfo(file);
+ yield return new VfsEntry(logical, info.Length, info.LastWriteTimeUtc);
+
+ await Task.CompletedTask;
+ }
+ }
+
+ private string Resolve(string path)
+ {
+ return Path.Combine(_root, VfsPath.Normalize(path));
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
new file mode 100644
index 00000000..9d9a918b
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
@@ -0,0 +1,41 @@
+using System.Text;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Vfs;
+
+public class PhysicalFileSystemTests
+{
+ [Fact]
+ public async Task Write_Read_List_Delete_RoundTrips()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "squidstd-vfs-" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var fs = new PhysicalFileSystem(root);
+ await fs.WriteAllBytesAsync("docs/cv.pdf", Encoding.UTF8.GetBytes("hello"));
+
+ Assert.True(await fs.ExistsAsync("docs/cv.pdf"));
+ Assert.Equal("hello", Encoding.UTF8.GetString((await fs.ReadAllBytesAsync("docs/cv.pdf"))!));
+
+ var entries = new List();
+ await foreach (var e in fs.ListAsync())
+ {
+ entries.Add(e.Path);
+ }
+ Assert.Contains("docs/cv.pdf", entries);
+
+ Assert.True(await fs.DeleteAsync("docs/cv.pdf"));
+ Assert.False(await fs.ExistsAsync("docs/cv.pdf"));
+ Assert.Null(await fs.ReadAllBytesAsync("docs/cv.pdf"));
+ Assert.False(await fs.DeleteAsync("docs/cv.pdf"));
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/VfsPathTests.cs b/tests/SquidStd.Tests/Vfs/VfsPathTests.cs
new file mode 100644
index 00000000..a33151ea
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/VfsPathTests.cs
@@ -0,0 +1,23 @@
+using SquidStd.Vfs.Internal;
+
+namespace SquidStd.Tests.Vfs;
+
+public class VfsPathTests
+{
+ [Theory]
+ [InlineData("docs/cv.pdf", "docs/cv.pdf")]
+ [InlineData("/docs//cv.pdf", "docs/cv.pdf")]
+ [InlineData("docs\\cv.pdf", "docs/cv.pdf")]
+ public void Normalize_ProducesForwardSlashRelativePath(string input, string expected)
+ {
+ Assert.Equal(expected, VfsPath.Normalize(input));
+ }
+
+ [Theory]
+ [InlineData("../escape")]
+ [InlineData("a/../../b")]
+ public void Normalize_RejectsTraversal(string input)
+ {
+ Assert.Throws(() => VfsPath.Normalize(input));
+ }
+}
From 02ff3ba4d6dff76ec82dab54a41d355e44a32184 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:40:30 +0200
Subject: [PATCH 15/77] feat(vfs): add in-memory filesystem provider
---
.../Services/InMemoryFileSystem.cs | 87 +++++++++++++++++++
.../Vfs/InMemoryFileSystemTests.cs | 27 ++++++
2 files changed, 114 insertions(+)
create mode 100644 src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
create mode 100644 tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
diff --git a/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
new file mode 100644
index 00000000..c7de6237
--- /dev/null
+++ b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
@@ -0,0 +1,87 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using SquidStd.Vfs.Abstractions.Data;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Internal;
+
+namespace SquidStd.Vfs.Services;
+
+/// An in-memory virtual filesystem. Ephemeral; useful for tests and as a backend decorator target.
+public sealed class InMemoryFileSystem : IVirtualFileSystem
+{
+ private readonly ConcurrentDictionary _files = new(StringComparer.Ordinal);
+
+ public ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(_files.ContainsKey(VfsPath.Normalize(path)));
+ }
+
+ public ValueTask ReadAllBytesAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(_files.TryGetValue(VfsPath.Normalize(path), out var entry) ? entry.Data : null);
+ }
+
+ public ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ {
+ _files[VfsPath.Normalize(path)] = (data.ToArray(), DateTimeOffset.UtcNow);
+
+ return ValueTask.CompletedTask;
+ }
+
+ public async Task OpenReadAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var data = await ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)
+ ?? throw new FileNotFoundException($"No file at '{path}'.", path);
+
+ return new MemoryStream(data, writable: false);
+ }
+
+ public Task OpenWriteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(new WriteBackStream(this, path));
+ }
+
+ public ValueTask DeleteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(_files.TryRemove(VfsPath.Normalize(path), out _));
+ }
+
+ public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
+
+ foreach (var (path, entry) in _files)
+ {
+ if (normalizedPrefix is not null && !path.StartsWith(normalizedPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return new VfsEntry(path, entry.Data.Length, entry.Modified);
+
+ await Task.CompletedTask;
+ }
+ }
+
+ private sealed class WriteBackStream : MemoryStream
+ {
+ private readonly InMemoryFileSystem _owner;
+ private readonly string _path;
+
+ public WriteBackStream(InMemoryFileSystem owner, string path)
+ {
+ _owner = owner;
+ _path = path;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _owner._files[VfsPath.Normalize(_path)] = (ToArray(), DateTimeOffset.UtcNow);
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
new file mode 100644
index 00000000..db3e538c
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
@@ -0,0 +1,27 @@
+using System.Text;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Vfs;
+
+public class InMemoryFileSystemTests
+{
+ [Fact]
+ public async Task Write_Read_List_Delete_RoundTrips()
+ {
+ var fs = new InMemoryFileSystem();
+ await fs.WriteAllBytesAsync("a/b.txt", Encoding.UTF8.GetBytes("x"));
+
+ Assert.True(await fs.ExistsAsync("a/b.txt"));
+ Assert.Equal("x", Encoding.UTF8.GetString((await fs.ReadAllBytesAsync("a/b.txt"))!));
+
+ var paths = new List();
+ await foreach (var e in fs.ListAsync("a"))
+ {
+ paths.Add(e.Path);
+ }
+ Assert.Equal(["a/b.txt"], paths);
+
+ Assert.True(await fs.DeleteAsync("a/b.txt"));
+ Assert.Null(await fs.ReadAllBytesAsync("a/b.txt"));
+ }
+}
From fe8fe566d43c46aa303a9620345c35f711382e5c Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:41:31 +0200
Subject: [PATCH 16/77] feat(vfs): add zip-backed filesystem provider
---
src/SquidStd.Vfs/Services/ZipFileSystem.cs | 130 ++++++++++++++++++
.../SquidStd.Tests/Vfs/ZipFileSystemTests.cs | 42 ++++++
2 files changed, 172 insertions(+)
create mode 100644 src/SquidStd.Vfs/Services/ZipFileSystem.cs
create mode 100644 tests/SquidStd.Tests/Vfs/ZipFileSystemTests.cs
diff --git a/src/SquidStd.Vfs/Services/ZipFileSystem.cs b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
new file mode 100644
index 00000000..294bb142
--- /dev/null
+++ b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
@@ -0,0 +1,130 @@
+using System.IO.Compression;
+using System.Runtime.CompilerServices;
+using SquidStd.Vfs.Abstractions.Data;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Internal;
+
+namespace SquidStd.Vfs.Services;
+
+/// A virtual filesystem backed by a single zip archive opened in update mode.
+public sealed class ZipFileSystem : IVirtualFileSystem, IAsyncDisposable, IDisposable
+{
+ private readonly ZipArchive _archive;
+ private readonly FileStream _file;
+
+ public ZipFileSystem(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ _file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+ _archive = new ZipArchive(_file, ZipArchiveMode.Update, leaveOpen: true);
+ }
+
+ public ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return ValueTask.FromResult(_archive.GetEntry(VfsPath.Normalize(path)) is not null);
+ }
+
+ public async ValueTask ReadAllBytesAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var entry = _archive.GetEntry(VfsPath.Normalize(path));
+
+ if (entry is null)
+ {
+ return null;
+ }
+
+ await using var stream = entry.Open();
+ using var buffer = new MemoryStream();
+ await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ return buffer.ToArray();
+ }
+
+ public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ {
+ var name = VfsPath.Normalize(path);
+ _archive.GetEntry(name)?.Delete();
+ var entry = _archive.CreateEntry(name, CompressionLevel.Optimal);
+
+ await using var stream = entry.Open();
+ await stream.WriteAsync(data, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task OpenReadAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var data = await ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)
+ ?? throw new FileNotFoundException($"No file at '{path}'.", path);
+
+ return new MemoryStream(data, writable: false);
+ }
+
+ public Task OpenWriteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(new WriteBackStream(this, path));
+ }
+
+ public ValueTask DeleteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var entry = _archive.GetEntry(VfsPath.Normalize(path));
+
+ if (entry is null)
+ {
+ return ValueTask.FromResult(false);
+ }
+
+ entry.Delete();
+
+ return ValueTask.FromResult(true);
+ }
+
+ public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
+
+ foreach (var entry in _archive.Entries)
+ {
+ if (normalizedPrefix is not null && !entry.FullName.StartsWith(normalizedPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return new VfsEntry(entry.FullName, entry.Length, entry.LastWriteTime);
+
+ await Task.CompletedTask;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _archive.Dispose();
+ await _file.DisposeAsync().ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ _archive.Dispose();
+ _file.Dispose();
+ }
+
+ private sealed class WriteBackStream : MemoryStream
+ {
+ private readonly ZipFileSystem _owner;
+ private readonly string _path;
+
+ public WriteBackStream(ZipFileSystem owner, string path)
+ {
+ _owner = owner;
+ _path = path;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _owner.WriteAllBytesAsync(_path, ToArray()).AsTask().GetAwaiter().GetResult();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/ZipFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/ZipFileSystemTests.cs
new file mode 100644
index 00000000..290a3983
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/ZipFileSystemTests.cs
@@ -0,0 +1,42 @@
+using System.Text;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Vfs;
+
+public class ZipFileSystemTests
+{
+ [Fact]
+ public async Task Write_Reopen_Read_Delete_RoundTrips()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "squidstd-vfs-" + Guid.NewGuid().ToString("N") + ".zip");
+
+ try
+ {
+ await using (var fs = new ZipFileSystem(path))
+ {
+ await fs.WriteAllBytesAsync("docs/cv.pdf", Encoding.UTF8.GetBytes("hello"));
+ await fs.WriteAllBytesAsync("note.txt", Encoding.UTF8.GetBytes("hi"));
+ }
+
+ await using (var fs = new ZipFileSystem(path))
+ {
+ Assert.Equal("hello", Encoding.UTF8.GetString((await fs.ReadAllBytesAsync("docs/cv.pdf"))!));
+ Assert.True(await fs.DeleteAsync("note.txt"));
+ Assert.False(await fs.ExistsAsync("note.txt"));
+ }
+
+ await using (var fs = new ZipFileSystem(path))
+ {
+ Assert.False(await fs.ExistsAsync("note.txt"));
+ Assert.True(await fs.ExistsAsync("docs/cv.pdf"));
+ }
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+}
From 4b583c4e6953e976154d2fecb84cb969f6efad0c Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:42:20 +0200
Subject: [PATCH 17/77] feat(vfs): add VfsDirectories and RegisterVfs DI
extension
---
.../Extensions/RegisterVfsExtensions.cs | 21 +++++++++
src/SquidStd.Vfs/Services/VfsDirectories.cs | 43 +++++++++++++++++++
.../Vfs/RegisterVfsExtensionsTests.cs | 19 ++++++++
.../SquidStd.Tests/Vfs/VfsDirectoriesTests.cs | 23 ++++++++++
4 files changed, 106 insertions(+)
create mode 100644 src/SquidStd.Vfs/Extensions/RegisterVfsExtensions.cs
create mode 100644 src/SquidStd.Vfs/Services/VfsDirectories.cs
create mode 100644 tests/SquidStd.Tests/Vfs/RegisterVfsExtensionsTests.cs
create mode 100644 tests/SquidStd.Tests/Vfs/VfsDirectoriesTests.cs
diff --git a/src/SquidStd.Vfs/Extensions/RegisterVfsExtensions.cs b/src/SquidStd.Vfs/Extensions/RegisterVfsExtensions.cs
new file mode 100644
index 00000000..c282b889
--- /dev/null
+++ b/src/SquidStd.Vfs/Extensions/RegisterVfsExtensions.cs
@@ -0,0 +1,21 @@
+using DryIoc;
+using SquidStd.Vfs.Abstractions.Interfaces;
+
+namespace SquidStd.Vfs.Extensions;
+
+/// DryIoc registration helpers for the VFS module.
+public static class RegisterVfsExtensions
+{
+ /// Container that receives the VFS registration.
+ extension(IContainer container)
+ {
+ /// Registers an singleton built by the factory.
+ public IContainer RegisterVfs(Func fileSystemFactory)
+ {
+ ArgumentNullException.ThrowIfNull(fileSystemFactory);
+ container.RegisterDelegate(fileSystemFactory, Reuse.Singleton);
+
+ return container;
+ }
+ }
+}
diff --git a/src/SquidStd.Vfs/Services/VfsDirectories.cs b/src/SquidStd.Vfs/Services/VfsDirectories.cs
new file mode 100644
index 00000000..a049e4ae
--- /dev/null
+++ b/src/SquidStd.Vfs/Services/VfsDirectories.cs
@@ -0,0 +1,43 @@
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Internal;
+
+namespace SquidStd.Vfs.Services;
+
+/// A VFS-backed analogue of DirectoriesConfig: named logical directories over any backend.
+public sealed class VfsDirectories
+{
+ private readonly IVirtualFileSystem _fileSystem;
+ private readonly HashSet _directories;
+
+ public VfsDirectories(IVirtualFileSystem fileSystem, IReadOnlyCollection directories)
+ {
+ ArgumentNullException.ThrowIfNull(directories);
+ _fileSystem = fileSystem;
+ _directories = directories.Select(VfsPath.Normalize).ToHashSet(StringComparer.Ordinal);
+ }
+
+ /// The logical path of a named directory.
+ public string this[string directoryName] => GetPath(directoryName);
+
+ /// The logical path of a named directory, by enum name.
+ public string this[Enum directoryType] => GetPath(directoryType.ToString());
+
+ /// Resolves the logical path of a named directory; throws when it was not declared.
+ public string GetPath(string directoryName)
+ {
+ var normalized = VfsPath.Normalize(directoryName);
+
+ if (!_directories.Contains(normalized))
+ {
+ throw new KeyNotFoundException($"Directory '{directoryName}' is not declared.");
+ }
+
+ return normalized;
+ }
+
+ /// Combines a named directory with a relative sub-path into a normalized logical path.
+ public string Combine(string directoryName, string relativePath)
+ {
+ return VfsPath.Normalize(GetPath(directoryName) + "/" + relativePath);
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/RegisterVfsExtensionsTests.cs b/tests/SquidStd.Tests/Vfs/RegisterVfsExtensionsTests.cs
new file mode 100644
index 00000000..4ef30132
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/RegisterVfsExtensionsTests.cs
@@ -0,0 +1,19 @@
+using DryIoc;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Extensions;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Vfs;
+
+public class RegisterVfsExtensionsTests
+{
+ [Fact]
+ public void RegisterVfs_RegistersSingletonFileSystem()
+ {
+ using var container = new Container();
+ container.RegisterVfs(_ => new InMemoryFileSystem());
+
+ var first = container.Resolve();
+ Assert.Same(first, container.Resolve());
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/VfsDirectoriesTests.cs b/tests/SquidStd.Tests/Vfs/VfsDirectoriesTests.cs
new file mode 100644
index 00000000..a82c1ca8
--- /dev/null
+++ b/tests/SquidStd.Tests/Vfs/VfsDirectoriesTests.cs
@@ -0,0 +1,23 @@
+using System.Text;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Vfs;
+
+public class VfsDirectoriesTests
+{
+ [Fact]
+ public async Task ResolvesNamedDirectoriesAndWritesUnderThem()
+ {
+ var fs = new InMemoryFileSystem();
+ var dirs = new VfsDirectories(fs, ["data", "logs"]);
+
+ Assert.Equal("data", dirs["data"]);
+ Assert.Equal("logs", dirs["logs"]);
+
+ var target = dirs.Combine("data", "cv.pdf");
+ await fs.WriteAllBytesAsync(target, Encoding.UTF8.GetBytes("x"));
+
+ Assert.Equal("data/cv.pdf", target);
+ Assert.True(await fs.ExistsAsync("data/cv.pdf"));
+ }
+}
From 057b93f08f1e27708a92740dddb0c51c4e637acc Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:43:35 +0200
Subject: [PATCH 18/77] feat(crypto): add vault options and Argon2id/HKDF key
derivation
---
src/SquidStd.Crypto/SquidStd.Crypto.csproj | 1 +
.../Vfs/Data/CryptoVaultOptions.cs | 17 ++++++++
.../Vfs/Internal/VaultKeyDerivation.cs | 41 +++++++++++++++++++
.../Crypto/Vfs/VaultKeyDerivationTests.cs | 32 +++++++++++++++
4 files changed, 91 insertions(+)
create mode 100644 src/SquidStd.Crypto/Vfs/Data/CryptoVaultOptions.cs
create mode 100644 src/SquidStd.Crypto/Vfs/Internal/VaultKeyDerivation.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/VaultKeyDerivationTests.cs
diff --git a/src/SquidStd.Crypto/SquidStd.Crypto.csproj b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
index fa0605cd..818d1293 100644
--- a/src/SquidStd.Crypto/SquidStd.Crypto.csproj
+++ b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/SquidStd.Crypto/Vfs/Data/CryptoVaultOptions.cs b/src/SquidStd.Crypto/Vfs/Data/CryptoVaultOptions.cs
new file mode 100644
index 00000000..7d9da9cd
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Data/CryptoVaultOptions.cs
@@ -0,0 +1,17 @@
+namespace SquidStd.Crypto.Vfs.Data;
+
+/// Tunables for an encrypted vault: chunk size and Argon2id key-derivation cost parameters.
+public sealed class CryptoVaultOptions
+{
+ /// Plaintext chunk size for per-entry AES-GCM, in bytes.
+ public int ChunkSize { get; init; } = 64 * 1024;
+
+ /// Argon2id memory cost in KiB.
+ public int Argon2MemoryKib { get; init; } = 65536;
+
+ /// Argon2id iteration (time) cost.
+ public int Argon2Iterations { get; init; } = 3;
+
+ /// Argon2id parallelism (lanes).
+ public int Argon2Parallelism { get; init; } = 1;
+}
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultKeyDerivation.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultKeyDerivation.cs
new file mode 100644
index 00000000..abb5212a
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultKeyDerivation.cs
@@ -0,0 +1,41 @@
+using System.Text;
+using Org.BouncyCastle.Crypto.Digests;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using SquidStd.Crypto.Vfs.Data;
+
+namespace SquidStd.Crypto.Vfs.Internal;
+
+/// Derives the vault master key (Argon2id) and per-purpose subkeys (HKDF-SHA256).
+internal static class VaultKeyDerivation
+{
+ public static byte[] DeriveMasterKey(string passphrase, byte[] salt, CryptoVaultOptions options)
+ {
+ var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
+ .WithVersion(Argon2Parameters.Version13)
+ .WithSalt(salt)
+ .WithIterations(options.Argon2Iterations)
+ .WithMemoryAsKB(options.Argon2MemoryKib)
+ .WithParallelism(options.Argon2Parallelism)
+ .Build();
+
+ var generator = new Argon2BytesGenerator();
+ generator.Init(parameters);
+
+ var key = new byte[32];
+ generator.GenerateBytes(Encoding.UTF8.GetBytes(passphrase), key);
+
+ return key;
+ }
+
+ public static byte[] DeriveSubKey(byte[] masterKey, string label)
+ {
+ var hkdf = new HkdfBytesGenerator(new Sha256Digest());
+ hkdf.Init(new HkdfParameters(masterKey, null, Encoding.UTF8.GetBytes(label)));
+
+ var subKey = new byte[32];
+ hkdf.GenerateBytes(subKey, 0, subKey.Length);
+
+ return subKey;
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/VaultKeyDerivationTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/VaultKeyDerivationTests.cs
new file mode 100644
index 00000000..c42a5cab
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/VaultKeyDerivationTests.cs
@@ -0,0 +1,32 @@
+using SquidStd.Crypto.Vfs.Data;
+using SquidStd.Crypto.Vfs.Internal;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class VaultKeyDerivationTests
+{
+ [Fact]
+ public void DeriveMasterKey_IsDeterministicForSameSaltAndPassphrase()
+ {
+ var options = new CryptoVaultOptions { Argon2MemoryKib = 8192, Argon2Iterations = 1 };
+ var salt = new byte[16];
+
+ var a = VaultKeyDerivation.DeriveMasterKey("pw", salt, options);
+ var b = VaultKeyDerivation.DeriveMasterKey("pw", salt, options);
+ var c = VaultKeyDerivation.DeriveMasterKey("other", salt, options);
+
+ Assert.Equal(32, a.Length);
+ Assert.Equal(a, b);
+ Assert.NotEqual(a, c);
+ }
+
+ [Fact]
+ public void DeriveSubKey_DiffersByLabel()
+ {
+ var master = new byte[32];
+ Assert.NotEqual(
+ VaultKeyDerivation.DeriveSubKey(master, "index"),
+ VaultKeyDerivation.DeriveSubKey(master, "entry:a1b2")
+ );
+ }
+}
From 68afcc7a900072085237808698b86dc3d4c83a3f Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:44:34 +0200
Subject: [PATCH 19/77] feat(crypto): add chunked AES-GCM entry cipher
---
.../Vfs/Internal/EntryCipher.cs | 119 ++++++++++++++++++
.../Crypto/Vfs/EntryCipherTests.cs | 40 ++++++
2 files changed, 159 insertions(+)
create mode 100644 src/SquidStd.Crypto/Vfs/Internal/EntryCipher.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
diff --git a/src/SquidStd.Crypto/Vfs/Internal/EntryCipher.cs b/src/SquidStd.Crypto/Vfs/Internal/EntryCipher.cs
new file mode 100644
index 00000000..f6005edd
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Internal/EntryCipher.cs
@@ -0,0 +1,119 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+
+namespace SquidStd.Crypto.Vfs.Internal;
+
+/// Streams a payload as chunked AES-GCM records: [len(4) | nonce(12) | ciphertext | tag(16)] per chunk.
+internal sealed class EntryCipher : IDisposable
+{
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+ private readonly AesGcm _aes;
+ private readonly int _chunkSize;
+
+ public EntryCipher(byte[] key, int chunkSize)
+ {
+ _aes = new AesGcm(key, TagSize);
+ _chunkSize = chunkSize;
+ }
+
+ public async Task EncryptAsync(Stream plaintext, Stream output, CancellationToken cancellationToken = default)
+ {
+ var buffer = new byte[_chunkSize];
+
+ while (true)
+ {
+ var read = await ReadFullAsync(plaintext, buffer, cancellationToken).ConfigureAwait(false);
+ await WriteRecordAsync(output, buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+
+ if (read == 0)
+ {
+ break; // terminator record written
+ }
+ }
+ }
+
+ public async Task DecryptAsync(Stream input, Stream output, CancellationToken cancellationToken = default)
+ {
+ var header = new byte[4 + NonceSize];
+
+ while (true)
+ {
+ await ReadExactAsync(input, header, cancellationToken).ConfigureAwait(false);
+ var length = BinaryPrimitives.ReadInt32BigEndian(header);
+ var nonce = header.AsMemory(4, NonceSize);
+
+ var cipher = new byte[length];
+ await ReadExactAsync(input, cipher, cancellationToken).ConfigureAwait(false);
+ var tag = new byte[TagSize];
+ await ReadExactAsync(input, tag, cancellationToken).ConfigureAwait(false);
+
+ if (length == 0)
+ {
+ _aes.Decrypt(nonce.Span, ReadOnlySpan.Empty, tag, Span.Empty);
+ break;
+ }
+
+ var plain = new byte[length];
+ _aes.Decrypt(nonce.Span, cipher, tag, plain);
+ await output.WriteAsync(plain, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task WriteRecordAsync(Stream output, ReadOnlyMemory plain, CancellationToken cancellationToken)
+ {
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[plain.Length];
+ var tag = new byte[TagSize];
+ _aes.Encrypt(nonce, plain.Span, cipher, tag);
+
+ var header = new byte[4 + NonceSize];
+ BinaryPrimitives.WriteInt32BigEndian(header, plain.Length);
+ nonce.CopyTo(header.AsSpan(4));
+
+ await output.WriteAsync(header, cancellationToken).ConfigureAwait(false);
+ await output.WriteAsync(cipher, cancellationToken).ConfigureAwait(false);
+ await output.WriteAsync(tag, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task ReadFullAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
+ {
+ var total = 0;
+
+ while (total < buffer.Length)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(total), cancellationToken).ConfigureAwait(false);
+
+ if (read == 0)
+ {
+ break;
+ }
+
+ total += read;
+ }
+
+ return total;
+ }
+
+ private static async Task ReadExactAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
+ {
+ var total = 0;
+
+ while (total < buffer.Length)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(total), cancellationToken).ConfigureAwait(false);
+
+ if (read == 0)
+ {
+ throw new EndOfStreamException("Encrypted entry is truncated.");
+ }
+
+ total += read;
+ }
+ }
+
+ public void Dispose()
+ {
+ _aes.Dispose();
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
new file mode 100644
index 00000000..1c13e880
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
@@ -0,0 +1,40 @@
+using System.Security.Cryptography;
+using System.Text;
+using SquidStd.Crypto.Vfs.Internal;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class EntryCipherTests
+{
+ [Fact]
+ public async Task EncryptThenDecrypt_RoundTripsAcrossChunkBoundaries()
+ {
+ var key = RandomNumberGenerator.GetBytes(32);
+ var payload = RandomNumberGenerator.GetBytes(200_000); // > 3 chunks at 64 KiB
+
+ using var cipher = new EntryCipher(key, chunkSize: 65536);
+ using var encrypted = new MemoryStream();
+ await cipher.EncryptAsync(new MemoryStream(payload), encrypted);
+
+ encrypted.Position = 0;
+ using var decrypted = new MemoryStream();
+ await cipher.DecryptAsync(encrypted, decrypted);
+
+ Assert.Equal(payload, decrypted.ToArray());
+ }
+
+ [Fact]
+ public async Task Decrypt_WrongKey_Throws()
+ {
+ var payload = Encoding.UTF8.GetBytes("secret");
+ using var enc = new EntryCipher(RandomNumberGenerator.GetBytes(32), 65536);
+ using var encrypted = new MemoryStream();
+ await enc.EncryptAsync(new MemoryStream(payload), encrypted);
+
+ using var wrong = new EntryCipher(RandomNumberGenerator.GetBytes(32), 65536);
+ encrypted.Position = 0;
+ await Assert.ThrowsAsync(
+ () => wrong.DecryptAsync(encrypted, new MemoryStream())
+ );
+ }
+}
From 94f46e2a13c5d0cdb1e0224ee948164b20ac635c Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:45:27 +0200
Subject: [PATCH 20/77] feat(crypto): add vault header and encrypted-index
models
---
.../Vfs/Internal/VaultHeader.cs | 19 +++++++
.../Vfs/Internal/VaultIndex.cs | 52 +++++++++++++++++++
.../Crypto/Vfs/VaultHeaderTests.cs | 22 ++++++++
.../Crypto/Vfs/VaultIndexTests.cs | 18 +++++++
4 files changed, 111 insertions(+)
create mode 100644 src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
create mode 100644 src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/VaultIndexTests.cs
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
new file mode 100644
index 00000000..e3c4ad21
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+
+namespace SquidStd.Crypto.Vfs.Internal;
+
+/// Cleartext vault header: format magic/version, Argon2id salt + cost params, and chunk size.
+internal sealed record VaultHeader(
+ string Magic, int Version, byte[] Salt, int MemoryKib, int Iterations, int Parallelism, int ChunkSize)
+{
+ public byte[] Serialize()
+ {
+ return JsonSerializer.SerializeToUtf8Bytes(this);
+ }
+
+ public static VaultHeader Parse(byte[] data)
+ {
+ return JsonSerializer.Deserialize(data)
+ ?? throw new InvalidDataException("Vault header is empty or invalid.");
+ }
+}
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
new file mode 100644
index 00000000..0fb1bb1c
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+
+namespace SquidStd.Crypto.Vfs.Internal;
+
+/// A single index record: the opaque backing blob id, plaintext size, and modification time.
+internal sealed record VaultIndexEntry(string BlobId, long Size, DateTimeOffset ModifiedUtc);
+
+/// The decrypted vault index: a map from logical path to its backing blob entry.
+internal sealed class VaultIndex
+{
+ private readonly Dictionary _entries;
+
+ public VaultIndex()
+ {
+ _entries = new Dictionary(StringComparer.Ordinal);
+ }
+
+ private VaultIndex(Dictionary entries)
+ {
+ _entries = entries;
+ }
+
+ public IReadOnlyDictionary Entries => _entries;
+
+ public void Set(string path, VaultIndexEntry entry)
+ {
+ _entries[path] = entry;
+ }
+
+ public bool TryGet(string path, out VaultIndexEntry? entry)
+ {
+ return _entries.TryGetValue(path, out entry);
+ }
+
+ public bool Remove(string path, out VaultIndexEntry? entry)
+ {
+ return _entries.Remove(path, out entry);
+ }
+
+ public byte[] Serialize()
+ {
+ return JsonSerializer.SerializeToUtf8Bytes(_entries);
+ }
+
+ public static VaultIndex Parse(byte[] data)
+ {
+ var map = JsonSerializer.Deserialize>(data)
+ ?? new Dictionary(StringComparer.Ordinal);
+
+ return new VaultIndex(new Dictionary(map, StringComparer.Ordinal));
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
new file mode 100644
index 00000000..a354f056
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
@@ -0,0 +1,22 @@
+using SquidStd.Crypto.Vfs.Internal;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class VaultHeaderTests
+{
+ [Fact]
+ public void Serialize_Parse_RoundTrips()
+ {
+ var header = new VaultHeader("SQVFS1", 1, [1, 2, 3, 4], MemoryKib: 8192, Iterations: 2, Parallelism: 1, ChunkSize: 65536);
+
+ var parsed = VaultHeader.Parse(header.Serialize());
+
+ Assert.Equal(header.Magic, parsed.Magic);
+ Assert.Equal(header.Version, parsed.Version);
+ Assert.Equal(header.Salt, parsed.Salt);
+ Assert.Equal(header.MemoryKib, parsed.MemoryKib);
+ Assert.Equal(header.Iterations, parsed.Iterations);
+ Assert.Equal(header.Parallelism, parsed.Parallelism);
+ Assert.Equal(header.ChunkSize, parsed.ChunkSize);
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/VaultIndexTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/VaultIndexTests.cs
new file mode 100644
index 00000000..a7f0efd9
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/VaultIndexTests.cs
@@ -0,0 +1,18 @@
+using SquidStd.Crypto.Vfs.Internal;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class VaultIndexTests
+{
+ [Fact]
+ public void Serialize_Parse_RoundTripsEntries()
+ {
+ var index = new VaultIndex();
+ index.Set("docs/cv.pdf", new VaultIndexEntry("a1b2", 100, DateTimeOffset.UnixEpoch));
+
+ var parsed = VaultIndex.Parse(index.Serialize());
+
+ Assert.True(parsed.TryGet("docs/cv.pdf", out var entry));
+ Assert.Equal("a1b2", entry!.BlobId);
+ }
+}
From 18b70a6366404a2eaac43f499d42b0c2b9c38e8a Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:48:41 +0200
Subject: [PATCH 21/77] feat(crypto): add crypto vault unlock/lock and index
lifecycle
Promote VfsPath to SquidStd.Vfs.Abstractions (public) so the crypto
decorator and the VFS providers share identical path normalization.
---
src/SquidStd.Crypto/Vfs/Internal/VaultBlob.cs | 40 +++
.../Vfs/Services/CryptoFileSystem.cs | 249 ++++++++++++++++++
src/SquidStd.Vfs.Abstractions/VfsPath.cs | 24 ++
.../Services/InMemoryFileSystem.cs | 2 +-
.../Services/PhysicalFileSystem.cs | 2 +-
src/SquidStd.Vfs/Services/VfsDirectories.cs | 2 +-
src/SquidStd.Vfs/Services/ZipFileSystem.cs | 2 +-
.../Crypto/Vfs/CryptoFileSystemLockTests.cs | 43 +++
tests/SquidStd.Tests/Vfs/VfsPathTests.cs | 2 +-
9 files changed, 361 insertions(+), 5 deletions(-)
create mode 100644 src/SquidStd.Crypto/Vfs/Internal/VaultBlob.cs
create mode 100644 src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
create mode 100644 src/SquidStd.Vfs.Abstractions/VfsPath.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultBlob.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultBlob.cs
new file mode 100644
index 00000000..515f90d4
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultBlob.cs
@@ -0,0 +1,40 @@
+using System.Security.Cryptography;
+
+namespace SquidStd.Crypto.Vfs.Internal;
+
+/// Single-shot AES-GCM for small blobs (the index): layout [nonce(12) | ciphertext | tag(16)].
+internal static class VaultBlob
+{
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+
+ public static byte[] Encrypt(byte[] key, byte[] plaintext)
+ {
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[plaintext.Length];
+ var tag = new byte[TagSize];
+
+ using var aes = new AesGcm(key, TagSize);
+ aes.Encrypt(nonce, plaintext, cipher, tag);
+
+ var output = new byte[NonceSize + cipher.Length + TagSize];
+ nonce.CopyTo(output, 0);
+ cipher.CopyTo(output, NonceSize);
+ tag.CopyTo(output, NonceSize + cipher.Length);
+
+ return output;
+ }
+
+ public static byte[] Decrypt(byte[] key, byte[] blob)
+ {
+ var nonce = blob.AsSpan(0, NonceSize);
+ var tag = blob.AsSpan(blob.Length - TagSize, TagSize);
+ var cipher = blob.AsSpan(NonceSize, blob.Length - NonceSize - TagSize);
+
+ var plaintext = new byte[cipher.Length];
+ using var aes = new AesGcm(key, TagSize);
+ aes.Decrypt(nonce, cipher, tag, plaintext);
+
+ return plaintext;
+ }
+}
diff --git a/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs b/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
new file mode 100644
index 00000000..eaf0ab90
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
@@ -0,0 +1,249 @@
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using SquidStd.Crypto.Vfs.Data;
+using SquidStd.Crypto.Vfs.Internal;
+using SquidStd.Vfs.Abstractions.Data;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Abstractions;
+
+namespace SquidStd.Crypto.Vfs.Services;
+
+/// An encrypted, lockable virtual filesystem decorating any inner .
+public sealed class CryptoFileSystem : ILockableFileSystem, IDisposable
+{
+ private const string HeaderPath = "_vfs_header";
+ private const string IndexPath = "_vfs_index";
+ private readonly IVirtualFileSystem _inner;
+ private readonly CryptoVaultOptions _options;
+ private byte[]? _masterKey;
+ private VaultIndex? _index;
+
+ public CryptoFileSystem(IVirtualFileSystem inner, CryptoVaultOptions? options = null)
+ {
+ _inner = inner;
+ _options = options ?? new CryptoVaultOptions();
+ }
+
+ public bool IsUnlocked => _masterKey is not null;
+
+ public void Unlock(string passphrase)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
+
+ var headerBytes = _inner.ReadAllBytesAsync(HeaderPath).AsTask().GetAwaiter().GetResult();
+ VaultHeader header;
+
+ if (headerBytes is null)
+ {
+ header = new VaultHeader(
+ "SQVFS1", 1, RandomNumberGenerator.GetBytes(16),
+ _options.Argon2MemoryKib, _options.Argon2Iterations, _options.Argon2Parallelism, _options.ChunkSize
+ );
+ _inner.WriteAllBytesAsync(HeaderPath, header.Serialize()).AsTask().GetAwaiter().GetResult();
+ }
+ else
+ {
+ header = VaultHeader.Parse(headerBytes);
+ }
+
+ var headerOptions = new CryptoVaultOptions
+ {
+ ChunkSize = header.ChunkSize,
+ Argon2MemoryKib = header.MemoryKib,
+ Argon2Iterations = header.Iterations,
+ Argon2Parallelism = header.Parallelism
+ };
+ var master = VaultKeyDerivation.DeriveMasterKey(passphrase, header.Salt, headerOptions);
+
+ var indexBytes = _inner.ReadAllBytesAsync(IndexPath).AsTask().GetAwaiter().GetResult();
+ _index = indexBytes is null
+ ? new VaultIndex()
+ : VaultIndex.Parse(VaultBlob.Decrypt(VaultKeyDerivation.DeriveSubKey(master, "index"), indexBytes));
+
+ _masterKey = master;
+ }
+
+ public void Lock()
+ {
+ if (_masterKey is null)
+ {
+ return;
+ }
+
+ FlushIndex();
+ PruneOrphans();
+ CryptographicOperations.ZeroMemory(_masterKey);
+ _masterKey = null;
+ _index = null;
+ }
+
+ public ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+
+ return ValueTask.FromResult(_index!.TryGet(VfsPath.Normalize(path), out _));
+ }
+
+ public async ValueTask ReadAllBytesAsync(string path, CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+
+ if (!_index!.TryGet(VfsPath.Normalize(path), out var entry))
+ {
+ return null;
+ }
+
+ var blob = await _inner.ReadAllBytesAsync(entry!.BlobId, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidDataException($"Backing blob '{entry.BlobId}' is missing.");
+
+ using var cipher = NewCipher(entry.BlobId);
+ using var input = new MemoryStream(blob);
+ using var output = new MemoryStream();
+ await cipher.DecryptAsync(input, output, cancellationToken).ConfigureAwait(false);
+
+ return output.ToArray();
+ }
+
+ public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+ var normalized = VfsPath.Normalize(path);
+
+ var blobId = _index!.TryGet(normalized, out var existing) ? existing!.BlobId : NewBlobId();
+
+ using var cipher = NewCipher(blobId);
+ using var input = new MemoryStream(data.ToArray());
+ using var output = new MemoryStream();
+ await cipher.EncryptAsync(input, output, cancellationToken).ConfigureAwait(false);
+
+ await _inner.WriteAllBytesAsync(blobId, output.ToArray(), cancellationToken).ConfigureAwait(false);
+ _index.Set(normalized, new VaultIndexEntry(blobId, data.Length, DateTimeOffset.UtcNow));
+ }
+
+ public async Task OpenReadAsync(string path, CancellationToken cancellationToken = default)
+ {
+ var data = await ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)
+ ?? throw new FileNotFoundException($"No file at '{path}'.", path);
+
+ return new MemoryStream(data, writable: false);
+ }
+
+ public Task OpenWriteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+
+ return Task.FromResult(new VaultWriteStream(this, path));
+ }
+
+ public async ValueTask DeleteAsync(string path, CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+
+ if (!_index!.Remove(VfsPath.Normalize(path), out var entry))
+ {
+ return false;
+ }
+
+ await _inner.DeleteAsync(entry!.BlobId, cancellationToken).ConfigureAwait(false);
+
+ return true;
+ }
+
+ public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ EnsureUnlocked();
+ var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
+
+ foreach (var (logicalPath, entry) in _index!.Entries)
+ {
+ if (normalizedPrefix is not null && !logicalPath.StartsWith(normalizedPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return new VfsEntry(logicalPath, entry.Size, entry.ModifiedUtc);
+
+ await Task.CompletedTask;
+ }
+ }
+
+ private EntryCipher NewCipher(string blobId)
+ {
+ return new EntryCipher(VaultKeyDerivation.DeriveSubKey(_masterKey!, "entry:" + blobId), _options.ChunkSize);
+ }
+
+ private static string NewBlobId()
+ {
+ return Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
+ }
+
+ private void FlushIndex()
+ {
+ var indexKey = VaultKeyDerivation.DeriveSubKey(_masterKey!, "index");
+ var encrypted = VaultBlob.Encrypt(indexKey, _index!.Serialize());
+ _inner.WriteAllBytesAsync(IndexPath, encrypted).AsTask().GetAwaiter().GetResult();
+ }
+
+ private void PruneOrphans()
+ {
+ var keep = new HashSet(_index!.Entries.Values.Select(e => e.BlobId), StringComparer.Ordinal)
+ {
+ HeaderPath,
+ IndexPath
+ };
+
+ var paths = new List();
+ var enumerator = _inner.ListAsync().GetAsyncEnumerator();
+ try
+ {
+ while (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult())
+ {
+ paths.Add(enumerator.Current.Path);
+ }
+ }
+ finally
+ {
+ enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+
+ foreach (var path in paths.Where(p => !keep.Contains(p)))
+ {
+ _inner.DeleteAsync(path).AsTask().GetAwaiter().GetResult();
+ }
+ }
+
+ private void EnsureUnlocked()
+ {
+ if (_masterKey is null)
+ {
+ throw new InvalidOperationException("The vault is locked.");
+ }
+ }
+
+ private sealed class VaultWriteStream : MemoryStream
+ {
+ private readonly CryptoFileSystem _owner;
+ private readonly string _path;
+
+ public VaultWriteStream(CryptoFileSystem owner, string path)
+ {
+ _owner = owner;
+ _path = path;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _owner.WriteAllBytesAsync(_path, ToArray()).AsTask().GetAwaiter().GetResult();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+
+ public void Dispose()
+ {
+ Lock();
+ }
+}
diff --git a/src/SquidStd.Vfs.Abstractions/VfsPath.cs b/src/SquidStd.Vfs.Abstractions/VfsPath.cs
new file mode 100644
index 00000000..4c3db181
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/VfsPath.cs
@@ -0,0 +1,24 @@
+namespace SquidStd.Vfs.Abstractions;
+
+/// Normalizes logical VFS paths to forward-slash, root-relative form and rejects traversal.
+public static class VfsPath
+{
+ /// Normalizes a logical path: forward slashes, no leading/empty segments, no ./...
+ public static string Normalize(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var segments = path.Replace('\\', '/')
+ .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var segment in segments)
+ {
+ if (segment is "." or "..")
+ {
+ throw new ArgumentException($"Path '{path}' must not contain relative segments.", nameof(path));
+ }
+ }
+
+ return string.Join('/', segments);
+ }
+}
diff --git a/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
index c7de6237..f363aa54 100644
--- a/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
@@ -2,7 +2,7 @@
using System.Runtime.CompilerServices;
using SquidStd.Vfs.Abstractions.Data;
using SquidStd.Vfs.Abstractions.Interfaces;
-using SquidStd.Vfs.Internal;
+using SquidStd.Vfs.Abstractions;
namespace SquidStd.Vfs.Services;
diff --git a/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
index f0514586..87bdbf8d 100644
--- a/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
@@ -1,7 +1,7 @@
using System.Runtime.CompilerServices;
using SquidStd.Vfs.Abstractions.Data;
using SquidStd.Vfs.Abstractions.Interfaces;
-using SquidStd.Vfs.Internal;
+using SquidStd.Vfs.Abstractions;
namespace SquidStd.Vfs.Services;
diff --git a/src/SquidStd.Vfs/Services/VfsDirectories.cs b/src/SquidStd.Vfs/Services/VfsDirectories.cs
index a049e4ae..f187d2d9 100644
--- a/src/SquidStd.Vfs/Services/VfsDirectories.cs
+++ b/src/SquidStd.Vfs/Services/VfsDirectories.cs
@@ -1,5 +1,5 @@
using SquidStd.Vfs.Abstractions.Interfaces;
-using SquidStd.Vfs.Internal;
+using SquidStd.Vfs.Abstractions;
namespace SquidStd.Vfs.Services;
diff --git a/src/SquidStd.Vfs/Services/ZipFileSystem.cs b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
index 294bb142..02067a46 100644
--- a/src/SquidStd.Vfs/Services/ZipFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
@@ -2,7 +2,7 @@
using System.Runtime.CompilerServices;
using SquidStd.Vfs.Abstractions.Data;
using SquidStd.Vfs.Abstractions.Interfaces;
-using SquidStd.Vfs.Internal;
+using SquidStd.Vfs.Abstractions;
namespace SquidStd.Vfs.Services;
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
new file mode 100644
index 00000000..ec150d8f
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
@@ -0,0 +1,43 @@
+using System.Security.Cryptography;
+using SquidStd.Crypto.Vfs.Data;
+using SquidStd.Crypto.Vfs.Services;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class CryptoFileSystemLockTests
+{
+ private static CryptoVaultOptions FastOptions()
+ {
+ return new CryptoVaultOptions { Argon2MemoryKib = 8192, Argon2Iterations = 1 };
+ }
+
+ [Fact]
+ public async Task LockedOperations_Throw_UntilUnlocked()
+ {
+ var vault = new CryptoFileSystem(new InMemoryFileSystem(), FastOptions());
+
+ Assert.False(vault.IsUnlocked);
+ await Assert.ThrowsAsync(
+ () => vault.WriteAllBytesAsync("a", new byte[] { 1 }).AsTask()
+ );
+
+ vault.Unlock("pw");
+ Assert.True(vault.IsUnlocked);
+
+ vault.Lock();
+ Assert.False(vault.IsUnlocked);
+ }
+
+ [Fact]
+ public void Unlock_WrongPassphrase_OnExistingVault_Throws()
+ {
+ var backend = new InMemoryFileSystem();
+ var first = new CryptoFileSystem(backend, FastOptions());
+ first.Unlock("correct");
+ first.Lock(); // writes header + index
+
+ var second = new CryptoFileSystem(backend, FastOptions());
+ Assert.Throws(() => second.Unlock("wrong"));
+ }
+}
diff --git a/tests/SquidStd.Tests/Vfs/VfsPathTests.cs b/tests/SquidStd.Tests/Vfs/VfsPathTests.cs
index a33151ea..903666fd 100644
--- a/tests/SquidStd.Tests/Vfs/VfsPathTests.cs
+++ b/tests/SquidStd.Tests/Vfs/VfsPathTests.cs
@@ -1,4 +1,4 @@
-using SquidStd.Vfs.Internal;
+using SquidStd.Vfs.Abstractions;
namespace SquidStd.Tests.Vfs;
From e40349d06b110660092050ef9e4d5aa36a17dea0 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:49:28 +0200
Subject: [PATCH 22/77] feat(crypto): implement encrypted per-entry
read/write/delete/list
---
.../Crypto/Vfs/CryptoFileSystemTests.cs | 88 +++++++++++++++++++
1 file changed, 88 insertions(+)
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
new file mode 100644
index 00000000..a3beffa6
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
@@ -0,0 +1,88 @@
+using System.Security.Cryptography;
+using System.Text;
+using SquidStd.Crypto.Vfs.Data;
+using SquidStd.Crypto.Vfs.Services;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class CryptoFileSystemTests
+{
+ private static CryptoVaultOptions FastOptions()
+ {
+ return new CryptoVaultOptions { Argon2MemoryKib = 8192, Argon2Iterations = 1 };
+ }
+
+ [Fact]
+ public async Task Write_Read_List_Delete_RoundTrips()
+ {
+ var vault = new CryptoFileSystem(new InMemoryFileSystem(), FastOptions());
+ vault.Unlock("pw");
+
+ await vault.WriteAllBytesAsync("docs/cv.pdf", Encoding.UTF8.GetBytes("hello"));
+ Assert.Equal("hello", Encoding.UTF8.GetString((await vault.ReadAllBytesAsync("docs/cv.pdf"))!));
+
+ var paths = new List();
+ await foreach (var e in vault.ListAsync())
+ {
+ paths.Add(e.Path);
+ }
+ Assert.Equal(["docs/cv.pdf"], paths);
+
+ Assert.True(await vault.DeleteAsync("docs/cv.pdf"));
+ Assert.Null(await vault.ReadAllBytesAsync("docs/cv.pdf"));
+ }
+
+ [Fact]
+ public async Task Persists_AcrossLockUnlock()
+ {
+ var backend = new InMemoryFileSystem();
+ var a = new CryptoFileSystem(backend, FastOptions());
+ a.Unlock("pw");
+ await a.WriteAllBytesAsync("a.txt", Encoding.UTF8.GetBytes("v1"));
+ a.Lock();
+
+ var b = new CryptoFileSystem(backend, FastOptions());
+ b.Unlock("pw");
+ Assert.Equal("v1", Encoding.UTF8.GetString((await b.ReadAllBytesAsync("a.txt"))!));
+ }
+
+ [Fact]
+ public async Task BackingStore_DoesNotLeakLogicalNames()
+ {
+ var backend = new InMemoryFileSystem();
+ var vault = new CryptoFileSystem(backend, FastOptions());
+ vault.Unlock("pw");
+ await vault.WriteAllBytesAsync("docs/secret.pdf", Encoding.UTF8.GetBytes("x"));
+ vault.Lock();
+
+ await foreach (var e in backend.ListAsync())
+ {
+ Assert.DoesNotContain("secret", e.Path, StringComparison.Ordinal);
+ Assert.DoesNotContain("docs", e.Path, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public async Task LargePayload_StreamsThroughChunks()
+ {
+ var vault = new CryptoFileSystem(new InMemoryFileSystem(), FastOptions());
+ vault.Unlock("pw");
+ var payload = RandomNumberGenerator.GetBytes(300_000);
+
+ await vault.WriteAllBytesAsync("big.bin", payload);
+ Assert.Equal(payload, await vault.ReadAllBytesAsync("big.bin"));
+ }
+
+ [Fact]
+ public async Task Update_ReplacesContent()
+ {
+ var vault = new CryptoFileSystem(new InMemoryFileSystem(), FastOptions());
+ vault.Unlock("pw");
+
+ await vault.WriteAllBytesAsync("a.txt", Encoding.UTF8.GetBytes("first"));
+ await vault.WriteAllBytesAsync("a.txt", Encoding.UTF8.GetBytes("second"));
+
+ Assert.Equal("second", Encoding.UTF8.GetString((await vault.ReadAllBytesAsync("a.txt"))!));
+ }
+}
From 750cd351b94bc747568179a74e0f4a4646ed2633 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:50:33 +0200
Subject: [PATCH 23/77] feat(crypto): add RegisterCryptoVault DI extension
---
src/SquidStd.Crypto/SquidStd.Crypto.csproj | 1 +
.../RegisterCryptoVaultExtensions.cs | 31 +++++++++++++++++++
.../Vfs/RegisterCryptoVaultExtensionsTests.cs | 31 +++++++++++++++++++
3 files changed, 63 insertions(+)
create mode 100644 src/SquidStd.Crypto/Vfs/Extensions/RegisterCryptoVaultExtensions.cs
create mode 100644 tests/SquidStd.Tests/Crypto/Vfs/RegisterCryptoVaultExtensionsTests.cs
diff --git a/src/SquidStd.Crypto/SquidStd.Crypto.csproj b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
index 818d1293..02d903fa 100644
--- a/src/SquidStd.Crypto/SquidStd.Crypto.csproj
+++ b/src/SquidStd.Crypto/SquidStd.Crypto.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/SquidStd.Crypto/Vfs/Extensions/RegisterCryptoVaultExtensions.cs b/src/SquidStd.Crypto/Vfs/Extensions/RegisterCryptoVaultExtensions.cs
new file mode 100644
index 00000000..d87faadd
--- /dev/null
+++ b/src/SquidStd.Crypto/Vfs/Extensions/RegisterCryptoVaultExtensions.cs
@@ -0,0 +1,31 @@
+using DryIoc;
+using SquidStd.Crypto.Vfs.Data;
+using SquidStd.Crypto.Vfs.Services;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Services;
+
+namespace SquidStd.Crypto.Vfs.Extensions;
+
+/// DryIoc registration helpers for an encrypted single-file vault.
+public static class RegisterCryptoVaultExtensions
+{
+ /// Container that receives the vault registration.
+ extension(IContainer container)
+ {
+ ///
+ /// Registers an singleton: a crypto vault over a single-file zip at
+ /// . The consumer calls at runtime.
+ ///
+ public IContainer RegisterCryptoVault(string path, CryptoVaultOptions? options = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ container.RegisterDelegate(
+ _ => new CryptoFileSystem(new ZipFileSystem(path), options),
+ Reuse.Singleton
+ );
+
+ return container;
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/RegisterCryptoVaultExtensionsTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/RegisterCryptoVaultExtensionsTests.cs
new file mode 100644
index 00000000..5c1a5ae1
--- /dev/null
+++ b/tests/SquidStd.Tests/Crypto/Vfs/RegisterCryptoVaultExtensionsTests.cs
@@ -0,0 +1,31 @@
+using DryIoc;
+using SquidStd.Crypto.Vfs.Extensions;
+using SquidStd.Vfs.Abstractions.Interfaces;
+
+namespace SquidStd.Tests.Crypto.Vfs;
+
+public class RegisterCryptoVaultExtensionsTests
+{
+ [Fact]
+ public void RegisterCryptoVault_RegistersLockableSingleton()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "squidstd-vault-di-" + Guid.NewGuid().ToString("N") + ".dat");
+
+ try
+ {
+ using var container = new Container();
+ container.RegisterCryptoVault(path);
+
+ var vault = container.Resolve();
+ Assert.False(vault.IsUnlocked);
+ Assert.Same(vault, container.Resolve());
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+}
From eab6e82115997f965ca4e71cec20ef2503a158cc Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 17:53:22 +0200
Subject: [PATCH 24/77] docs(vfs): add SquidStd.Vfs README and crypto vault
docs; format module
---
src/SquidStd.Crypto/README.md | 43 ++++++++++++++
.../Vfs/Internal/VaultHeader.cs | 9 ++-
.../Vfs/Internal/VaultIndex.cs | 4 +-
.../Vfs/Services/CryptoFileSystem.cs | 31 ++++++----
src/SquidStd.Vfs/README.md | 56 +++++++++++++++++++
.../Services/InMemoryFileSystem.cs | 12 +++-
.../Services/PhysicalFileSystem.cs | 8 ++-
src/SquidStd.Vfs/Services/VfsDirectories.cs | 12 ++--
src/SquidStd.Vfs/Services/ZipFileSystem.cs | 8 ++-
.../Crypto/Vfs/CryptoFileSystemLockTests.cs | 3 +-
.../Crypto/Vfs/CryptoFileSystemTests.cs | 1 +
.../Crypto/Vfs/EntryCipherTests.cs | 3 +-
.../Crypto/Vfs/VaultHeaderTests.cs | 10 +++-
.../Vfs/InMemoryFileSystemTests.cs | 1 +
.../Vfs/PhysicalFileSystemTests.cs | 1 +
15 files changed, 170 insertions(+), 32 deletions(-)
create mode 100644 src/SquidStd.Vfs/README.md
diff --git a/src/SquidStd.Crypto/README.md b/src/SquidStd.Crypto/README.md
index 69baf78c..abec6e15 100644
--- a/src/SquidStd.Crypto/README.md
+++ b/src/SquidStd.Crypto/README.md
@@ -67,3 +67,46 @@ await keyring.LoadAsync(container.Resolve());
read before decrypting; the encrypt and sign stream paths flow straight through.
- Passphrases are supplied per operation and never persisted (only passphrase-protected secret blocks are
stored).
+
+## Crypto vault (encrypted virtual filesystem)
+
+`CryptoFileSystem` is an `ILockableFileSystem` that **decorates any `IVirtualFileSystem`**
+(`SquidStd.Vfs`), encrypting file content and names. `Crypto(Zip("vault.dat"))` is a single-file encrypted
+vault you unlock with a passphrase, write to, and lock again.
+
+```csharp
+using DryIoc;
+using SquidStd.Crypto.Vfs.Extensions;
+using SquidStd.Vfs.Abstractions.Interfaces;
+
+// Single-file vault (crypto over a zip backend), registered as a singleton.
+container.RegisterCryptoVault("/var/lib/app/vault.dat");
+
+var vault = container.Resolve();
+vault.Unlock("my passphrase"); // derives the key (Argon2id)
+await vault.WriteAllBytesAsync("docs/cv.pdf", bytes);
+byte[]? back = await vault.ReadAllBytesAsync("docs/cv.pdf");
+vault.Lock(); // flushes, prunes, zeroes the key
+```
+
+Compose other backends directly:
+
+```csharp
+using SquidStd.Crypto.Vfs.Services;
+using SquidStd.Vfs.Services;
+
+var folderVault = new CryptoFileSystem(new PhysicalFileSystem("/secure/dir"));
+```
+
+### Notes
+
+- **Unlock with a passphrase**: a 256-bit key is derived with **Argon2id** (salt + cost params live in the
+ cleartext header). Per-purpose subkeys are derived with HKDF-SHA256. The passphrase is never persisted.
+- **Per-entry encryption**: each file is stored as **chunked AES-GCM** (64 KiB chunks), so large files stream
+ with bounded memory and tampering/truncation is detected.
+- **Encrypted name index**: logical paths and structure live in an encrypted index; backing entries use opaque
+ ids, so a locked vault leaks neither file names nor layout.
+- **Read-write, lockable**: add/update/delete any time while unlocked; `Lock()`/`Dispose()` flush the encrypted
+ index, prune orphaned blobs, and zero the key with `CryptographicOperations.ZeroMemory`.
+- A wrong passphrase fails the index authentication tag → `CryptographicException`; operations on a locked
+ vault throw `InvalidOperationException`.
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
index e3c4ad21..289a9867 100644
--- a/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultHeader.cs
@@ -4,7 +4,14 @@ namespace SquidStd.Crypto.Vfs.Internal;
/// Cleartext vault header: format magic/version, Argon2id salt + cost params, and chunk size.
internal sealed record VaultHeader(
- string Magic, int Version, byte[] Salt, int MemoryKib, int Iterations, int Parallelism, int ChunkSize)
+ string Magic,
+ int Version,
+ byte[] Salt,
+ int MemoryKib,
+ int Iterations,
+ int Parallelism,
+ int ChunkSize
+)
{
public byte[] Serialize()
{
diff --git a/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs b/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
index 0fb1bb1c..d11447d1 100644
--- a/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
+++ b/src/SquidStd.Crypto/Vfs/Internal/VaultIndex.cs
@@ -10,6 +10,8 @@ internal sealed class VaultIndex
{
private readonly Dictionary _entries;
+ public IReadOnlyDictionary Entries => _entries;
+
public VaultIndex()
{
_entries = new Dictionary(StringComparer.Ordinal);
@@ -20,8 +22,6 @@ private VaultIndex(Dictionary entries)
_entries = entries;
}
- public IReadOnlyDictionary Entries => _entries;
-
public void Set(string path, VaultIndexEntry entry)
{
_entries[path] = entry;
diff --git a/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs b/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
index eaf0ab90..044b6b00 100644
--- a/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
+++ b/src/SquidStd.Crypto/Vfs/Services/CryptoFileSystem.cs
@@ -18,14 +18,14 @@ public sealed class CryptoFileSystem : ILockableFileSystem, IDisposable
private byte[]? _masterKey;
private VaultIndex? _index;
+ public bool IsUnlocked => _masterKey is not null;
+
public CryptoFileSystem(IVirtualFileSystem inner, CryptoVaultOptions? options = null)
{
_inner = inner;
_options = options ?? new CryptoVaultOptions();
}
- public bool IsUnlocked => _masterKey is not null;
-
public void Unlock(string passphrase)
{
ArgumentException.ThrowIfNullOrWhiteSpace(passphrase);
@@ -36,8 +36,13 @@ public void Unlock(string passphrase)
if (headerBytes is null)
{
header = new VaultHeader(
- "SQVFS1", 1, RandomNumberGenerator.GetBytes(16),
- _options.Argon2MemoryKib, _options.Argon2Iterations, _options.Argon2Parallelism, _options.ChunkSize
+ "SQVFS1",
+ 1,
+ RandomNumberGenerator.GetBytes(16),
+ _options.Argon2MemoryKib,
+ _options.Argon2Iterations,
+ _options.Argon2Parallelism,
+ _options.ChunkSize
);
_inner.WriteAllBytesAsync(HeaderPath, header.Serialize()).AsTask().GetAwaiter().GetResult();
}
@@ -104,7 +109,9 @@ public ValueTask ExistsAsync(string path, CancellationToken cancellationTo
return output.ToArray();
}
- public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ public async ValueTask WriteAllBytesAsync(
+ string path, ReadOnlyMemory data, CancellationToken cancellationToken = default
+ )
{
EnsureUnlocked();
var normalized = VfsPath.Normalize(path);
@@ -149,7 +156,9 @@ public async ValueTask DeleteAsync(string path, CancellationToken cancella
return true;
}
- public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ListAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
{
EnsureUnlocked();
var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
@@ -220,6 +229,11 @@ private void EnsureUnlocked()
}
}
+ public void Dispose()
+ {
+ Lock();
+ }
+
private sealed class VaultWriteStream : MemoryStream
{
private readonly CryptoFileSystem _owner;
@@ -241,9 +255,4 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
}
-
- public void Dispose()
- {
- Lock();
- }
}
diff --git a/src/SquidStd.Vfs/README.md b/src/SquidStd.Vfs/README.md
new file mode 100644
index 00000000..b9b1f462
--- /dev/null
+++ b/src/SquidStd.Vfs/README.md
@@ -0,0 +1,56 @@
+
SquidStd.Vfs
+
+A path-based **virtual filesystem** abstraction for SquidStd with interchangeable backends. One interface
+(`IVirtualFileSystem`) is implemented by real directories, a single zip archive, and an in-memory store — and
+decorated by an encrypted vault in `SquidStd.Crypto`. `VfsDirectories` is a VFS-backed analogue of
+`DirectoriesConfig`, so named directory layouts work over any backend (a zip or an encrypted container can
+stand in for real folders).
+
+## Install
+
+```bash
+dotnet add package SquidStd.Vfs
+```
+
+## Usage
+
+```csharp
+using DryIoc;
+using SquidStd.Vfs.Abstractions.Interfaces;
+using SquidStd.Vfs.Extensions;
+using SquidStd.Vfs.Services;
+
+// Register a backend as IVirtualFileSystem (singleton).
+container.RegisterVfs(_ => new PhysicalFileSystem("/var/lib/app/data"));
+// or: new ZipFileSystem("/var/lib/app/data.zip")
+// or: new InMemoryFileSystem()
+
+var fs = container.Resolve();
+
+await fs.WriteAllBytesAsync("docs/cv.pdf", bytes);
+byte[]? data = await fs.ReadAllBytesAsync("docs/cv.pdf");
+
+await foreach (var entry in fs.ListAsync("docs"))
+{
+ Console.WriteLine($"{entry.Path} ({entry.Size} bytes)");
+}
+
+await fs.DeleteAsync("docs/cv.pdf");
+```
+
+### Named directories over any backend
+
+```csharp
+var dirs = new VfsDirectories(fs, ["data", "logs"]);
+var target = dirs.Combine("data", "report.csv"); // "data/report.csv"
+await fs.WriteAllBytesAsync(target, csvBytes);
+```
+
+## Providers
+
+- **`PhysicalFileSystem(root)`** — maps logical paths onto a real directory tree.
+- **`ZipFileSystem(path)`** — a single `.zip` archive opened in update mode; `IAsyncDisposable`.
+- **`InMemoryFileSystem()`** — ephemeral, in-process; handy for tests and as a decorator target.
+
+Logical paths are normalized (forward slashes, root-relative) and reject `..` traversal via
+`SquidStd.Vfs.Abstractions.VfsPath`.
diff --git a/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
index f363aa54..31030dee 100644
--- a/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/InMemoryFileSystem.cs
@@ -9,7 +9,9 @@ namespace SquidStd.Vfs.Services;
/// An in-memory virtual filesystem. Ephemeral; useful for tests and as a backend decorator target.
public sealed class InMemoryFileSystem : IVirtualFileSystem
{
- private readonly ConcurrentDictionary _files = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _files = new(
+ StringComparer.Ordinal
+ );
public ValueTask ExistsAsync(string path, CancellationToken cancellationToken = default)
{
@@ -21,7 +23,9 @@ public ValueTask ExistsAsync(string path, CancellationToken cancellationTo
return ValueTask.FromResult(_files.TryGetValue(VfsPath.Normalize(path), out var entry) ? entry.Data : null);
}
- public ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ public ValueTask WriteAllBytesAsync(
+ string path, ReadOnlyMemory data, CancellationToken cancellationToken = default
+ )
{
_files[VfsPath.Normalize(path)] = (data.ToArray(), DateTimeOffset.UtcNow);
@@ -46,7 +50,9 @@ public ValueTask DeleteAsync(string path, CancellationToken cancellationTo
return ValueTask.FromResult(_files.TryRemove(VfsPath.Normalize(path), out _));
}
- public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ListAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
{
var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
diff --git a/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
index 87bdbf8d..0231595d 100644
--- a/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/PhysicalFileSystem.cs
@@ -31,7 +31,9 @@ public ValueTask ExistsAsync(string path, CancellationToken cancellationTo
: null;
}
- public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ public async ValueTask WriteAllBytesAsync(
+ string path, ReadOnlyMemory data, CancellationToken cancellationToken = default
+ )
{
var full = Resolve(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
@@ -72,7 +74,9 @@ public ValueTask DeleteAsync(string path, CancellationToken cancellationTo
return ValueTask.FromResult(true);
}
- public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ListAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
{
if (!Directory.Exists(_root))
{
diff --git a/src/SquidStd.Vfs/Services/VfsDirectories.cs b/src/SquidStd.Vfs/Services/VfsDirectories.cs
index f187d2d9..531a3a57 100644
--- a/src/SquidStd.Vfs/Services/VfsDirectories.cs
+++ b/src/SquidStd.Vfs/Services/VfsDirectories.cs
@@ -9,6 +9,12 @@ public sealed class VfsDirectories
private readonly IVirtualFileSystem _fileSystem;
private readonly HashSet _directories;
+ /// The logical path of a named directory.
+ public string this[string directoryName] => GetPath(directoryName);
+
+ /// The logical path of a named directory, by enum name.
+ public string this[Enum directoryType] => GetPath(directoryType.ToString());
+
public VfsDirectories(IVirtualFileSystem fileSystem, IReadOnlyCollection directories)
{
ArgumentNullException.ThrowIfNull(directories);
@@ -16,12 +22,6 @@ public VfsDirectories(IVirtualFileSystem fileSystem, IReadOnlyCollection
_directories = directories.Select(VfsPath.Normalize).ToHashSet(StringComparer.Ordinal);
}
- /// The logical path of a named directory.
- public string this[string directoryName] => GetPath(directoryName);
-
- /// The logical path of a named directory, by enum name.
- public string this[Enum directoryType] => GetPath(directoryType.ToString());
-
/// Resolves the logical path of a named directory; throws when it was not declared.
public string GetPath(string directoryName)
{
diff --git a/src/SquidStd.Vfs/Services/ZipFileSystem.cs b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
index 02067a46..742738d0 100644
--- a/src/SquidStd.Vfs/Services/ZipFileSystem.cs
+++ b/src/SquidStd.Vfs/Services/ZipFileSystem.cs
@@ -40,7 +40,9 @@ public ValueTask ExistsAsync(string path, CancellationToken cancellationTo
return buffer.ToArray();
}
- public async ValueTask WriteAllBytesAsync(string path, ReadOnlyMemory data, CancellationToken cancellationToken = default)
+ public async ValueTask WriteAllBytesAsync(
+ string path, ReadOnlyMemory data, CancellationToken cancellationToken = default
+ )
{
var name = VfsPath.Normalize(path);
_archive.GetEntry(name)?.Delete();
@@ -77,7 +79,9 @@ public ValueTask DeleteAsync(string path, CancellationToken cancellationTo
return ValueTask.FromResult(true);
}
- public async IAsyncEnumerable ListAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ListAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
{
var normalizedPrefix = string.IsNullOrEmpty(prefix) ? null : VfsPath.Normalize(prefix);
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
index ec150d8f..94353dfb 100644
--- a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemLockTests.cs
@@ -18,8 +18,7 @@ public async Task LockedOperations_Throw_UntilUnlocked()
var vault = new CryptoFileSystem(new InMemoryFileSystem(), FastOptions());
Assert.False(vault.IsUnlocked);
- await Assert.ThrowsAsync(
- () => vault.WriteAllBytesAsync("a", new byte[] { 1 }).AsTask()
+ await Assert.ThrowsAsync(() => vault.WriteAllBytesAsync("a", new byte[] { 1 }).AsTask()
);
vault.Unlock("pw");
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
index a3beffa6..394594c6 100644
--- a/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Vfs/CryptoFileSystemTests.cs
@@ -27,6 +27,7 @@ public async Task Write_Read_List_Delete_RoundTrips()
{
paths.Add(e.Path);
}
+
Assert.Equal(["docs/cv.pdf"], paths);
Assert.True(await vault.DeleteAsync("docs/cv.pdf"));
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
index 1c13e880..fb67926a 100644
--- a/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Vfs/EntryCipherTests.cs
@@ -33,8 +33,7 @@ public async Task Decrypt_WrongKey_Throws()
using var wrong = new EntryCipher(RandomNumberGenerator.GetBytes(32), 65536);
encrypted.Position = 0;
- await Assert.ThrowsAsync(
- () => wrong.DecryptAsync(encrypted, new MemoryStream())
+ await Assert.ThrowsAsync(() => wrong.DecryptAsync(encrypted, new MemoryStream())
);
}
}
diff --git a/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs b/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
index a354f056..93ad01c0 100644
--- a/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
+++ b/tests/SquidStd.Tests/Crypto/Vfs/VaultHeaderTests.cs
@@ -7,7 +7,15 @@ public class VaultHeaderTests
[Fact]
public void Serialize_Parse_RoundTrips()
{
- var header = new VaultHeader("SQVFS1", 1, [1, 2, 3, 4], MemoryKib: 8192, Iterations: 2, Parallelism: 1, ChunkSize: 65536);
+ var header = new VaultHeader(
+ "SQVFS1",
+ 1,
+ [1, 2, 3, 4],
+ MemoryKib: 8192,
+ Iterations: 2,
+ Parallelism: 1,
+ ChunkSize: 65536
+ );
var parsed = VaultHeader.Parse(header.Serialize());
diff --git a/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
index db3e538c..55cf8c16 100644
--- a/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
+++ b/tests/SquidStd.Tests/Vfs/InMemoryFileSystemTests.cs
@@ -19,6 +19,7 @@ public async Task Write_Read_List_Delete_RoundTrips()
{
paths.Add(e.Path);
}
+
Assert.Equal(["a/b.txt"], paths);
Assert.True(await fs.DeleteAsync("a/b.txt"));
diff --git a/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
index 9d9a918b..f3efa01e 100644
--- a/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
+++ b/tests/SquidStd.Tests/Vfs/PhysicalFileSystemTests.cs
@@ -23,6 +23,7 @@ public async Task Write_Read_List_Delete_RoundTrips()
{
entries.Add(e.Path);
}
+
Assert.Contains("docs/cv.pdf", entries);
Assert.True(await fs.DeleteAsync("docs/cv.pdf"));
From 8a4fbdeed110eb50306957de8660b1ee4471031c Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 18:57:40 +0200
Subject: [PATCH 25/77] feat(secrets): add ListNamesAsync to ISecretStore and
FileSecretStore
---
.../Interfaces/Secrets/ISecretStore.cs | 8 ++++
.../Services/Storage/FileSecretStore.cs | 18 +++++++
.../FileSecretStoreEnumerationTests.cs | 47 +++++++++++++++++++
3 files changed, 73 insertions(+)
create mode 100644 tests/SquidStd.Tests/Security/FileSecretStoreEnumerationTests.cs
diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs
index 199f23ff..f499b937 100644
--- a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs
+++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs
@@ -36,4 +36,12 @@ public interface ISecretStore
/// The secret value.
/// Token used to cancel the operation.
ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default);
+
+ ///
+ /// Enumerates stored secret names, optionally filtered by prefix.
+ ///
+ /// Optional name prefix; null or empty returns all names.
+ /// Token used to cancel the enumeration.
+ /// An async sequence of secret names.
+ IAsyncEnumerable ListNamesAsync(string? prefix = null, CancellationToken cancellationToken = default);
}
diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs
index 6ec3513a..e497ea51 100644
--- a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs
+++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs
@@ -1,3 +1,4 @@
+using System.Runtime.CompilerServices;
using System.Text;
using SquidStd.Core.Data.Storage;
using SquidStd.Core.Interfaces.Secrets;
@@ -67,6 +68,23 @@ public async ValueTask SetAsync(string name, string value, CancellationToken can
await _storageService.SaveAsync(ToStorageKey(name), protectedData, cancellationToken);
}
+ ///
+ public async IAsyncEnumerable ListNamesAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ const string suffix = ".secret";
+ var keyPrefix = string.IsNullOrEmpty(prefix) ? null : prefix;
+
+ await foreach (var key in _storageService.ListKeysAsync(keyPrefix, cancellationToken).ConfigureAwait(false))
+ {
+ if (key.EndsWith(suffix, StringComparison.Ordinal))
+ {
+ yield return key[..^suffix.Length];
+ }
+ }
+ }
+
private static string ToStorageKey(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
diff --git a/tests/SquidStd.Tests/Security/FileSecretStoreEnumerationTests.cs b/tests/SquidStd.Tests/Security/FileSecretStoreEnumerationTests.cs
new file mode 100644
index 00000000..d4887644
--- /dev/null
+++ b/tests/SquidStd.Tests/Security/FileSecretStoreEnumerationTests.cs
@@ -0,0 +1,47 @@
+using System.Security.Cryptography;
+using SquidStd.Core.Data.Storage;
+using SquidStd.Services.Core.Services.Storage;
+using SquidStd.Tests.Support;
+
+namespace SquidStd.Tests.Security;
+
+public class FileSecretStoreEnumerationTests
+{
+ [Fact]
+ public async Task ListNamesAsync_ReturnsSetNames_AndHonoursPrefix()
+ {
+ using var temp = new TempDirectory();
+ var variableName = "SQUIDSTD_TEST_SECRET_ENUM_KEY";
+ var previous = Environment.GetEnvironmentVariable(variableName);
+
+ try
+ {
+ Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
+ var config = new SecretsConfig { RootDirectory = temp.Path, KeyEnvironmentVariable = variableName };
+ var store = new FileSecretStore(config, new AesGcmSecretProtector(config));
+
+ await store.SetAsync("db/main", "a");
+ await store.SetAsync("db/replica", "b");
+ await store.SetAsync("api/key", "c");
+
+ var all = new List();
+ await foreach (var name in store.ListNamesAsync())
+ {
+ all.Add(name);
+ }
+
+ var db = new List();
+ await foreach (var name in store.ListNamesAsync("db/"))
+ {
+ db.Add(name);
+ }
+
+ Assert.Equal(["api/key", "db/main", "db/replica"], all.OrderBy(x => x).ToArray());
+ Assert.Equal(["db/main", "db/replica"], db.OrderBy(x => x).ToArray());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variableName, previous);
+ }
+ }
+}
From a225409d0a919c26c19f81864e774a5c17c4f0cd Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 18:59:00 +0200
Subject: [PATCH 26/77] build(secrets): scaffold SquidStd.Secrets.Aws module
---
SquidStd.slnx | 1 +
.../Data/AwsSecretsManagerOptions.cs | 13 +++++
.../Data/KmsSecretProtectorOptions.cs | 13 +++++
.../Internal/AwsClientFactory.cs | 52 +++++++++++++++++++
.../SquidStd.Secrets.Aws.csproj | 21 ++++++++
tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 +
6 files changed, 101 insertions(+)
create mode 100644 src/SquidStd.Secrets.Aws/Data/AwsSecretsManagerOptions.cs
create mode 100644 src/SquidStd.Secrets.Aws/Data/KmsSecretProtectorOptions.cs
create mode 100644 src/SquidStd.Secrets.Aws/Internal/AwsClientFactory.cs
create mode 100644 src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj
diff --git a/SquidStd.slnx b/SquidStd.slnx
index a850a11c..3b463679 100644
--- a/SquidStd.slnx
+++ b/SquidStd.slnx
@@ -40,6 +40,7 @@
+
diff --git a/src/SquidStd.Secrets.Aws/Data/AwsSecretsManagerOptions.cs b/src/SquidStd.Secrets.Aws/Data/AwsSecretsManagerOptions.cs
new file mode 100644
index 00000000..a2520094
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Data/AwsSecretsManagerOptions.cs
@@ -0,0 +1,13 @@
+using SquidStd.Aws.Abstractions.Data.Config;
+
+namespace SquidStd.Secrets.Aws.Data;
+
+/// Options for the AWS Secrets Manager store: AWS connection and an optional name prefix.
+public sealed class AwsSecretsManagerOptions
+{
+ /// AWS-style connection config (region/credentials/endpoint override).
+ public AwsConfigEntry Aws { get; set; } = new();
+
+ /// Optional prefix applied to and stripped from secret names (e.g. myapp/).
+ public string? NamePrefix { get; set; }
+}
diff --git a/src/SquidStd.Secrets.Aws/Data/KmsSecretProtectorOptions.cs b/src/SquidStd.Secrets.Aws/Data/KmsSecretProtectorOptions.cs
new file mode 100644
index 00000000..e8205ad9
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Data/KmsSecretProtectorOptions.cs
@@ -0,0 +1,13 @@
+using SquidStd.Aws.Abstractions.Data.Config;
+
+namespace SquidStd.Secrets.Aws.Data;
+
+/// Options for the KMS-backed secret protector: AWS connection and the KMS key id/alias.
+public sealed class KmsSecretProtectorOptions
+{
+ /// AWS-style connection config (region/credentials/endpoint override).
+ public AwsConfigEntry Aws { get; set; } = new();
+
+ /// The KMS key id or alias used to generate data keys (e.g. alias/app).
+ public string KeyId { get; set; } = string.Empty;
+}
diff --git a/src/SquidStd.Secrets.Aws/Internal/AwsClientFactory.cs b/src/SquidStd.Secrets.Aws/Internal/AwsClientFactory.cs
new file mode 100644
index 00000000..eaaf6c8c
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Internal/AwsClientFactory.cs
@@ -0,0 +1,52 @@
+using Amazon;
+using Amazon.KeyManagementService;
+using Amazon.Runtime;
+using Amazon.SecretsManager;
+using SquidStd.Aws.Abstractions.Data.Config;
+
+namespace SquidStd.Secrets.Aws.Internal;
+
+/// Builds AWS SDK clients/credentials from a shared .
+internal static class AwsClientFactory
+{
+ public static AWSCredentials Credentials(AwsConfigEntry aws)
+ {
+ if (!string.IsNullOrWhiteSpace(aws.AccessKey) && !string.IsNullOrWhiteSpace(aws.SecretKey))
+ {
+ return string.IsNullOrWhiteSpace(aws.SessionToken)
+ ? new BasicAWSCredentials(aws.AccessKey, aws.SecretKey)
+ : new SessionAWSCredentials(aws.AccessKey, aws.SecretKey, aws.SessionToken);
+ }
+
+ return FallbackCredentialsFactory.GetCredentials();
+ }
+
+ public static AmazonKeyManagementServiceConfig KmsConfig(AwsConfigEntry aws)
+ {
+ var config = new AmazonKeyManagementServiceConfig();
+ Configure(config, aws);
+
+ return config;
+ }
+
+ public static AmazonSecretsManagerConfig SecretsManagerConfig(AwsConfigEntry aws)
+ {
+ var config = new AmazonSecretsManagerConfig();
+ Configure(config, aws);
+
+ return config;
+ }
+
+ private static void Configure(ClientConfig config, AwsConfigEntry aws)
+ {
+ if (!string.IsNullOrWhiteSpace(aws.ServiceUrl))
+ {
+ config.ServiceURL = aws.ServiceUrl;
+ config.AuthenticationRegion = aws.Region;
+ }
+ else
+ {
+ config.RegionEndpoint = RegionEndpoint.GetBySystemName(aws.Region);
+ }
+ }
+}
diff --git a/src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj b/src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj
new file mode 100644
index 00000000..e9517927
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/SquidStd.Secrets.Aws.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj
index 0c7805d4..a2127844 100644
--- a/tests/SquidStd.Tests/SquidStd.Tests.csproj
+++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj
@@ -73,6 +73,7 @@
+
From 8478e4878f327dc2c35343b81f9a6970cd14a30d Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 19:02:21 +0200
Subject: [PATCH 27/77] feat(secrets): add KMS envelope-encryption secret
protector
- KmsSecretProtector implements ISecretProtector via AWS KMS data keys
- KmsEnvelope frames [wrappedKeyLen|wrappedKey|nonce|tag|ciphertext] with AES-GCM
- zero the plaintext data key after sealing/opening; dispose owns KMS client
- LocalStack-backed integration test round-trips a >4KB payload
---
.../Internal/KmsEnvelope.cs | 60 +++++++++++++++
.../Services/KmsSecretProtector.cs | 75 +++++++++++++++++++
.../Secrets/Aws/KmsSecretProtectorTests.cs | 44 +++++++++++
.../Aws/Support/LocalStackSecretsFixture.cs | 35 +++++++++
4 files changed, 214 insertions(+)
create mode 100644 src/SquidStd.Secrets.Aws/Internal/KmsEnvelope.cs
create mode 100644 src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
create mode 100644 tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
create mode 100644 tests/SquidStd.Tests/Secrets/Aws/Support/LocalStackSecretsFixture.cs
diff --git a/src/SquidStd.Secrets.Aws/Internal/KmsEnvelope.cs b/src/SquidStd.Secrets.Aws/Internal/KmsEnvelope.cs
new file mode 100644
index 00000000..5728b7f3
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Internal/KmsEnvelope.cs
@@ -0,0 +1,60 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+
+namespace SquidStd.Secrets.Aws.Internal;
+
+/// Frames an AES-GCM envelope: [int32 wrappedKeyLen | wrappedKey | nonce(12) | tag(16) | ciphertext].
+internal static class KmsEnvelope
+{
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+
+ public static byte[] Seal(byte[] dataKey, byte[] wrappedKey, byte[] plaintext)
+ {
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[plaintext.Length];
+ var tag = new byte[TagSize];
+
+ using (var aes = new AesGcm(dataKey, TagSize))
+ {
+ aes.Encrypt(nonce, plaintext, cipher, tag);
+ }
+
+ var output = new byte[4 + wrappedKey.Length + NonceSize + TagSize + cipher.Length];
+ var span = output.AsSpan();
+ BinaryPrimitives.WriteInt32BigEndian(span, wrappedKey.Length);
+ wrappedKey.CopyTo(span[4..]);
+ nonce.CopyTo(span[(4 + wrappedKey.Length)..]);
+ tag.CopyTo(span[(4 + wrappedKey.Length + NonceSize)..]);
+ cipher.CopyTo(span[(4 + wrappedKey.Length + NonceSize + TagSize)..]);
+
+ return output;
+ }
+
+ public static byte[] ReadWrappedKey(byte[] blob)
+ {
+ var wrappedLen = BinaryPrimitives.ReadInt32BigEndian(blob);
+
+ if (wrappedLen <= 0 || 4 + wrappedLen + NonceSize + TagSize > blob.Length)
+ {
+ throw new ArgumentException("Malformed KMS envelope.", nameof(blob));
+ }
+
+ return blob[4..(4 + wrappedLen)];
+ }
+
+ public static byte[] Open(byte[] dataKey, byte[] blob)
+ {
+ var wrappedLen = BinaryPrimitives.ReadInt32BigEndian(blob);
+ var offset = 4 + wrappedLen;
+ var nonce = blob.AsSpan(offset, NonceSize);
+ var tag = blob.AsSpan(offset + NonceSize, TagSize);
+ var cipher = blob.AsSpan(offset + NonceSize + TagSize);
+
+ var plaintext = new byte[cipher.Length];
+ using var aes = new AesGcm(dataKey, TagSize);
+ aes.Decrypt(nonce, cipher, tag, plaintext);
+
+ return plaintext;
+ }
+}
diff --git a/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs b/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
new file mode 100644
index 00000000..cff331ff
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
@@ -0,0 +1,75 @@
+using System.Security.Cryptography;
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Internal;
+
+namespace SquidStd.Secrets.Aws.Services;
+
+/// An that envelope-encrypts with AWS KMS data keys.
+public sealed class KmsSecretProtector : ISecretProtector, IDisposable
+{
+ private readonly AmazonKeyManagementServiceClient _kms;
+ private readonly string _keyId;
+
+ public KmsSecretProtector(KmsSecretProtectorOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(options.KeyId);
+
+ _keyId = options.KeyId;
+ _kms = new AmazonKeyManagementServiceClient(
+ AwsClientFactory.Credentials(options.Aws), AwsClientFactory.KmsConfig(options.Aws)
+ );
+ }
+
+ ///
+ public byte[] Protect(byte[] plaintext)
+ {
+ ArgumentNullException.ThrowIfNull(plaintext);
+
+ var generated = _kms.GenerateDataKeyAsync(
+ new GenerateDataKeyRequest { KeyId = _keyId, KeySpec = DataKeySpec.AES_256 }
+ ).GetAwaiter().GetResult();
+
+ var dataKey = generated.Plaintext.ToArray();
+
+ try
+ {
+ return KmsEnvelope.Seal(dataKey, generated.CiphertextBlob.ToArray(), plaintext);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+
+ ///
+ public byte[] Unprotect(byte[] protectedData)
+ {
+ ArgumentNullException.ThrowIfNull(protectedData);
+
+ var wrappedKey = KmsEnvelope.ReadWrappedKey(protectedData);
+ var decrypted = _kms.DecryptAsync(
+ new DecryptRequest { CiphertextBlob = new MemoryStream(wrappedKey) }
+ ).GetAwaiter().GetResult();
+
+ var dataKey = decrypted.Plaintext.ToArray();
+
+ try
+ {
+ return KmsEnvelope.Open(dataKey, protectedData);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ _kms.Dispose();
+ }
+}
diff --git a/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs b/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
new file mode 100644
index 00000000..5fb3106b
--- /dev/null
+++ b/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
@@ -0,0 +1,44 @@
+using System.Text;
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Services;
+using SquidStd.Tests.Secrets.Aws.Support;
+
+namespace SquidStd.Tests.Secrets.Aws;
+
+[Collection(LocalStackSecretsCollection.Name)]
+public class KmsSecretProtectorTests
+{
+ private readonly LocalStackSecretsFixture _localStack;
+
+ public KmsSecretProtectorTests(LocalStackSecretsFixture localStack)
+ {
+ _localStack = localStack;
+ }
+
+ [Fact]
+ public async Task Protect_Unprotect_RoundTripsLargePayload_WithoutPlaintext()
+ {
+ var keyId = await CreateKeyAsync();
+ var protector = new KmsSecretProtector(new KmsSecretProtectorOptions { Aws = _localStack.Aws, KeyId = keyId });
+ var plaintext = Encoding.UTF8.GetBytes(new string('s', 10_000)); // > 4 KB → exercises envelope
+
+ var blob = protector.Protect(plaintext);
+ var roundTrip = protector.Unprotect(blob);
+
+ Assert.Equal(plaintext, roundTrip);
+ Assert.DoesNotContain("ssss", Encoding.UTF8.GetString(blob), StringComparison.Ordinal);
+ }
+
+ private async Task CreateKeyAsync()
+ {
+ using var kms = new AmazonKeyManagementServiceClient(
+ new Amazon.Runtime.BasicAWSCredentials("test", "test"),
+ new AmazonKeyManagementServiceConfig { ServiceURL = _localStack.Aws.ServiceUrl, AuthenticationRegion = "us-east-1" }
+ );
+ var created = await kms.CreateKeyAsync(new CreateKeyRequest());
+
+ return created.KeyMetadata.KeyId;
+ }
+}
diff --git a/tests/SquidStd.Tests/Secrets/Aws/Support/LocalStackSecretsFixture.cs b/tests/SquidStd.Tests/Secrets/Aws/Support/LocalStackSecretsFixture.cs
new file mode 100644
index 00000000..ed07dbe2
--- /dev/null
+++ b/tests/SquidStd.Tests/Secrets/Aws/Support/LocalStackSecretsFixture.cs
@@ -0,0 +1,35 @@
+using SquidStd.Aws.Abstractions.Data.Config;
+using Testcontainers.LocalStack;
+
+namespace SquidStd.Tests.Secrets.Aws.Support;
+
+/// Starts a LocalStack container (KMS + Secrets Manager) once for the collection.
+public sealed class LocalStackSecretsFixture : IAsyncLifetime
+{
+ private readonly LocalStackContainer _container =
+ new LocalStackBuilder().WithImage("localstack/localstack:3").Build();
+
+ public AwsConfigEntry Aws => new()
+ {
+ Region = "us-east-1",
+ AccessKey = "test",
+ SecretKey = "test",
+ ServiceUrl = _container.GetConnectionString()
+ };
+
+ public Task DisposeAsync()
+ {
+ return _container.DisposeAsync().AsTask();
+ }
+
+ public Task InitializeAsync()
+ {
+ return _container.StartAsync();
+ }
+}
+
+[CollectionDefinition(Name)]
+public sealed class LocalStackSecretsCollection : ICollectionFixture
+{
+ public const string Name = "LocalStackSecrets";
+}
From 0a2cffdeaf22bd559609a861a753d96066913586 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 19:04:23 +0200
Subject: [PATCH 28/77] feat(secrets): add AWS Secrets Manager store
- AwsSecretsManagerStore implements ISecretStore over AWS Secrets Manager
- Set upserts via PutSecretValue, falling back to CreateSecret when absent
- Delete gates on existence so a missing secret returns false on both real AWS and LocalStack
- ListNamesAsync paginates ListSecrets and strips the configured NamePrefix
- store disposes its Secrets Manager client
- LocalStack-backed integration test covers the full set/get/exists/list/delete cycle
---
.../Services/AwsSecretsManagerStore.cs | 141 ++++++++++++++++++
.../Aws/AwsSecretsManagerStoreTests.cs | 43 ++++++
2 files changed, 184 insertions(+)
create mode 100644 src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
create mode 100644 tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
diff --git a/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs b/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
new file mode 100644
index 00000000..6af97fb1
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
@@ -0,0 +1,141 @@
+using System.Runtime.CompilerServices;
+using Amazon.SecretsManager;
+using Amazon.SecretsManager.Model;
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Internal;
+
+namespace SquidStd.Secrets.Aws.Services;
+
+/// An backed by AWS Secrets Manager.
+public sealed class AwsSecretsManagerStore : ISecretStore, IDisposable
+{
+ private readonly AmazonSecretsManagerClient _client;
+ private readonly string _prefix;
+
+ public AwsSecretsManagerStore(AwsSecretsManagerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ _prefix = options.NamePrefix ?? string.Empty;
+ _client = new AmazonSecretsManagerClient(
+ AwsClientFactory.Credentials(options.Aws), AwsClientFactory.SecretsManagerConfig(options.Aws)
+ );
+ }
+
+ ///
+ public async ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ if (!await ExistsAsync(name, cancellationToken).ConfigureAwait(false))
+ {
+ return false;
+ }
+
+ try
+ {
+ await _client.DeleteSecretAsync(
+ new DeleteSecretRequest { SecretId = _prefix + name, ForceDeleteWithoutRecovery = true },
+ cancellationToken
+ ).ConfigureAwait(false);
+
+ return true;
+ }
+ catch (ResourceNotFoundException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ public async ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ try
+ {
+ await _client.DescribeSecretAsync(
+ new DescribeSecretRequest { SecretId = _prefix + name }, cancellationToken
+ ).ConfigureAwait(false);
+
+ return true;
+ }
+ catch (ResourceNotFoundException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ public async ValueTask GetAsync(string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ try
+ {
+ var response = await _client.GetSecretValueAsync(
+ new GetSecretValueRequest { SecretId = _prefix + name }, cancellationToken
+ ).ConfigureAwait(false);
+
+ return response.SecretString;
+ }
+ catch (ResourceNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ public async ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentNullException.ThrowIfNull(value);
+
+ var secretId = _prefix + name;
+
+ try
+ {
+ await _client.PutSecretValueAsync(
+ new PutSecretValueRequest { SecretId = secretId, SecretString = value }, cancellationToken
+ ).ConfigureAwait(false);
+ }
+ catch (ResourceNotFoundException)
+ {
+ await _client.CreateSecretAsync(
+ new CreateSecretRequest { Name = secretId, SecretString = value }, cancellationToken
+ ).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public async IAsyncEnumerable ListNamesAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var fullPrefix = _prefix + (prefix ?? string.Empty);
+ string? token = null;
+
+ do
+ {
+ var response = await _client.ListSecretsAsync(
+ new ListSecretsRequest { NextToken = token, MaxResults = 100 }, cancellationToken
+ ).ConfigureAwait(false);
+
+ foreach (var secret in response.SecretList)
+ {
+ if (secret.Name.StartsWith(fullPrefix, StringComparison.Ordinal))
+ {
+ yield return secret.Name[_prefix.Length..];
+ }
+ }
+
+ token = response.NextToken;
+ }
+ while (!string.IsNullOrEmpty(token));
+ }
+
+ ///
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+}
diff --git a/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs b/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
new file mode 100644
index 00000000..a7f74aef
--- /dev/null
+++ b/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
@@ -0,0 +1,43 @@
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Services;
+using SquidStd.Tests.Secrets.Aws.Support;
+
+namespace SquidStd.Tests.Secrets.Aws;
+
+[Collection(LocalStackSecretsCollection.Name)]
+public class AwsSecretsManagerStoreTests
+{
+ private readonly LocalStackSecretsFixture _localStack;
+
+ public AwsSecretsManagerStoreTests(LocalStackSecretsFixture localStack)
+ {
+ _localStack = localStack;
+ }
+
+ [Fact]
+ public async Task Set_Get_Exists_List_Delete_RoundTrips()
+ {
+ var prefix = "test-" + Guid.NewGuid().ToString("N") + "/";
+ var store = new AwsSecretsManagerStore(new AwsSecretsManagerOptions { Aws = _localStack.Aws, NamePrefix = prefix });
+
+ Assert.Null(await store.GetAsync("db/main"));
+ Assert.False(await store.ExistsAsync("db/main"));
+
+ await store.SetAsync("db/main", "secret-1");
+ await store.SetAsync("db/main", "secret-2"); // update existing
+
+ Assert.Equal("secret-2", await store.GetAsync("db/main"));
+ Assert.True(await store.ExistsAsync("db/main"));
+
+ var names = new List();
+ await foreach (var name in store.ListNamesAsync())
+ {
+ names.Add(name);
+ }
+ Assert.Contains("db/main", names);
+
+ Assert.True(await store.DeleteAsync("db/main"));
+ Assert.Null(await store.GetAsync("db/main"));
+ Assert.False(await store.DeleteAsync("db/main"));
+ }
+}
From ed5a6d79075cc852d744afd4e38cd8c5a0f7c5cc Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 19:05:01 +0200
Subject: [PATCH 29/77] feat(secrets): add RegisterKmsSecretProtector and
RegisterAwsSecretsManagerStore DI extensions
- RegisterKmsSecretProtector wires KmsSecretProtector as the singleton ISecretProtector
- RegisterAwsSecretsManagerStore wires AwsSecretsManagerStore as the singleton ISecretStore
- both use the Action configure pattern over settable options DTOs
- offline tests verify singleton registration without any AWS call
---
...egisterAwsSecretsManagerStoreExtensions.cs | 26 ++++++++++++++++
.../RegisterKmsSecretProtectorExtensions.cs | 26 ++++++++++++++++
.../Aws/RegisterSecretsAwsExtensionsTests.cs | 31 +++++++++++++++++++
3 files changed, 83 insertions(+)
create mode 100644 src/SquidStd.Secrets.Aws/Extensions/RegisterAwsSecretsManagerStoreExtensions.cs
create mode 100644 src/SquidStd.Secrets.Aws/Extensions/RegisterKmsSecretProtectorExtensions.cs
create mode 100644 tests/SquidStd.Tests/Secrets/Aws/RegisterSecretsAwsExtensionsTests.cs
diff --git a/src/SquidStd.Secrets.Aws/Extensions/RegisterAwsSecretsManagerStoreExtensions.cs b/src/SquidStd.Secrets.Aws/Extensions/RegisterAwsSecretsManagerStoreExtensions.cs
new file mode 100644
index 00000000..152c604d
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Extensions/RegisterAwsSecretsManagerStoreExtensions.cs
@@ -0,0 +1,26 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Services;
+
+namespace SquidStd.Secrets.Aws.Extensions;
+
+/// DryIoc registration helper for the AWS Secrets Manager store.
+public static class RegisterAwsSecretsManagerStoreExtensions
+{
+ /// Container that receives the store registration.
+ extension(IContainer container)
+ {
+ /// Registers as the singleton .
+ public IContainer RegisterAwsSecretsManagerStore(Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var options = new AwsSecretsManagerOptions();
+ configure(options);
+ container.RegisterInstance(new AwsSecretsManagerStore(options));
+
+ return container;
+ }
+ }
+}
diff --git a/src/SquidStd.Secrets.Aws/Extensions/RegisterKmsSecretProtectorExtensions.cs b/src/SquidStd.Secrets.Aws/Extensions/RegisterKmsSecretProtectorExtensions.cs
new file mode 100644
index 00000000..6d6da03a
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/Extensions/RegisterKmsSecretProtectorExtensions.cs
@@ -0,0 +1,26 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Secrets.Aws.Data;
+using SquidStd.Secrets.Aws.Services;
+
+namespace SquidStd.Secrets.Aws.Extensions;
+
+/// DryIoc registration helper for the KMS secret protector.
+public static class RegisterKmsSecretProtectorExtensions
+{
+ /// Container that receives the protector registration.
+ extension(IContainer container)
+ {
+ /// Registers as the singleton .
+ public IContainer RegisterKmsSecretProtector(Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var options = new KmsSecretProtectorOptions();
+ configure(options);
+ container.RegisterInstance(new KmsSecretProtector(options));
+
+ return container;
+ }
+ }
+}
diff --git a/tests/SquidStd.Tests/Secrets/Aws/RegisterSecretsAwsExtensionsTests.cs b/tests/SquidStd.Tests/Secrets/Aws/RegisterSecretsAwsExtensionsTests.cs
new file mode 100644
index 00000000..0dcae842
--- /dev/null
+++ b/tests/SquidStd.Tests/Secrets/Aws/RegisterSecretsAwsExtensionsTests.cs
@@ -0,0 +1,31 @@
+using DryIoc;
+using SquidStd.Core.Interfaces.Secrets;
+using SquidStd.Secrets.Aws.Extensions;
+using SquidStd.Secrets.Aws.Services;
+
+namespace SquidStd.Tests.Secrets.Aws;
+
+public class RegisterSecretsAwsExtensionsTests
+{
+ [Fact]
+ public void RegisterKmsSecretProtector_RegistersSingletonProtector()
+ {
+ using var container = new Container();
+ container.RegisterKmsSecretProtector(o => o.KeyId = "alias/test");
+
+ var protector = container.Resolve();
+ Assert.IsType(protector);
+ Assert.Same(protector, container.Resolve());
+ }
+
+ [Fact]
+ public void RegisterAwsSecretsManagerStore_RegistersSingletonStore()
+ {
+ using var container = new Container();
+ container.RegisterAwsSecretsManagerStore(o => { });
+
+ var store = container.Resolve();
+ Assert.IsType(store);
+ Assert.Same(store, container.Resolve());
+ }
+}
From 0670eb3bed6eb2eda51b6e190c4ebf2a348d69a6 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 19:07:11 +0200
Subject: [PATCH 30/77] docs(secrets): add SquidStd.Secrets.Aws README and
format module
- README documents the two AWS secret seams, registration, usage, and notes
- apply SquidStd cleanup profile across the new module and changed secret files
---
src/SquidStd.Secrets.Aws/README.md | 64 +++++++++++++++++++
.../Services/AwsSecretsManagerStore.cs | 47 +++++++++-----
.../Services/KmsSecretProtector.cs | 15 +++--
.../Aws/AwsSecretsManagerStoreTests.cs | 1 +
.../Secrets/Aws/KmsSecretProtectorTests.cs | 3 +-
5 files changed, 107 insertions(+), 23 deletions(-)
create mode 100644 src/SquidStd.Secrets.Aws/README.md
diff --git a/src/SquidStd.Secrets.Aws/README.md b/src/SquidStd.Secrets.Aws/README.md
new file mode 100644
index 00000000..01ba2da8
--- /dev/null
+++ b/src/SquidStd.Secrets.Aws/README.md
@@ -0,0 +1,64 @@
+
SquidStd.Secrets.Aws
+
+AWS adapters for the two SquidStd secret seams: an `ISecretProtector` that envelope-encrypts payloads
+with **AWS KMS** data keys, and an `ISecretStore` backed by **AWS Secrets Manager**. Drop either into the
+container in place of the file-backed defaults to push key custody and secret storage into AWS-managed
+services, keeping the rest of the application unchanged.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Secrets.Aws
+```
+
+## Usage
+
+```csharp
+using SquidStd.Secrets.Aws.Extensions;
+
+// KMS-backed ISecretProtector (envelope encryption)
+container.RegisterKmsSecretProtector(o =>
+{
+ o.KeyId = "alias/my-app"; // KMS key id, ARN, or alias
+ o.Aws.Region = "eu-west-1";
+});
+
+// Secrets Manager-backed ISecretStore
+container.RegisterAwsSecretsManagerStore(o =>
+{
+ o.NamePrefix = "my-app/"; // optional logical namespace
+ o.Aws.Region = "eu-west-1";
+});
+```
+
+Both seams are then resolved like any other SquidStd secret service:
+
+```csharp
+var protector = container.Resolve();
+byte[] sealed = protector.Protect(payload);
+byte[] restored = protector.Unprotect(sealed);
+
+var store = container.Resolve();
+await store.SetAsync("db/main", connectionString);
+string? value = await store.GetAsync("db/main");
+await foreach (var name in store.ListNamesAsync("db/")) { /* ... */ }
+```
+
+## Notes
+
+- **Envelope encryption** — `KmsSecretProtector` calls `GenerateDataKey` per `Protect`, encrypts the payload
+ locally with AES-256-GCM, and frames the KMS-wrapped data key alongside the ciphertext. The plaintext
+ data key is zeroed immediately after use; `Unprotect` calls `Decrypt` to unwrap it. KMS never sees the
+ payload, and large payloads are not bound by the 4 KB KMS direct-encrypt limit.
+- **NamePrefix** — `AwsSecretsManagerStore` prepends `NamePrefix` to every secret id, giving each
+ application its own namespace inside a shared account. `ListNamesAsync` strips the prefix so callers
+ always see logical names. `Delete` returns `false` for a missing secret.
+- **Credentials** — when `Aws.AccessKey` / `Aws.SecretKey` are omitted, the AWS SDK default credential
+ chain is used (environment, shared profile, EC2/ECS role). Set `Aws.ServiceUrl` to target LocalStack
+ or another compatible endpoint.
+- **Tested against LocalStack** — the KMS and Secrets Manager adapters are covered by integration tests
+ running on a `localstack/localstack` container.
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs b/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
index 6af97fb1..f1f48c04 100644
--- a/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
+++ b/src/SquidStd.Secrets.Aws/Services/AwsSecretsManagerStore.cs
@@ -19,7 +19,8 @@ public AwsSecretsManagerStore(AwsSecretsManagerOptions options)
_prefix = options.NamePrefix ?? string.Empty;
_client = new AmazonSecretsManagerClient(
- AwsClientFactory.Credentials(options.Aws), AwsClientFactory.SecretsManagerConfig(options.Aws)
+ AwsClientFactory.Credentials(options.Aws),
+ AwsClientFactory.SecretsManagerConfig(options.Aws)
);
}
@@ -36,9 +37,10 @@ public async ValueTask DeleteAsync(string name, CancellationToken cancella
try
{
await _client.DeleteSecretAsync(
- new DeleteSecretRequest { SecretId = _prefix + name, ForceDeleteWithoutRecovery = true },
- cancellationToken
- ).ConfigureAwait(false);
+ new DeleteSecretRequest { SecretId = _prefix + name, ForceDeleteWithoutRecovery = true },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
return true;
}
@@ -56,8 +58,10 @@ public async ValueTask ExistsAsync(string name, CancellationToken cancella
try
{
await _client.DescribeSecretAsync(
- new DescribeSecretRequest { SecretId = _prefix + name }, cancellationToken
- ).ConfigureAwait(false);
+ new DescribeSecretRequest { SecretId = _prefix + name },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
return true;
}
@@ -75,8 +79,10 @@ await _client.DescribeSecretAsync(
try
{
var response = await _client.GetSecretValueAsync(
- new GetSecretValueRequest { SecretId = _prefix + name }, cancellationToken
- ).ConfigureAwait(false);
+ new GetSecretValueRequest { SecretId = _prefix + name },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
return response.SecretString;
}
@@ -97,19 +103,25 @@ public async ValueTask SetAsync(string name, string value, CancellationToken can
try
{
await _client.PutSecretValueAsync(
- new PutSecretValueRequest { SecretId = secretId, SecretString = value }, cancellationToken
- ).ConfigureAwait(false);
+ new PutSecretValueRequest { SecretId = secretId, SecretString = value },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
}
catch (ResourceNotFoundException)
{
await _client.CreateSecretAsync(
- new CreateSecretRequest { Name = secretId, SecretString = value }, cancellationToken
- ).ConfigureAwait(false);
+ new CreateSecretRequest { Name = secretId, SecretString = value },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
}
}
///
- public async IAsyncEnumerable ListNamesAsync(string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ public async IAsyncEnumerable ListNamesAsync(
+ string? prefix = null, [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
{
var fullPrefix = _prefix + (prefix ?? string.Empty);
string? token = null;
@@ -117,8 +129,10 @@ public async IAsyncEnumerable ListNamesAsync(string? prefix = null, [Enu
do
{
var response = await _client.ListSecretsAsync(
- new ListSecretsRequest { NextToken = token, MaxResults = 100 }, cancellationToken
- ).ConfigureAwait(false);
+ new ListSecretsRequest { NextToken = token, MaxResults = 100 },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
foreach (var secret in response.SecretList)
{
@@ -129,8 +143,7 @@ public async IAsyncEnumerable ListNamesAsync(string? prefix = null, [Enu
}
token = response.NextToken;
- }
- while (!string.IsNullOrEmpty(token));
+ } while (!string.IsNullOrEmpty(token));
}
///
diff --git a/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs b/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
index cff331ff..32388dac 100644
--- a/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
+++ b/src/SquidStd.Secrets.Aws/Services/KmsSecretProtector.cs
@@ -20,7 +20,8 @@ public KmsSecretProtector(KmsSecretProtectorOptions options)
_keyId = options.KeyId;
_kms = new AmazonKeyManagementServiceClient(
- AwsClientFactory.Credentials(options.Aws), AwsClientFactory.KmsConfig(options.Aws)
+ AwsClientFactory.Credentials(options.Aws),
+ AwsClientFactory.KmsConfig(options.Aws)
);
}
@@ -30,8 +31,10 @@ public byte[] Protect(byte[] plaintext)
ArgumentNullException.ThrowIfNull(plaintext);
var generated = _kms.GenerateDataKeyAsync(
- new GenerateDataKeyRequest { KeyId = _keyId, KeySpec = DataKeySpec.AES_256 }
- ).GetAwaiter().GetResult();
+ new GenerateDataKeyRequest { KeyId = _keyId, KeySpec = DataKeySpec.AES_256 }
+ )
+ .GetAwaiter()
+ .GetResult();
var dataKey = generated.Plaintext.ToArray();
@@ -52,8 +55,10 @@ public byte[] Unprotect(byte[] protectedData)
var wrappedKey = KmsEnvelope.ReadWrappedKey(protectedData);
var decrypted = _kms.DecryptAsync(
- new DecryptRequest { CiphertextBlob = new MemoryStream(wrappedKey) }
- ).GetAwaiter().GetResult();
+ new DecryptRequest { CiphertextBlob = new MemoryStream(wrappedKey) }
+ )
+ .GetAwaiter()
+ .GetResult();
var dataKey = decrypted.Plaintext.ToArray();
diff --git a/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs b/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
index a7f74aef..e3a148cd 100644
--- a/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
+++ b/tests/SquidStd.Tests/Secrets/Aws/AwsSecretsManagerStoreTests.cs
@@ -34,6 +34,7 @@ public async Task Set_Get_Exists_List_Delete_RoundTrips()
{
names.Add(name);
}
+
Assert.Contains("db/main", names);
Assert.True(await store.DeleteAsync("db/main"));
diff --git a/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs b/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
index 5fb3106b..86319911 100644
--- a/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
+++ b/tests/SquidStd.Tests/Secrets/Aws/KmsSecretProtectorTests.cs
@@ -35,7 +35,8 @@ private async Task CreateKeyAsync()
{
using var kms = new AmazonKeyManagementServiceClient(
new Amazon.Runtime.BasicAWSCredentials("test", "test"),
- new AmazonKeyManagementServiceConfig { ServiceURL = _localStack.Aws.ServiceUrl, AuthenticationRegion = "us-east-1" }
+ new AmazonKeyManagementServiceConfig
+ { ServiceURL = _localStack.Aws.ServiceUrl, AuthenticationRegion = "us-east-1" }
);
var created = await kms.CreateKeyAsync(new CreateKeyRequest());
From 1479482500826da91e206740f4f872bf0f8a7485 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 20:41:04 +0200
Subject: [PATCH 31/77] docs: group package list by domain in README
- split the single flat package table into 13 domain-grouped subtables
- add the missing Actors, Crypto, Secrets.Aws, Vfs and Vfs.Abstractions packages
---
README.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 81 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 3b420dd6..af215a2c 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,8 @@ block until cancellation for long-running hosts.
## Packages
+### Core & hosting
+
| Package | Description | Links |
|---------|-------------|-------|
| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, serialization, YAML/JSON, Serilog sink). | [](src/SquidStd.Core/README.md) · [](https://www.nuget.org/packages/SquidStd.Core/) |
@@ -73,38 +75,103 @@ block until cancellation for long-running hosts.
| `SquidStd.Generators` | Roslyn source generators for event listener, service, config, worker, and Lua registration helpers. | [](src/SquidStd.Generators/README.md) · [](https://www.nuget.org/packages/SquidStd.Generators/) |
| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, health checks, secrets. | [](src/SquidStd.Services.Core/README.md) · [](https://www.nuget.org/packages/SquidStd.Services.Core/) |
| `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [](src/SquidStd.AspNetCore/README.md) · [](https://www.nuget.org/packages/SquidStd.AspNetCore/) |
+| `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [](src/SquidStd.Plugin.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) |
+
+### Networking
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [](src/SquidStd.Network/README.md) · [](https://www.nuget.org/packages/SquidStd.Network/) |
+
+### Messaging & actors
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Messaging.Abstractions` | Messaging contracts (`IMessageQueue`, `IQueueProvider`, serializer/metrics, listeners). | [](src/SquidStd.Messaging.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.Abstractions/) |
+| `SquidStd.Messaging` | In-memory messaging transport (`AddInMemoryMessaging`). | [](src/SquidStd.Messaging/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging/) |
+| `SquidStd.Messaging.RabbitMq` | RabbitMQ messaging transport (`AddRabbitMqMessaging`). | [](src/SquidStd.Messaging.RabbitMq/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.RabbitMq/) |
+| `SquidStd.Messaging.Sqs` | AWS SQS/SNS transport: `IQueueProvider` over SQS (redrive→DLQ) and `ITopicProvider` via SNS+SQS fan-out (`AddSqsMessaging`). | [](src/SquidStd.Messaging.Sqs/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.Sqs/) |
+| `SquidStd.Actors` | Ordered, single-threaded, lock-free per-entity message processing on TPL Dataflow (`Actor`, `TellAsync`/`AskAsync`). | [](src/SquidStd.Actors/README.md) · [](https://www.nuget.org/packages/SquidStd.Actors/) |
+
+### Persistence & database
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Persistence.Abstractions` | Binary-persistence contracts (`IEntityStore`, `IPersistenceService`, journal/snapshot/registry, `PersistenceConfig`). | [](src/SquidStd.Persistence.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Persistence.Abstractions/) |
| `SquidStd.Persistence` | In-memory entity store with durable binary snapshot + journal (WAL); serializer-agnostic engine. | [](src/SquidStd.Persistence/README.md) · [](https://www.nuget.org/packages/SquidStd.Persistence/) |
| `SquidStd.Persistence.MessagePack` | MessagePack-backed binary `IDataSerializer` for SquidStd.Persistence (recommended default). | [](src/SquidStd.Persistence.MessagePack/README.md) · [](https://www.nuget.org/packages/SquidStd.Persistence.MessagePack/) |
-| `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [](src/SquidStd.Plugin.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) |
| `SquidStd.Database.Abstractions` | Provider-agnostic data-access contracts (`IDataAccess`, `BaseEntity`, `PagedResultData`). | [](src/SquidStd.Database.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Database.Abstractions/) |
| `SquidStd.Database` | FreeSql-backed data access (CRUD/bulk/paging, URI connection strings, ZLinq helpers). | [](src/SquidStd.Database/README.md) · [](https://www.nuget.org/packages/SquidStd.Database/) |
-| `SquidStd.Messaging.Abstractions` | Messaging contracts (`IMessageQueue`, `IQueueProvider`, serializer/metrics, listeners). | [](src/SquidStd.Messaging.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.Abstractions/) |
-| `SquidStd.Messaging` | In-memory messaging transport (`AddInMemoryMessaging`). | [](src/SquidStd.Messaging/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging/) |
-| `SquidStd.Messaging.RabbitMq` | RabbitMQ messaging transport (`AddRabbitMqMessaging`). | [](src/SquidStd.Messaging.RabbitMq/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.RabbitMq/) |
-| `SquidStd.Messaging.Sqs` | AWS SQS/SNS transport: `IQueueProvider` over SQS (redrive→DLQ) and `ITopicProvider` via SNS+SQS fan-out (`AddSqsMessaging`). | [](src/SquidStd.Messaging.Sqs/README.md) · [](https://www.nuget.org/packages/SquidStd.Messaging.Sqs/) |
-| `SquidStd.Aws.Abstractions` | Shared AWS connection config (`AwsConfigEntry`: region, credentials, endpoint override) for AWS-SDK providers. | [](src/SquidStd.Aws.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Aws.Abstractions/) |
-| `SquidStd.Telemetry.Abstractions` | Shared telemetry config (`TelemetryOptions`, OTLP protocol, `SquidStd.*` ActivitySource convention). | [](src/SquidStd.Telemetry.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Telemetry.Abstractions/) |
-| `SquidStd.Telemetry.OpenTelemetry` | OpenTelemetry tracing + metrics export (OTLP/console), standard instrumentation, metrics-snapshot bridge (`AddSquidStdTelemetry`). | [](src/SquidStd.Telemetry.OpenTelemetry/README.md) · [](https://www.nuget.org/packages/SquidStd.Telemetry.OpenTelemetry/) |
+
+### Caching
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Caching.Abstractions` | Caching contracts (`ICacheService`, `ICacheProvider`, `CacheService` facade, metrics, connection string). | [](src/SquidStd.Caching.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching.Abstractions/) |
| `SquidStd.Caching` | In-memory cache backend (`AddInMemoryCache`). | [](src/SquidStd.Caching/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching/) |
| `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [](src/SquidStd.Caching.Redis/README.md) · [](https://www.nuget.org/packages/SquidStd.Caching.Redis/) |
+
+### Storage & virtual filesystem
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Storage.Abstractions` | Storage contracts (`IStorageService`, `IObjectStorageService`, `StorageConfig`, `ListKeysAsync`). | [](src/SquidStd.Storage.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage.Abstractions/) |
| `SquidStd.Storage` | Local file storage backend (`AddFileStorage`). | [](src/SquidStd.Storage/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage/) |
| `SquidStd.Storage.S3` | S3/MinIO storage backend (`AddS3Storage`). | [](src/SquidStd.Storage.S3/README.md) · [](https://www.nuget.org/packages/SquidStd.Storage.S3/) |
-| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [](src/SquidStd.Scripting.Lua/README.md) · [](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) |
-| `SquidStd.Templating` | Scriban templating with a named-template registry and `templates/*.tmpl` auto-load (`AddTemplating`). | [](src/SquidStd.Templating/README.md) · [](https://www.nuget.org/packages/SquidStd.Templating/) |
-| `SquidStd.Workers.Abstractions` | Worker/manager shared contracts (`JobRequest`, `WorkerHeartbeat`, `WorkerInfo`, `WorkerChannels`). | [](src/SquidStd.Workers.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers.Abstractions/) |
-| `SquidStd.Workers` | Worker runtime: consume jobs, dispatch to `IJobHandler`s, publish heartbeats (`AddWorkers`). | [](src/SquidStd.Workers/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers/) |
-| `SquidStd.Workers.Manager` | Job enqueue, heartbeat registry, offline sweep, opt-in ASP.NET endpoints (`AddWorkerManager`). | [](src/SquidStd.Workers.Manager/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers.Manager/) |
-| `SquidStd.Templates` | `dotnet new` templates for scaffolding SquidStd projects (host, ASP.NET, worker, manager). | [](src/SquidStd.Templates/README.md) · [](https://www.nuget.org/packages/SquidStd.Templates/) |
+| `SquidStd.Vfs.Abstractions` | Virtual filesystem contracts (`IVirtualFileSystem`, `ILockableFileSystem`, `VfsPath`). | [](src/SquidStd.Vfs.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Vfs.Abstractions/) |
+| `SquidStd.Vfs` | Virtual filesystem providers — physical, in-memory, and zip — plus `VfsDirectories`, a VFS-backed `DirectoriesConfig`. | [](src/SquidStd.Vfs/README.md) · [](https://www.nuget.org/packages/SquidStd.Vfs/) |
+
+### Security — crypto & secrets
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Crypto` | OpenPGP key management/operations over an indexed keyring (`SquidStd.Crypto.Pgp`, `RegisterPgp`) plus the encrypted VFS vault decorator (Argon2id + per-entry AES-GCM). | [](src/SquidStd.Crypto/README.md) · [](https://www.nuget.org/packages/SquidStd.Crypto/) |
+| `SquidStd.Secrets.Aws` | AWS adapters for the secret seams — KMS envelope `ISecretProtector` and Secrets Manager `ISecretStore`. | [](src/SquidStd.Secrets.Aws/README.md) · [](https://www.nuget.org/packages/SquidStd.Secrets.Aws/) |
+
+### Search
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Search.Abstractions` | Search/indexing contracts (`IIndexableEntity`, `[SearchIndex]`, `ISearchService`). | [](src/SquidStd.Search.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Search.Abstractions/) |
| `SquidStd.Search.Elasticsearch` | Elasticsearch indexing + constrained LINQ query provider (`AddElasticsearch`). | [](src/SquidStd.Search.Elasticsearch/README.md) · [](https://www.nuget.org/packages/SquidStd.Search.Elasticsearch/) |
+
+### Mail
+
+| Package | Description | Links |
+|---------|-------------|-------|
| `SquidStd.Mail.Abstractions` | Mail contracts (`MailMessage`, `MailReceivedEvent`, `IMailReader`, `MailOptions`). | [](src/SquidStd.Mail.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Mail.Abstractions/) |
| `SquidStd.Mail.MailKit` | IMAP/POP3 mail poller that publishes `MailReceivedEvent` (`AddMail`). | [](src/SquidStd.Mail.MailKit/README.md) · [](https://www.nuget.org/packages/SquidStd.Mail.MailKit/) |
| `SquidStd.Mail.Queue` | Outbound mail send queue over the messaging queue (`AddMailQueue`, `IMailQueue`). | [](src/SquidStd.Mail.Queue/README.md) · [](https://www.nuget.org/packages/SquidStd.Mail.Queue/) |
+### Workers
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Workers.Abstractions` | Worker/manager shared contracts (`JobRequest`, `WorkerHeartbeat`, `WorkerInfo`, `WorkerChannels`). | [](src/SquidStd.Workers.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers.Abstractions/) |
+| `SquidStd.Workers` | Worker runtime: consume jobs, dispatch to `IJobHandler`s, publish heartbeats (`AddWorkers`). | [](src/SquidStd.Workers/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers/) |
+| `SquidStd.Workers.Manager` | Job enqueue, heartbeat registry, offline sweep, opt-in ASP.NET endpoints (`AddWorkerManager`). | [](src/SquidStd.Workers.Manager/README.md) · [](https://www.nuget.org/packages/SquidStd.Workers.Manager/) |
+
+### Scripting & templating
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [](src/SquidStd.Scripting.Lua/README.md) · [](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) |
+| `SquidStd.Templating` | Scriban templating with a named-template registry and `templates/*.tmpl` auto-load (`AddTemplating`). | [](src/SquidStd.Templating/README.md) · [](https://www.nuget.org/packages/SquidStd.Templating/) |
+
+### Telemetry
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Telemetry.Abstractions` | Shared telemetry config (`TelemetryOptions`, OTLP protocol, `SquidStd.*` ActivitySource convention). | [](src/SquidStd.Telemetry.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Telemetry.Abstractions/) |
+| `SquidStd.Telemetry.OpenTelemetry` | OpenTelemetry tracing + metrics export (OTLP/console), standard instrumentation, metrics-snapshot bridge (`AddSquidStdTelemetry`). | [](src/SquidStd.Telemetry.OpenTelemetry/README.md) · [](https://www.nuget.org/packages/SquidStd.Telemetry.OpenTelemetry/) |
+
+### Shared & tooling
+
+| Package | Description | Links |
+|---------|-------------|-------|
+| `SquidStd.Aws.Abstractions` | Shared AWS connection config (`AwsConfigEntry`: region, credentials, endpoint override) for AWS-SDK providers. | [](src/SquidStd.Aws.Abstractions/README.md) · [](https://www.nuget.org/packages/SquidStd.Aws.Abstractions/) |
+| `SquidStd.Templates` | `dotnet new` templates for scaffolding SquidStd projects (host, ASP.NET, worker, manager). | [](src/SquidStd.Templates/README.md) · [](https://www.nuget.org/packages/SquidStd.Templates/) |
+
## Related projects
- **[Felix Network](https://github.com/tgiachi/SquidStd-Felix)** — a standalone secure binary
From c00e43982b1ce96a9d0dcad7741e688a26a81eb8 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:03:26 +0200
Subject: [PATCH 32/77] docs: expand root README with TOC, templates, fuller
example and acknowledgements
- add a Contents table of contents
- broaden the Overview to cover all current domains (crypto, persistence, VFS, actors, search, mail, workers, telemetry, AWS adapters)
- correct the Testcontainers list (RabbitMQ, Redis, Elasticsearch, MinIO, LocalStack) in Requirements and Test
- add an event bus + provider example alongside the cache quick start
- add a Project templates section (dotnet new install + short names)
- add a Built on acknowledgements section
---
README.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 100 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index af215a2c..ff64646b 100644
--- a/README.md
+++ b/README.md
@@ -12,20 +12,43 @@
+## Contents
+
+- [Overview](#overview)
+- [Requirements](#requirements)
+- [Quick Start](#quick-start)
+- [Project templates](#project-templates)
+- [Packages](#packages)
+- [Related projects](#related-projects)
+- [Architecture](#architecture)
+- [Documentation](#documentation)
+- [Build](#build)
+- [Test](#test)
+- [Contributing](#contributing)
+- [Versioning & Releases](#versioning--releases)
+- [Built on](#built-on)
+- [License](#license)
+
## Overview
**squid-std** is a batteries-included standard library for .NET, distilled from years and years
of building real-world server software. Instead of re-solving the same problems on every project,
it bundles the foundations you reach for again and again behind small, well-defined contracts:
-- **Security & hashing** — password hashing and verification (`HashUtils`), AES-GCM secret
- protection and a pluggable secret store (`ISecretProtector` / `ISecretStore`).
-- **Configuration** — a YAML-backed config manager with section registration and environment-variable expansion.
-- **Serialization** — unified JSON/YAML utilities (`JsonUtils`, `YamlUtils`) and a shared `IDataSerializer` / `IDataDeserializer`.
-- **String & platform helpers** — case converters (camel/kebab/snake/pascal/…), network, version, platform and resource utilities.
-- **Runtime services** — DI bootstrap, event bus, job system, timer/cron scheduler, metrics and storage.
-- **Infrastructure modules** — messaging (in-memory + RabbitMQ), caching (in-memory + Redis),
- database access, networking (TCP/UDP) and Lua scripting.
+- **Security & crypto** — password hashing (`HashUtils`), AES-GCM secret protection and a pluggable
+ secret store (`ISecretProtector` / `ISecretStore`), an OpenPGP keyring (`SquidStd.Crypto`), and
+ AWS KMS / Secrets Manager adapters.
+- **Configuration & serialization** — a YAML-backed config manager with section registration and
+ environment-variable expansion; unified JSON/YAML utilities and a shared `IDataSerializer`.
+- **Runtime services** — DI bootstrap, event bus, command dispatcher, actors (ordered per-entity
+ mailboxes), job system, timer/cron scheduler, metrics and health checks.
+- **Messaging & caching** — in-memory, RabbitMQ and AWS SQS/SNS transports; in-memory and Redis caches.
+- **Data & storage** — FreeSql data access, binary persistence (snapshot + WAL), local / S3 / MinIO
+ object storage, and a virtual filesystem (physical / zip / in-memory + an encrypted vault).
+- **Search, mail & workers** — Elasticsearch indexing with a constrained LINQ provider, IMAP/POP3
+ mail polling and an outbound mail queue, and a worker / manager runtime.
+- **Networking, scripting & observability** — TCP/UDP servers with a framing/middleware pipeline,
+ Lua scripting and Scriban templating, and OpenTelemetry tracing + metrics export.
Everything is modular: take only the packages you need, each behind a clean abstraction with an
in-memory implementation for tests and an external backend for production.
@@ -33,7 +56,7 @@ in-memory implementation for tests and an external backend for production.
## Requirements
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
-- [Docker](https://www.docker.com/) — only for running the integration tests (Testcontainers spin up RabbitMQ and Redis).
+- [Docker](https://www.docker.com/) — only for the integration tests (Testcontainers spin up RabbitMQ, Redis, Elasticsearch, MinIO and LocalStack on demand).
## Quick Start
@@ -64,6 +87,59 @@ await bootstrap.StopAsync();
`StartAsync` / `StopAsync` lifecycle of every registered `ISquidStdService`. Use `RunAsync` to
block until cancellation for long-running hosts.
+### A fuller example: event bus + a provider
+
+```csharp
+using SquidStd.Core.Interfaces.Events;
+using SquidStd.Caching.Abstractions.Interfaces;
+using SquidStd.Caching.Extensions;
+using SquidStd.Services.Core.Services.Bootstrap;
+
+public sealed record OrderPlaced(string OrderId) : IEvent;
+
+var bootstrap = SquidStdBootstrap.Create()
+ .ConfigureServices(container => container.AddInMemoryCache());
+
+await bootstrap.StartAsync();
+
+var eventBus = bootstrap.Resolve(); // core service, always available
+var cache = bootstrap.Resolve();
+
+// React to domain events…
+using var subscription = eventBus.Subscribe(async (e, ct) =>
+ await cache.SetAsync($"order:{e.OrderId}", DateTimeOffset.UtcNow, TimeSpan.FromHours(1)));
+
+// …and publish them.
+await eventBus.PublishAsync(new OrderPlaced("A-1001"));
+
+await bootstrap.StopAsync();
+```
+
+The event bus, config manager, job system, scheduler and metrics are wired by the bootstrapper out
+of the box; modules such as caching are opted in through `ConfigureServices`.
+
+## Project templates
+
+Scaffold a ready-to-run project with the `dotnet new` template pack:
+
+```bash
+dotnet new install SquidStd.Templates
+```
+
+| Template | Short name | What you get |
+|---------------------|-----------------------|-----------------------------------------------------------------|
+| Console host | `squidstd-host` | A `SquidStdBootstrap` console host. |
+| ASP.NET minimal API | `squidstd-aspnetcore` | `UseSquidStd` + health checks + a sample endpoint + Dockerfile. |
+| Worker microservice | `squidstd-worker` | `AddWorkers` + a sample `IJobHandler` + Dockerfile. |
+| Worker manager | `squidstd-manager` | `AddWorkerManager` + `MapWorkerManagerEndpoints` + Dockerfile. |
+
+```bash
+dotnet new squidstd-worker -n Acme.Resizer
+dotnet new squidstd-manager -n Acme.Manager --messaging inmemory
+```
+
+`--messaging` (`rabbitmq` default | `inmemory`) is available on the worker and manager templates.
+
## Packages
### Core & hosting
@@ -215,8 +291,8 @@ dotnet build SquidStd.slnx
dotnet test SquidStd.slnx
```
-Integration tests (RabbitMQ, Redis) need Docker running; they use Testcontainers to start
-disposable containers automatically.
+Integration tests need Docker running; Testcontainers starts disposable RabbitMQ, Redis,
+Elasticsearch, MinIO and LocalStack containers automatically.
## Contributing
@@ -232,6 +308,19 @@ Releases are automated with [semantic-release](https://semantic-release.gitbook.
numbers and the changelog are derived from the conventional-commit history, and the NuGet packages
are published on release. Package versions follow [Semantic Versioning](https://semver.org/).
+## Built on
+
+squid-std stands on a small set of well-established libraries:
+
+- **DI & logging** — [DryIoc](https://github.com/dadhi/DryIoc), [Serilog](https://serilog.net/)
+- **Data** — [FreeSql](https://github.com/dotnetcore/FreeSql), [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp), [YamlDotNet](https://github.com/aaubry/YamlDotNet)
+- **Messaging & cache** — [RabbitMQ.Client](https://github.com/rabbitmq/rabbitmq-dotnet-client), [StackExchange.Redis](https://github.com/StackExchange/StackExchange.Redis), [AWS SDK for .NET](https://github.com/aws/aws-sdk-net)
+- **Search, mail & storage** — [Elastic.Clients.Elasticsearch](https://github.com/elastic/elasticsearch-net), [MailKit](https://github.com/jstedfast/MailKit), [Minio](https://github.com/minio/minio-dotnet)
+- **Crypto** — [PgpCore](https://github.com/mattosaurus/PgpCore) over [BouncyCastle](https://www.bouncycastle.org/)
+- **Scripting & templating** — [MoonSharp](https://www.moonsharp.org/) (Lua), [Scriban](https://github.com/scriban/scriban)
+- **Observability** — [OpenTelemetry](https://opentelemetry.io/)
+- **Scheduling** — [Cronos](https://github.com/HangfireIO/Cronos)
+
## License
MIT - see [LICENSE](LICENSE).
From ec6a445fa2b646c041bf2e1d66819833d5b7aaef Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:08:28 +0200
Subject: [PATCH 33/77] =?UTF-8?q?docs:=20rewrite=20landing=20page=20with?=
=?UTF-8?q?=20full=20surface=20and=20Di=C3=A1taxis=20entry=20points?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/index.md | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/docs/index.md b/docs/index.md
index 54cd303d..ebd366c9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,11 +8,19 @@ _layout: landing
# SquidStd
-A modular .NET toolkit: foundational service contracts and utilities, a DryIoc-based service stack,
-networking, plugins, data access, messaging, and Lua scripting — published as focused NuGet packages.
+A batteries-included, modular standard library for .NET 10 — distilled from years of building
+real-world server software. Each capability ships behind a small contract with an in-memory
+implementation for tests and a production backend, published as a focused NuGet package.
-- **[Getting started](articles/getting-started.md)** — install and bootstrap the core services.
-- **[API reference](api/index.md)** — the full type/member documentation.
-- **Packages** — see the per-package guides under [Articles](articles/getting-started.md).
-- **[Felix Network](articles/felix.md)** — companion secure binary mesh-networking library
- (.NET + C/ESP32).
+Security & crypto, configuration, persistence, messaging, caching, storage, a virtual filesystem,
+search, mail, workers, actors, telemetry, scripting — take only what you need.
+
+## Start here
+
+- **[Tutorials](tutorials/index.md)** — learn by building: bootstrap, caching, messaging, workers,
+ crypto, persistence, and more.
+- **[Guides](articles/guides/configuration.md)** — task-focused how-to and "which provider" decision guides.
+- **[Concepts](articles/concepts/architecture.md)** — the architecture and the ideas behind it.
+- **[Packages](articles/getting-started.md)** — per-package reference.
+- **[API reference](api/index.md)** — full type/member documentation.
+- **[Felix Network](articles/felix.md)** — companion secure binary mesh-networking library (.NET + C/ESP32).
From d895bf82ef89d7f2812ab5cc0783fc59b7d54ce8 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:08:28 +0200
Subject: [PATCH 34/77] docs: refresh getting-started with current bootstrap
API
---
docs/articles/getting-started.md | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md
index 88533d39..94412372 100644
--- a/docs/articles/getting-started.md
+++ b/docs/articles/getting-started.md
@@ -7,14 +7,19 @@ dotnet add package SquidStd.Services.Core
```
```csharp
-using DryIoc;
-using SquidStd.Services.Core.Extensions;
+using SquidStd.Core.Data.Bootstrap;
+using SquidStd.Services.Core.Services.Bootstrap;
-var container = new Container();
+// Core services wired automatically: config manager, event bus, command dispatcher,
+// job system, timer/cron scheduler, metrics, health checks, storage and secrets.
+var bootstrap = SquidStdBootstrap.Create(
+ new SquidStdOptions { ConfigName = "squidstd", RootDirectory = AppContext.BaseDirectory });
-// config manager + event bus + jobs + timer wheel + dispatcher + metrics + storage + secrets
-container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory());
+await bootstrap.StartAsync();
+// … resolve services, opt into modules with bootstrap.ConfigureServices(…) …
+await bootstrap.StopAsync();
```
-From here, add focused packages as needed — see the per-package guides in the sidebar and the
-[API reference](../api/index.md).
+Opt into modules with `ConfigureServices`, e.g. `container.AddInMemoryCache()`. See the
+[Concepts](concepts/bootstrap-lifecycle.md) for the full lifecycle and
+[Packages](getting-started.md) for per-module reference.
From 4dbca359484f4d5aca05d1e95dc012cc0e774ae1 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:12:36 +0200
Subject: [PATCH 35/77] docs: add reference articles for Actors, Crypto, Vfs,
Secrets.Aws and Persistence
---
docs/articles/actors.md | 1 +
docs/articles/crypto.md | 1 +
docs/articles/persistence-abstractions.md | 1 +
docs/articles/persistence-messagepack.md | 1 +
docs/articles/persistence.md | 1 +
docs/articles/secrets-aws.md | 1 +
docs/articles/toc.yml | 16 ++++++++++++++
docs/articles/vfs-abstractions.md | 1 +
docs/articles/vfs.md | 1 +
src/SquidStd.Vfs.Abstractions/README.md | 26 +++++++++++++++++++++++
10 files changed, 50 insertions(+)
create mode 100644 docs/articles/actors.md
create mode 100644 docs/articles/crypto.md
create mode 100644 docs/articles/persistence-abstractions.md
create mode 100644 docs/articles/persistence-messagepack.md
create mode 100644 docs/articles/persistence.md
create mode 100644 docs/articles/secrets-aws.md
create mode 100644 docs/articles/vfs-abstractions.md
create mode 100644 docs/articles/vfs.md
create mode 100644 src/SquidStd.Vfs.Abstractions/README.md
diff --git a/docs/articles/actors.md b/docs/articles/actors.md
new file mode 100644
index 00000000..ee50eff9
--- /dev/null
+++ b/docs/articles/actors.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Actors/README.md)]
diff --git a/docs/articles/crypto.md b/docs/articles/crypto.md
new file mode 100644
index 00000000..3ba608c8
--- /dev/null
+++ b/docs/articles/crypto.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Crypto/README.md)]
diff --git a/docs/articles/persistence-abstractions.md b/docs/articles/persistence-abstractions.md
new file mode 100644
index 00000000..96f9fb84
--- /dev/null
+++ b/docs/articles/persistence-abstractions.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Persistence.Abstractions/README.md)]
diff --git a/docs/articles/persistence-messagepack.md b/docs/articles/persistence-messagepack.md
new file mode 100644
index 00000000..a3bc077d
--- /dev/null
+++ b/docs/articles/persistence-messagepack.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Persistence.MessagePack/README.md)]
diff --git a/docs/articles/persistence.md b/docs/articles/persistence.md
new file mode 100644
index 00000000..2104287c
--- /dev/null
+++ b/docs/articles/persistence.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Persistence/README.md)]
diff --git a/docs/articles/secrets-aws.md b/docs/articles/secrets-aws.md
new file mode 100644
index 00000000..2418ebfa
--- /dev/null
+++ b/docs/articles/secrets-aws.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Secrets.Aws/README.md)]
diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml
index 2669ef5b..c80a43c9 100644
--- a/docs/articles/toc.yml
+++ b/docs/articles/toc.yml
@@ -18,6 +18,12 @@
href: database-abstractions.md
- name: SquidStd.Database
href: database.md
+- name: SquidStd.Persistence.Abstractions
+ href: persistence-abstractions.md
+- name: SquidStd.Persistence
+ href: persistence.md
+- name: SquidStd.Persistence.MessagePack
+ href: persistence-messagepack.md
- name: SquidStd.Messaging.Abstractions
href: messaging-abstractions.md
- name: SquidStd.Messaging
@@ -26,6 +32,8 @@
href: messaging-rabbitmq.md
- name: SquidStd.Messaging.Sqs
href: messaging-sqs.md
+- name: SquidStd.Actors
+ href: actors.md
- name: SquidStd.Caching.Abstractions
href: caching-abstractions.md
- name: SquidStd.Caching
@@ -38,6 +46,10 @@
href: storage.md
- name: SquidStd.Storage.S3
href: storage-s3.md
+- name: SquidStd.Vfs.Abstractions
+ href: vfs-abstractions.md
+- name: SquidStd.Vfs
+ href: vfs.md
- name: SquidStd.Scripting.Lua
href: scripting-lua.md
- name: SquidStd.Templating
@@ -72,5 +84,9 @@
href: telemetry-opentelemetry.md
- name: SquidStd.Aws.Abstractions
href: aws-abstractions.md
+- name: SquidStd.Crypto
+ href: crypto.md
+- name: SquidStd.Secrets.Aws
+ href: secrets-aws.md
- name: Felix Network
href: felix.md
diff --git a/docs/articles/vfs-abstractions.md b/docs/articles/vfs-abstractions.md
new file mode 100644
index 00000000..6de38df3
--- /dev/null
+++ b/docs/articles/vfs-abstractions.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Vfs.Abstractions/README.md)]
diff --git a/docs/articles/vfs.md b/docs/articles/vfs.md
new file mode 100644
index 00000000..247ccdc1
--- /dev/null
+++ b/docs/articles/vfs.md
@@ -0,0 +1 @@
+[!include[](../../src/SquidStd.Vfs/README.md)]
diff --git a/src/SquidStd.Vfs.Abstractions/README.md b/src/SquidStd.Vfs.Abstractions/README.md
new file mode 100644
index 00000000..a68e0c3b
--- /dev/null
+++ b/src/SquidStd.Vfs.Abstractions/README.md
@@ -0,0 +1,26 @@
+
SquidStd.Vfs.Abstractions
+
+Virtual filesystem contracts for SquidStd: a path-based file/directory abstraction implemented by physical, in-memory and zip backends.
+
+## Install
+
+```bash
+dotnet add package SquidStd.Vfs.Abstractions
+```
+
+## Key types
+
+| Type | Purpose |
+|------|---------|
+| `IVirtualFileSystem` | Path-based filesystem over a pluggable backend (directory, zip, encrypted container): exists, read/write bytes, open read/write streams, delete and list entries. |
+| `ILockableFileSystem` | An `IVirtualFileSystem` that stays locked until unlocked with a passphrase (e.g. an encrypted vault), exposing `IsUnlocked`, `Unlock` and `Lock`. |
+| `VfsPath` | Static helper that normalizes logical paths to forward-slash, root-relative form and rejects `.`/`..` traversal segments. |
+| `VfsEntry` | Record describing a listed entry: its logical path, byte size and last-modified UTC timestamp. |
+
+## Related
+
+- Tutorial: [Virtual filesystem](https://tgiachi.github.io/squid-std/tutorials/vfs.html)
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
From a7ba8d5410cb668f7cecef944e12de91eaf223aa Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:21:44 +0200
Subject: [PATCH 36/77] docs: normalise core & hosting package READMEs
---
src/SquidStd.Abstractions/README.md | 15 +++-------
src/SquidStd.AspNetCore/README.md | 13 +++------
src/SquidStd.Core/README.md | 19 +++----------
src/SquidStd.Generators/README.md | 33 +++++++++++++++++-----
src/SquidStd.Plugin.Abstractions/README.md | 10 +++----
src/SquidStd.Services.Core/README.md | 18 ++++--------
6 files changed, 47 insertions(+), 61 deletions(-)
diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md
index 3a83a2d9..d4829145 100644
--- a/src/SquidStd.Abstractions/README.md
+++ b/src/SquidStd.Abstractions/README.md
@@ -21,17 +21,6 @@ discoverable way (tracked through ordered registration lists).
dotnet add package SquidStd.Abstractions
```
-## Features
-
-- `ISquidStdService` — a `StartAsync`/`StopAsync` lifecycle contract for managed services.
-- `RegisterEventListenerAttribute` — mark `IEventListener` classes for generated registration.
-- `RegisterStdServiceAttribute` — mark service implementations for generated lifecycle registration.
-- `RegisterConfigSectionAttribute` — mark config models for generated config-section registration.
-- `RegisterStdService()` — register a singleton service and record it in the
- ordered service list (with optional priority).
-- `RegisterConfigSection(sectionName)` — register a YAML config section for the config manager.
-- `AddToRegisterTypedList(...)` — maintain ordered registration lists in the container.
-
## Usage
```csharp
@@ -59,6 +48,10 @@ container.RegisterConfigSection("my");
| `ServiceRegistrationData` | Ordered service registration record. |
| `ConfigRegistrationData` | Config section registration record. |
+## Related
+
+- Tutorial: [Source generators: registration](https://tgiachi.github.io/squid-std/tutorials/source-generators-registration.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md
index 2c621eb4..0d12ec48 100644
--- a/src/SquidStd.AspNetCore/README.md
+++ b/src/SquidStd.AspNetCore/README.md
@@ -21,15 +21,6 @@ DryIoc container into the web host and registers a hosted service that starts an
dotnet add package SquidStd.AspNetCore
```
-## Features
-
-- `WebApplicationBuilder.UseSquidStd(...)` — plug the SquidStd container and services into a web app.
-- Configures DryIoc as the host's service-provider factory.
-- Registers `SquidStdHostedService` to start/stop SquidStd services with the host.
-- Optional `SquidStdOptions` configuration callback.
-- `WebApplicationBuilder.AddSquidStdHealthChecks()` — bridges every SquidStd `IHealthCheck` into the standard ASP.NET Core
- health-check system (one entry per check), exposed via the standard `app.MapHealthChecks(...)`.
-
## Usage
```csharp
@@ -71,6 +62,10 @@ Each registered `IHealthCheck` appears as its own entry in the report. Check nam
| `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. |
| `SquidStdHealthChecksExtensions` | `AddSquidStdHealthChecks(...)` — bridge to ASP.NET Core health checks. |
+## Related
+
+- Tutorial: [Build an ASP.NET Core app](https://tgiachi.github.io/squid-std/tutorials/aspnetcore-app.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md
index 711bedae..b96c1c09 100644
--- a/src/SquidStd.Core/README.md
+++ b/src/SquidStd.Core/README.md
@@ -22,21 +22,6 @@ SquidStd packages build on.
dotnet add package SquidStd.Core
```
-## Features
-
-- Configuration contracts: `IConfigEntry` (a YAML section) and `IConfigManagerService`.
-- In-process messaging: `IEventBus` with `ISyncEventListener` / `IAsyncEventListener` over `IEvent`.
-- Command dispatch: `ICommandDispatcher` with `ICommandHandler`, fan-out, fault isolation, and a `CommandDispatchResult`; `ICommandContextFactory` builds the context from a seed for `ISeededCommandDispatcher`.
-- Background work & timing: `IJobSystem`, `ITimerService`, `IMainThreadDispatcher`.
-- Metrics & secrets: `IMetricProvider` and secret-protection contracts.
-- Serialization: `IDataSerializer` / `IDataDeserializer` (default `JsonDataSerializer`), plus `YamlUtils` / `JsonUtils`.
-- File watching: `IFileWatcherService` / `FileWatcherService` — recursive, debounced watchers that publish `FileChangedEvent` on the event bus.
-- Object pooling: `ObjectPool` — thread-safe, non-blocking, factory-based reuse with optional reset.
-- Cryptography: `CryptoUtils` (AES-GCM authenticated encrypt/decrypt + key generation), `EncryptString`/`DecryptString` string helpers, base64 extensions, and `SslUtils` for loading PEM/PFX TLS certificates.
-- Randomness: `BuiltInRng` (seedable ambient RNG), `RandomUtils` (dice, coin flips), and collection `Shuffle`/`RandomElement`/`RandomSample` extensions.
-- Utilities: a Serilog `EventSink`, and string/env/directory extensions.
-- Shared domain enums under `Types` (e.g. `LogLevelType`, `PlatformType`, `FileChangeKind`).
-
## Usage
```csharp
@@ -88,6 +73,10 @@ pool.Return(builder);
| `ObjectPool` | Thread-safe, non-blocking object pool. |
| `ICommandDispatcher` | Typed protocol command dispatch with context. |
+## Related
+
+- Tutorial: [Events, jobs & scheduling](https://tgiachi.github.io/squid-std/tutorials/events-jobs-scheduling.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Generators/README.md b/src/SquidStd.Generators/README.md
index 07567ef1..dc4de0d9 100644
--- a/src/SquidStd.Generators/README.md
+++ b/src/SquidStd.Generators/README.md
@@ -1,4 +1,15 @@
-# SquidStd.Generators
+
+
+
+
+
SquidStd.Generators
+
+
+
+
+
+
+
Roslyn source generators for SquidStd compile-time registration helpers.
@@ -8,7 +19,7 @@ Roslyn source generators for SquidStd compile-time registration helpers.
dotnet add package SquidStd.Generators
```
-## Event listeners
+## Usage
The event listener generator discovers concrete `IEventListener` implementations marked with `[RegisterEventListener]` and generates a DryIoc registration extension:
@@ -26,17 +37,25 @@ public sealed class PingListener : IEventListener
container.RegisterGeneratedEventListeners();
```
-The generated method reuses the normal `RegisterEventListener()` runtime path, so listener activation stays compatible with `SquidStd.Services.Core`.
-
-## Other generated registrations
+The generated method reuses the normal `RegisterEventListener()` runtime path, so listener activation stays compatible with `SquidStd.Services.Core`. Each registration family has its own marker attribute and generated extension method, all calling the same runtime APIs as manual registration (`RegisterStdService`, `RegisterConfigSection`, `AddJobHandler`, `RegisterScriptModule`).
-Each registration family has its own marker attribute and generated extension method:
+## Key types
| Marker attribute | Generated method |
|------------------|------------------|
+| `[RegisterEventListener]` | `RegisterGeneratedEventListeners()` |
| `[RegisterStdService(typeof(IMyService), Priority = 10)]` | `RegisterGeneratedStdServices()` |
| `[RegisterConfigSection("workers", Priority = -50)]` | `RegisterGeneratedConfigSections()` |
| `[RegisterJobHandler]` | `RegisterGeneratedJobHandlers()` |
| `[RegisterScriptModule]` with `[ScriptModule("name")]` | `RegisterGeneratedScriptModules()` |
-The generated methods call the same runtime APIs as manual registration: `RegisterStdService`, `RegisterConfigSection`, `AddJobHandler`, and `RegisterScriptModule`.
+## Related
+
+- Tutorial: [Source generators: event listeners](https://tgiachi.github.io/squid-std/tutorials/source-generators-event-listeners.html)
+- Tutorial: [Source generators: registration](https://tgiachi.github.io/squid-std/tutorials/source-generators-registration.html)
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
+
+
diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md
index 57af815d..0952933b 100644
--- a/src/SquidStd.Plugin.Abstractions/README.md
+++ b/src/SquidStd.Plugin.Abstractions/README.md
@@ -21,12 +21,6 @@ with shared boot data.
dotnet add package SquidStd.Plugin.Abstractions
```
-## Features
-
-- `ISquidStdPlugin` — the plugin entry point: `Metadata` + `Configure(IContainer, PluginContext)`.
-- `PluginMetadata` — id, name, `Version`, author, optional description, and dependency declarations.
-- `PluginContext` — a typed bag of boot data shared with the plugin (`GetData(key)`).
-
## Usage
```csharp
@@ -59,6 +53,10 @@ public sealed class MyPlugin : ISquidStdPlugin
| `PluginMetadata` | Plugin identity and dependency declarations. |
| `PluginContext` | Shared boot data passed to the plugin. |
+## Related
+
+- Tutorial: [Plugins](https://tgiachi.github.io/squid-std/tutorials/plugins.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md
index d541f658..e009f7fb 100644
--- a/src/SquidStd.Services.Core/README.md
+++ b/src/SquidStd.Services.Core/README.md
@@ -21,18 +21,6 @@ main-thread dispatcher, metrics collection, storage, and secrets services.
dotnet add package SquidStd.Services.Core
```
-## Features
-
-- One-line bootstrap: `container.RegisterCoreServices()` registers the full default service set.
-- `ConfigManagerService` — loads/saves YAML config sections and substitutes `$ENV_VAR` tokens.
-- `EventBusService` — in-process publish/subscribe over `IEvent`.
-- `CommandDispatcher` — typed protocol command dispatch with fan-out, fault isolation, and a `CommandDispatchResult` (`RegisterCommandDispatcher` / `RegisterCommandHandler` / `RegisterSeededCommandDispatcher`).
-- `JobSystemService` — background job execution; `TimerWheelService` + cron scheduling for timed work.
-- `MainThreadDispatcherService` — marshal work back onto a main thread.
-- `MetricsCollectionService` — aggregates `IMetricProvider` samples.
-- `HealthCheckService` — aggregates `IHealthCheck`s into one report (`RegisterHealthChecksService`).
-- AES-GCM-protected secret store. (File/object storage moved to `SquidStd.Storage`, opt-in via `AddFileStorage`.)
-
## Usage
```csharp
@@ -45,7 +33,7 @@ var container = new Container();
container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory());
```
-## Command dispatch
+### Command dispatch
```csharp
// Register a dispatcher for your context type and handlers.
@@ -85,6 +73,10 @@ await seeded.DispatchAsync(command, connection); // factory maps connection ->
| `MainThreadDispatcherService` | Main-thread work dispatch. |
| `MetricsCollectionService` | Metric sample aggregation. |
+## Related
+
+- Tutorial: [Events, jobs & scheduling](https://tgiachi.github.io/squid-std/tutorials/events-jobs-scheduling.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
From a961341071c621e71694e15f0988f23dc7947628 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:23:50 +0200
Subject: [PATCH 37/77] docs: normalise networking & actors package READMEs
---
src/SquidStd.Actors/README.md | 18 +++++++++---------
src/SquidStd.Network/README.md | 12 ++++--------
2 files changed, 13 insertions(+), 17 deletions(-)
diff --git a/src/SquidStd.Actors/README.md b/src/SquidStd.Actors/README.md
index d71004a4..3c5b15e6 100644
--- a/src/SquidStd.Actors/README.md
+++ b/src/SquidStd.Actors/README.md
@@ -53,6 +53,15 @@ using SquidStd.Actors.Extensions;
using var sub = session.SubscribeToEventBus(eventBus, (UserJoinedEvent e) => new SendText($":{e.Nick} JOIN"));
```
+## Key types
+
+| Type | Purpose |
+|---------------------------|----------------------------------------------------|
+| `Actor` | Mailbox base class (`TellAsync` / `AskAsync`). |
+| `ActorRequest` | Base record for request/response messages. |
+| `IActorRequest` | Request contract (implement directly if not using the base). |
+| `ActorOptions` | Capacity / overflow / error configuration. |
+
## Options
`ActorOptions` controls the mailbox:
@@ -70,15 +79,6 @@ using var sub = session.SubscribeToEventBus(eventBus, (UserJoinedEvent e) => new
`DisposeAsync` completes the mailbox, drains in-flight work, and faults any still-pending requests.
-## Key types
-
-| Type | Purpose |
-|---------------------------|----------------------------------------------------|
-| `Actor` | Mailbox base class (`TellAsync` / `AskAsync`). |
-| `ActorRequest` | Base record for request/response messages. |
-| `IActorRequest` | Request contract (implement directly if not using the base). |
-| `ActorOptions` | Capacity / overflow / error configuration. |
-
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Network/README.md b/src/SquidStd.Network/README.md
index 6a2cb3c6..122c178d 100644
--- a/src/SquidStd.Network/README.md
+++ b/src/SquidStd.Network/README.md
@@ -21,14 +21,6 @@ designed for low-allocation, high-throughput byte processing.
dotnet add package SquidStd.Network
```
-## Features
-
-- TCP server/client (`SquidTcpServer`, `SquidStdTcpClient`) with optional TLS.
-- UDP server/client (`SquidStdUdpServer`, `SquidStdUdpClient`).
-- Session management (`ISessionManager`) with typed per-connection state.
-- Composable framing (`INetFramer`) and middleware pipeline (`INetMiddleware`).
-- Zero-copy binary I/O via `SpanReader` / `SpanWriter` and a reusable `CircularBuffer`.
-
## Usage
```csharp
@@ -52,6 +44,10 @@ await server.StopAsync(CancellationToken.None);
| `INetMiddleware` | Pipeline stage over inbound/outbound data. |
| `SpanReader` / `SpanWriter` | Allocation-free binary read/write. |
+## Related
+
+- Tutorial: [Networking](https://tgiachi.github.io/squid-std/tutorials/networking.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
From ea2e5ab15b6df2254a4d0426700566256d7eb460 Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:26:30 +0200
Subject: [PATCH 38/77] docs: normalise messaging package READMEs
---
src/SquidStd.Messaging.Abstractions/README.md | 13 +++------
src/SquidStd.Messaging.RabbitMq/README.md | 11 +++----
src/SquidStd.Messaging.Sqs/README.md | 29 +++++++++++++++++++
src/SquidStd.Messaging/README.md | 12 +++-----
4 files changed, 41 insertions(+), 24 deletions(-)
diff --git a/src/SquidStd.Messaging.Abstractions/README.md b/src/SquidStd.Messaging.Abstractions/README.md
index 6995e8d6..98adf634 100644
--- a/src/SquidStd.Messaging.Abstractions/README.md
+++ b/src/SquidStd.Messaging.Abstractions/README.md
@@ -22,15 +22,6 @@ implementation (in-memory or RabbitMQ) from a companion package.
dotnet add package SquidStd.Messaging.Abstractions
```
-## Features
-
-- `IMessageQueue` — typed `PublishAsync` / `Subscribe` facade over named queues.
-- `IQueueProvider` — the raw transport contract implemented per backend.
-- `IMessageSerializer` (+ default `JsonMessageSerializer`) — payload (de)serialization.
-- `IQueueMessageListener` / `IQueueMessageListenerAsync` — sync/async subscribers.
-- `IMessagingMetrics` (+ `MessagingMetricsProvider`, `NoOpMessagingMetrics`) — delivery metrics.
-- `MessagingOptions` and `MessagingConnectionString` — configuration and connection parsing.
-
## Usage
```csharp
@@ -59,6 +50,10 @@ public async Task PublishAsync(IMessageQueue queue)
| `IMessagingMetrics` | Delivery metrics sink. |
| `MessagingOptions` | Delivery attempts, retry delay, and dead-letter-queue suffix. |
+## Related
+
+- Tutorial: [Messaging](https://tgiachi.github.io/squid-std/tutorials/messaging.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Messaging.RabbitMq/README.md b/src/SquidStd.Messaging.RabbitMq/README.md
index 8c2fe3f3..19feb08f 100644
--- a/src/SquidStd.Messaging.RabbitMq/README.md
+++ b/src/SquidStd.Messaging.RabbitMq/README.md
@@ -21,13 +21,6 @@ so the same `IMessageQueue` API publishes to and consumes from a real broker. Re
dotnet add package SquidStd.Messaging.RabbitMq
```
-## Features
-
-- One-line registration: `container.AddRabbitMqMessaging(connectionString)` or with `RabbitMqOptions`.
-- Broker-backed `IQueueProvider` reusing the shared `IMessageQueue` facade and serializer.
-- Connection via a `rabbitmq://` connection string or explicit `RabbitMqOptions` (host/port/vhost/credentials).
-- Configurable consumer prefetch count (`?prefetch=` on the connection string, or `RabbitMqOptions.PrefetchCount`).
-
## Usage
```csharp
@@ -50,6 +43,10 @@ await queue.PublishAsync("orders", new { Id = 1 });
| `RabbitMqQueueProvider` | RabbitMQ-backed `IQueueProvider`. |
| `RabbitMqOptions` | Connection + prefetch configuration. |
+## Related
+
+- Tutorial: [Messaging](https://tgiachi.github.io/squid-std/tutorials/messaging.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Messaging.Sqs/README.md b/src/SquidStd.Messaging.Sqs/README.md
index 1cc0ae7e..49be76c5 100644
--- a/src/SquidStd.Messaging.Sqs/README.md
+++ b/src/SquidStd.Messaging.Sqs/README.md
@@ -1,5 +1,16 @@
+
+
+
+
SquidStd.Messaging.Sqs
+
+
+
+
+
+
+
AWS SQS/SNS transport for SquidStd.Messaging. Implements `IQueueProvider` over SQS (with a redrive
policy to a dead-letter queue) and `ITopicProvider` via SNS+SQS fan-out, behind the same
`IMessageQueue` / `IMessageTopic` API as the other providers. Registered with a single
@@ -29,3 +40,21 @@ container.AddSqsMessaging(new SqsOptions { Aws = new AwsConfigEntry { Region = "
- Topics map to SNS; each subscriber gets a dedicated SQS queue subscribed with raw message delivery.
- Names are sanitized to the SQS/SNS alphabet (the default `.dlq` suffix becomes `-dlq`).
- Payloads travel base64-encoded in the message body.
+
+## Key types
+
+| Type | Purpose |
+|-------------------------------------|------------------------------------------|
+| `SqsMessagingRegistrationExtensions` | `AddSqsMessaging(...)` registration. |
+| `SqsQueueProvider` | SQS-backed `IQueueProvider`. |
+| `SqsTopicProvider` | SNS+SQS-backed `ITopicProvider`. |
+| `SqsOptions` | AWS connection + queue/topic configuration. |
+
+## Related
+
+- Tutorial: [Messaging](https://tgiachi.github.io/squid-std/tutorials/messaging.html)
+
+## License
+
+MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
+
diff --git a/src/SquidStd.Messaging/README.md b/src/SquidStd.Messaging/README.md
index dec66618..8f1841a2 100644
--- a/src/SquidStd.Messaging/README.md
+++ b/src/SquidStd.Messaging/README.md
@@ -21,14 +21,6 @@ with a single `AddInMemoryMessaging()` call. Ideal for single-process apps, test
dotnet add package SquidStd.Messaging
```
-## Features
-
-- One-line registration: `container.AddInMemoryMessaging()` (facade, provider, serializer, metrics).
-- Channel-based per-queue buffering with a dedicated consumer loop.
-- Round-robin delivery across multiple subscribers of the same queue.
-- Retry with configurable max attempts and dead-letter queues (`MessagingOptions`).
-- Built-in delivery metrics via `MessagingMetricsProvider`.
-
## Usage
```csharp
@@ -50,6 +42,10 @@ await queue.PublishAsync("orders", new { Id = 1 });
| `MessagingRegistrationExtensions` | `AddInMemoryMessaging(...)` registration. |
| `InMemoryQueueProvider` | Channel-based in-memory `IQueueProvider`. |
+## Related
+
+- Tutorial: [Messaging](https://tgiachi.github.io/squid-std/tutorials/messaging.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
From 39bce517950198cc4fd484eb898c53082eac298d Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:28:39 +0200
Subject: [PATCH 39/77] docs: normalise persistence & database package READMEs
---
src/SquidStd.Database.Abstractions/README.md | 13 ++++---------
src/SquidStd.Database/README.md | 13 ++++---------
src/SquidStd.Persistence/README.md | 16 ----------------
3 files changed, 8 insertions(+), 34 deletions(-)
diff --git a/src/SquidStd.Database.Abstractions/README.md b/src/SquidStd.Database.Abstractions/README.md
index 0526ab16..abd63625 100644
--- a/src/SquidStd.Database.Abstractions/README.md
+++ b/src/SquidStd.Database.Abstractions/README.md
@@ -21,15 +21,6 @@ operations, paging, and composable queries — without binding to any specific O
dotnet add package SquidStd.Database.Abstractions
```
-## Features
-
-- `IDataAccess` — `InsertAsync`, `GetByIdAsync`, `UpdateAsync`, `DeleteAsync`, `CountAsync`,
- `ExistsAsync`, bulk insert/update/delete, `QueryAsync`, and `GetPagedAsync`.
-- `BaseEntity` — `Guid Id` plus `DateTimeOffset Created` / `Updated` (UTC), set by the data layer.
-- `PagedResultData` — items + `Page`, `PageSize`, `TotalCount`, `TotalPages`, `HasNext`, `HasPrevious`.
-- `DatabaseConfig` — URI connection string + `AutoMigrate` flag.
-- `DatabaseProviderType` — `Sqlite`, `Postgres`, `SqlServer`, `MySql`.
-
## Usage
```csharp
@@ -59,6 +50,10 @@ public async Task ExampleAsync(IDataAccess users)
| `DatabaseConfig` | Connection string + auto-migrate config section. |
| `DatabaseProviderType` | Supported provider enum. |
+## Related
+
+- Tutorial: [Database](https://tgiachi.github.io/squid-std/tutorials/database.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Database/README.md b/src/SquidStd.Database/README.md
index 87db6627..f9664144 100644
--- a/src/SquidStd.Database/README.md
+++ b/src/SquidStd.Database/README.md
@@ -22,15 +22,6 @@ on startup.
dotnet add package SquidStd.Database
```
-## Features
-
-- One-line registration: `container.RegisterDatabase()` (config section + service + open-generic `IDataAccess<>`).
-- Providers via URI scheme: `sqlite://`, `postgres://`, `sqlserver://`, `mysql://`.
-- `FreeSqlDataAccess` — CRUD, bulk insert/update/delete, `QueryAsync`, `GetPagedAsync`; writes
- run inside a unit of work and roll back on error. Sets `Id`/`Created`/`Updated` automatically.
-- Optional `AutoMigrate` (FreeSql `AutoSyncStructure`) to create/update tables on startup.
-- ZLinq in-memory helpers for zero-allocation post-processing of materialized results.
-
## Usage
```csharp
@@ -57,6 +48,10 @@ var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name
| `ConnectionStringParser` | URI → provider + native connection string. |
| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. |
+## Related
+
+- Tutorial: [Database](https://tgiachi.github.io/squid-std/tutorials/database.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Persistence/README.md b/src/SquidStd.Persistence/README.md
index d6afcd5b..9a1ed633 100644
--- a/src/SquidStd.Persistence/README.md
+++ b/src/SquidStd.Persistence/README.md
@@ -23,22 +23,6 @@ dotnet add package SquidStd.Persistence
dotnet add package SquidStd.Persistence.MessagePack # recommended binary serializer
```
-## Features
-
-- **Snapshot + journal**: in-memory state, WAL journal of every upsert/remove, periodic full snapshot + trim.
-- **Crash-safe**: journal records are length+FNV-1a-checksum framed — a torn/corrupt trailing record is
- detected on read and the tail is discarded. Snapshots are written atomically (temp + rename).
-- **Serializer-agnostic**: per-entity payloads go through `IDataSerializer`/`IDataDeserializer`; the journal
- and snapshot envelopes use a fixed binary layout. Pair with `SquidStd.Persistence.MessagePack` for a
- compact binary default, or use the JSON serializer from `SquidStd.Core`.
-- **Detached reads**: `GetByIdAsync`/`GetAllAsync`/`Query()` return deep clones, so callers never mutate
- stored instances.
-- **Write-ordered journaling**: writes serialize end-to-end (apply + append) so journal order always
- matches sequence order — replay is deterministic.
-- **Lifecycle service**: `PersistenceService` is an `ISquidStdService` that loads + replays at start,
- autosaves on a timer, and snapshots on stop. Optional `IEventBus` integration raises
- `SnapshotSaveStartedEvent`/`SnapshotSaveCompletedEvent`.
-
## Usage
```csharp
From e3c4bb560c1449d17e093aa5a49360d19dba3c0f Mon Sep 17 00:00:00 2001
From: Tom
Date: Sun, 28 Jun 2026 21:30:59 +0200
Subject: [PATCH 40/77] docs: normalise caching package READMEs
---
src/SquidStd.Caching.Abstractions/README.md | 12 ++++--------
src/SquidStd.Caching.Redis/README.md | 12 ++++--------
src/SquidStd.Caching/README.md | 12 ++++--------
3 files changed, 12 insertions(+), 24 deletions(-)
diff --git a/src/SquidStd.Caching.Abstractions/README.md b/src/SquidStd.Caching.Abstractions/README.md
index 93d452fe..c9780811 100644
--- a/src/SquidStd.Caching.Abstractions/README.md
+++ b/src/SquidStd.Caching.Abstractions/README.md
@@ -22,14 +22,6 @@ backend implementation (in-memory or Redis) from a companion package.
dotnet add package SquidStd.Caching.Abstractions
```
-## Features
-
-- `ICacheService` — typed `GetAsync` / `SetAsync` / `RemoveAsync` / `ExistsAsync` / `GetOrSetAsync` facade.
-- `ICacheProvider` — the raw byte-level backend contract implemented per provider.
-- `CacheService` — shared facade that serializes values, applies the key prefix and default TTL, and implements cache-aside.
-- `ICacheMetrics` (+ `CacheMetricsProvider`, `NoOpCacheMetrics`) — hit/miss/set/remove metrics.
-- `CacheOptions` and `CacheConnectionString` — configuration and connection parsing.
-
## Usage
```csharp
@@ -51,6 +43,10 @@ public Task GetOrComputeAsync(ICacheService cache)
| `CacheOptions` | Default TTL and key prefix. |
| `CacheConnectionString` | `scheme://host[?params]` parsing into `CacheOptions`. |
+## Related
+
+- Tutorial: [Caching](https://tgiachi.github.io/squid-std/tutorials/caching.html)
+
## License
MIT — part of [SquidStd](https://github.com/tgiachi/squid-std).
diff --git a/src/SquidStd.Caching.Redis/README.md b/src/SquidStd.Caching.Redis/README.md
index ebaa1742..a84ef265 100644
--- a/src/SquidStd.Caching.Redis/README.md
+++ b/src/SquidStd.Caching.Redis/README.md
@@ -21,14 +21,6 @@ Registered with a single `AddRedisCache(...)` call.
dotnet add package SquidStd.Caching.Redis
```
-## Features
-
-- One-line registration: `container.AddRedisCache(connectionString)` or with `RedisCacheOptions`.
-- Redis-backed `ICacheProvider` reusing the shared `ICacheService` facade and serializer.
-- Native TTL via Redis key expiry (`SET ... EX`).
-- Connection via a `redis://` connection string or an explicit StackExchange.Redis configuration string.
-- Built-in hit/miss metrics via `CacheMetricsProvider`.
-
## Usage
```csharp
@@ -52,6 +44,10 @@ var user = await cache.GetAsync