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
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | using System; |
| 2 | using System.Collections.Generic; |
| 3 | using System.Globalization; |
| 4 | using System.Linq; |
| 5 | using System.Net.Http; |
| 6 | using System.Text; |
| 7 | using System.Text.RegularExpressions; |
| 8 | using System.Threading; |
| 9 | using System.Threading.Tasks; |
| 10 | using 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. |
| 22 | namespace 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", "<64 hex digits>", "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://<issuer>/.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 | } |