/*
* dbMango
*
* Copyright 2025 Deutsche Bank AG
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
namespace Rms.Service.Bootstrap.Security;
public static partial class InternalPasswordManager
{
public static ILogger? _log;
///
/// Decrypt password.
/// If encrypted password starts with '*' it should be in base64 format.
/// If encrypted password starts with '@' it should contain a file name. File should be binary.
///
///
/// var pass1 = PasswordManager.DecryptPassword( @"@FileName.pwd", cert);
/// var pass2 = PasswordManager.DecryptPassword( @"*abZf/dh+", cert);
/// var pass3 = PasswordManager.DecryptPassword( @"clear text pass",cert);
///
///
///
///
public static string DecryptPassword( string encryptedPassword, X509Certificate2 cert )
{
var decrypted = DecryptPasswordUnchecked( encryptedPassword, cert );
CheckGoodPassword( decrypted );
return decrypted;
}
private static string DecryptPasswordUnchecked(string encryptedPassword, X509Certificate2 cert)
{
if ( string.IsNullOrWhiteSpace( encryptedPassword ) )
return encryptedPassword;
if (encryptedPassword.StartsWith("*")) // encoded right here
{
return Decrypt(encryptedPassword[1..], cert);
}
if (encryptedPassword.StartsWith("#")) // clear text password located in secrets-mounted file
{
var fileName = Environment.ExpandEnvironmentVariables( encryptedPassword[1..] );
if ( File.Exists(fileName) )
return File.ReadAllText(fileName).TrimEnd('\n').TrimEnd('\r');
return encryptedPassword;
}
if (encryptedPassword.StartsWith("@")) // file name. contents encrypted
{
var fileName = Environment.ExpandEnvironmentVariables( encryptedPassword[1..] );
var password = Encoding.ASCII.GetString(File.ReadAllBytes(fileName));
return Decrypt(password, cert);
}
// it's not actually encrypted
return encryptedPassword;
}
///
/// Encrypt password.
/// Encrypted password starts with '*' and it's in base64 format.
///
///
/// var pass2 = PasswordManager.EncryptPassword( @"myPass", cert);
///
///
///
///
public static string EncryptPassword(string clearTextPassword, X509Certificate2 cert)
{
if ( string.IsNullOrWhiteSpace( clearTextPassword ) )
return clearTextPassword;
return "*"+Encrypt(clearTextPassword, cert);
}
private static T Repeat( Func func, string message )
{
Exception? firstException = null;
var stop = DateTime.Now + TimeSpan.FromSeconds( 5 );
while ( DateTime.Now < stop )
{
try
{
return func();
}
catch ( Exception e )
{
firstException ??= e;
Thread.Sleep( (int)(333 + new Random().NextDouble()*333.0) );
}
}
_log?.LogError( firstException, message );
throw firstException ?? new ApplicationException(message);
}
public static X509Certificate2 LoadPrivateKey( string fileName, string password )
{
var keyColl = LoadCertificateCollection(fileName, password, true);
_log?.LogDebug( $"Certificate collection loaded from File=\"{fileName}\" Count={keyColl.Count}\n\t"
+string.Join("\n\t", keyColl.Select(x => $"Subject=\"{x.Subject}\" Valid=\"{x.NotAfter:s}\" Issuer=\"{x.Issuer}\"")));
var key = SelectTheRightKey(keyColl);
_log?.LogDebug( $"Private key loaded from File=\"{fileName}\" Subject=\"{key.Subject}\" Issuer={key.Issuer} Expiry=\"{key.NotAfter}\"" );
return key;
}
public static X509Certificate2 SelectTheRightKey(X509Certificate2Collection keyColl, string? keyName = null )
{
if ( keyName != null )
return keyColl.First(x => x.Subject == keyName);
var key = keyColl.FirstOrDefault(key => key.Subject.EndsWith(":Forge", StringComparison.OrdinalIgnoreCase));
if ( key == null && keyColl.Count == 1 )
key = keyColl[0];
return key ?? keyColl[^1]; // return the last one as CA keys should be upfront
}
public static X509Certificate2Collection LoadCertificateCollection(string fileName, string password, bool withPrivateKey = false)
{
if ( fileName == null )
throw new ArgumentNullException(nameof(fileName));
fileName = Environment.ExpandEnvironmentVariables(fileName);
if ( !File.Exists(fileName) )
throw new ApplicationException($"File=\"{fileName}\" does not exists");
var flags = withPrivateKey
? X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet
: X509KeyStorageFlags.DefaultKeySet
;
var keyColl = Repeat(() =>
{
X509Certificate2Collection keyCollection;
try
{
keyCollection = X509CertificateLoader.LoadPkcs12Collection(
File.ReadAllBytes(fileName),
string.IsNullOrWhiteSpace(password) ? "" : password,
flags
);
// keyCollection.Import(
// fileName,
// string.IsNullOrWhiteSpace( password ) ? "" : password,
// flags
// );
}
catch (Exception)
{
try
{
keyCollection = X509CertificateLoader.LoadPkcs12Collection(
File.ReadAllBytes(fileName),
password?.ToUpper(),
flags
);
// keyCollection.Import(fileName, password?.ToUpper(), flags);
}
catch (Exception)
{
keyCollection = X509CertificateLoader.LoadPkcs12Collection(
File.ReadAllBytes(fileName),
null,
flags
);
// keyCollection.Import(fileName, null, flags);
_log?.LogWarning($"Keystore=\"{fileName}\" is not password protected");
}
}
return keyCollection;
}, $"Error loading private key from File=\"{fileName}\"");
return keyColl;
}
///
/// Decrypt text. Source is base64 form or encrypted text.
///
/// Text to decrypt in base64
/// decryptionKey
/// Decrypted text
private static string Decrypt(string encryptedText, X509Certificate2 certificate)
{
if ( string.IsNullOrWhiteSpace(encryptedText) )
return encryptedText;
if ( certificate == null )
throw new ArgumentNullException(nameof(certificate));
try
{
if (!certificate.HasPrivateKey)
throw new CryptographicException($"Private key not contained within certificate {certificate.Subject}");
byte[] decryptedBytes;
using ( var rsa = certificate.GetRSAPrivateKey() )
{
decryptedBytes = rsa!.Decrypt( Convert.FromBase64String( encryptedText ), RSAEncryptionPadding.OaepSHA1 );
}
// Check to make sure we decrypted the string
return decryptedBytes.Length == 0 ? string.Empty : Encoding.ASCII.GetString( decryptedBytes );
}
catch ( Exception e )
{
var m = $"Unable to decrypt data by {certificate.Subject}";
_log?.LogError( m, e );
throw new CryptographicException( m, e );
}
}
///
/// Decrypt text. Source is base64 form or encrypted text.
///
/// Text to decrypt in base64
/// decryptionKey
/// Decrypted text
public static string Pkcs7Decrypt(string encryptedText, X509Certificate2 recipientCertificate)
=> Encoding.UTF8.GetString(Pkcs7DecryptBinary(encryptedText, recipientCertificate));
///
/// Decrypt text. Source is base64 form or encrypted text.
///
/// Text to decrypt in base64
/// decryptionKey
/// Decrypted text
public static byte[] Pkcs7DecryptBinary(string encryptedText, X509Certificate2 recipientCertificate)
{
if ( string.IsNullOrWhiteSpace(encryptedText) )
throw new ArgumentNullException(nameof(encryptedText));
if ( recipientCertificate == null )
throw new ArgumentNullException(nameof(recipientCertificate));
try
{
if (!recipientCertificate.HasPrivateKey)
throw new CryptographicException($"Private key not contained within certificate {recipientCertificate.Subject}");
var envelopedCms = new EnvelopedCms();
var bytes = Convert.FromBase64String(encryptedText);
envelopedCms.Decode(bytes);
var recipientInfo = new X509Certificate2Collection { recipientCertificate };
envelopedCms.Decrypt(recipientInfo);
var decryptedBytes = envelopedCms.ContentInfo.Content;
return decryptedBytes;
}
catch ( Exception e )
{
try
{
var m = $"Unable to decrypt data by {recipientCertificate.Subject}";
_log?.LogError( m, e );
throw new CryptographicException( m, e );
}
catch ( Exception )
{
const string m = "Unable to decrypt data";
_log?.LogError( e,m );
throw new CryptographicException( m, e );
}
}
}
///
/// Encrypt text and get result in base64 form.
///
/// Text to encrypt
/// Use LoadEncryptionKey to get encryptionKey
///
/// Encrypted text in base64 form
private static string Encrypt( string plainText, X509Certificate2 certificate, byte[]? bytes = null )
{
if ( certificate == null )
throw new ArgumentNullException(nameof(certificate));
using var rsa = certificate.GetRSAPrivateKey();
try
{
bytes ??= Encoding.ASCII.GetBytes(plainText);
var decryptedBytes = rsa!.Encrypt( bytes, RSAEncryptionPadding.OaepSHA1 );
// Check to make sure we decrypted the string
return decryptedBytes.Length == 0 ? string.Empty : Convert.ToBase64String( decryptedBytes );
}
catch ( Exception e )
{
try
{
var m = $"Unable to encrypt data by {certificate.Subject}";
_log?.LogError( m, e );
throw new CryptographicException( m, e );
}
catch ( Exception )
{
const string m = "Unable to encrypt data";
_log?.LogError( e, m );
throw new CryptographicException( m, e );
}
}
}
///
/// Encrypt text and get result in PKCS#7 form.
///
/// Text to encrypt
/// Public key container
/// Encrypted text in base64 form
public static string Pkcs7Encrypt( string plainText, X509Certificate2 recipientCertificate )
=> Pkcs7Encrypt( Encoding.UTF8.GetBytes( plainText ), recipientCertificate );
///
/// Encrypt binary data and get result in PKCS#7 form.
///
/// Public key container
///
/// Encrypted text in base64 form
public static string Pkcs7Encrypt( byte[] bytes, X509Certificate2 recipientCertificate )
{
if ( recipientCertificate == null )
throw new ArgumentNullException(nameof(recipientCertificate));
var recipient = new CmsRecipient(recipientCertificate);
var contentInfo = new ContentInfo(bytes);
var envelopedCms = new EnvelopedCms(contentInfo);
envelopedCms.Encrypt(recipient);
return SplitBy80(Convert.ToBase64String(envelopedCms.Encode()));
}
private static string SplitBy80(string str, int chunkSize = 80)
{
var nChunks = (str.Length + chunkSize - 1) / chunkSize;
return string.Join("\r\n", Enumerable.Range(0, nChunks)
.Select(i => str.Substring(i * chunkSize, Math.Min(chunkSize, str.Length-chunkSize*i))));
}
[GeneratedRegex("^[ A-Za-z0-9_@\\./\\\\$%#=\\\"\\'\\`\\^\\&\\+\\-\\(\\)\\{\\}\\[\\]!~\\?\\:;<>|]+$", RegexOptions.Compiled)]
private static partial Regex GoodPasswordRegex();
private static void CheckGoodPassword( string decrypted )
{
try
{
if ( string.IsNullOrWhiteSpace( decrypted ) )
_log?.LogWarning( "Decrypted password is empty" );
else
{
var rx = GoodPasswordRegex();
var m = rx.Match( decrypted );
if ( !m.Success )
{
_log?.LogDebug( "Decrypted password does not match the 'good password regex'" );
}
}
}
catch( Exception ex )
{
_log?.LogWarning( "Decrypted password causes 'good password regex' to fail: " + ex.Message );
}
}
}