Implemented with Antigravity.

This commit is contained in:
Creeper Lv
2026-06-01 05:09:20 +10:00
parent aaad155a30
commit e8ab8e0684
38 changed files with 3908 additions and 47 deletions
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using SNote.Server.Security;
namespace SNote.Server.Security;
public static class AuthHelper
{
public static string? GetAuthenticatedUser(HttpContext context, CertificateManager certificateManager)
{
var token = GetBearerToken(context);
if (string.IsNullOrEmpty(token)) return null;
try
{
var principal = ValidateToken(token, certificateManager);
// Verify it has a Name claim and is not just a server token
var isServer = principal.HasClaim(c => c.Type == "IsServer" && c.Value == "true");
if (isServer) return null;
return principal.Identity?.Name;
}
catch
{
return null;
}
}
public static bool IsServerAuthenticated(HttpContext context, CertificateManager certificateManager)
{
var token = GetBearerToken(context);
if (string.IsNullOrEmpty(token)) return false;
try
{
var principal = ValidateToken(token, certificateManager);
return principal.HasClaim(c => c.Type == "IsServer" && c.Value == "true");
}
catch
{
return false;
}
}
// Helper to generate a server token for outgoing sync requests
public static string GenerateServerToken(CertificateManager certificateManager)
{
var cert = certificateManager.GetCertificate();
var privateKey = new X509SecurityKey(cert);
var claims = new[]
{
new Claim(ClaimTypes.Name, "SNoteServerNetwork"),
new Claim("IsServer", "true")
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(15), // Short-lived for security
SigningCredentials = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256)
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private static string? GetBearerToken(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return authHeader.Substring("Bearer ".Length).Trim();
}
private static ClaimsPrincipal ValidateToken(string token, CertificateManager certificateManager)
{
var cert = certificateManager.GetCertificate();
var publicKey = new X509SecurityKey(cert);
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = publicKey,
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.FromMinutes(5)
};
var handler = new JwtSecurityTokenHandler();
return handler.ValidateToken(token, validationParameters, out _);
}
}
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
namespace SNote.Server.Security;
public class CertificateManager
{
private readonly string _certPath;
private readonly string _certPassword = "snote-password";
private X509Certificate2? _certificate;
public CertificateManager(IConfiguration configuration)
{
var path = configuration["SharedCertificatePath"];
if (string.IsNullOrEmpty(path))
{
path = Path.Combine(AppContext.BaseDirectory, "snote-shared.pfx");
}
_certPath = path;
}
public X509Certificate2 GetCertificate()
{
if (_certificate != null) return _certificate;
if (File.Exists(_certPath))
{
try
{
var bytes = File.ReadAllBytes(_certPath);
// EphemeralKeySet is standard and cross-platform for loading certificates from memory
_certificate = new X509Certificate2(bytes, _certPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
return _certificate;
}
catch (Exception ex)
{
Console.WriteLine($"Error loading certificate at {_certPath}: {ex.Message}. Re-generating.");
}
}
// Generate new self-signed certificate
_certificate = GenerateCertificate();
return _certificate;
}
private X509Certificate2 GenerateCertificate()
{
Console.WriteLine($"Generating a new shared network certificate at {_certPath}...");
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("cn=SNoteSharedNetwork", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
var selfSigned = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(20));
var pfxBytes = selfSigned.Export(X509ContentType.Pfx, _certPassword);
var dir = Path.GetDirectoryName(_certPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(_certPath, pfxBytes);
// Reload it to ensure it contains exportable keys and correct storage flags
return new X509Certificate2(pfxBytes, _certPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Security.Cryptography;
namespace SNote.Server.Security;
public static class PasswordHasher
{
private const int SaltSize = 16; // 128 bit
private const int KeySize = 32; // 256 bit
private const int Iterations = 10000;
public static string HashPassword(string password)
{
using var algorithm = new Rfc2898DeriveBytes(password, SaltSize, Iterations, HashAlgorithmName.SHA256);
var key = Convert.ToBase64String(algorithm.GetBytes(KeySize));
var salt = Convert.ToBase64String(algorithm.Salt);
return $"{Iterations}.{salt}.{key}";
}
public static bool VerifyPassword(string hash, string password)
{
var parts = hash.Split('.', 3);
if (parts.Length != 3) return false;
var iterations = int.Parse(parts[0]);
var salt = Convert.FromBase64String(parts[1]);
var key = Convert.FromBase64String(parts[2]);
using var algorithm = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
var keyToCheck = algorithm.GetBytes(KeySize);
return CryptographicOperations.FixedTimeEquals(key, keyToCheck);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace SNote.Server.Security;
public class PeerCache
{
private readonly ConcurrentDictionary<string, byte> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DateTime> _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase);
public void RegisterPeer(string peerUrl)
{
if (string.IsNullOrWhiteSpace(peerUrl)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/');
_downstreamPeers.TryAdd(cleanUrl, 0);
Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl}");
}
public void RemovePeer(string peerUrl)
{
if (string.IsNullOrWhiteSpace(peerUrl)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/');
if (_downstreamPeers.TryRemove(cleanUrl, out _))
{
Console.WriteLine($"[PeerCache] Evicted offline peer node: {cleanUrl}");
}
}
public List<string> GetPeers()
{
return new List<string>(_downstreamPeers.Keys);
}
public bool TryProcessBroadcast(string broadcastId)
{
if (string.IsNullOrWhiteSpace(broadcastId)) return false;
// Clean up old entries to prevent infinite memory growth (older than 10 minutes)
var cutoff = DateTime.UtcNow.AddMinutes(-10);
foreach (var kvp in _recentBroadcastIds)
{
if (kvp.Value < cutoff)
{
_recentBroadcastIds.TryRemove(kvp.Key, out _);
}
}
return _recentBroadcastIds.TryAdd(broadcastId, DateTime.UtcNow);
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SNote.Server.Security;
public static class SecurityHelper
{
// Encrypts plaintext using AES-256 with a randomized IV, prepending IV to the ciphertext.
public static string Encrypt(string plaintext, string base64Key)
{
if (string.IsNullOrEmpty(plaintext)) return plaintext;
try
{
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.GenerateIV();
var iv = aes.IV;
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
using var ms = new MemoryStream();
// Prepend IV
ms.Write(iv, 0, iv.Length);
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
using (var sw = new StreamWriter(cs, Encoding.UTF8))
{
sw.Write(plaintext);
}
return Convert.ToBase64String(ms.ToArray());
}
catch (Exception ex)
{
Console.WriteLine($"Encryption error: {ex.Message}");
return plaintext;
}
}
// Decrypts ciphertext (which has prepended IV) using AES-256.
public static string Decrypt(string ciphertext, string base64Key)
{
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
try
{
var fullCipher = Convert.FromBase64String(ciphertext);
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
var iv = new byte[16];
var cipherTextBytes = new byte[fullCipher.Length - 16];
Buffer.BlockCopy(fullCipher, 0, iv, 0, 16);
Buffer.BlockCopy(fullCipher, 16, cipherTextBytes, 0, cipherTextBytes.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
using var ms = new MemoryStream(cipherTextBytes);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var sr = new StreamReader(cs, Encoding.UTF8);
return sr.ReadToEnd();
}
catch (Exception ex)
{
Console.WriteLine($"Decryption error: {ex.Message}. Returning ciphertext as-is.");
return ciphertext;
}
}
// Server-to-server signing token or helper (optional, or we do JWTs)
}