2025-01-07 09:40:39 +01:00
// Copyright (C) 2025, The Duplicati Team
2024-10-28 08:08:29 +01:00
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
2024-10-24 15:56:39 +02:00
using System.Text.Json ;
using System.Web ;
using Duplicati.Library.Interface ;
namespace Duplicati.Library.SecretProvider ;
/// <summary>
/// Secret provider that reads secrets from a file
/// </summary>
public class FileSecretProvider : ISecretProvider
{
private const string PASSPHRASE_OPTION = "passphrase" ;
2025-11-30 23:01:57 +01:00
private static readonly JsonSerializerOptions SerializerOptions = new ()
{
WriteIndented = true
};
2024-10-24 15:56:39 +02:00
/// <inheritdoc />
public string Key => "file-secret" ;
/// <inheritdoc />
public string DisplayName => Strings . FileSecretProvider . DisplayName ;
/// <inheritdoc />
public string Description => Strings . FileSecretProvider . Description ;
/// <inheritdoc />
2025-12-01 15:29:24 +01:00
public Task < bool > IsSupported ( CancellationToken cancellationToken ) => Task . FromResult ( true );
2025-11-30 23:01:57 +01:00
/// <inheritdoc />
public bool IsSetSupported => ! string . IsNullOrWhiteSpace ( _passphrase );
/// <inheritdoc />
private Dictionary < string , string >? _secrets ;
/// <summary>
/// The file path to read/write secrets from
/// </summary>
private string? _filePath ;
/// <summary>
/// The passphrase to use for encrypting/decrypting the file
/// </summary>
private string? _passphrase ;
2024-10-24 15:56:39 +02:00
/// <inheritdoc />
public IList < ICommandLineArgument > SupportedCommands => [
new CommandLineArgument ( PASSPHRASE_OPTION , CommandLineArgument . ArgumentType . String , Strings . FileSecretProvider . PassphraseDescriptionShort , Strings . FileSecretProvider . PassphraseDescriptionLong , null ),
];
/// <inheritdoc />
public async Task InitializeAsync ( Uri config , CancellationToken cancellationToken )
{
2025-11-30 23:01:57 +01:00
_filePath = config . LocalPath ;
if ( _filePath is null || ! File . Exists ( _filePath ))
throw new FileNotFoundException ( $"File not found: {_filePath}" );
2024-10-24 15:56:39 +02:00
// Get the passphrase from the secrets-passphrase query parameter
var args = HttpUtility . ParseQueryString ( config . Query );
2025-11-30 23:01:57 +01:00
_passphrase = args [ PASSPHRASE_OPTION ];
2025-12-02 09:54:03 +01:00
2025-11-30 23:01:57 +01:00
using var fs = File . OpenRead ( _filePath );
2025-12-02 09:54:03 +01:00
if ( fs . Length == 0 )
throw new UserInformationException ( "The secret file is empty" , "EmptySecretFile" );
2024-10-24 15:56:39 +02:00
Dictionary < string , string > secrets ;
2025-11-30 23:01:57 +01:00
if ( string . IsNullOrEmpty ( _passphrase ))
2024-10-24 15:56:39 +02:00
{
secrets = await JsonSerializer . DeserializeAsync < Dictionary < string , string >>( fs , cancellationToken : cancellationToken ). ConfigureAwait ( false )
2024-10-24 21:23:58 +02:00
?? throw new UserInformationException ( "The file does not contain any secrets" , "NoSecrets" );
2024-10-24 15:56:39 +02:00
}
else
{
using var ms = new MemoryStream ();
2025-11-30 23:01:57 +01:00
await SharpAESCrypt . AESCrypt . DecryptAsync ( _passphrase , fs , ms , SharpAESCrypt . DecryptionOptions . Default with { LeaveOpen = true }, cancellationToken ). ConfigureAwait ( false );
2024-10-24 15:56:39 +02:00
ms . Position = 0 ;
secrets = await JsonSerializer . DeserializeAsync < Dictionary < string , string >>( ms , cancellationToken : cancellationToken ). ConfigureAwait ( false )
2024-10-24 21:23:58 +02:00
?? throw new UserInformationException ( "The file does not contain any secrets" , "NoSecrets" );
2024-10-24 15:56:39 +02:00
}
2025-11-30 23:01:57 +01:00
// Make secret keys case-insensitive
_secrets = secrets . ToDictionary ( kvp => kvp . Key , kvp => kvp . Value , StringComparer . OrdinalIgnoreCase );
2024-10-24 15:56:39 +02:00
}
/// <inheritdoc />
public Task < Dictionary < string , string >> ResolveSecretsAsync ( IEnumerable < string > keys , CancellationToken cancellationToken )
{
if ( _secrets is null )
throw new InvalidOperationException ( "The secret provider has not been initialized" );
2025-12-01 15:39:21 +01:00
return Task . FromResult ( keys . ToDictionary ( k => k , k => _secrets . TryGetValue ( k , out var value ) ? value : throw new UserInformationException ( $"The key '{k}' was not found" , "KeyNotFound" )));
2024-10-24 15:56:39 +02:00
}
2025-11-30 23:01:57 +01:00
/// <inheritdoc />
public async Task SetSecretAsync ( string key , string value , bool overwrite , CancellationToken cancellationToken )
{
if ( _secrets is null || string . IsNullOrEmpty ( _filePath ))
throw new InvalidOperationException ( "The secret provider has not been initialized" );
if ( string . IsNullOrEmpty ( _passphrase ))
2025-12-01 15:39:21 +01:00
throw new UserInformationException ( "The secret provider does not support setting secrets without a passphrase" , "PassphraseRequired" );
2025-11-30 23:01:57 +01:00
if (! overwrite && _secrets . ContainsKey ( key ))
2025-12-01 15:39:21 +01:00
throw new UserInformationException ( $"The key '{key}' already exists" , "KeyAlreadyExists" );
2025-11-30 23:01:57 +01:00
_secrets [ key ] = value ;
var directory = Path . GetDirectoryName ( _filePath );
if (! string . IsNullOrEmpty ( directory ))
Directory . CreateDirectory ( directory );
if ( string . IsNullOrEmpty ( _passphrase ))
{
await using var fs = File . Create ( _filePath );
await JsonSerializer . SerializeAsync ( fs , _secrets , SerializerOptions , cancellationToken ). ConfigureAwait ( false );
}
else
{
await using var jsonStream = new MemoryStream ();
await JsonSerializer . SerializeAsync ( jsonStream , _secrets , SerializerOptions , cancellationToken ). ConfigureAwait ( false );
jsonStream . Position = 0 ;
await using var fs = File . Create ( _filePath );
await SharpAESCrypt . AESCrypt . EncryptAsync ( _passphrase , jsonStream , fs , SharpAESCrypt . EncryptionOptions . Default with { LeaveOpen = true }, cancellationToken ). ConfigureAwait ( false );
}
}
2024-10-24 15:56:39 +02:00
}