Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeySafeActions.cs

v0.1.0-build.85 Latest Commit ede38f5

Size
31 KB
Lines
555
Language
csharp
SHA-256
e7fb32081208…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1using System;
2using System.Collections.Generic;
3using System.Globalization;
4using System.Linq;
5using System.Net.Http;
6using System.Text;
7using System.Text.RegularExpressions;
8using System.Threading;
9using System.Threading.Tasks;
10using OpenCKey.Interop;
11
12// CKey Safe Actions for the receiving app (spec/CKey-Safe-Actions.md).
13//
14// The SDK, not the app, decides whether a Safe Action runs:
15// - anything that is not a verified Safe Action for this app runs nothing;
16// - a verified same-device action runs its registered handler at once;
17// - a verified cross-device action runs only after the SDK's own confirmation
18// (CKey.Wpf's CKeySafeActionPrompt, or CKeySafeActionConsolePrompt), which
19// names the verified issuer and the requesting device and place.
20// CKeySafeActionRequest.Decide is internal: an app has no API that approves a
21// cross-device action without that confirmation. Verification is the C core's.
22namespace OpenCKey
23{
24 /// <summary>Where a Safe Action's effect happens.</summary>
25 public enum CKeySafeActionScope
26 {
27 /// <summary>The result stays on the scanning device, inside this app. Runs at once.</summary>
28 SameDevice = 0,
29 /// <summary>The action approves something elsewhere (another browser, a payment). Needs the SDK's confirmation.</summary>
30 CrossDevice = 1,
31 }
32
33 /// <summary>A Safe Action whose signature, app, action, lifetime and freshness were verified.</summary>
34 public sealed class CKeyVerifiedSafeAction
35 {
36 internal CKeyVerifiedSafeAction(string issuer, string app, string action, string keyId, string requestId,
37 CKeySafeActionScope scope, long issuedAt, long expiresAt, byte[] parameters)
38 {
39 Issuer = issuer; App = app; Action = action; KeyId = keyId; RequestId = requestId;
40 Scope = scope; IssuedAt = issuedAt; ExpiresAt = expiresAt; Params = parameters;
41 }
42
43 /// <summary>The issuer's DNS name.</summary>
44 public string Issuer { get; }
45 /// <summary>The receiving app's ID.</summary>
46 public string App { get; }
47 /// <summary>The registered action name.</summary>
48 public string Action { get; }
49 /// <summary>The issuer key that signed it.</summary>
50 public string KeyId { get; }
51 /// <summary>The single-use request ID.</summary>
52 public string RequestId { get; }
53 /// <summary>The scope the issuer declared.</summary>
54 public CKeySafeActionScope Scope { get; }
55 /// <summary>Unix seconds.</summary>
56 public long IssuedAt { get; }
57 /// <summary>Unix seconds.</summary>
58 public long ExpiresAt { get; }
59 /// <summary>The parameters as signed (UTF-8 JSON is recommended).</summary>
60 public IReadOnlyList<byte> Params { get; }
61
62 /// <summary><see cref="Params"/> as UTF-8 text, or null when they are not valid UTF-8.</summary>
63 public string? ParamsText
64 {
65 get
66 {
67 try { return new UTF8Encoding(false, true).GetString(Params.ToArray()); }
68 catch (ArgumentException) { return null; }
69 }
70 }
71 }
72
73 /// <summary>What the issuer says about a pending cross-device request: who is asking.</summary>
74 public sealed class CKeySafeActionRequestInfo
75 {
76 internal CKeySafeActionRequestInfo(string? device, string? location, string? requestedAt)
77 {
78 Device = device; Location = location; RequestedAt = requestedAt;
79 }
80
81 /// <summary>For example "Chrome on Windows".</summary>
82 public string? Device { get; }
83 /// <summary>For example "near Lagos, Nigeria".</summary>
84 public string? Location { get; }
85 /// <summary>When the request was made, as the issuer reports it.</summary>
86 public string? RequestedAt { get; }
87 }
88
89 /// <summary>
90 /// A verified cross-device Safe Action waiting for the person to confirm it.
91 /// Show it with CKey.Wpf's <c>CKeySafeActionPrompt</c> or <see cref="CKeySafeActionConsolePrompt"/>;
92 /// approval is internal to the SDK.
93 /// </summary>
94 public sealed class CKeySafeActionRequest
95 {
96 private readonly Func<CKeyVerifiedSafeAction, Task> _handler;
97 private int _decided;
98
99 internal CKeySafeActionRequest(string title, CKeyVerifiedSafeAction details, CKeySafeActionRequestInfo info, Func<CKeyVerifiedSafeAction, Task> handler)
100 {
101 Title = title; Details = details; RequestInfo = info; _handler = handler;
102 }
103
104 /// <summary>The confirmation's title.</summary>
105 public string Title { get; }
106 /// <summary>The verified action.</summary>
107 public CKeyVerifiedSafeAction Details { get; }
108 /// <summary>The issuer's account of who is asking.</summary>
109 public CKeySafeActionRequestInfo RequestInfo { get; }
110 /// <summary>True once the person approved or declined; a request is decided once.</summary>
111 public bool IsDecided => Volatile.Read(ref _decided) != 0;
112
113 /// <summary>The confirmation text every SDK prompt shows.</summary>
114 public string Message
115 {
116 get
117 {
118 var b = new StringBuilder();
119 b.Append("Requested by ").Append(Details.Issuer).Append(" (verified)\n");
120 if (RequestInfo.Device != null) b.Append("Device: ").Append(RequestInfo.Device).Append('\n');
121 if (RequestInfo.Location != null) b.Append("Location: ").Append(RequestInfo.Location).Append('\n');
122 if (RequestInfo.RequestedAt != null) b.Append("Requested: ").Append(RequestInfo.RequestedAt).Append('\n');
123 b.Append('\n').Append("Only continue if this is the device and place you are using right now. ")
124 .Append("Someone may be trying to use a code meant for their own session.");
125 return b.ToString();
126 }
127 }
128
129 /// <summary>Called only by the SDK's prompts. Returns true when the handler ran.</summary>
130 internal async Task<bool> Decide(bool approved)
131 {
132 if (Interlocked.Exchange(ref _decided, 1) != 0) return false;
133 if (!approved) return false;
134 await _handler(Details).ConfigureAwait(false);
135 return true;
136 }
137 }
138
139 /// <summary>The kind of <see cref="CKeySafeActionOutcome"/>.</summary>
140 public enum CKeySafeActionOutcomeKind
141 {
142 /// <summary>Not a Safe Action payload: follow the ordinary payload rules (<see cref="CKeyPayload"/>).</summary>
143 NotASafeAction,
144 /// <summary>Claims to be a Safe Action but could not be verified. Run nothing; say so, with <see cref="CKeySafeActionOutcome.Reason"/>.</summary>
145 Rejected,
146 /// <summary>A verified same-device action whose handler has run.</summary>
147 Performed,
148 /// <summary>A verified cross-device action: show <see cref="CKeySafeActionOutcome.Request"/> in an SDK prompt.</summary>
149 NeedsConfirmation,
150 }
151
152 /// <summary>The result of <see cref="CKeySafeActions.HandleAsync"/>.</summary>
153 public sealed class CKeySafeActionOutcome
154 {
155 private CKeySafeActionOutcome(CKeySafeActionOutcomeKind kind, string? reason = null, CKeyVerifiedSafeAction? action = null, CKeySafeActionRequest? request = null)
156 {
157 Kind = kind; Reason = reason; Action = action; Request = request;
158 }
159
160 internal static readonly CKeySafeActionOutcome NotASafeAction = new CKeySafeActionOutcome(CKeySafeActionOutcomeKind.NotASafeAction);
161 internal static CKeySafeActionOutcome Rejected(string reason) => new CKeySafeActionOutcome(CKeySafeActionOutcomeKind.Rejected, reason);
162 internal static CKeySafeActionOutcome Performed(CKeyVerifiedSafeAction a) => new CKeySafeActionOutcome(CKeySafeActionOutcomeKind.Performed, action: a);
163 internal static CKeySafeActionOutcome Confirm(CKeySafeActionRequest r) => new CKeySafeActionOutcome(CKeySafeActionOutcomeKind.NeedsConfirmation, action: r.Details, request: r);
164
165 /// <summary>What happened.</summary>
166 public CKeySafeActionOutcomeKind Kind { get; }
167 /// <summary>Why it was rejected (Rejected only).</summary>
168 public string? Reason { get; }
169 /// <summary>The verified action (Performed and NeedsConfirmation).</summary>
170 public CKeyVerifiedSafeAction? Action { get; }
171 /// <summary>The request to confirm (NeedsConfirmation only).</summary>
172 public CKeySafeActionRequest? Request { get; }
173 }
174
175 /// <summary>
176 /// The receiving app's Safe Actions policy. Thread-safe.
177 /// </summary>
178 /// <example>
179 /// <code>
180 /// var actions = new CKeySafeActions("com.viadere")
181 /// .Trust("citeory.com", "2026-11", "&lt;64 hex digits&gt;", "https://citeory.com/api/ckey/requests/{request_id}")
182 /// .Register("signin.request", new[] { "citeory.com" }, CKeySafeActionScope.CrossDevice, null, a => SignInAsync(a));
183 /// var outcome = await actions.HandleAsync(text);
184 /// if (outcome.Kind == CKeySafeActionOutcomeKind.NeedsConfirmation) await CKeySafeActionPrompt.ShowAsync(window, outcome.Request!);
185 /// </code>
186 /// </example>
187 public sealed class CKeySafeActions
188 {
189 /// <summary>The Safe Action text prefix.</summary>
190 public const string Prefix = "ckey-action:1:";
191 private const int MaxLifetimeCap = 3600;
192 private static readonly TimeSpan KeyFileTtl = TimeSpan.FromHours(1);
193 private const long ClockToleranceMs = 120_000;
194 private static readonly Regex AppIdPattern = new Regex("^[A-Za-z0-9._-]{1,255}$", RegexOptions.CultureInvariant);
195 private static readonly Regex IssuerPattern = new Regex("^[a-z0-9.-]+\\.[a-z0-9-]+$", RegexOptions.CultureInvariant);
196 private static readonly Regex ActionPattern = new Regex("^[a-z0-9._-]{1,64}$", RegexOptions.CultureInvariant);
197 private static readonly Regex KeyIdPattern = new Regex("^[A-Za-z0-9._-]{1,32}$", RegexOptions.CultureInvariant);
198 private static readonly Regex HexKeyPattern = new Regex("^[0-9A-Fa-f]{64}$", RegexOptions.CultureInvariant);
199
200 /// <summary>The core's verdict. Replaced only by unit tests.</summary>
201 internal delegate int CoreFn(byte[] payload, byte[]? publicKey, long nowSeconds, int maxLifetime, out CKeyVerifiedSafeAction? fields, out string message);
202
203 private sealed class Trusted { public byte[] Key = Array.Empty<byte>(); public string? RequestInfo; }
204 private sealed class Registered { public HashSet<string> Issuers = new HashSet<string>(); public CKeySafeActionScope Scope; public string? Title; public Func<CKeyVerifiedSafeAction, Task> Handler = _ => Task.CompletedTask; }
205 private sealed class KeyFile { public DateTimeOffset At; public Dictionary<string, (byte[] Key, DateTimeOffset? NotAfter)> Keys = new Dictionary<string, (byte[], DateTimeOffset?)>(); public string? RequestInfo; }
206
207 private readonly object _lock = new object();
208 private readonly Dictionary<string, Trusted> _keys = new Dictionary<string, Trusted>();
209 private readonly Dictionary<string, Registered> _actions = new Dictionary<string, Registered>();
210 private readonly Dictionary<string, long> _seen = new Dictionary<string, long>();
211 private readonly Dictionary<string, KeyFile?> _keyFiles = new Dictionary<string, KeyFile?>();
212 private readonly HttpClient _http;
213
214 internal CoreFn Core = NativeCore;
215 internal Func<string, CancellationToken, Task<string?>> Fetch;
216 internal Func<DateTimeOffset> Clock = () => DateTimeOffset.UtcNow;
217
218 /// <param name="appId">This app's ID, for example <c>com.viadere</c>.</param>
219 /// <param name="maxLifetime">The longest lifetime this app accepts, 1 to 3600 seconds.</param>
220 /// <param name="useKeyFiles">Also look up keys in <c>https://&lt;issuer&gt;/.well-known/ckey-safe-actions.json</c>. Pinned keys are preferred for sign-in and payments.</param>
221 /// <param name="http">The HttpClient for key files and request information; one is created when null.</param>
222 public CKeySafeActions(string appId, int maxLifetime = 600, bool useKeyFiles = false, HttpClient? http = null)
223 {
224 if (appId == null || !AppIdPattern.IsMatch(appId)) throw new ArgumentException("appId must be an app ID such as com.example.app", nameof(appId));
225 if (maxLifetime < 1 || maxLifetime > MaxLifetimeCap) throw new ArgumentOutOfRangeException(nameof(maxLifetime), "maxLifetime is 1 to 3600 seconds");
226 AppId = appId;
227 MaxLifetime = maxLifetime;
228 UseKeyFiles = useKeyFiles;
229 _http = http ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(5) };
230 Fetch = HttpsGet;
231 }
232
233 /// <summary>This app's ID.</summary>
234 public string AppId { get; }
235 /// <summary>The longest lifetime accepted, in seconds.</summary>
236 public int MaxLifetime { get; }
237 /// <summary>Whether issuer key files are consulted.</summary>
238 public bool UseKeyFiles { get; }
239
240 /// <summary>True when text is a Safe Action payload. Says nothing about whether it is valid.</summary>
241 public static bool IsSafeAction(string? text) => text != null && text.StartsWith(Prefix, StringComparison.Ordinal) && text.Length > Prefix.Length;
242
243 /// <summary>Pins an issuer's Ed25519 public key (64 hex digits). <paramref name="requestInfo"/> is the issuer's request_info URL template, required for cross-device actions.</summary>
244 public CKeySafeActions Trust(string issuer, string keyId, string publicKeyHex, string? requestInfo = null)
245 {
246 if (issuer == null || !IssuerPattern.IsMatch(issuer)) throw new ArgumentException("issuer is a lower-case domain name", nameof(issuer));
247 if (keyId == null || !KeyIdPattern.IsMatch(keyId)) throw new ArgumentException("keyId is 1 to 32 letters, digits, . _ or -", nameof(keyId));
248 byte[] key = HexKey(publicKeyHex) ?? throw new ArgumentException("a public key is 64 hexadecimal digits", nameof(publicKeyHex));
249 lock (_lock) _keys[issuer + " " + keyId] = new Trusted { Key = key, RequestInfo = requestInfo };
250 return this;
251 }
252
253 /// <summary>
254 /// Registers an action this app accepts from <paramref name="issuers"/>. The stricter of
255 /// <paramref name="scope"/> and the envelope's own scope applies.
256 /// </summary>
257 public CKeySafeActions Register(string action, IEnumerable<string> issuers, CKeySafeActionScope scope, string? title, Func<CKeyVerifiedSafeAction, Task> handler)
258 {
259 if (action == null || !ActionPattern.IsMatch(action)) throw new ArgumentException("action is 1 to 64 of a-z 0-9 . _ -", nameof(action));
260 var set = new HashSet<string>(issuers ?? throw new ArgumentNullException(nameof(issuers)), StringComparer.Ordinal);
261 if (set.Count == 0) throw new ArgumentException("name the issuer or issuers allowed to request this action", nameof(issuers));
262 if (handler == null) throw new ArgumentNullException(nameof(handler));
263 lock (_lock) _actions[action] = new Registered { Issuers = set, Scope = scope, Title = ShortText(title, 80), Handler = handler };
264 return this;
265 }
266
267 /// <summary>Registers a same-device action with a synchronous handler.</summary>
268 public CKeySafeActions Register(string action, IEnumerable<string> issuers, Action<CKeyVerifiedSafeAction> handler)
269 {
270 if (handler == null) throw new ArgumentNullException(nameof(handler));
271 return Register(action, issuers, CKeySafeActionScope.SameDevice, null, a => { handler(a); return Task.CompletedTask; });
272 }
273
274 /// <summary>Handles a decoded text payload.</summary>
275 public async Task<CKeySafeActionOutcome> HandleAsync(string text, CancellationToken cancellationToken = default)
276 {
277 if (!IsSafeAction(text)) return CKeySafeActionOutcome.NotASafeAction;
278 byte[] bytes = Encoding.UTF8.GetBytes(text);
279 if (Core(bytes, null, 0, MaxLifetimeCap, out var claimed, out string parseMessage) != 0 || claimed == null)
280 return CKeySafeActionOutcome.Rejected(string.IsNullOrEmpty(parseMessage) ? "malformed Safe Action" : parseMessage);
281 if (claimed.App != AppId) return CKeySafeActionOutcome.Rejected($"this code is for another app ({claimed.App})");
282 Registered? registered;
283 lock (_lock) _actions.TryGetValue(claimed.Action, out registered);
284 if (registered == null) return CKeySafeActionOutcome.Rejected($"this app does not accept the action {claimed.Action}");
285 if (!registered.Issuers.Contains(claimed.Issuer)) return CKeySafeActionOutcome.Rejected($"{claimed.Issuer} may not request {claimed.Action}");
286 Trusted? trusted = await KeyFor(claimed.Issuer, claimed.KeyId, cancellationToken).ConfigureAwait(false);
287 if (trusted == null) return CKeySafeActionOutcome.Rejected($"no trusted key {claimed.KeyId} for {claimed.Issuer}");
288 long nowMs = Clock().ToUnixTimeMilliseconds();
289 int status = Core(bytes, trusted.Key, nowMs / 1000, MaxLifetime, out var verified, out string message);
290 if (status != 0 || verified == null) return CKeySafeActionOutcome.Rejected(string.IsNullOrEmpty(message) ? $"verification failed ({(CKeyStatus)status})" : message);
291 if (verified.App != claimed.App || verified.Action != claimed.Action || verified.Issuer != claimed.Issuer) return CKeySafeActionOutcome.Rejected("verification mismatch");
292 lock (_lock)
293 {
294 foreach (var stale in _seen.Where(kv => kv.Value < nowMs).Select(kv => kv.Key).ToList()) _seen.Remove(stale);
295 if (_seen.ContainsKey(verified.RequestId)) return CKeySafeActionOutcome.Rejected("this code has already been used");
296 _seen[verified.RequestId] = verified.ExpiresAt * 1000 + ClockToleranceMs;
297 }
298 bool crossDevice = verified.Scope == CKeySafeActionScope.CrossDevice || registered.Scope == CKeySafeActionScope.CrossDevice;
299 if (!crossDevice)
300 {
301 await registered.Handler(verified).ConfigureAwait(false);
302 return CKeySafeActionOutcome.Performed(verified);
303 }
304 var info = await RequestInfo(trusted.RequestInfo, verified.Issuer, verified.RequestId, cancellationToken).ConfigureAwait(false);
305 if (info == null) return CKeySafeActionOutcome.Rejected("the issuer did not confirm a pending request, so this cross-device action cannot run");
306 return CKeySafeActionOutcome.Confirm(new CKeySafeActionRequest(registered.Title ?? $"Continue with {verified.Issuer}?", verified, info, registered.Handler));
307 }
308
309 private async Task<Trusted?> KeyFor(string issuer, string keyId, CancellationToken ct)
310 {
311 lock (_lock) if (_keys.TryGetValue(issuer + " " + keyId, out var pinned)) return pinned;
312 if (!UseKeyFiles) return null;
313 KeyFile? file = await LoadKeyFile(issuer, ct).ConfigureAwait(false);
314 if (file == null || !file.Keys.TryGetValue(keyId, out var entry)) return null;
315 if (entry.NotAfter.HasValue && entry.NotAfter.Value < Clock()) return null;
316 return new Trusted { Key = entry.Key, RequestInfo = file.RequestInfo };
317 }
318
319 private async Task<KeyFile?> LoadKeyFile(string issuer, CancellationToken ct)
320 {
321 DateTimeOffset now = Clock();
322 lock (_lock)
323 if (_keyFiles.TryGetValue(issuer, out var cached) && (cached == null ? false : now - cached.At < KeyFileTtl)) return cached;
324 string? body = await Fetch("https://" + issuer + "/.well-known/ckey-safe-actions.json", ct).ConfigureAwait(false);
325 KeyFile? file = null;
326 if (body != null && MiniJson.Parse(body) is Dictionary<string, object?> root && root.TryGetValue("issuer", out var i) && (i as string) == issuer)
327 {
328 file = new KeyFile { At = now, RequestInfo = root.TryGetValue("request_info", out var ri) ? ri as string : null };
329 if (root.TryGetValue("keys", out var keys) && keys is List<object?> list)
330 {
331 foreach (var k in list)
332 {
333 if (!(k is Dictionary<string, object?> m)) continue;
334 if (!(m.TryGetValue("id", out var id) && id is string idText && KeyIdPattern.IsMatch(idText))) continue;
335 if (!(m.TryGetValue("alg", out var alg) && (alg as string) == "Ed25519")) continue;
336 byte[]? key = m.TryGetValue("public_key", out var pk) && pk is string hex ? HexKey(hex) : null;
337 if (key == null) continue;
338 DateTimeOffset? notAfter = null;
339 if (m.TryGetValue("not_after", out var na) && na is string naText)
340 {
341 if (!DateTimeOffset.TryParse(naText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) continue;
342 notAfter = parsed;
343 }
344 file.Keys[idText] = (key, notAfter);
345 }
346 }
347 }
348 // A failed fetch is not cached, so a transient failure does not lock an issuer out for an hour.
349 if (file != null) lock (_lock) _keyFiles[issuer] = file;
350 return file;
351 }
352
353 private async Task<CKeySafeActionRequestInfo?> RequestInfo(string? template, string issuer, string requestId, CancellationToken ct)
354 {
355 string? url = RequestInfoUrl(template, issuer, requestId);
356 if (url == null) return null;
357 string? body = await Fetch(url, ct).ConfigureAwait(false);
358 if (body == null || !(MiniJson.Parse(body) is Dictionary<string, object?> info)) return null;
359 string? Field(string name) => info.TryGetValue(name, out var v) ? v as string : null;
360 if (Field("request_id") != requestId || Field("status") != "pending") return null;
361 return new CKeySafeActionRequestInfo(ShortText(Field("device")), ShortText(Field("location")), ShortText(Field("requested_at"), 40));
362 }
363
364 /// <summary>Request information must come over HTTPS from the issuer's own domain.</summary>
365 internal static string? RequestInfoUrl(string? template, string issuer, string requestId)
366 {
367 if (template == null) return null;
368 string text = template.Replace("{request_id}", Uri.EscapeDataString(requestId));
369 if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)) return null;
370 string host = uri.Host.ToLowerInvariant();
371 if (uri.Scheme != "https" || uri.UserInfo.Length != 0 || !(host == issuer || host.EndsWith("." + issuer, StringComparison.Ordinal))) return null;
372 return uri.AbsoluteUri;
373 }
374
375 internal static byte[]? HexKey(string? hex)
376 {
377 if (hex == null || !HexKeyPattern.IsMatch(hex)) return null;
378 var key = new byte[32];
379 for (int i = 0; i < 32; i++) key[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
380 return key;
381 }
382
383 private static string? ShortText(string? v, int max = 120) => v != null && v.Length > 0 && v.Length <= max ? v : null;
384
385 private async Task<string?> HttpsGet(string url, CancellationToken ct)
386 {
387 if (!url.StartsWith("https://", StringComparison.Ordinal)) return null;
388 try
389 {
390 using var request = new HttpRequestMessage(HttpMethod.Get, url);
391 request.Headers.Accept.ParseAdd("application/json");
392 using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
393 if ((int)response.StatusCode != 200) return null;
394 if (response.Content.Headers.ContentLength > 64 * 1024) return null;
395 byte[] body = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
396 return body.Length > 64 * 1024 ? null : Encoding.UTF8.GetString(body);
397 }
398 catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is InvalidOperationException)
399 {
400 return null;
401 }
402 }
403
404 private static unsafe int NativeCore(byte[] payload, byte[]? publicKey, long nowSeconds, int maxLifetime, out CKeyVerifiedSafeAction? fields, out string message)
405 {
406 NativeSafeAction sa = default;
407 NativeDiagnostics diag = default;
408 diag.AbiVersion = NativeConstants.AbiVersion;
409 int status;
410 fixed (byte* p = payload)
411 {
412 if (publicKey == null)
413 {
414 status = NativeMethods.ckey_safe_action_parse(p, (nuint)payload.Length, &sa, &diag);
415 }
416 else
417 {
418 if (publicKey.Length != 32) throw new ArgumentException("a public key is 32 bytes", nameof(publicKey));
419 fixed (byte* k = publicKey)
420 status = NativeMethods.ckey_safe_action_verify(p, (nuint)payload.Length, k, (ulong)Math.Max(0, nowSeconds), (uint)maxLifetime, &sa, &diag);
421 }
422 }
423 if (status != 0)
424 {
425 fields = null;
426 message = Native.Message(ref diag);
427 return status;
428 }
429 int n = (int)Math.Min((ulong)sa.ParamsLength, 1024UL);
430 var parameters = new byte[n];
431 for (int i = 0; i < n; i++) parameters[i] = sa.Params[i];
432 fields = new CKeyVerifiedSafeAction(
433 Native.FromUtf8Z(sa.Issuer, 254) ?? "", Native.FromUtf8Z(sa.App, 256) ?? "", Native.FromUtf8Z(sa.Action, 65) ?? "",
434 Native.FromUtf8Z(sa.KeyId, 33) ?? "", Native.FromUtf8Z(sa.RequestId, 65) ?? "",
435 sa.Scope == 1 ? CKeySafeActionScope.CrossDevice : CKeySafeActionScope.SameDevice,
436 (long)Math.Min(sa.IssuedAt, long.MaxValue), (long)Math.Min(sa.ExpiresAt, long.MaxValue), parameters);
437 message = string.Empty;
438 return 0;
439 }
440 }
441
442 /// <summary>
443 /// The SDK's console confirmation for a cross-device Safe Action. It reads the
444 /// answer from the interactive console only; with redirected input it declines.
445 /// </summary>
446 public static class CKeySafeActionConsolePrompt
447 {
448 /// <summary>Asks on the console. Returns true when the person approved and the handler ran.</summary>
449 public static Task<bool> AskAsync(CKeySafeActionRequest request)
450 {
451 if (request == null) throw new ArgumentNullException(nameof(request));
452 if (request.IsDecided) return Task.FromResult(false);
453 if (Console.IsInputRedirected) return request.Decide(false);
454 Console.Out.WriteLine(request.Title);
455 Console.Out.WriteLine(request.Message);
456 Console.Out.Write("Type 'continue' to approve, anything else to cancel: ");
457 string? answer = Console.ReadLine();
458 return request.Decide(string.Equals(answer?.Trim(), "continue", StringComparison.OrdinalIgnoreCase));
459 }
460 }
461
462 /// <summary>A small JSON reader for key files and request information.</summary>
463 internal static class MiniJson
464 {
465 internal static object? Parse(string text)
466 {
467 try
468 {
469 int i = 0;
470 object? v = Value(text, ref i, 0);
471 Ws(text, ref i);
472 return i == text.Length ? v : null;
473 }
474 catch (Exception e) when (e is FormatException || e is IndexOutOfRangeException || e is ArgumentOutOfRangeException || e is OverflowException)
475 {
476 return null;
477 }
478 }
479
480 private static void Ws(string s, ref int i) { while (i < s.Length && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n')) i++; }
481 private static void Expect(bool ok) { if (!ok) throw new FormatException(); }
482
483 private static object? Value(string s, ref int i, int depth)
484 {
485 Expect(depth < 32);
486 Ws(s, ref i);
487 char c = s[i];
488 if (c == '{')
489 {
490 i++;
491 var m = new Dictionary<string, object?>(StringComparer.Ordinal);
492 Ws(s, ref i);
493 if (s[i] == '}') { i++; return m; }
494 while (true)
495 {
496 Ws(s, ref i); string k = Str(s, ref i); Ws(s, ref i); Expect(s[i] == ':'); i++;
497 m[k] = Value(s, ref i, depth + 1); Ws(s, ref i);
498 if (s[i] == ',') { i++; continue; }
499 Expect(s[i] == '}'); i++; return m;
500 }
501 }
502 if (c == '[')
503 {
504 i++;
505 var a = new List<object?>();
506 Ws(s, ref i);
507 if (s[i] == ']') { i++; return a; }
508 while (true)
509 {
510 a.Add(Value(s, ref i, depth + 1)); Ws(s, ref i);
511 if (s[i] == ',') { i++; continue; }
512 Expect(s[i] == ']'); i++; return a;
513 }
514 }
515 if (c == '"') return Str(s, ref i);
516 if (string.CompareOrdinal(s, i, "true", 0, 4) == 0) { i += 4; return true; }
517 if (string.CompareOrdinal(s, i, "false", 0, 5) == 0) { i += 5; return false; }
518 if (string.CompareOrdinal(s, i, "null", 0, 4) == 0) { i += 4; return null; }
519 int start = i;
520 while (i < s.Length && (char.IsDigit(s[i]) || s[i] == '-' || s[i] == '+' || s[i] == '.' || s[i] == 'e' || s[i] == 'E')) i++;
521 Expect(i > start);
522 return double.Parse(s.Substring(start, i - start), NumberStyles.Float, CultureInfo.InvariantCulture);
523 }
524
525 private static string Str(string s, ref int i)
526 {
527 Expect(s[i] == '"'); i++;
528 var b = new StringBuilder();
529 while (true)
530 {
531 char c = s[i++];
532 if (c == '"') return b.ToString();
533 if (c == '\\')
534 {
535 char e = s[i++];
536 switch (e)
537 {
538 case 'n': b.Append('\n'); break;
539 case 't': b.Append('\t'); break;
540 case 'r': b.Append('\r'); break;
541 case 'b': b.Append('\b'); break;
542 case 'f': b.Append('\f'); break;
543 case 'u': b.Append((char)int.Parse(s.Substring(i, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); i += 4; break;
544 default: b.Append(e); break;
545 }
546 }
547 else
548 {
549 Expect(c >= ' ');
550 b.Append(c);
551 }
552 }
553 }
554 }
555}