From 2c6d11577c89e5bfd6839e22bffc55c4bc243b81 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 00:22:02 +0100 Subject: [PATCH 1/4] Yavsc.Org: set KeyId on signing credentials IdentityServer8 was emitting JWTs without a 'kid' header and serving the JWKS without per-key identifiers, because LoadSigningCredentialsInner constructed RsaSecurityKey / ECDsaSecurityKey objects without an explicit KeyId. Resource servers (Yavsc.Blogs, Yavsc.Api) cannot match a token to a key in the JWKS without one, so every signature validation failed with 'The signature key was not found' (Microsoft.IdentityModel IDX10500). Root cause: SigningCredentials were built directly from the BC-parsed key parameters, bypassing the X509Certificate2 path IdentityServer normally derives the kid from. The fix derives a stable KeyId from the certificate's SHA-256 thumbprint (truncated to 16 hex chars) and sets it on both SecurityKey variants before constructing SigningCredentials. The thumbprint-based kid is stable across process restarts as long as the cert doesn't change, and changes naturally on LetsEncrypt renewal (~90 days), which is the right behaviour: old tokens age out, resource servers refresh their JWKS cache to discover the new kid. Production rollout: redeploy Yavsc.Org and re-login (or let the refresh-token path rotate) so newly issued tokens carry the kid. Pre-restart tokens will continue to be rejected with IDX10500 until they expire or are refreshed. --- contrib/bruno/opencollection.yml | 1 + src/Yavsc.Org/Extensions/HostingExtensions.cs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/contrib/bruno/opencollection.yml b/contrib/bruno/opencollection.yml index 1d584584..f072bf40 100644 --- a/contrib/bruno/opencollection.yml +++ b/contrib/bruno/opencollection.yml @@ -20,6 +20,7 @@ request: flow: authorization_code authorizationUrl: "{{Authority}}/connect/authorize" accessTokenUrl: "{{Authority}}/connect/token" + refreshTokenUrl: https://yavsc.pschneider.fr/connect/token callbackUrl: "{{Authority}}" credentials: clientId: postit diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index b29f37ce..b813f681 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -478,6 +478,21 @@ public static class HostingExtensions // Validate the cert is readable (used downstream for token // audience/subject validation; signing itself uses the key). + // Derive a stable KeyId from the certificate's SHA-256 + // thumbprint. Without an explicit KeyId, IdentityServer emits + // JWTs without a 'kid' header and the JWKS without per-key + // identifiers, which breaks signature validation on resource + // servers (they cannot match a token to a key in the JWKS, + // they fail with IDX10500 "The signature key was not found"). + // Truncating to 16 hex chars is enough to be globally unique + // within a deployment and keeps the JWT header compact. The + // thumbprint changes on cert renewal, which is the desired + // behaviour: old tokens age out, resource servers refresh + // their JWKS cache for the new kid. + var certForKid = new X509Certificate2(certPath); + var certHash = certForKid.GetCertHash(); + var kid = Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)]; + string keyPem = File.ReadAllText(keyPath); // BouncyCastle's PemReader accepts every flavour of unencrypted @@ -513,7 +528,7 @@ public static class HostingExtensions #pragma warning disable CA1416 // Valider la compatibilité de la plateforme var rsaDotNet = DotNetUtilities.ToRSA(rsa); #pragma warning restore CA1416 // Valider la compatibilité de la plateforme - var key = new RsaSecurityKey(rsaDotNet); + var key = new RsaSecurityKey(rsaDotNet) { KeyId = kid }; return new SigningCredentials(key, SecurityAlgorithms.RsaSha256); } case ECPrivateKeyParameters ec: @@ -525,7 +540,7 @@ public static class HostingExtensions }; var ecdsa = ECDsa.Create(); ecdsa.ImportParameters(ecParams); - var key = new ECDsaSecurityKey(ecdsa); + var key = new ECDsaSecurityKey(ecdsa) { KeyId = kid }; return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); } default: From 375e6482a653baa689192cc71dce95421345d109 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 20:28:58 +0100 Subject: [PATCH 2/4] test(yavsc.org): cover the kid derivation in ComputeKid Extract the kid calculation out of LoadSigningCredentialsInner into a new internal static HostingExtensions.ComputeKid(string), and cover it with five focused unit tests in Yavsc.Org.Tests.ComputeKidTests. The kid is the bit of signing-credential metadata that ties a JWT to the right key in the JWKS. Without it, resource servers (Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500 'The signature key was not found', as fixed in 2c6d1157. That fix inlined three lines of thumbprint-truncation logic at the top of LoadSigningCredentialsInner, but left the calculation untested. The tests in this commit pin its shape, value, stability, and uniqueness, so a future refactor (e.g. switching from SHA-1 to SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has to update them deliberately instead of silently changing the JWKS key id. Concretely: - InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs gives the test project access to the new internal method without forcing LoadSigningCredentialsInner to leak further. - ComputeKid(string) is the single source of truth for the 16-hex truncation; the production call site in LoadSigningCredentialsInner now reads 'var kid = ComputeKid(certPath);'. - The inline comment block is updated to say SHA-1 (which is what X509Certificate2.GetCertHash() actually returns) instead of the previous SHA-256 claim. The behaviour is unchanged. - ComputeKid uses X509CertificateLoader.LoadCertificateFromFile rather than the obsolete 'new X509Certificate2(string)' ctor (SYSLIB0057); same on-disk behaviour, no obsolete warning. Tests cover: - 16-char upper-case hex output matching the first 16 hex chars of the cert's GetCertHash(); - stability across repeated reads of the same cert; - distinctness between two independently generated certs; - the SHA-1 size of the underlying thumbprint (20 bytes), so a future switch to SHA-256 forces a test update; - CryptographicException propagation for a missing cert file (Assert.ThrowsAny to stay portable across the Linux OpenSSL and Windows leaf exception types). --- src/Yavsc.Org.Tests/ComputeKidTests.cs | 165 ++++++++++++++++++ src/Yavsc.Org/AssemblyInfo.cs | 6 + src/Yavsc.Org/Extensions/HostingExtensions.cs | 51 ++++-- 3 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 src/Yavsc.Org.Tests/ComputeKidTests.cs diff --git a/src/Yavsc.Org.Tests/ComputeKidTests.cs b/src/Yavsc.Org.Tests/ComputeKidTests.cs new file mode 100644 index 00000000..af9d94a9 --- /dev/null +++ b/src/Yavsc.Org.Tests/ComputeKidTests.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; +using Yavsc.Extensions; + +namespace Yavsc.Org.Tests; + +/// +/// Tests for , the +/// helper that derives the JWT kid header / JWKS key id +/// from the signing certificate. The kid is consumed by every +/// resource server (Yavsc.Blogs, Yavsc.Api) to match a token to +/// the right key in the JWKS, so getting its shape and stability +/// right is the whole point of the fix in commit 2c6d1157 +/// (IDX10500 regression). +/// +/// +/// We don't load the production cert (Let's Encrypt PEM + RSA +/// private key) — we generate throwaway self-signed certs in a +/// temp dir. The contract under test is the truncation / +/// encoding of the thumbprint, which is independent of the key +/// type and the cert issuer. +/// +public class ComputeKidTests : IDisposable +{ + private readonly string _tempDir; + + public ComputeKidTests() + { + _tempDir = Path.Combine( + Path.GetTempPath(), + "yavsc-compute-kid-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best effort — Temp gets cleaned eventually */ } + } + + [Fact] + public void ComputeKid_returns_first_16_hex_chars_of_cert_thumbprint() + { + var certPath = WriteSelfSignedCertRsa(out var expectedThumbHex); + + var kid = HostingExtensions.ComputeKid(certPath); + + // 16 hex chars = 64 bits, enough to be globally unique + // within a deployment and compact enough for a JWT header. + Assert.Equal(16, kid.Length); + Assert.True( + kid.All(c => "0123456789ABCDEF".Contains(c)), + $"kid '{kid}' contains non-uppercase-hex characters"); + + // Match the first 16 chars of the thumbprint exactly. We + // compute the expected value from the same cert the helper + // was given — no magic constants, no copy-paste of the + // truncation logic under test. + Assert.Equal(expectedThumbHex[..16], kid); + } + + [Fact] + public void ComputeKid_is_stable_across_repeated_reads() + { + var certPath = WriteSelfSignedCertRsa(out _); + + var first = HostingExtensions.ComputeKid(certPath); + var second = HostingExtensions.ComputeKid(certPath); + var third = HostingExtensions.ComputeKid(certPath); + + // Stability matters: a non-deterministic kid would + // invalidate tokens on every IdentityServer restart. + Assert.Equal(first, second); + Assert.Equal(second, third); + } + + [Fact] + public void ComputeKid_differs_between_distinct_certificates() + { + var certPathA = WriteSelfSignedCertRsa(out _); + var certPathB = WriteSelfSignedCertRsa(out _); + + var kidA = HostingExtensions.ComputeKid(certPathA); + var kidB = HostingExtensions.ComputeKid(certPathB); + + // Two independent RNG-drawn RSA keys will (in practice + // always) yield different thumbprints. A 64-bit truncated + // space has collisions at ~2^32 certs; we won't get there. + Assert.NotEqual(kidA, kidB); + } + + [Fact] + public void ComputeKid_uses_thumbprint_not_subject_or_serial() + { + // The previous fix-message claimed SHA-256; the helper + // actually reads X509Certificate2.GetCertHash() which is + // SHA-1. Pin that behaviour so a future refactor that + // switches to SHA-256 (or any other digest) is forced to + // update the test deliberately. + var certPath = WriteSelfSignedCertRsa(out var thumbHex); + + var kid = HostingExtensions.ComputeKid(certPath); + + // 16 hex chars is half of a 20-byte SHA-1 thumbprint. + // SHA-256 would be 32 bytes (64 hex chars) before + // truncation; SHA-1 is the only common digest whose + // hex encoding fits the 16-char prefix we observe. + Assert.Equal(20, thumbHex.Length / 2); + Assert.Equal(thumbHex[..16], kid); + } + + [Fact] + public void ComputeKid_propagates_cryptographic_exception_for_missing_file() + { + // The wrapper LoadSigningCredentials wraps this in an + // InvalidOperationException, but ComputeKid itself is a + // plain helper — it must surface the parser error so the + // wrapper can attach the cert path to the message. We + // assert against the base CryptographicException rather + // than the concrete subtype because the runtime picks + // different leaf types per platform (on Linux/OpenSSL we + // get Interop+Crypto+OpenSslCryptographicException, on + // Windows we'd get the older CryptographicException + // directly); the contract is the same either way. + var missing = Path.Combine(_tempDir, "does-not-exist.pem"); + + Assert.ThrowsAny( + () => HostingExtensions.ComputeKid(missing)); + } + + // --- helpers ---------------------------------------------------- + + /// + /// Generate a throwaway self-signed RSA-2048 cert, export it + /// as PEM to a file inside the test temp dir, and return the + /// path. The out parameter receives the upper-case hex form + /// of the cert's SHA-1 thumbprint so tests can pin the + /// expected kid without re-implementing the helper. + /// + private string WriteSelfSignedCertRsa(out string thumbHex) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=yavsc-test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + + // Capture the thumbprint before exporting — the cert is + // disposed by `using` and the exported PEM is what the + // helper will read. + thumbHex = Convert.ToHexString(cert.GetCertHash()); + + var path = Path.Combine(_tempDir, "cert-" + Guid.NewGuid().ToString("N") + ".pem"); + File.WriteAllText(path, cert.ExportCertificatePem()); + return path; + } +} diff --git a/src/Yavsc.Org/AssemblyInfo.cs b/src/Yavsc.Org/AssemblyInfo.cs index 55821c70..c29735e1 100644 --- a/src/Yavsc.Org/AssemblyInfo.cs +++ b/src/Yavsc.Org/AssemblyInfo.cs @@ -1,3 +1,9 @@ using Microsoft.Extensions.Localization; +using System.Runtime.CompilerServices; [assembly: RootNamespace("Yavsc")] + +// Expose internals to the Yavsc.Org.Tests project so unit tests can +// reach the signing-credential loader (LoadSigningCredentials / kid +// derivation) without going through the full IdentityServer boot. +[assembly: InternalsVisibleTo("Yavsc.Org.Tests")] diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index b813f681..6fc5b9c4 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -478,20 +478,19 @@ public static class HostingExtensions // Validate the cert is readable (used downstream for token // audience/subject validation; signing itself uses the key). - // Derive a stable KeyId from the certificate's SHA-256 - // thumbprint. Without an explicit KeyId, IdentityServer emits - // JWTs without a 'kid' header and the JWKS without per-key - // identifiers, which breaks signature validation on resource - // servers (they cannot match a token to a key in the JWKS, - // they fail with IDX10500 "The signature key was not found"). - // Truncating to 16 hex chars is enough to be globally unique - // within a deployment and keeps the JWT header compact. The - // thumbprint changes on cert renewal, which is the desired + // Derive a stable KeyId from the certificate's SHA-1 + // thumbprint (the default for X509Certificate2.GetCertHash()). + // Without an explicit KeyId, IdentityServer emits JWTs without + // a 'kid' header and the JWKS without per-key identifiers, + // which breaks signature validation on resource servers (they + // cannot match a token to a key in the JWKS, they fail with + // IDX10500 "The signature key was not found"). Truncating the + // 40-hex-char SHA-1 to 16 hex chars is enough to be globally + // unique within a deployment and keeps the JWT header compact. + // The thumbprint changes on cert renewal, which is the desired // behaviour: old tokens age out, resource servers refresh // their JWKS cache for the new kid. - var certForKid = new X509Certificate2(certPath); - var certHash = certForKid.GetCertHash(); - var kid = Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)]; + var kid = ComputeKid(certPath); string keyPem = File.ReadAllText(keyPath); @@ -550,6 +549,34 @@ public static class HostingExtensions } } + /// + /// Derive the kid used to identify the signing key in the + /// JWT header and the JWKS. Takes the first 16 hex characters of + /// the certificate's SHA-1 thumbprint. See the inline rationale in + /// for why this is + /// needed (IdentityServer8 + IDX10500). + /// + /// + /// Internal so unit tests in Yavsc.Org.Tests can exercise + /// the truncation/encoding without going through the full PEM / + /// BouncyCastle pipeline. The input is a path rather than a + /// pre-loaded to match the + /// production call site. + /// + internal static string ComputeKid(string certPath) + { + // X509CertificateLoader is the .NET 9+ replacement for the + // obsolete `new X509Certificate2(string)` ctor (SYSLIB0057). + // Same on-disk format (PEM or DER), same thumbprint, just + // doesn't trip the obsolete-API warning at build time. + var certForKid = X509CertificateLoader.LoadCertificateFromFile(certPath); + var certHash = certForKid.GetCertHash(); + // GetCertHash() returns a SHA-1 thumbprint (20 bytes, 40 hex + // chars). Truncating to 16 hex chars keeps the JWT header + // compact; Math.Min guards against an unexpected short hash. + return Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)]; + } + /// /// Map a BouncyCastle to a /// that From d3664c5cdcaa9c0935d626333dc250f4c90b771b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 9 Jul 2026 21:24:48 +0100 Subject: [PATCH 3/4] postIt: scope list in SettingsPage, fix Settings DI re-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the PostIt settings surface, both in service of the same observation: opening the Settings page did not reflect the loaded state, and edits to Authority / ClientId did not persist. 1. Settings was registered twice in the DI container: once as a singleton (the already-Load()'d instance) and again as a transient, with the transient registration winning. The Settings page's DataContext was therefore a brand-new, empty Settings instance on every push — Authority and ClientId bound to null, and even if the user typed into the fields, the edits landed on the throwaway instance and were silently lost. The fix is the obvious one: keep Settings as a singleton and drop the transient override. 2. The Scopes field of AuthenticationSettings is a string[], which doesn't bind to a TextBox without a converter. The Settings page already shows the other auth fields as plain TextBoxes, so the same treatment is given to scopes via a new space-separated view property: - AuthenticationSettings.ScopeListText (string, [ObservableProperty], [JsonIgnore]) is the view. - OnScopeListTextChanged splits on any whitespace and re-assigns Scopes, skipping the write when the parsed array is element-wise equal to the current one to avoid a PropertyChanged loop with OnScopesChanged. - OnScopesChanged keeps ScopeListText in sync when Scopes is reassigned from outside (JSON hydration, MergeScopes, programmatic updates), again short- circuiting when the textual representation hasn't changed so the TextBox caret doesn't flicker on load. - RefreshScopeListText is the explicit re-sync entry point; Settings.ApplyJson calls it after a successful hydration to normalise any whitespace the JSON might have introduced. SettingsPage.axaml gets a new Scopes row between ClientId and the Blogs API URL; the Grid.RowDefinitions are bumped to 13 to match. Scopes remains the on-disk format — only ScopeListText is presentation. The shape of the on-disk postit-settings.json is unchanged: [JsonIgnore] on ScopeListText, and the serialization path in Settings still round-trips Scopes directly. MergeScopes in Settings.GetOidcClientOptions is untouched. Tests: 3/3 SettingsLoadTests passing (PostIt.Tests); PostIt.csproj builds clean (0 errors). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host, so we trust the unit-level coverage and the build. --- src/PostIt/PostIt/App.axaml.cs | 1 - .../PostIt/Settings/AuthenticationSettings.cs | 79 +++++++++++++++++++ src/PostIt/PostIt/ViewModels/Settings.cs | 9 +++ src/PostIt/PostIt/Views/SettingsPage.axaml | 24 +++--- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index b474b37b..84312064 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -69,7 +69,6 @@ public partial class App : Application services.AddSingleton(api); services.AddSingleton(client); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 9ece1e45..ad71063b 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using System; +using System.Text.Json.Serialization; public partial class AuthenticationSettings : ObservableObject { @@ -40,4 +41,82 @@ public partial class AuthenticationSettings : ObservableObject [ObservableProperty] public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; + /// + /// Space-separated view of . Exists for the + /// SettingsPage TextBox binding — a string[] does not + /// round-trip through XAML binding to TextBox.Text, so we + /// expose the array as a string here and re-parse on assignment. + /// + /// [JsonIgnore] on purpose: is the + /// persisted shape (matches the on-disk format in + /// postit-settings.json and the runtime contract in + /// ). + /// Writing this property back to disk would duplicate the + /// information and confuse the deserializer. + /// + /// + [JsonIgnore] + [ObservableProperty] + public partial string ScopeListText { get; set; } = string.Empty; + + /// + /// Refresh from so + /// the TextBox shows the current persisted state after a Load(). + /// Called from Settings.ApplyJson on each disk / embedded + /// hydration; the source generator's OnScopesChanged partial + /// below keeps the two in sync in the other direction (edits made + /// in the TextBox). + /// + public void RefreshScopeListText() + { + ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes); + } + + partial void OnScopeListTextChanged(string value) + { + if (Scopes is null) + { + Scopes = Array.Empty(); + } + // Split on any whitespace, drop empties. Matches what + // string.Join(' ', Scopes) produces when Scopes is null-free, + // so a round-trip (Display → Edit → Display) is lossless + // for sane inputs. + var parts = value?.Split( + new[] { ' ', '\t', '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + + // Skip the write if the parsed array is equal to the current + // one — avoids a PropertyChanged loop between OnScopesChanged + // and OnScopeListTextChanged when RefreshScopeListText runs. + if (Scopes is not null && Scopes.Length == parts.Length) + { + var same = true; + for (var i = 0; i < parts.Length; i++) + { + if (!string.Equals(Scopes[i], parts[i], StringComparison.Ordinal)) + { + same = false; + break; + } + } + if (same) return; + } + Scopes = parts; + } + + partial void OnScopesChanged(string[] value) + { + // Keep ScopeListText in sync when Scopes is reassigned from + // outside (JSON hydration, MergeScopes, programmatic + // updates). Compute the new value and only fire if it + // differs from what's already shown, otherwise the TextBox + // would briefly flicker / re-set the caret on every load. + var newText = value is null ? string.Empty : string.Join(' ', value); + if (!string.Equals(ScopeListText, newText, StringComparison.Ordinal)) + { + ScopeListText = newText; + } + } + } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 98085f0c..5bd3a844 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -361,6 +361,15 @@ public partial class Settings : ViewModelBase // triggered by the assignments above doesn't leave it // stuck at true. IsDirty = false; + // Refresh the space-separated ScopeListText view after + // hydration so the SettingsPage TextBox reflects the + // loaded scopes (and not the default empty string the + // ObservableProperty was constructed with). OnScopesChanged + // already tries to do this, but it skips when the new + // array parses to the same text — calling explicitly + // forces a re-sync and normalises any whitespace the + // JSON might have introduced. + this.Authentication?.RefreshScopeListText(); // Re-notify the command in case the button was bound // before Load finished and the CanExecute cache is // stale. diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml index aeba19f0..5d18f8ad 100644 --- a/src/PostIt/PostIt/Views/SettingsPage.axaml +++ b/src/PostIt/PostIt/Views/SettingsPage.axaml @@ -20,6 +20,8 @@ + + @@ -30,25 +32,29 @@ - - + + + + - - + - - + + -