diff --git a/keyring/DiscrepancyException.cs b/keyring/DiscrepancyException.cs
new file mode 100644
index 0000000..bdb0bba
--- /dev/null
+++ b/keyring/DiscrepancyException.cs
@@ -0,0 +1,9 @@
+using System.Security;
+
+namespace CoolandonRS.keyring.Yubikey;
+
+public class DiscrepancyException : SecurityException {
+ public DiscrepancyException() : base() {}
+ public DiscrepancyException(string msg) : base(msg) {}
+
+}
\ No newline at end of file
diff --git a/keyring/Yubikey/YubiOTP.cs b/keyring/Yubikey/YubiOTP.cs
new file mode 100644
index 0000000..a974765
--- /dev/null
+++ b/keyring/Yubikey/YubiOTP.cs
@@ -0,0 +1,120 @@
+using System.Buffers.Text;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.JavaScript;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace CoolandonRS.keyring.Yubikey;
+
+public static class YubiOTP {
+ private static readonly string[] ApiEndpoints = new []{ "api", "api2", "api3", "api4", "api5" }.Select(s => $"https://{s}.yubico.com/wsapi/2.0/verify").ToArray();
+ private static readonly char[] NonceChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".ToCharArray();
+
+ internal enum YubicoApiStatus {
+ OK,
+ BAD_OTP,
+ REPLAYED_OTP,
+ BAD_SIGNATURE,
+ MISSING_PARAMETER,
+ NO_SUCH_CLIENT,
+ OPERATION_NOT_ALLOWED,
+ BACKEND_ERROR,
+ NOT_ENOUGH_ANSWERS,
+ REPLAYED_REQUEST
+ }
+
+ ///
+ /// Verifies that an OTP is valid
+ ///
+ /// The provided OTP
+ /// The api authentication to use
+ /// Whether or not to only allow factory OTP configurations
+ ///
+ public static async Task Verify(string otp, (string, string) api, bool factoryOnly = false) => await Verify(otp, api, (string[]?) null);
+
+ ///
+ /// Verifies that an OTP is valid, optionally that it belongs to a specific yubikey
+ ///
+ /// The provided OTP
+ /// The api authentication to use
+ /// The public identifier of the authorized yubikey. When specified, if the OTP does not match this id, returns false.
+ /// Whether or not to only allow factory OTP configurations
+ /// True if the otp is valid, and if id or serial is set, is authorized.
+ public static async Task Verify(string otp, (string id, string key) api, string? id = null, bool factoryOnly = false) => await Verify(otp, api, id == null ? null : new[] { id }, factoryOnly);
+
+ ///
+ /// Verifies that an OTP is valid, optionally that it belongs to one of several yubikeys
+ ///
+ /// The provided OTP
+ /// The api authentication to use
+ /// The public identifiers of authorized yubikeys. When specified, if the OTP does not match one of these ids, returns false.
+ /// Whether or not to only allow factory OTP configurations
+ /// True if the otp is valid (and authorized). False if the otp is valid (and unauthorized). Throws on an invalid otp (YubicoErrorException or DiscrepancyException)
+ public static async Task Verify(string otp, (string id, string key) api, string[]? ids = null, bool factoryOnly = false) {
+ if (otp.Length is < 32 or > 48) throw new YubicoErrorException(YubicoApiStatus.BAD_OTP);
+ if (factoryOnly && otp[..2] != "cc") return false;
+ var nonce = BuildNonce();
+ var request = $"id={api.id}&nonce={nonce}&otp={otp}";
+ var hash = HMACSHA1.HashData(Convert.FromBase64String(api.key), Encoding.UTF8.GetBytes(request));
+ request += $"&h={Convert.ToBase64String(hash)}";
+ var response = (await Request(request)).Trim().Split("\r\n").Select(prop => prop.Split('=')).ToDictionary(prop => prop[0], prop => string.Join("", prop[1..]));
+ var respStatus = Enum.Parse(response["status"]);
+ if (respStatus != YubicoApiStatus.OK) throw new YubicoErrorException(respStatus);
+ var respOtp = response["otp"];
+ var respNonce = response["nonce"];
+ var respHash = response["h"];
+ var respSl = response["sl"];
+ var respT = response["t"];
+ var computedRespHash = Convert.ToBase64String(HMACSHA1.HashData(Convert.FromBase64String(api.key), Encoding.UTF8.GetBytes($"nonce={respNonce}&otp={otp}&sl={respSl}&status={respStatus.ToString()}&t={respT}"))).TrimEnd('=');
+ if (otp != respOtp) throw new DiscrepancyException("OTP mismatch");
+ if (nonce != respNonce) throw new DiscrepancyException("Nonce mismatch");
+ if (respHash != computedRespHash) throw new DiscrepancyException("Signing error");
+ return ids != null && ids.Contains(otp[..^32]);
+ }
+
+ ///
+ /// Creates a nonce
+ ///
+ /// How long the nonce should be. If null a random number between 16 and 40 (inclusive)
+ ///
+ internal static string BuildNonce(int? len = null) {
+ len ??= RandomNumberGenerator.GetInt32(16, 41);
+ var builder = new StringBuilder();
+ for (var i = 0; i <= len; i++) {
+ builder.Append(NonceChars[RandomNumberGenerator.GetInt32(0, NonceChars.Length)]);
+ }
+ return builder.ToString();
+ }
+
+ internal static async Task Request(string request) {
+ var cancelSource = new CancellationTokenSource();
+ var task = new TaskCompletionSource();
+ void Complete(string str) {
+ cancelSource.Cancel();
+ task.SetResult(str);
+ }
+ // https://developers.yubico.com/yubikey-val/Getting_Started_Writing_Clients.html: Clients should send authentication requests to all of them in parallel, and utilize the first response
+ foreach (var endpoint in ApiEndpoints) {
+ try {
+ #pragma warning disable CS4014
+ SendRequest(endpoint + $"?{request}", Complete, cancelSource.Token);
+ #pragma warning restore CS4014
+ } catch (OperationCanceledException) {
+ // intentional
+ break;
+ }
+ }
+
+ if (await Task.WhenAny(task.Task, Task.Delay(TimeSpan.FromSeconds(5))) == task.Task) return await task.Task;
+ cancelSource.Cancel();
+ throw new TimeoutException("Yubikey servers did not respond in time");
+ }
+
+ internal static async Task SendRequest(string fullRequest, Action callback, CancellationToken token) {
+ var response = await new HttpClient().SendAsync(new HttpRequestMessage(HttpMethod.Get, fullRequest), token);
+ if (!response.IsSuccessStatusCode) return;
+ token.ThrowIfCancellationRequested();
+ callback(await response.Content.ReadAsStringAsync(token));
+ }
+}
\ No newline at end of file
diff --git a/keyring/Yubikey/YubicoErrorException.cs b/keyring/Yubikey/YubicoErrorException.cs
new file mode 100644
index 0000000..bfc0454
--- /dev/null
+++ b/keyring/Yubikey/YubicoErrorException.cs
@@ -0,0 +1,5 @@
+namespace CoolandonRS.keyring.Yubikey;
+
+public class YubicoErrorException : InvalidOperationException {
+ internal YubicoErrorException(YubiOTP.YubicoApiStatus status) : base(status.ToString()) {}
+}
\ No newline at end of file