mirror of
https://github.com/actions/runner.git
synced 2025-12-10 12:36:23 +00:00
949 lines
40 KiB
C#
949 lines
40 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using GitHub.Runner.Common.Util;
|
|
using Newtonsoft.Json;
|
|
using System.IO;
|
|
using System.Runtime.Serialization;
|
|
using System.Security.Cryptography;
|
|
using GitHub.Runner.Sdk;
|
|
|
|
namespace GitHub.Runner.Common
|
|
{
|
|
// The purpose of this class is to store user's credential during runner configuration and retrive the credential back at runtime.
|
|
#if OS_WINDOWS
|
|
[ServiceLocator(Default = typeof(WindowsRunnerCredentialStore))]
|
|
#elif OS_OSX
|
|
[ServiceLocator(Default = typeof(MacOSRunnerCredentialStore))]
|
|
#else
|
|
[ServiceLocator(Default = typeof(LinuxRunnerCredentialStore))]
|
|
#endif
|
|
public interface IRunnerCredentialStore : IRunnerService
|
|
{
|
|
NetworkCredential Write(string target, string username, string password);
|
|
|
|
// throw exception when target not found from cred store
|
|
NetworkCredential Read(string target);
|
|
|
|
// throw exception when target not found from cred store
|
|
void Delete(string target);
|
|
}
|
|
|
|
#if OS_WINDOWS
|
|
// Windows credential store is per user.
|
|
// This is a limitation for user configure the runner run as windows service, when user's current login account is different with the service run as account.
|
|
// Ex: I login the box as domain\admin, configure the runner as windows service and run as domian\buildserver
|
|
// domain\buildserver won't read the stored credential from domain\admin's windows credential store.
|
|
// To workaround this limitation.
|
|
// Anytime we try to save a credential:
|
|
// 1. store it into current user's windows credential store
|
|
// 2. use DP-API do a machine level encrypt and store the encrypted content on disk.
|
|
// At the first time we try to read the credential:
|
|
// 1. read from current user's windows credential store, delete the DP-API encrypted backup content on disk if the windows credential store read succeed.
|
|
// 2. if credential not found in current user's windows credential store, read from the DP-API encrypted backup content on disk,
|
|
// write the credential back the current user's windows credential store and delete the backup on disk.
|
|
public sealed class WindowsRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
|
{
|
|
private string _credStoreFile;
|
|
private Dictionary<string, string> _credStore;
|
|
|
|
public override void Initialize(IHostContext hostContext)
|
|
{
|
|
base.Initialize(hostContext);
|
|
|
|
_credStoreFile = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
|
if (File.Exists(_credStoreFile))
|
|
{
|
|
_credStore = IOUtil.LoadObject<Dictionary<string, string>>(_credStoreFile);
|
|
}
|
|
else
|
|
{
|
|
_credStore = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
public NetworkCredential Write(string target, string username, string password)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
|
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
|
|
|
// save to .credential_store file first, then Windows credential store
|
|
string usernameBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(username));
|
|
string passwordBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(password));
|
|
|
|
// Base64Username:Base64Password -> DP-API machine level encrypt -> Base64Encoding
|
|
string encryptedUsernamePassword = Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes($"{usernameBase64}:{passwordBase64}"), null, DataProtectionScope.LocalMachine));
|
|
Trace.Info($"Credentials for '{target}' written to credential store file.");
|
|
_credStore[target] = encryptedUsernamePassword;
|
|
|
|
// save to .credential_store file
|
|
SyncCredentialStoreFile();
|
|
|
|
// save to Windows Credential Store
|
|
return WriteInternal(target, username, password);
|
|
}
|
|
|
|
public NetworkCredential Read(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
IntPtr credPtr = IntPtr.Zero;
|
|
try
|
|
{
|
|
if (CredRead(target, CredentialType.Generic, 0, out credPtr))
|
|
{
|
|
Credential credStruct = (Credential)Marshal.PtrToStructure(credPtr, typeof(Credential));
|
|
int passwordLength = (int)credStruct.CredentialBlobSize;
|
|
string password = passwordLength > 0 ? Marshal.PtrToStringUni(credStruct.CredentialBlob, passwordLength / sizeof(char)) : String.Empty;
|
|
string username = Marshal.PtrToStringUni(credStruct.UserName);
|
|
Trace.Info($"Credentials for '{target}' read from windows credential store.");
|
|
|
|
// delete from .credential_store file since we are able to read it from windows credential store
|
|
if (_credStore.Remove(target))
|
|
{
|
|
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
|
SyncCredentialStoreFile();
|
|
}
|
|
|
|
return new NetworkCredential(username, password);
|
|
}
|
|
else
|
|
{
|
|
// Can't read from Windows Credential Store, fail back to .credential_store file
|
|
if (_credStore.ContainsKey(target) && !string.IsNullOrEmpty(_credStore[target]))
|
|
{
|
|
Trace.Info($"Credentials for '{target}' read from credential store file.");
|
|
|
|
// Base64Decode -> DP-API machine level decrypt -> Base64Username:Base64Password -> Base64Decode
|
|
string decryptedUsernamePassword = Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(_credStore[target]), null, DataProtectionScope.LocalMachine));
|
|
|
|
string[] credential = decryptedUsernamePassword.Split(':');
|
|
if (credential.Length == 2 && !string.IsNullOrEmpty(credential[0]) && !string.IsNullOrEmpty(credential[1]))
|
|
{
|
|
string username = Encoding.UTF8.GetString(Convert.FromBase64String(credential[0]));
|
|
string password = Encoding.UTF8.GetString(Convert.FromBase64String(credential[1]));
|
|
|
|
// store back to windows credential store for current user
|
|
NetworkCredential creds = WriteInternal(target, username, password);
|
|
|
|
// delete from .credential_store file since we are able to write the credential to windows credential store for current user.
|
|
if (_credStore.Remove(target))
|
|
{
|
|
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
|
SyncCredentialStoreFile();
|
|
}
|
|
|
|
return creds;
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(decryptedUsernamePassword));
|
|
}
|
|
}
|
|
|
|
throw new Win32Exception(Marshal.GetLastWin32Error(), $"CredRead throw an error for '{target}'");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (credPtr != IntPtr.Zero)
|
|
{
|
|
CredFree(credPtr);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Delete(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
|
|
// remove from .credential_store file
|
|
if (_credStore.Remove(target))
|
|
{
|
|
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
|
SyncCredentialStoreFile();
|
|
}
|
|
|
|
// remove from windows credential store
|
|
if (!CredDelete(target, CredentialType.Generic, 0))
|
|
{
|
|
throw new Win32Exception(Marshal.GetLastWin32Error(), $"Failed to delete credentials for {target}");
|
|
}
|
|
else
|
|
{
|
|
Trace.Info($"Credentials for '{target}' deleted from windows credential store.");
|
|
}
|
|
}
|
|
|
|
private NetworkCredential WriteInternal(string target, string username, string password)
|
|
{
|
|
// save to Windows Credential Store
|
|
Credential credential = new Credential()
|
|
{
|
|
Type = CredentialType.Generic,
|
|
Persist = (UInt32)CredentialPersist.LocalMachine,
|
|
TargetName = Marshal.StringToCoTaskMemUni(target),
|
|
UserName = Marshal.StringToCoTaskMemUni(username),
|
|
CredentialBlob = Marshal.StringToCoTaskMemUni(password),
|
|
CredentialBlobSize = (UInt32)Encoding.Unicode.GetByteCount(password),
|
|
AttributeCount = 0,
|
|
Comment = IntPtr.Zero,
|
|
Attributes = IntPtr.Zero,
|
|
TargetAlias = IntPtr.Zero
|
|
};
|
|
|
|
try
|
|
{
|
|
if (CredWrite(ref credential, 0))
|
|
{
|
|
Trace.Info($"Credentials for '{target}' written to windows credential store.");
|
|
return new NetworkCredential(username, password);
|
|
}
|
|
else
|
|
{
|
|
int error = Marshal.GetLastWin32Error();
|
|
throw new Win32Exception(error, "Failed to write credentials");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (credential.CredentialBlob != IntPtr.Zero)
|
|
{
|
|
Marshal.FreeCoTaskMem(credential.CredentialBlob);
|
|
}
|
|
if (credential.TargetName != IntPtr.Zero)
|
|
{
|
|
Marshal.FreeCoTaskMem(credential.TargetName);
|
|
}
|
|
if (credential.UserName != IntPtr.Zero)
|
|
{
|
|
Marshal.FreeCoTaskMem(credential.UserName);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void SyncCredentialStoreFile()
|
|
{
|
|
Trace.Info("Sync in-memory credential store with credential store file.");
|
|
|
|
// delete the cred store file first anyway, since it's a readonly file.
|
|
IOUtil.DeleteFile(_credStoreFile);
|
|
|
|
// delete cred store file when all creds gone
|
|
if (_credStore.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
IOUtil.SaveObject(_credStore, _credStoreFile);
|
|
File.SetAttributes(_credStoreFile, File.GetAttributes(_credStoreFile) | FileAttributes.Hidden);
|
|
}
|
|
}
|
|
|
|
[DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
internal static extern bool CredDelete(string target, CredentialType type, int reservedFlag);
|
|
|
|
[DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
internal static extern bool CredRead(string target, CredentialType type, int reservedFlag, out IntPtr CredentialPtr);
|
|
|
|
[DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
internal static extern bool CredWrite([In] ref Credential userCredential, [In] UInt32 flags);
|
|
|
|
[DllImport("Advapi32.dll", EntryPoint = "CredFree", SetLastError = true)]
|
|
internal static extern bool CredFree([In] IntPtr cred);
|
|
|
|
internal enum CredentialPersist : UInt32
|
|
{
|
|
Session = 0x01,
|
|
LocalMachine = 0x02
|
|
}
|
|
|
|
internal enum CredentialType : uint
|
|
{
|
|
Generic = 0x01,
|
|
DomainPassword = 0x02,
|
|
DomainCertificate = 0x03
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
internal struct Credential
|
|
{
|
|
public UInt32 Flags;
|
|
public CredentialType Type;
|
|
public IntPtr TargetName;
|
|
public IntPtr Comment;
|
|
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
public UInt32 CredentialBlobSize;
|
|
public IntPtr CredentialBlob;
|
|
public UInt32 Persist;
|
|
public UInt32 AttributeCount;
|
|
public IntPtr Attributes;
|
|
public IntPtr TargetAlias;
|
|
public IntPtr UserName;
|
|
}
|
|
}
|
|
#elif OS_OSX
|
|
public sealed class MacOSRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
|
{
|
|
private const string _osxRunnerCredStoreKeyChainName = "_GITHUB_ACTIONS_RUNNER_CREDSTORE_INTERNAL_";
|
|
|
|
// Keychain requires a password, but this is not intended to add security
|
|
private const string _osxRunnerCredStoreKeyChainPassword = "C46F23C36AF94B72B1EAEE32C68670A0";
|
|
|
|
private string _securityUtil;
|
|
|
|
private string _runnerCredStoreKeyChain;
|
|
|
|
public override void Initialize(IHostContext hostContext)
|
|
{
|
|
base.Initialize(hostContext);
|
|
|
|
_securityUtil = WhichUtil.Which("security", true, Trace);
|
|
|
|
_runnerCredStoreKeyChain = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
|
|
|
// Create osx key chain if it doesn't exists.
|
|
if (!File.Exists(_runnerCredStoreKeyChain))
|
|
{
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"create-keychain -p {_osxRunnerCredStoreKeyChainPassword} \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info($"Successfully create-keychain for {_runnerCredStoreKeyChain}");
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security create-keychain' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Try unlock and lock the keychain, make sure it's still in good stage
|
|
UnlockKeyChain();
|
|
LockKeyChain();
|
|
}
|
|
}
|
|
|
|
public NetworkCredential Write(string target, string username, string password)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
|
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
|
|
|
try
|
|
{
|
|
UnlockKeyChain();
|
|
|
|
// base64encode username + ':' + base64encode password
|
|
// OSX keychain requires you provide -s target and -a username to retrieve password
|
|
// So, we will trade both username and password as 'secret' store into keychain
|
|
string usernameBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(username));
|
|
string passwordBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(password));
|
|
string secretForKeyChain = $"{usernameBase64}:{passwordBase64}";
|
|
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"add-generic-password -s {target} -a GITHUBACTIONSRUNNER -w {secretForKeyChain} -T \"{_securityUtil}\" \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info($"Successfully add-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security add-generic-password' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
|
|
return new NetworkCredential(username, password);
|
|
}
|
|
finally
|
|
{
|
|
LockKeyChain();
|
|
}
|
|
}
|
|
|
|
public NetworkCredential Read(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
|
|
try
|
|
{
|
|
UnlockKeyChain();
|
|
|
|
string username;
|
|
string password;
|
|
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"find-generic-password -s {target} -a GITHUBACTIONSRUNNER -w -g \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
string keyChainSecret = securityOut.First();
|
|
string[] secrets = keyChainSecret.Split(':');
|
|
if (secrets.Length == 2 && !string.IsNullOrEmpty(secrets[0]) && !string.IsNullOrEmpty(secrets[1]))
|
|
{
|
|
Trace.Info($"Successfully find-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
|
username = Encoding.UTF8.GetString(Convert.FromBase64String(secrets[0]));
|
|
password = Encoding.UTF8.GetString(Convert.FromBase64String(secrets[1]));
|
|
return new NetworkCredential(username, password);
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(keyChainSecret));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security find-generic-password' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
LockKeyChain();
|
|
}
|
|
}
|
|
|
|
public void Delete(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
|
|
try
|
|
{
|
|
UnlockKeyChain();
|
|
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"delete-generic-password -s {target} -a GITHUBACTIONSRUNNER \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info($"Successfully delete-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security delete-generic-password' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
LockKeyChain();
|
|
}
|
|
}
|
|
|
|
private void UnlockKeyChain()
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(_securityUtil, nameof(_securityUtil));
|
|
ArgUtil.NotNullOrEmpty(_runnerCredStoreKeyChain, nameof(_runnerCredStoreKeyChain));
|
|
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"unlock-keychain -p {_osxRunnerCredStoreKeyChainPassword} \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info($"Successfully unlock-keychain for {_runnerCredStoreKeyChain}");
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security unlock-keychain' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void LockKeyChain()
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(_securityUtil, nameof(_securityUtil));
|
|
ArgUtil.NotNullOrEmpty(_runnerCredStoreKeyChain, nameof(_runnerCredStoreKeyChain));
|
|
|
|
List<string> securityOut = new List<string>();
|
|
List<string> securityError = new List<string>();
|
|
object outputLock = new object();
|
|
using (var p = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
|
{
|
|
if (!string.IsNullOrEmpty(stdout.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityOut.Add(stdout.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
|
{
|
|
if (!string.IsNullOrEmpty(stderr.Data))
|
|
{
|
|
lock (outputLock)
|
|
{
|
|
securityError.Add(stderr.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
|
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
|
fileName: _securityUtil,
|
|
arguments: $"lock-keychain \"{_runnerCredStoreKeyChain}\"",
|
|
environment: null,
|
|
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info($"Successfully lock-keychain for {_runnerCredStoreKeyChain}");
|
|
}
|
|
else
|
|
{
|
|
if (securityOut.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
|
}
|
|
if (securityError.Count > 0)
|
|
{
|
|
Trace.Error(string.Join(Environment.NewLine, securityError));
|
|
}
|
|
|
|
throw new InvalidOperationException($"'security lock-keychain' failed with exit code {exitCode}.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
public sealed class LinuxRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
|
{
|
|
// 'ghrunner' 128 bits iv
|
|
private readonly byte[] iv = new byte[] { 0x67, 0x68, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72, 0x67, 0x68, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72 };
|
|
|
|
// 256 bits key
|
|
private byte[] _symmetricKey;
|
|
private string _credStoreFile;
|
|
private Dictionary<string, Credential> _credStore;
|
|
|
|
public override void Initialize(IHostContext hostContext)
|
|
{
|
|
base.Initialize(hostContext);
|
|
|
|
_credStoreFile = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
|
if (File.Exists(_credStoreFile))
|
|
{
|
|
_credStore = IOUtil.LoadObject<Dictionary<string, Credential>>(_credStoreFile);
|
|
}
|
|
else
|
|
{
|
|
_credStore = new Dictionary<string, Credential>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
string machineId;
|
|
if (File.Exists("/etc/machine-id"))
|
|
{
|
|
// try use machine-id as encryption key
|
|
// this helps avoid accidental information disclosure, but isn't intended for true security
|
|
machineId = File.ReadAllLines("/etc/machine-id").FirstOrDefault();
|
|
Trace.Info($"machine-id length {machineId?.Length ?? 0}.");
|
|
|
|
// machine-id doesn't exist or machine-id is not 256 bits
|
|
if (string.IsNullOrEmpty(machineId) || machineId.Length != 32)
|
|
{
|
|
Trace.Warning("Can not get valid machine id from '/etc/machine-id'.");
|
|
machineId = "43e7fe5da07740cf914b90f1dac51c2a";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// /etc/machine-id not exist
|
|
Trace.Warning("/etc/machine-id doesn't exist.");
|
|
machineId = "43e7fe5da07740cf914b90f1dac51c2a";
|
|
}
|
|
|
|
List<byte> keyBuilder = new List<byte>();
|
|
foreach (var c in machineId)
|
|
{
|
|
keyBuilder.Add(Convert.ToByte(c));
|
|
}
|
|
|
|
_symmetricKey = keyBuilder.ToArray();
|
|
}
|
|
|
|
public NetworkCredential Write(string target, string username, string password)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
|
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
|
|
|
Trace.Info($"Store credential for '{target}' to cred store.");
|
|
Credential cred = new Credential(username, Encrypt(password));
|
|
_credStore[target] = cred;
|
|
SyncCredentialStoreFile();
|
|
return new NetworkCredential(username, password);
|
|
}
|
|
|
|
public NetworkCredential Read(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
Trace.Info($"Read credential for '{target}' from cred store.");
|
|
if (_credStore.ContainsKey(target))
|
|
{
|
|
Credential cred = _credStore[target];
|
|
if (!string.IsNullOrEmpty(cred.UserName) && !string.IsNullOrEmpty(cred.Password))
|
|
{
|
|
Trace.Info($"Return credential for '{target}' from cred store.");
|
|
return new NetworkCredential(cred.UserName, Decrypt(cred.Password));
|
|
}
|
|
}
|
|
|
|
throw new KeyNotFoundException(target);
|
|
}
|
|
|
|
public void Delete(string target)
|
|
{
|
|
Trace.Entering();
|
|
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
|
|
|
if (_credStore.ContainsKey(target))
|
|
{
|
|
Trace.Info($"Delete credential for '{target}' from cred store.");
|
|
_credStore.Remove(target);
|
|
SyncCredentialStoreFile();
|
|
}
|
|
else
|
|
{
|
|
throw new KeyNotFoundException(target);
|
|
}
|
|
}
|
|
|
|
private void SyncCredentialStoreFile()
|
|
{
|
|
Trace.Entering();
|
|
Trace.Info("Sync in-memory credential store with credential store file.");
|
|
|
|
// delete cred store file when all creds gone
|
|
if (_credStore.Count == 0)
|
|
{
|
|
IOUtil.DeleteFile(_credStoreFile);
|
|
return;
|
|
}
|
|
|
|
if (!File.Exists(_credStoreFile))
|
|
{
|
|
CreateCredentialStoreFile();
|
|
}
|
|
|
|
IOUtil.SaveObject(_credStore, _credStoreFile);
|
|
}
|
|
|
|
private string Encrypt(string secret)
|
|
{
|
|
using (Aes aes = Aes.Create())
|
|
{
|
|
aes.Key = _symmetricKey;
|
|
aes.IV = iv;
|
|
|
|
// Create a decrytor to perform the stream transform.
|
|
ICryptoTransform encryptor = aes.CreateEncryptor();
|
|
|
|
// Create the streams used for encryption.
|
|
using (MemoryStream msEncrypt = new MemoryStream())
|
|
{
|
|
using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
|
|
{
|
|
using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
|
|
{
|
|
swEncrypt.Write(secret);
|
|
}
|
|
|
|
return Convert.ToBase64String(msEncrypt.ToArray());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private string Decrypt(string encryptedText)
|
|
{
|
|
using (Aes aes = Aes.Create())
|
|
{
|
|
aes.Key = _symmetricKey;
|
|
aes.IV = iv;
|
|
|
|
// Create a decrytor to perform the stream transform.
|
|
ICryptoTransform decryptor = aes.CreateDecryptor();
|
|
|
|
// Create the streams used for decryption.
|
|
using (MemoryStream msDecrypt = new MemoryStream(Convert.FromBase64String(encryptedText)))
|
|
{
|
|
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
|
|
{
|
|
using (StreamReader srDecrypt = new StreamReader(csDecrypt))
|
|
{
|
|
// Read the decrypted bytes from the decrypting stream and place them in a string.
|
|
return srDecrypt.ReadToEnd();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void CreateCredentialStoreFile()
|
|
{
|
|
File.WriteAllText(_credStoreFile, "");
|
|
File.SetAttributes(_credStoreFile, File.GetAttributes(_credStoreFile) | FileAttributes.Hidden);
|
|
|
|
// Try to lock down the .credentials_store file to the owner/group
|
|
var chmodPath = WhichUtil.Which("chmod", trace: Trace);
|
|
if (!String.IsNullOrEmpty(chmodPath))
|
|
{
|
|
var arguments = $"600 {new FileInfo(_credStoreFile).FullName}";
|
|
using (var invoker = HostContext.CreateService<IProcessInvoker>())
|
|
{
|
|
var exitCode = invoker.ExecuteAsync(HostContext.GetDirectory(WellKnownDirectory.Root), chmodPath, arguments, null, default(CancellationToken)).GetAwaiter().GetResult();
|
|
if (exitCode == 0)
|
|
{
|
|
Trace.Info("Successfully set permissions for credentials store file {0}", _credStoreFile);
|
|
}
|
|
else
|
|
{
|
|
Trace.Warning("Unable to successfully set permissions for credentials store file {0}. Received exit code {1} from {2}", _credStoreFile, exitCode, chmodPath);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Trace.Warning("Unable to locate chmod to set permissions for credentials store file {0}.", _credStoreFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
[DataContract]
|
|
internal class Credential
|
|
{
|
|
public Credential()
|
|
{ }
|
|
|
|
public Credential(string userName, string password)
|
|
{
|
|
UserName = userName;
|
|
Password = password;
|
|
}
|
|
|
|
[DataMember(IsRequired = true)]
|
|
public string UserName { get; set; }
|
|
|
|
[DataMember(IsRequired = true)]
|
|
public string Password { get; set; }
|
|
}
|
|
#endif
|
|
}
|