Repository navigation
Expand file tree
/
Copy pathAuthForgeClient.OfflineActivation.cs
More file actions
208 lines (195 loc) · 9.04 KB
/
Copy pathAuthForgeClient.OfflineActivation.cs
File metadata and controls
208 lines (195 loc) · 9.04 KB
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
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AuthForge
{
/// <summary>
/// Self-serve offline activation: one online call to
/// <c>/auth/offline/activate</c> turns a license key into a lifetime,
/// HWID-bound <c>.authforge</c> file. After that the app runs from the
/// file with <see cref="LoginFromFile"/> and no network.
///
/// This is separate from air-gapped offline files (minted by the operator,
/// no App Secret on the machine): activation is an online call and needs
/// the App Secret.
/// </summary>
public sealed partial class AuthForgeClient
{
private const string OfflineActivatePath = "/auth/offline/activate";
/// <summary>
/// <c>replay</c> from the last successful <see cref="ActivateOffline"/>
/// response (<c>true</c> when the server returned the file this machine
/// already had); <c>null</c> before the first success. Test hook.
/// </summary>
internal bool? LastOfflineActivationReplay { get; private set; }
/// <summary>
/// Exchange a license key for a lifetime, HWID-bound offline license
/// file in one online call, then authenticate from it exactly as
/// <see cref="LoginFromFile"/> does (offline session, variables,
/// <see cref="GetOfflineLicense"/>, no background thread).
///
/// The returned file is verified locally (signature against
/// <see cref="PublicKeys"/>, <see cref="AppId"/>, this machine's HWID)
/// before anything is written. When <paramref name="path"/> is set the
/// file is written there byte-for-byte and atomically (temp file in the
/// same directory, then rename), creating parent directories.
///
/// A new machine costs the app owner 2 credits; activating a machine
/// that already has a file returns the identical file for free.
///
/// Failures throw <see cref="AuthForgeException"/> with the server code
/// (<c>offline_activation_disabled</c>, <c>offline_activation_requires_perpetual</c>,
/// <c>offline_activation_requires_seats</c>, <c>offline_activation_limit_reached</c>,
/// <c>hwid_mismatch</c> (no free seat), <c>invalid_key</c>, <c>revoked</c>, ...)
/// or an SDK code (<c>offline_file_rejected</c>, <c>file_write_failed</c>,
/// <c>unexpected_response</c>, <c>network_error</c>, <c>timeout</c>, ...).
/// On failure nothing is written to <paramref name="path"/>, the client is
/// not authenticated (an existing session is left as it was), and neither <see cref="OnFailure"/> nor
/// <see cref="Environment.Exit"/> is called.
/// </summary>
/// <param name="licenseKey">The license key the buyer entered.</param>
/// <param name="path">Where to save the <c>.authforge</c> file; <c>null</c> to skip writing.</param>
/// <returns><c>true</c> on success.</returns>
/// <exception cref="ArgumentException"><paramref name="licenseKey"/> is empty, or the client has no App Secret.</exception>
/// <exception cref="AuthForgeException">The activation was refused or failed.</exception>
public bool ActivateOffline(string licenseKey, string? path = null)
{
if (string.IsNullOrEmpty(licenseKey))
{
throw new ArgumentException("license_key must be a non-empty string", nameof(licenseKey));
}
RequireAppSecret();
try
{
var result = RequestOfflineActivation(licenseKey, out var replay);
if (!string.IsNullOrEmpty(path))
{
WriteFileAtomic(path!, result.File);
}
ApplyOfflineLicense(result.Verified);
LastOfflineActivationReplay = replay;
return true;
}
catch (AuthForgeException)
{
// A failed activation authenticates nothing and leaves any
// existing session as it was.
LastOfflineActivationReplay = null;
throw;
}
}
private (string File, VerifyLicenseFileResult Verified) RequestOfflineActivation(string licenseKey, out bool replay)
{
var body = new Dictionary<string, object?>
{
["appId"] = AppId,
["appSecret"] = AppSecret,
["licenseKey"] = licenseKey,
["hwid"] = _hwid,
["nonce"] = GenerateNonce(),
};
Dictionary<string, JsonElement> responseObj;
try
{
// Errors are thrown to the caller, never routed to
// OnFailure / Environment.Exit.
responseObj = PostJson(OfflineActivatePath, body);
}
catch (ArgumentException ex)
{
// invalid_json_response / response_not_json_object on a 2xx body.
throw new AuthForgeException("unexpected_response", "unexpected_response: " + ex.Message, ex);
}
responseObj.TryGetValue("status", out var statusElement);
if (!IsSuccessStatus(statusElement))
{
// {"status":"failed","error":"<code>"} -> that code; anything else -> unexpected_response.
throw HeartbeatRejection(responseObj);
}
if (!responseObj.TryGetValue("file", out var fileElement)
|| fileElement.ValueKind != JsonValueKind.String
|| string.IsNullOrEmpty(fileElement.GetString()))
{
throw new AuthForgeException(
"unexpected_response",
$"unexpected_response: success without file (file={DescribeJson(fileElement)})");
}
var file = fileElement.GetString()!;
// The server's hwid_mismatch means "no free seat"; a file that fails
// local checks is always offline_file_rejected, with the verifier's
// reason in the message.
var verified = VerifyLicenseFile(file, AppId, PublicKeys, _hwid);
if (!verified.Ok)
{
var reason = verified.Error ?? "unknown_error";
throw new AuthForgeException("offline_file_rejected", "offline_file_rejected: " + reason);
}
replay = responseObj.TryGetValue("replay", out var replayElement)
&& replayElement.ValueKind == JsonValueKind.True;
return (file, verified);
}
/// <summary>
/// Writes <paramref name="contents"/> as UTF-8 (no BOM, no newline
/// changes) to a temp file next to <paramref name="path"/> and renames it
/// over the target, so the target is either untouched or complete.
/// </summary>
private static void WriteFileAtomic(string path, string contents)
{
string tempPath = string.Empty;
try
{
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(contents);
tempPath = Path.Combine(
directory ?? string.Empty,
"." + Path.GetFileName(fullPath) + "." + Guid.NewGuid().ToString("N") + ".tmp");
using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
stream.Write(bytes, 0, bytes.Length);
stream.Flush(flushToDisk: true);
}
#if NETCOREAPP3_0_OR_GREATER
File.Move(tempPath, fullPath, overwrite: true);
#else
if (File.Exists(fullPath))
{
File.Replace(tempPath, fullPath, null);
}
else
{
File.Move(tempPath, fullPath);
}
#endif
tempPath = string.Empty;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is NotSupportedException || ex is ArgumentException || ex is System.Security.SecurityException)
{
throw new AuthForgeException(
"file_write_failed",
"file_write_failed: " + ex.Message + " (the machine is activated; activating again returns the same file for free)",
ex);
}
finally
{
if (tempPath.Length > 0)
{
try
{
File.Delete(tempPath);
}
catch
{
// Best effort cleanup of the temp file.
}
}
}
}
}
}