/
OnePasswordManager.cs
301 lines (250 loc) · 12.7 KB
/
OnePasswordManager.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
using System.Diagnostics;
using System.IO.Compression;
using System.Text;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using OnePassword.Common;
namespace OnePassword;
/// <summary>Represents the 1Password CLI executable manager.</summary>
public sealed partial class OnePasswordManager : IOnePasswordManager
{
/// <inheritdoc />
public string Version { get; private set; }
#if NET7_0_OR_GREATER
private static readonly Regex VersionRegex = GeneratedVersionRegex();
#else
private static readonly Regex VersionRegex = new (@"Version ([^\s]+) is now available\.", RegexOptions.Compiled);
#endif
private readonly string[] _excludedAccountCommands = ["--version", "update", "account list", "account add", "account forget", "signout --all"];
private readonly string[] _excludedSessionCommands = ["--version", "update", "account list", "account add", "account forget", "signin", "signout", "signout --all"];
private readonly Mode _mode = Mode.Interactive;
private readonly string _opPath;
private readonly string _serviceAccountToken;
private readonly string[] _serviceAccountUnsupportedCommands = ["events-api", "group", "user"];
private readonly bool _verbose;
private string _account = "";
private string _session = "";
/// <summary>Initializes a new instance of <see cref="OnePasswordManager" /> using the specified options.</summary>
/// <param name="options">The configuration options.</param>
/// <exception cref="FileNotFoundException">Thrown when the 1Password CLI executable cannot be found.</exception>
public OnePasswordManager(Action<OnePasswordManagerOptions> options) : this(ConfigureOptions(options))
{
}
/// <summary>Initializes a new instance of <see cref="OnePasswordManager" /> using the specified options.</summary>
/// <param name="options">The configuration options.</param>
/// <exception cref="FileNotFoundException">Thrown when the 1Password CLI executable cannot be found.</exception>
public OnePasswordManager(OnePasswordManagerOptions? options = null)
{
var configuration = ValidateOptions(options);
_opPath = configuration.Path.Length > 0 ? Path.Combine(configuration.Path, configuration.Executable) : Path.Combine(Directory.GetCurrentDirectory(), configuration.Executable);
if (!File.Exists(_opPath))
throw new FileNotFoundException($"The 1Password CLI executable ({configuration.Executable}) was not found in folder \"{Path.GetDirectoryName(_opPath)}\".");
_verbose = configuration.Verbose;
if (configuration.AppIntegrated)
_mode = Mode.AppIntegrated;
_serviceAccountToken = configuration.ServiceAccountToken;
if (_serviceAccountToken.Length > 0)
_mode = Mode.ServiceAccount;
Version = GetVersion();
}
/// <summary>Initializes a new instance of <see cref="OnePasswordManager" /> for the specified 1Password CLI executable.</summary>
/// <param name="path">The path to the 1Password CLI executable.</param>
/// <param name="executable">The name of the 1Password CLI executable.</param>
/// <param name="verbose">When <see langword="true" />, commands sent to the 1Password CLI executable are output to the console.</param>
/// <param name="appIntegrated">
/// Set to <see langword="true" /> when authentication is integrated into the 1Password desktop application (see <a href="https://developer.1password.com/docs/cli/get-started/#sign-in">documentation</a>). When
/// <see langword="false" />, a password will be required to sign in.
/// </param>
/// <exception cref="FileNotFoundException">Thrown when the 1Password CLI executable cannot be found.</exception>
[Obsolete($"This constructor is deprecated. Please use the constructor overload with '{nameof(OnePasswordManagerOptions)}' as argument.")]
public OnePasswordManager(string path = "", string executable = "op.exe", bool verbose = false, bool appIntegrated = false)
{
_opPath = path is not null && path.Length > 0 ? Path.Combine(path, executable) : Path.Combine(Directory.GetCurrentDirectory(), executable);
if (!File.Exists(_opPath))
throw new FileNotFoundException($"The 1Password CLI executable ({executable}) was not found in folder \"{Path.GetDirectoryName(_opPath)}\".");
_verbose = verbose;
if (appIntegrated)
_mode = Mode.AppIntegrated;
_serviceAccountToken = "";
Version = GetVersion();
}
/// <inheritdoc />
public bool Update()
{
var updated = false;
var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDirectory);
var command = $"update --directory \"{tempDirectory}\"";
var result = Op(command);
var match = VersionRegex.Match(result);
if (match.Success)
{
foreach (var file in Directory.GetFiles(tempDirectory, "*.zip"))
{
using var zipArchive = ZipFile.Open(file, ZipArchiveMode.Read);
var entry = zipArchive.GetEntry("op.exe");
if (entry is null)
continue;
entry.ExtractToFile(_opPath, true);
Version = GetVersion();
updated = true;
}
}
Directory.Delete(tempDirectory, true);
return updated;
}
/// <inheritdoc />
public string GetSecret(string reference)
{
if (reference is null || reference.Length == 0)
throw new ArgumentException($"{nameof(reference)} cannot be empty.", nameof(reference));
var trimmedReference = reference.Trim();
if (trimmedReference.Length == 0)
throw new ArgumentException($"{nameof(trimmedReference)} cannot be empty.", nameof(reference));
var command = $"read {reference} --no-newline";
return Op(command);
}
/// <inheritdoc />
public void SaveSecret(string reference, string filePath, string? fileMode = null)
{
if (reference is null || reference.Length == 0)
throw new ArgumentException($"{nameof(reference)} cannot be empty.", nameof(reference));
var trimmedReference = reference.Trim();
if (trimmedReference.Length == 0)
throw new ArgumentException($"{nameof(trimmedReference)} cannot be empty.", nameof(reference));
if (filePath is null || filePath.Length == 0)
throw new ArgumentException($"{nameof(filePath)} cannot be empty.", nameof(filePath));
var trimmedFilePath = filePath.Trim();
if (trimmedFilePath.Length == 0)
throw new ArgumentException($"{nameof(trimmedFilePath)} cannot be empty.", nameof(filePath));
var trimmedFileMode = fileMode?.Trim();
var command = $"read {reference} --no-newline --force --out-file \"{trimmedFilePath}\"";
if (trimmedFileMode is not null && trimmedFileMode.Length > 0)
command += $" --file-mode {trimmedFileMode}";
Op(command);
}
private static OnePasswordManagerOptions ConfigureOptions(Action<OnePasswordManagerOptions> configure)
{
if (configure is null)
return OnePasswordManagerOptions.Default;
var options = OnePasswordManagerOptions.Default;
configure(options);
return options;
}
private static OnePasswordManagerOptions ValidateOptions(OnePasswordManagerOptions? options)
{
if (options is { AppIntegrated: true, ServiceAccountToken.Length: > 0 })
throw new InvalidOperationException("Cannot use a service account token when running in app integrated mode.");
return options ?? OnePasswordManagerOptions.Default;
}
private string GetVersion()
{
const string command = "--version";
return Op(command);
}
private static string GetStandardError(Process process)
{
var error = new StringBuilder();
while (process.StandardError.Peek() > -1)
error.Append((char)process.StandardError.Read());
return error.ToString();
}
private static string GetStandardOutput(Process process)
{
var output = new StringBuilder();
while (process.StandardOutput.Peek() > -1)
output.Append((char)process.StandardOutput.Read());
return output.ToString();
}
private TResult Op<TResult>(JsonTypeInfo<TResult> jsonTypeInfo, string command, string? input = null, bool returnError = false) where TResult : class
{
var result = Op(command, input is null ? Array.Empty<string>() : [input], returnError);
var obj = JsonSerializer.Deserialize(result, jsonTypeInfo) ?? throw new SerializationException("Could not deserialize the command result.");
if (obj is ITracked item)
item.AcceptChanges();
return obj;
}
private string Op(string command, string? input = null, bool returnError = false) => Op(command, input is null ? Array.Empty<string>() : [input], returnError);
private string Op(string command, IEnumerable<string> input, bool returnError)
{
var arguments = command;
if (command != "--version")
arguments += " --format json --no-color";
switch (_mode)
{
case Mode.ServiceAccount:
if (IsUnsupportedCommand(command, _serviceAccountUnsupportedCommands))
throw new InvalidOperationException($"Unsupported command {command} when using ServiceAccount");
break;
case Mode.Interactive:
case Mode.AppIntegrated:
default:
var excluded = IsExcludedCommand(command, _excludedAccountCommands);
var requireAccount = _mode != Mode.AppIntegrated && !excluded;
var passAccount = _account.Length != 0 && !excluded;
if (requireAccount && !passAccount)
throw new InvalidOperationException("Cannot execute command because account has not been set.");
var passSession = !(_mode == Mode.AppIntegrated || IsExcludedCommand(command, _excludedSessionCommands));
if (passSession && _session.Length == 0)
throw new InvalidOperationException("Cannot execute command because account has not been signed in.");
if (passAccount)
arguments += $" --account {_account}";
if (passSession)
arguments += $" --session {_session}";
break;
}
if (_verbose)
Console.WriteLine($"{Path.GetDirectoryName(_opPath)}>op {arguments}");
var startInfo = new ProcessStartInfo(_opPath, arguments)
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
if (_mode == Mode.ServiceAccount)
startInfo.EnvironmentVariables["OP_SERVICE_ACCOUNT_TOKEN"] = _serviceAccountToken;
var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start process for {_opPath}.");
foreach (var inputLine in input)
{
var lastChar = inputLine.Substring(inputLine.Length - 1, 1);
if (lastChar == "\x04")
{
process.StandardInput.WriteLine(inputLine[..^1]);
process.StandardInput.Flush();
}
else
{
process.StandardInput.WriteLine(inputLine);
process.StandardInput.Flush();
}
}
process.StandardInput.Close();
var output = GetStandardOutput(process);
if (_verbose)
Console.WriteLine(output);
var error = GetStandardError(process);
if (_verbose)
Console.WriteLine(error);
if (!error.StartsWith("[ERROR]", StringComparison.InvariantCulture))
return output;
if (returnError)
return error;
throw new InvalidOperationException(error.Length > 28 ? error[28..].Trim() : error);
}
private static bool IsExcludedCommand(string command, IEnumerable<string> excludedCommands)
{
return excludedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture));
}
private static bool IsUnsupportedCommand(string command, IEnumerable<string> unsupportedCommands)
{
return unsupportedCommands.Any(x => command.StartsWith(x, StringComparison.InvariantCulture));
}
#if NET7_0_OR_GREATER
[GeneratedRegex(@"Version ([^\s]+) is now available\.", RegexOptions.Compiled)]
private static partial Regex GeneratedVersionRegex();
#endif
}