diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5b64dc0..8c0ba83 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,7 @@ updates: directory: "/" schedule: interval: monthly - open-pull-requests-limit: 10 + open-pull-requests-limit: 1 registries: - nuget-feed-nuget-pkg-github-com-ashleighadams-index-json - public @@ -26,4 +26,4 @@ updates: - "patch" ignore: - dependency-name: "*" - update-types: ["version-update:semver-major"] \ No newline at end of file + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 099ade2..4dcf95e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -45,12 +45,12 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Setup Verlite run: | #verlite_version="$(dotnet msbuild Directory.Build.props -getitem:PackageReference | jq -r '.Items.PackageReference[] | select(.Identity=="Verlite.MsBuild") .Version')" - verlite_version="2.5.0" + verlite_version="2.7.0" dotnet tool install --global Verlite.CLI --version "$verlite_version" verlite . --enable-shadow-repo --auto-fetch --verbosity verbatim @@ -97,12 +97,12 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Setup Verlite run: | #verlite_version="$(dotnet msbuild Directory.Build.props -getitem:PackageReference | jq -r '.Items.PackageReference[] | select(.Identity=="Verlite.MsBuild") .Version')" - verlite_version="2.5.0" + verlite_version="2.7.0" dotnet tool install --global Verlite.CLI --version "$verlite_version" verlite . --enable-shadow-repo --auto-fetch --verbosity verbatim diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index eddd701..1e7c72e 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -31,9 +31,9 @@ jobs: # - rid: linux-arm64 # os: ubuntu-latest # project: src/Keyden.Desktop.Unix - - rid: osx-x64 - os: macos-13 - project: src/Keyden.Desktop.Unix + #- rid: osx-x64 + # os: macos-13 + # project: src/Keyden.Desktop.Unix # - rid: osx-arm64 # os: macos-13 # project: src/Keyden.Desktop.Unix @@ -46,12 +46,12 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.x + dotnet-version: 10.x - name: Setup Verlite run: | #verlite_version="$(dotnet msbuild Directory.Build.props -getitem:PackageReference | jq -r '.Items.PackageReference[] | select(.Identity=="Verlite.MsBuild") .Version')" - verlite_version="2.5.0" + verlite_version="2.7.0" dotnet tool install --global Verlite.CLI --version "$verlite_version" verlite . --enable-shadow-repo --auto-fetch --verbosity verbatim diff --git a/Directory.Build.props b/Directory.Build.props index 9827602..25aae6c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ enable - 13.0 + 14.0 @@ -25,7 +25,7 @@ true true - 9.0 + 10.0 true @@ -65,7 +65,7 @@ true - + diff --git a/Directory.Packages.props b/Directory.Packages.props index 453b199..0629633 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,30 +4,31 @@ true - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - - - + + + + - + \ No newline at end of file diff --git a/Keyden.sln b/Keyden.sln index 3fbab7b..6de6c95 100644 --- a/Keyden.sln +++ b/Keyden.sln @@ -34,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub", "GitHub", "{7CFFD7 .github\workflows\deploy-release.yml = .github\workflows\deploy-release.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Keyden.1Password.Sdk", "src\Keyden.1Password.Sdk\Keyden.1Password.Sdk.csproj", "{10A5765F-A07B-33D4-F164-85AAFF3181E3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -168,6 +170,26 @@ Global {9D7AA409-A924-484E-9E25-85795D1D013F}.Release|x64.Build.0 = Release|x64 {9D7AA409-A924-484E-9E25-85795D1D013F}.Release|x86.ActiveCfg = Release|x86 {9D7AA409-A924-484E-9E25-85795D1D013F}.Release|x86.Build.0 = Release|x86 + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|ARM.ActiveCfg = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|ARM.Build.0 = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|ARM64.Build.0 = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|x64.Build.0 = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Debug|x86.Build.0 = Debug|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|Any CPU.Build.0 = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|ARM.ActiveCfg = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|ARM.Build.0 = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|ARM64.ActiveCfg = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|ARM64.Build.0 = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|x64.ActiveCfg = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|x64.Build.0 = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|x86.ActiveCfg = Release|Any CPU + {10A5765F-A07B-33D4-F164-85AAFF3181E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/global.json b/global.json index 10054b8..4d1a435 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "latestFeature" } } diff --git a/src/Keyden.1Password.Sdk/Base64JsonConverter.cs b/src/Keyden.1Password.Sdk/Base64JsonConverter.cs new file mode 100644 index 0000000..e840985 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Base64JsonConverter.cs @@ -0,0 +1,18 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK; + +internal class Base64JsonConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return OnePassSdkNative.Utf8.GetString(reader.GetBytesFromBase64()); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteBase64StringValue(OnePassSdkNative.Utf8.GetBytes(value)); + } +} diff --git a/src/Keyden.1Password.Sdk/ByteArrayJsonConverter.cs b/src/Keyden.1Password.Sdk/ByteArrayJsonConverter.cs new file mode 100644 index 0000000..d0e813b --- /dev/null +++ b/src/Keyden.1Password.Sdk/ByteArrayJsonConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK; + +internal class ByteArrayJsonConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var ms = new MemoryStream(); + + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException($"Expected start array, got {reader.TokenType}", null, null, reader.TokenStartIndex); + + while (reader.Read() && reader.TokenType == JsonTokenType.Number) + { + if (!reader.TryGetByte(out byte x)) + throw new JsonException($"Expected number between [0..255]", null, null, reader.TokenStartIndex); + ms.WriteByte(x); + } + + if (reader.TokenType != JsonTokenType.EndArray) + throw new JsonException($"Expected end array, got {reader.TokenType}", null, null, reader.TokenStartIndex); + + ms.Flush(); + return OnePassSdkNative.Utf8.GetString(ms.ToArray()); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + var bytes = OnePassSdkNative.Utf8.GetBytes(value); + + writer.WriteStartArray(); + foreach (byte x in bytes) + writer.WriteNumberValue(x); + writer.WriteEndArray(); + } +} diff --git a/src/Keyden.1Password.Sdk/Keyden.1Password.Sdk.csproj b/src/Keyden.1Password.Sdk/Keyden.1Password.Sdk.csproj new file mode 100644 index 0000000..2083859 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Keyden.1Password.Sdk.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + Keyden.OnePassword.SDK + True + + + + + + + diff --git a/src/Keyden.1Password.Sdk/OnePassSdk.cs b/src/Keyden.1Password.Sdk/OnePassSdk.cs new file mode 100644 index 0000000..06376e3 --- /dev/null +++ b/src/Keyden.1Password.Sdk/OnePassSdk.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Keyden.OnePassword.SDK.Types; + +namespace Keyden.OnePassword.SDK; + +static file class TaskRunMarshaller +{ + public static YieldTask Yield() + { + return new(); + } + + public struct YieldTask + { + public YieldAwaiter GetAwaiter() + { + return new(); + } + } + + public struct YieldAwaiter : ICriticalNotifyCompletion + { + public bool IsCompleted => false; + public void GetResult() { } + + private static ContextCallback? Runner { get; set; } + public void OnCompleted(Action continuation) + { + [DebuggerNonUserCode] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static void executionContextRunner(object obj) => (obj as Action)!(); + Runner ??= executionContextRunner!; + + var ctx = ExecutionContext.Capture(); + if (ctx is not null) + Task.Run(() => ExecutionContext.Run(ctx, Runner, continuation)); + else + Task.Run(() => continuation()); + + } + + public void UnsafeOnCompleted(Action continuation) + { + Task.Run(() => continuation()); + } + } +} + +public partial class OnePassSdk : IDisposable, IAsyncDisposable +{ + private OnePassSdkConfig Config; + public string? AccountName { get; set; } + + private record struct ConnectionInformation(int ClientId, string AccountName); + private ConnectionInformation? ConnectionInfo { get; set; } + + public OnePassSdk(OnePassSdkConfig config) + { + Config = config; + } + + private readonly object ConnectLock = new(); + private void Connect() + { + lock (ConnectLock) + { + if (AccountName is null) + throw new InvalidOperationException("Must assign an account name prior to use"); + + var accountName = AccountName; + var configJson = OnePassSdkConfig.ToJson(Config); + var initResponse = OnePassSdkInterop.CallSharedLibrary(new() + { + Kind = OnePassSdkInterop.PayloadKind.InitClient, + AccountName = accountName, + Payload = configJson, + }); + + var clientId = int.Parse(initResponse.Payload, CultureInfo.InvariantCulture); + + ConnectionInfo = new(clientId, accountName); + } + } + + private void Disconnect() + { + lock (ConnectLock) + { + if (!ConnectionInfo.HasValue) + return; + + try + { + _ = OnePassSdkInterop.CallSharedLibrary(new() + { + Kind = OnePassSdkInterop.PayloadKind.ReleaseClient, + AccountName = AccountName ?? string.Empty, + Payload = ConnectionInfo.Value.ClientId.ToString(null, CultureInfo.InvariantCulture), + }); + } + catch { } + + ConnectionInfo = null; + } + } + + private bool Disposed; + protected virtual void Dispose(bool disposing) + { + if (!Disposed) + { + Disconnect(); + Disposed = true; + } + } + + private string Invoke(string name, [StringSyntax("json")] string parameters) + { + bool retry; + int tryCount = 0; + do + { + retry = false; + tryCount++; + try + { + if (!ConnectionInfo.HasValue) + Connect(); + if (!ConnectionInfo.HasValue) + throw new OnePassSdkChannelClosedException(); + + var connectionInfo = ConnectionInfo.Value; + return OnePassSdkInterop.Invoke(connectionInfo.AccountName, new() + { + Invocation = new() + { + ClientId = connectionInfo.ClientId, + Parameters = new() + { + Name = name, + Parameters = parameters, + } + }, + }); + } + catch (OnePassSdkChannelConnectionDroppedException) + { + Disconnect(); + retry = true; + } + catch (OnePassSdkDesktopSessionExpiredException) + { + Disconnect(); + retry = true; + } + } while (retry && tryCount <= 3); + + throw new OnePassSdkChannelClosedException(); + } + + public string ResolveSecret(string secretReference) + { + var payload = ResolveSecretPayload.ToJson(new() + { + SecretReference = secretReference, + }); + + var response = Invoke("SecretsResolve", payload); + return JsonSerializer.Deserialize(response, OnePassTypesJsonContext.Default.String) + ?? throw new JsonException($"Failed to parse params: {response}"); + } + + public async Task ResolveSecretAsync(string secretReference) + { + await TaskRunMarshaller.Yield(); + return ResolveSecret(secretReference); + } + + public ResolveSecretsResponse ResolveSecrets(IReadOnlyList secretReferences) + { + var payload = ResolveSecretsPayload.ToJson(new() + { + SecretReferences = secretReferences, + }); + var response = Invoke("SecretsResolveAll", payload); + return ResolveSecretsResponse.FromJson(response); + } + + public async Task ResolveSecretsAsync(IReadOnlyList secretReferences) + { + await TaskRunMarshaller.Yield(); + return ResolveSecrets(secretReferences); + } + + public void ValidateSecretReference(string secretReference) + { + var payload = ValidateSecretReferencePayload.ToJson(new() + { + SecretReference = secretReference, + }); + + _ = Invoke("SecretsResolve", payload); + } + + public async Task ValidateSecretReferenceAsync(string secretReference) + { + await TaskRunMarshaller.Yield(); + ValidateSecretReference(secretReference); + } + + public GeneratePasswordResponse GeneratePassword(PasswordRecipe recipe) + { + throw new NotImplementedException(); + } + + public IReadOnlyList GetAllVaults() + { + var res = Invoke("VaultsList", @"{""params"": null}"); + return JsonSerializer.Deserialize(res, OnePassTypesJsonContext.Default.IReadOnlyListGetVaultResponse) ?? []; + } + + public async Task> GetAllVaultsAsync() + { + await TaskRunMarshaller.Yield(); + return GetAllVaults(); + } + + public Item UpdateItem(Item item) + { + var res = Invoke("ItemsPut", ItemsPutPayload.ToJson(new() + { + Item = item, + })); + return Item.FromJson(res); + } + + public async Task UpdateItemAsync(Item item) + { + await TaskRunMarshaller.Yield(); + return UpdateItem(item); + } + + public void DeleteItem(string vaultId, string itemId) + { + _ = Invoke("ItemsDelete", DeleteItemPayload.ToJson(new() + { + VaultId = vaultId, + ItemId = itemId, + })); + } + + public async Task DeleteItemAsync(string vaultId, string itemId) + { + await TaskRunMarshaller.Yield(); + DeleteItem(vaultId, itemId); + } + + public ItemsDeleteAllResponse DeleteItems(string vaultId, IReadOnlyList itemIds) + { + var res = Invoke("ItemsDeleteAll", DeleteAllItemsPayload.ToJson(new() + { + VaultId = vaultId, + ItemIds = itemIds, + })); + return ItemsDeleteAllResponse.FromJson(res); + } + + public async Task DeleteItemsAsync(string vaultId, IReadOnlyList itemIds) + { + await TaskRunMarshaller.Yield(); + return DeleteItems(vaultId, itemIds); + } + + public Item CreateItem(ItemCreateParams itemParam) + { + var res = Invoke("ItemsCreate", ItemsCreatePayload.ToJson(new() { Params = itemParam })); + return Item.FromJson(res); + } + + public async Task CreateItemAsync(ItemCreateParams itemParam) + { + await TaskRunMarshaller.Yield(); + return CreateItem(itemParam); + } + + public ItemsUpdateAllResponse CreateItems(string vaultId, IReadOnlyList itemsParam) + { + var res = Invoke("ItemsCreateAll", ItemsCreateAllPayload.ToJson(new() + { + VaultId = vaultId, + Params = itemsParam, + })); + return ItemsUpdateAllResponse.FromJson(res); + } + + public async Task CreateItemsAsync(string vaultId, IReadOnlyList itemsParam) + { + await TaskRunMarshaller.Yield(); + return CreateItems(vaultId, itemsParam); + } + + // https://github.com/1Password/onepassword-sdk-python/blob/main/src/onepassword/items.py#L91 + public Item GetItem(string vaultId, string itemId) + { + var res = Invoke("ItemsGet", GetItemsPayload.ToJson(new() + { + VaultId = vaultId, + ItemId = itemId, + })); + return Item.FromJson(res); + } + + public async Task GetItemAsync(string vaultId, string itemId) + { + await TaskRunMarshaller.Yield(); + return GetItem(vaultId, itemId); + } + + public ItemsGetAllResponse GetItems(string vaultId, IReadOnlyList itemIds) + { + var res = Invoke("ItemsGetAll", GetAllItemsPayload.ToJson(new() + { + VaultId = vaultId, + ItemIds = itemIds, + })); + return ItemsGetAllResponse.FromJson(res); + } + + public async Task GetItemsAsync(string vaultId, IReadOnlyList itemIds) + { + await TaskRunMarshaller.Yield(); + return GetItems(vaultId, itemIds); + } + + public IReadOnlyList ListItems(string vaultId, IReadOnlyList filters) + { + var res = Invoke("ItemsList", ListItemsPayload.ToJson(new() + { + VaultId = vaultId, + Filters = filters, + })); + return JsonSerializer.Deserialize(res, OnePassTypesJsonContext.Default.IReadOnlyListItemOverview) + ?? []; + } + + public async Task> ListItemsAsync(string vaultId, IReadOnlyList filters) + { + await TaskRunMarshaller.Yield(); + return ListItems(vaultId, filters); + } + + ~OnePassSdk() + { + Dispose(disposing: false); + } + + public void Dispose() + { + // Do not change this code. UpdateItem cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await TaskRunMarshaller.Yield(); + Dispose(true); + GC.SuppressFinalize(this); + } +} diff --git a/src/Keyden.1Password.Sdk/OnePassSdkExceptions.cs b/src/Keyden.1Password.Sdk/OnePassSdkExceptions.cs new file mode 100644 index 0000000..a719188 --- /dev/null +++ b/src/Keyden.1Password.Sdk/OnePassSdkExceptions.cs @@ -0,0 +1,16 @@ +using System; + +namespace Keyden.OnePassword.SDK; + +public class OnePassSdkException : Exception +{ + public OnePassSdkException() {} + public OnePassSdkException(string? message) : base(message) { } + public OnePassSdkException(string? message, Exception? innerException) : base(message, innerException) { } +} + +public class OnePassSdkChannelClosedException() : OnePassSdkException("Desktop app connection channel is closed.") { } +public class OnePassSdkChannelConnectionDroppedException() : OnePassSdkException("Connection was unexpectedly dropped by the desktop app.") { } +public class OnePassSdkInternalException(int returnCode) : OnePassSdkException($"An internal error occurred. Please contact 1Password support and mention the return returnCode: {returnCode}") { } +public class OnePassSdkDesktopSessionExpiredException(string message) : OnePassSdkException(message) { } +public class OnePassSdkRateLimitExceededException(string message) : OnePassSdkException(message) { } diff --git a/src/Keyden.1Password.Sdk/OnePassSdkInterop.cs b/src/Keyden.1Password.Sdk/OnePassSdkInterop.cs new file mode 100644 index 0000000..fb2e4b9 --- /dev/null +++ b/src/Keyden.1Password.Sdk/OnePassSdkInterop.cs @@ -0,0 +1,179 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Keyden.OnePassword.SDK.Types; + +namespace Keyden.OnePassword.SDK; + +internal unsafe static partial class OnePassSdkInterop +{ + [JsonSerializable(typeof(PayloadMessage))] + [JsonSerializable(typeof(PayloadResponse))] + [JsonSerializable(typeof(PayloadTypedError))] + [JsonSerializable(typeof(InvocationPayload))] + internal partial class InteropJsonContext : JsonSerializerContext + { + } + + [JsonConverter(typeof(PayloadKindJsonConverter))] + public enum PayloadKind + { + None, + InitClient, + Invoke, + ReleaseClient, + } + + private sealed class PayloadKindJsonConverter : JsonConverter + { + public override PayloadKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Unexpected token parsing {nameof(AutofillBehavior)}. Expected String, got {reader.TokenType}."); + + return reader.GetString() switch + { + "none" => PayloadKind.None, + "init_client" => PayloadKind.InitClient, + "invoke" => PayloadKind.Invoke, + "release_client" => PayloadKind.ReleaseClient, + _ => throw new NotImplementedException(), + }; + } + + public override void Write(Utf8JsonWriter writer, PayloadKind value, JsonSerializerOptions options) + { + var valueStr = value switch + { + PayloadKind.None => "none", + PayloadKind.InitClient => "init_client", + PayloadKind.Invoke => "invoke", + PayloadKind.ReleaseClient => "release_client", + _ => throw new NotImplementedException(), + }; + writer.WriteStringValue(valueStr); + } + } + + public record struct PayloadMessage() + { + [JsonPropertyName("kind")] + public PayloadKind Kind { get; set; } = PayloadKind.None; + + [JsonPropertyName("account_name")] + public required string AccountName { get; set; } + + [JsonPropertyName("payload")] + [JsonConverter(typeof(Base64JsonConverter))] + public required string Payload { get; set; } + } + + public record struct PayloadResponse() + { + [JsonPropertyName("success")] + public required bool Success { get; set; } = false; + + [JsonPropertyName("payload")] + [JsonConverter(typeof(ByteArrayJsonConverter))] + public required string Payload { get; set; } + } + + public record struct PayloadTypedError + { + public PayloadTypedError() { } + [JsonPropertyName("name")] + public string ErrorName { get; set; } = string.Empty; + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } + + [DoesNotReturn] + public static void ThrowTypedException(string payload) + { + PayloadTypedError typedError; + try + { + typedError = JsonSerializer.Deserialize(payload, InteropJsonContext.Default.PayloadTypedError); + } + catch + { + throw new OnePassSdkException(payload); + } + + throw typedError switch + { + { ErrorName: "DesktopSessionExpired" } => new OnePassSdkDesktopSessionExpiredException(payload), + { ErrorName: "RateLimitExceeded" } => new OnePassSdkRateLimitExceededException(payload), + { Message: not "" or null } => new OnePassSdkException(typedError.Message), + _ => new OnePassSdkException(payload), + }; + } + + public static PayloadResponse CallSharedLibrary(PayloadMessage payload) + { + var json = JsonSerializer.Serialize(payload, InteropJsonContext.Default.PayloadMessage); + var returnCode = OnePassSdkNative.SendMessage(json, out var responseStr); + + if (returnCode != 0) + { + bool isOsx = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + throw returnCode switch + { + -3 when isOsx => new OnePassSdkChannelClosedException(), + -7 when isOsx => new OnePassSdkChannelConnectionDroppedException(), + -2 when !isOsx => new OnePassSdkChannelClosedException(), + -5 when !isOsx => new OnePassSdkChannelConnectionDroppedException(), + _ => new OnePassSdkInternalException(returnCode), + }; + } + + var response = JsonSerializer.Deserialize(responseStr, InteropJsonContext.Default.PayloadResponse); + if (!response.Success) + ThrowTypedException(response.Payload); + + return response; + } + + public static string Invoke(string accountName, InvocationPayload payload) + { + var json = JsonSerializer.Serialize(payload, InteropJsonContext.Default.InvocationPayload); + + var ret = CallSharedLibrary(new() + { + Kind = PayloadKind.Invoke, + AccountName = accountName, + Payload = json, + }); + + return ret.Payload; + } + + public record struct InvocationPayload() + { + [JsonPropertyName("invocation")] + public required InvocationObject Invocation { get; set; } + + public record struct InvocationObject() + { + [JsonPropertyName("clientId")] + public required int ClientId { get; set; } + + [JsonPropertyName("parameters")] + public required ParametersObject Parameters { get; set; } + + public record struct ParametersObject() + { + [JsonPropertyName("name")] + public required string Name { get; set; } = ""; + + [JsonPropertyName("parameters")] + [JsonConverter(typeof(RawJsonConverter))] + public required string Parameters { get; set; } + } + } + } +} diff --git a/src/Keyden.1Password.Sdk/OnePassSdkNative.cs b/src/Keyden.1Password.Sdk/OnePassSdkNative.cs new file mode 100644 index 0000000..0cac37e --- /dev/null +++ b/src/Keyden.1Password.Sdk/OnePassSdkNative.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +namespace Keyden.OnePassword.SDK; + +// using the python SDK as a reference +// https://github.com/1Password/onepassword-sdk-python/blob/main/src/onepassword/desktop_core.py + +internal unsafe static partial class OnePassSdkNative +{ + public const string LibSdkIpcClient = "op_sdk_ipc_client"; + + [LibraryImport(LibSdkIpcClient, EntryPoint = "op_sdk_ipc_send_message", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial int SendMessage(byte* msg, nuint msgLen, out byte* response, out nuint responseLen, out nuint responseCap); + + [LibraryImport(LibSdkIpcClient, EntryPoint = "op_sdk_ipc_free_response", StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + public static partial void FreeResponse(byte* msg, nuint length, nuint capacity); + + internal static readonly UTF8Encoding Utf8 = new(false); + public static int SendMessage(string message, out string response) + { + var messageUtf8 = Utf8.GetBytes(message); + var messageUtf8Len = (nuint)messageUtf8.Length; + + byte* responsePtr = null; + nuint responseLen = nuint.Zero; + nuint responseCap = nuint.Zero; + int retCode; + + fixed (byte* messageUtf8Ptr = messageUtf8) + { + retCode = SendMessage(messageUtf8Ptr, messageUtf8Len, out responsePtr, out responseLen, out responseCap); + } + + if ((nuint)responsePtr == nuint.Zero) + { + response = string.Empty; + } + else + { + response = Utf8.GetString(responsePtr, checked((int)responseLen)); + FreeResponse(responsePtr, responseLen, responseCap); + } + + return retCode; + } + + static OnePassSdkNative() + { + NativeLibrary.SetDllImportResolver(Assembly.GetExecutingAssembly(), DllImportResolver); + } + + private static nint DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != LibSdkIpcClient) + return nint.Zero; + + string[] paths = []; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + paths = [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "1password/op_sdk_ipc_client.dll"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "1password/app/8/op_sdk_ipc_client.dll"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "1password/app/8/op_sdk_ipc_client.dll"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "1password/app/8/op_sdk_ipc_client.dll"), + ]; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + paths = [ + Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications/1Password.app/Contents/Frameworks/libop_sdk_ipc_client.dylib"), + Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "1Password.app/Contents/Frameworks/libop_sdk_ipc_client.dylib"), + ]; + } + else // assume linux/unix + { + paths = [ + "/usr/bin/1password/libop_sdk_ipc_client.so", + "/opt/1Password/libop_sdk_ipc_client.so", + "/snap/bin/1password/libop_sdk_ipc_client.so", + ]; + } + + foreach (var path in paths) + { + if (File.Exists(path)) + return NativeLibrary.Load(path, assembly, null); + } + + return nint.Zero; + } +} diff --git a/src/Keyden.1Password.Sdk/RawJsonConverter.cs b/src/Keyden.1Password.Sdk/RawJsonConverter.cs new file mode 100644 index 0000000..1a53180 --- /dev/null +++ b/src/Keyden.1Password.Sdk/RawJsonConverter.cs @@ -0,0 +1,19 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK; + +internal class RawJsonConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + return doc.RootElement.GetRawText(); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteRawValue(value, true); + } +} diff --git a/src/Keyden.1Password.Sdk/Types/AutofillBehavior.cs b/src/Keyden.1Password.Sdk/Types/AutofillBehavior.cs new file mode 100644 index 0000000..7f06b7d --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/AutofillBehavior.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +[JsonConverter(typeof(AutofillBehaviorJsonConverter))] +public enum AutofillBehavior +{ + AnywhereOnWebsite, + ExactDomain, + Never, + Unknown, +} + +public sealed class AutofillBehaviorJsonConverter : JsonConverter +{ + public override AutofillBehavior Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Unexpected token parsing {nameof(AutofillBehavior)}. Expected String, got {reader.TokenType}."); + + return reader.GetString() switch + { + "AnywhereOnWebsite" => AutofillBehavior.AnywhereOnWebsite, + "ExactDomain" => AutofillBehavior.ExactDomain, + "Never" => AutofillBehavior.Never, + _ => AutofillBehavior.Unknown, + }; + } + + public override void Write(Utf8JsonWriter writer, AutofillBehavior value, JsonSerializerOptions options) + { + var valueStr = value switch + { + AutofillBehavior.AnywhereOnWebsite => "AnywhereOnWebsite", + AutofillBehavior.ExactDomain => "ExactDomain", + AutofillBehavior.Never => "Never", + _ => throw new NotImplementedException(), + }; + writer.WriteStringValue(valueStr); + } +} diff --git a/src/Keyden.1Password.Sdk/Types/DocumentCreateParams.cs b/src/Keyden.1Password.Sdk/Types/DocumentCreateParams.cs new file mode 100644 index 0000000..cdb4407 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/DocumentCreateParams.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct DocumentCreateParams() +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("content")] + public required byte[] Content { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/FileAttributes.cs b/src/Keyden.1Password.Sdk/Types/FileAttributes.cs new file mode 100644 index 0000000..1abf7f7 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/FileAttributes.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct FileAttributes() +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("size")] + public required int Size { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/FileCreateParams.cs b/src/Keyden.1Password.Sdk/Types/FileCreateParams.cs new file mode 100644 index 0000000..8d9c93b --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/FileCreateParams.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct FileCreateParams() +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("content")] + public required byte[] Content { get; set; } + + [JsonPropertyName("sectionId")] + public required string SectionId { get; set; } + + [JsonPropertyName("fieldId")] + public required string FieldId { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/GeneratePasswordResponse.cs b/src/Keyden.1Password.Sdk/Types/GeneratePasswordResponse.cs new file mode 100644 index 0000000..74a486b --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/GeneratePasswordResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Keyden.OnePassword.SDK.Types; + +public record struct GeneratePasswordResponse() +{ + + internal static string ToJson(GeneratePasswordResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.GeneratePasswordResponse); + internal static GeneratePasswordResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.GeneratePasswordResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/GetVaultResponse.cs b/src/Keyden.1Password.Sdk/Types/GetVaultResponse.cs new file mode 100644 index 0000000..1ad80e4 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/GetVaultResponse.cs @@ -0,0 +1,38 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public record struct GetVaultResponse() +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("description")] + public required string Description { get; set; } + + [JsonPropertyName("vaultType")] + public required string VaultType { get; set; } + + [JsonPropertyName("activeItemCount")] + public required int ActiveItemCount { get; set; } + + [JsonPropertyName("contentVersion")] + public required int ContentVersion { get; set; } + + [JsonPropertyName("attributeVersion")] + public required int AttributeVersion { get; set; } + + [JsonPropertyName("createdAt")] + public required DateTime CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public required DateTime UpdatedAt { get; set; } + + internal static string ToJson(GetVaultResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.GetVaultResponse); + internal static GetVaultResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.GetVaultResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/Item.cs b/src/Keyden.1Password.Sdk/Types/Item.cs new file mode 100644 index 0000000..1a59cc9 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/Item.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public record struct Item() +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("category")] + [JsonConverter(typeof(ItemCategoryJsonConverter))] + public required ItemCategory Category { get; set; } + + [JsonPropertyName("vaultId")] + public required string VaultId { get; set; } + + [JsonPropertyName("fields")] + public required IReadOnlyList Fields { get; set; } + + [JsonPropertyName("sections")] + public required IReadOnlyList Sections { get; set; } + + [JsonPropertyName("notes")] + public required string Notes { get; set; } + + [JsonPropertyName("tags")] + public required IReadOnlyList Tags { get; set; } + + [JsonPropertyName("websites")] + public required IReadOnlyList Websites { get; set; } + + [JsonPropertyName("version")] + public required int Version { get; set; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; set; } + + [JsonPropertyName("document")] + public FileAttributes? Document { get; set; } + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } + + public static string ToJson(Item value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.Item); + public static Item FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.Item); +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemCategory.cs b/src/Keyden.1Password.Sdk/Types/ItemCategory.cs new file mode 100644 index 0000000..3896364 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemCategory.cs @@ -0,0 +1,106 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +[JsonConverter(typeof(ItemCategoryJsonConverter))] +public enum ItemCategory +{ + Login, + SecureNote, + CreditCard, + CryptoWallet, + Identity, + Password, + Document, + ApiCredentials, + BankAccount, + Database, + DriverLicense, + Email, + MedicalRecord, + Membership, + OutdoorLicense, + Passport, + Rewards, + Router, + Server, + SshKey, + SocialSecurityNumber, + SoftwareLicense, + Person, + Unsupported, + Unknown, +} + +public sealed class ItemCategoryJsonConverter : JsonConverter +{ + public override ItemCategory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Unexpected token parsing {nameof(ItemCategory)}. Expected String, got {reader.TokenType}."); + + return reader.GetString() switch + { + "Login" => ItemCategory.Login, + "SecureNote" => ItemCategory.SecureNote, + "CreditCard" => ItemCategory.CreditCard, + "CryptoWallet" => ItemCategory.CryptoWallet, + "Identity" => ItemCategory.Identity, + "Password" => ItemCategory.Password, + "Document" => ItemCategory.Document, + "ApiCredentials" => ItemCategory.ApiCredentials, + "BankAccount" => ItemCategory.BankAccount, + "Database" => ItemCategory.Database, + "DriverLicense" => ItemCategory.DriverLicense, + "Email" => ItemCategory.Email, + "MedicalRecord" => ItemCategory.MedicalRecord, + "Membership" => ItemCategory.Membership, + "OutdoorLicense" => ItemCategory.OutdoorLicense, + "Passport" => ItemCategory.Passport, + "Rewards" => ItemCategory.Rewards, + "Router" => ItemCategory.Router, + "Server" => ItemCategory.Server, + "SshKey" => ItemCategory.SshKey, + "SocialSecurityNumber" => ItemCategory.SocialSecurityNumber, + "SoftwareLicense" => ItemCategory.SoftwareLicense, + "Person" => ItemCategory.Person, + "Unsupported" => ItemCategory.Unsupported, + _ => ItemCategory.Unknown, + }; + } + + public override void Write(Utf8JsonWriter writer, ItemCategory value, JsonSerializerOptions options) + { + var valueStr = value switch + { + ItemCategory.Login => "Login", + ItemCategory.SecureNote => "SecureNote", + ItemCategory.CreditCard => "CreditCard", + ItemCategory.CryptoWallet => "CryptoWallet", + ItemCategory.Identity => "Identity", + ItemCategory.Password => "Password", + ItemCategory.Document => "Document", + ItemCategory.ApiCredentials => "ApiCredentials", + ItemCategory.BankAccount => "BankAccount", + ItemCategory.Database => "Database", + ItemCategory.DriverLicense => "DriverLicense", + ItemCategory.Email => "Email", + ItemCategory.MedicalRecord => "MedicalRecord", + ItemCategory.Membership => "Membership", + ItemCategory.OutdoorLicense => "OutdoorLicense", + ItemCategory.Passport => "Passport", + ItemCategory.Rewards => "Rewards", + ItemCategory.Router => "Router", + ItemCategory.Server => "Server", + ItemCategory.SshKey => "SshKey", + ItemCategory.SocialSecurityNumber => "SocialSecurityNumber", + ItemCategory.SoftwareLicense => "SoftwareLicense", + ItemCategory.Person => "Person", + ItemCategory.Unsupported => "Unsupported", + _ => throw new NotImplementedException(), + }; + writer.WriteStringValue(valueStr); + } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemCreateParams.cs b/src/Keyden.1Password.Sdk/Types/ItemCreateParams.cs new file mode 100644 index 0000000..1a047ea --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemCreateParams.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemCreateParams() +{ + [JsonPropertyName("category")] + public required ItemCategory Category { get; set; } + + [JsonPropertyName("vaultId")] + public required string VaultId { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("fields")] + public IReadOnlyList? Fields { get; set; } = null; + + [JsonPropertyName("sections")] + public IReadOnlyList? Sections { get; set; } = null; + + [JsonPropertyName("notes")] + public string? Notes { get; set; } = null; + + [JsonPropertyName("tags")] + public IReadOnlyList? Tags { get; set; } = null; + + [JsonPropertyName("websites")] + public IReadOnlyList? Websites { get; set; } = null; + + [JsonPropertyName("files")] + public IReadOnlyList? Files { get; set; } = null; + + [JsonPropertyName("document")] + public DocumentCreateParams? Document { get; set; } = null; + + public static string ToJson(ItemCreateParams value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemCreateParams); + public static ItemCreateParams FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemCreateParams); + + public static string ToJson(IReadOnlyList value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.IReadOnlyListItemCreateParams); + +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemErrorResponse.cs b/src/Keyden.1Password.Sdk/Types/ItemErrorResponse.cs new file mode 100644 index 0000000..4d7b45b --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemErrorResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemErrorResponse +{ + [JsonPropertyName("type")] + public string Type { get; set; } // internal/itemNotFound + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemField.cs b/src/Keyden.1Password.Sdk/Types/ItemField.cs new file mode 100644 index 0000000..bce6388 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemField.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemField() +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("sectionId")] + public string? SectionId { get; set; } + + [JsonPropertyName("fieldType")] + [JsonConverter(typeof(ItemFieldTypeJsonConverter))] + public required ItemFieldType FieldType { get; set; } + + [JsonPropertyName("value")] + public required string Value { get; set; } + + [JsonPropertyName("details")] + public ItemFieldDetails? Details { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemFieldDetails.cs b/src/Keyden.1Password.Sdk/Types/ItemFieldDetails.cs new file mode 100644 index 0000000..cc8730b --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemFieldDetails.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemFieldDetails() +{ + [JsonPropertyName("type")] + public required string Type { get; set; } // Otp/SshKey/Address + + [JsonPropertyName("content")] + [JsonConverter(typeof(RawJsonConverter))] + public required string Content { get; set; } // https://github.com/1Password/onepassword-sdk-python/blob/main/src/onepassword/types.py#L335 +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemFieldType.cs b/src/Keyden.1Password.Sdk/Types/ItemFieldType.cs new file mode 100644 index 0000000..9d0e7f5 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemFieldType.cs @@ -0,0 +1,78 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public enum ItemFieldType +{ + Text, + Concealed, + CreditCardType, + CreditCardNumber, + Phone, + Url, + Totp, + Email, + Reference, + SshKey, + Menu, + MonthYear, + Address, + Date, + Unsupported, + Unknown, +} + +public sealed class ItemFieldTypeJsonConverter : JsonConverter +{ + public override ItemFieldType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Unexpected token parsing {nameof(ItemFieldType)}. Expected String, got {reader.TokenType}."); + + return reader.GetString() switch + { + "Text" => ItemFieldType.Text, + "Concealed" => ItemFieldType.Concealed, + "CreditCardType" => ItemFieldType.CreditCardType, + "CreditCardNumber" => ItemFieldType.CreditCardNumber, + "Phone" => ItemFieldType.Phone, + "Url" => ItemFieldType.Url, + "Totp" => ItemFieldType.Totp, + "Email" => ItemFieldType.Email, + "Reference" => ItemFieldType.Reference, + "SshKey" => ItemFieldType.SshKey, + "Menu" => ItemFieldType.Menu, + "MonthYear" => ItemFieldType.MonthYear, + "Address" => ItemFieldType.Address, + "Date" => ItemFieldType.Date, + "Unsupported" => ItemFieldType.Unsupported, + _ => ItemFieldType.Unknown, + }; + } + + public override void Write(Utf8JsonWriter writer, ItemFieldType value, JsonSerializerOptions options) + { + var valueStr = value switch + { + ItemFieldType.Text => "Text", + ItemFieldType.Concealed => "Concealed", + ItemFieldType.CreditCardType => "CreditCardType", + ItemFieldType.CreditCardNumber => "CreditCardNumber", + ItemFieldType.Phone => "Phone", + ItemFieldType.Url => "Url", + ItemFieldType.Totp => "Totp", + ItemFieldType.Email => "Email", + ItemFieldType.Reference => "Reference", + ItemFieldType.SshKey => "SshKey", + ItemFieldType.Menu => "Menu", + ItemFieldType.MonthYear => "MonthYear", + ItemFieldType.Address => "Address", + ItemFieldType.Date => "Date", + ItemFieldType.Unsupported => "Unsupported", + _ => throw new NotImplementedException(), + }; + writer.WriteStringValue(valueStr); + } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemFile.cs b/src/Keyden.1Password.Sdk/Types/ItemFile.cs new file mode 100644 index 0000000..5f6b1f3 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemFile.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemFile() +{ + [JsonPropertyName("attributes")] + public required FileAttributes Attributes { get; set; } + + [JsonPropertyName("sectionId")] + public required string SectionId { get; set; } + + [JsonPropertyName("fieldId")] + public required string FieldId { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemOverview.cs b/src/Keyden.1Password.Sdk/Types/ItemOverview.cs new file mode 100644 index 0000000..89957ea --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemOverview.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemOverview() +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("category")] + [JsonConverter(typeof(ItemCategoryJsonConverter))] + public required ItemCategory Category { get; set; } + + [JsonPropertyName("vaultId")] + public required string VaultId { get; set; } + + [JsonPropertyName("websites")] + public required IReadOnlyList Websites { get; set; } + + [JsonPropertyName("tags")] + public required IReadOnlyList Tags { get; set; } + + [JsonPropertyName("createdAt")] + public required DateTime CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public required DateTime UpdatedAt { get; set; } + + [JsonPropertyName("state")] + [JsonConverter(typeof(ItemStateJsonConverter))] + public required ItemState State { get; set; } +} + +public struct ItemListFilter() +{ + // TODO: +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemSection.cs b/src/Keyden.1Password.Sdk/Types/ItemSection.cs new file mode 100644 index 0000000..7b585a8 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemSection.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemSection() +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title {get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemState.cs b/src/Keyden.1Password.Sdk/Types/ItemState.cs new file mode 100644 index 0000000..00a3549 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemState.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +[JsonConverter(typeof(ItemStateJsonConverter))] +public enum ItemState +{ + Active, + Archived, + Unknown, +} + +public sealed class ItemStateJsonConverter : JsonConverter +{ + public override ItemState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Unexpected token parsing {nameof(ItemState)}. Expected String, got {reader.TokenType}."); + + return reader.GetString() switch + { + "active" => ItemState.Active, + "archived" => ItemState.Archived, + _ => ItemState.Unknown, + }; + } + + public override void Write(Utf8JsonWriter writer, ItemState value, JsonSerializerOptions options) + { + var valueStr = value switch + { + ItemState.Active => "active", + ItemState.Archived => "archived", + _ => throw new NotImplementedException(), + }; + writer.WriteStringValue(valueStr); + } +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemUpdateFailureReason.cs b/src/Keyden.1Password.Sdk/Types/ItemUpdateFailureReason.cs new file mode 100644 index 0000000..b19cf67 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemUpdateFailureReason.cs @@ -0,0 +1,5 @@ +namespace Keyden.OnePassword.SDK.Types; + +public record struct ItemUpdateFailureReason() +{ +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemsDeleteAllResponse.cs b/src/Keyden.1Password.Sdk/Types/ItemsDeleteAllResponse.cs new file mode 100644 index 0000000..602bddf --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemsDeleteAllResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemsDeleteAllResponse() +{ + [JsonPropertyName("individualResponses")] + public required IReadOnlyList> IndividualResponses { get; set; } + + public static string ToJson(ItemsDeleteAllResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsDeleteAllResponse); + public static ItemsDeleteAllResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsDeleteAllResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemsGetAllResponse.cs b/src/Keyden.1Password.Sdk/Types/ItemsGetAllResponse.cs new file mode 100644 index 0000000..37ca9ba --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemsGetAllResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemsGetAllResponse() +{ + [JsonPropertyName("individualResponses")] + public required IReadOnlyList> IndividualResponses { get; set; } + + public static string ToJson(ItemsGetAllResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsGetAllResponse); + public static ItemsGetAllResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsGetAllResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/ItemsUpdateAllResponse.cs b/src/Keyden.1Password.Sdk/Types/ItemsUpdateAllResponse.cs new file mode 100644 index 0000000..d5de706 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ItemsUpdateAllResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct ItemsUpdateAllResponse() +{ + [JsonPropertyName("individualResponses")] + public required IReadOnlyList> IndividualResponses { get; set; } + + public static string ToJson(ItemsUpdateAllResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsUpdateAllResponse); + public static ItemsUpdateAllResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsUpdateAllResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/None.cs b/src/Keyden.1Password.Sdk/Types/None.cs new file mode 100644 index 0000000..3b1f875 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/None.cs @@ -0,0 +1,4 @@ +namespace Keyden.OnePassword.SDK.Types; + +public record struct None() +{} diff --git a/src/Keyden.1Password.Sdk/Types/OnePassSdkConfig.cs b/src/Keyden.1Password.Sdk/Types/OnePassSdkConfig.cs new file mode 100644 index 0000000..373f36b --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/OnePassSdkConfig.cs @@ -0,0 +1,65 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Runtime.InteropServices; + +namespace Keyden.OnePassword.SDK.Types; + +using CpuArchitecture = Architecture; + +public record class OnePassSdkConfig +{ + [JsonPropertyName("programmingLanguage")] + public string ProgrammingLanguage { get; init; } = "C# (Keyden)"; + + [JsonPropertyName("sdkVersion")] + public string SdkVersion { get; init; } = "0040003"; + + [JsonPropertyName("integrationName")] + public string IntegrationName { get; init; } = "Unknown"; + + [JsonPropertyName("integrationVersion")] + public string IntegrationVersion { get; init; } = "Unknown"; + + [JsonPropertyName("requestLibraryName")] + public string RequestLibraryName { get; init; } = "none"; + + [JsonPropertyName("requestLibraryVersion")] + public string RequestLibraryVersion { get; init; } = "0.0.0"; + + [JsonPropertyName("os")] + public string OS { get; init; } = Environment.OSVersion.Platform switch + { + PlatformID.Win32S => "Windows", + PlatformID.Win32Windows => "Windows", + PlatformID.Win32NT => "Windows", + PlatformID.WinCE => "Windows", + PlatformID.Unix => "Linux/Unix", + PlatformID.Xbox => "Xbox", + PlatformID.MacOSX => "MacOS", + PlatformID.Other => "Unknown", + _ => "Unknown", + }; + + [JsonPropertyName("osVersion")] + public string OSVersion { get; init; } = Environment.OSVersion.Version.ToString(); + + [JsonPropertyName("architecture")] + public string Architecture { get; init; } = RuntimeInformation.ProcessArchitecture switch + { + CpuArchitecture.X86 => "x86", + CpuArchitecture.X64 => "x64", + CpuArchitecture.Arm => "arm", + CpuArchitecture.Arm64 => "arm64", + CpuArchitecture.Wasm => "wasm", + CpuArchitecture.S390x => "s390x", + CpuArchitecture.LoongArch64 => "loongarch64", + CpuArchitecture.Armv6 => "armv6", + CpuArchitecture.Ppc64le => "ppc64le", + CpuArchitecture.RiscV64 => "riscv64", + _ => throw new NotImplementedException(), + }; + + internal static string ToJson(OnePassSdkConfig value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.OnePassSdkConfig); + internal static OnePassSdkConfig? FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.OnePassSdkConfig); +} diff --git a/src/Keyden.1Password.Sdk/Types/OnePassTypes.Internal.cs b/src/Keyden.1Password.Sdk/Types/OnePassTypes.Internal.cs new file mode 100644 index 0000000..3ee0457 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/OnePassTypes.Internal.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +[JsonSerializable(typeof(OnePassSdkConfig))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(ResolveSecretPayload))] +[JsonSerializable(typeof(ResolveSecretsPayload))] +[JsonSerializable(typeof(ResolveSecretsResponse))] +[JsonSerializable(typeof(ValidateSecretReferencePayload))] +[JsonSerializable(typeof(PasswordRecipe))] +[JsonSerializable(typeof(GeneratePasswordResponse))] +[JsonSerializable(typeof(GetVaultResponse))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(ItemOverview))] +[JsonSerializable(typeof(ItemsGetAllResponse))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(ItemCreateParams))] +[JsonSerializable(typeof(ItemsUpdateAllResponse))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(ItemsPutPayload))] +[JsonSerializable(typeof(GetItemPayload))] +[JsonSerializable(typeof(GetItemsPayload))] +[JsonSerializable(typeof(GetAllItemsPayload))] +[JsonSerializable(typeof(ListItemsPayload))] +[JsonSerializable(typeof(DeleteItemPayload))] +[JsonSerializable(typeof(DeleteAllItemsPayload))] +[JsonSerializable(typeof(ItemsDeleteAllResponse))] +[JsonSerializable(typeof(ItemsCreateAllPayload))] +[JsonSerializable(typeof(ItemsCreatePayload))] +internal partial class OnePassTypesJsonContext : JsonSerializerContext +{ +} + +internal record struct ResolveSecretPayload() +{ + [JsonPropertyName("secret_reference")] + public required string SecretReference { get; set; } + + internal static string ToJson(ResolveSecretPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ResolveSecretPayload); + internal static ResolveSecretPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ResolveSecretPayload); +} + +internal record struct ResolveSecretsPayload() +{ + [JsonPropertyName("secret_references")] + public required IReadOnlyList SecretReferences { get; set; } + + internal static string ToJson(ResolveSecretsPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ResolveSecretsPayload); + internal static ResolveSecretsPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ResolveSecretsPayload); +} + +internal record struct ValidateSecretReferencePayload() +{ + [JsonPropertyName("secret_reference")] + public required string SecretReference { get; set; } + + internal static string ToJson(ValidateSecretReferencePayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ValidateSecretReferencePayload); + internal static ValidateSecretReferencePayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ValidateSecretReferencePayload); +} + +internal record struct GetAllItemsPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("item_ids")] + public required IReadOnlyList ItemIds { get; set; } + + internal static string ToJson(GetAllItemsPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.GetAllItemsPayload); + internal static GetAllItemsPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.GetAllItemsPayload); +} + +internal record struct GetItemsPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("item_id")] + public required string ItemId { get; set; } + + internal static string ToJson(GetItemsPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.GetItemsPayload); + internal static GetItemsPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.GetItemsPayload); +} + +internal record struct GetItemPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("item_id")] + public required IReadOnlyList ItemIds { get; set; } + + internal static string ToJson(GetItemPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.GetItemPayload); + internal static GetItemPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.GetItemPayload); +} + +internal record struct ListItemsPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("filters")] + public required IReadOnlyList Filters { get; set; } + + internal static string ToJson(ListItemsPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ListItemsPayload); + internal static ListItemsPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ListItemsPayload); +} + +internal record struct ItemsPutPayload() +{ + [JsonPropertyName("item")] + public required Item Item { get; set; } + + internal static string ToJson(ItemsPutPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsPutPayload); + internal static ItemsPutPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsPutPayload); +} + +internal record struct DeleteItemPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("item_id")] + public required string ItemId { get; set; } + + internal static string ToJson(DeleteItemPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.DeleteItemPayload); + internal static DeleteItemPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.DeleteItemPayload); +} + +internal record struct DeleteAllItemsPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("item_ids")] + public required IReadOnlyList ItemIds { get; set; } + + internal static string ToJson(DeleteAllItemsPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.DeleteAllItemsPayload); + internal static DeleteAllItemsPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.DeleteAllItemsPayload); +} + +internal record struct ItemsCreatePayload() +{ + [JsonPropertyName("params")] + public required ItemCreateParams Params { get; set; } + + internal static string ToJson(ItemsCreatePayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsCreatePayload); + internal static ItemsCreatePayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsCreatePayload); +} + +internal record struct ItemsCreateAllPayload() +{ + [JsonPropertyName("vault_id")] + public required string VaultId { get; set; } + + [JsonPropertyName("params")] + public required IReadOnlyList Params { get; set; } + + internal static string ToJson(ItemsCreateAllPayload value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ItemsCreateAllPayload); + internal static ItemsCreateAllPayload FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ItemsCreateAllPayload); +} + diff --git a/src/Keyden.1Password.Sdk/Types/PasswordRecipe.cs b/src/Keyden.1Password.Sdk/Types/PasswordRecipe.cs new file mode 100644 index 0000000..6bf35d9 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/PasswordRecipe.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace Keyden.OnePassword.SDK.Types; + +public record struct PasswordRecipe() +{ + + internal static string ToJson(PasswordRecipe value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.PasswordRecipe); + internal static PasswordRecipe FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.PasswordRecipe); +} diff --git a/src/Keyden.1Password.Sdk/Types/ResolveSecretsResponse.cs b/src/Keyden.1Password.Sdk/Types/ResolveSecretsResponse.cs new file mode 100644 index 0000000..d6f1dee --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/ResolveSecretsResponse.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public record struct ResolveSecretsResponse() +{ + public struct ResponseObject() + { + [JsonPropertyName("content")] + public ContentObject? Content { get; set; } + public struct ContentObject() + { + [JsonPropertyName("secret")] + public string Secret { get; set; } = string.Empty; + + [JsonPropertyName("itemId")] + public string ItemId { get; set; } = string.Empty; + + [JsonPropertyName("vaultId")] + public string VaultId { get; set; } = string.Empty; + } + + [JsonPropertyName("error")] + public ErrorObject? Error { get; set; } + public struct ErrorObject() + { + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + } + } + + [JsonPropertyName("individualResponses")] + public required IReadOnlyDictionary IndividualResponses { get; set; } + + internal static string ToJson(ResolveSecretsResponse value) => JsonSerializer.Serialize(value, OnePassTypesJsonContext.Default.ResolveSecretsResponse); + internal static ResolveSecretsResponse FromJson(string value) => JsonSerializer.Deserialize(value, OnePassTypesJsonContext.Default.ResolveSecretsResponse); +} diff --git a/src/Keyden.1Password.Sdk/Types/Response.cs b/src/Keyden.1Password.Sdk/Types/Response.cs new file mode 100644 index 0000000..5844b82 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/Response.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct Response + where TValue : struct + where TError : struct +{ + [JsonPropertyName("content")] + public TValue? Content { get; set; } + [JsonPropertyName("error")] + public TError? Error { get; set; } +} diff --git a/src/Keyden.1Password.Sdk/Types/Website.cs b/src/Keyden.1Password.Sdk/Types/Website.cs new file mode 100644 index 0000000..7c42459 --- /dev/null +++ b/src/Keyden.1Password.Sdk/Types/Website.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Keyden.OnePassword.SDK.Types; + +public struct Website() +{ + [JsonPropertyName("url")] + public required string Url { get; set; } + + [JsonPropertyName("label")] + public required string Label { get; set; } + + [JsonPropertyName("autofillBehavior")] + [JsonConverter(typeof(AutofillBehaviorJsonConverter))] + public required AutofillBehavior AutofillBehavior { get; set; } +} diff --git a/src/Keyden.App/App.axaml.cs b/src/Keyden.App/App.axaml.cs index a3175d9..b4ef007 100644 --- a/src/Keyden.App/App.axaml.cs +++ b/src/Keyden.App/App.axaml.cs @@ -18,6 +18,7 @@ using System.Reflection.Metadata; using System.Threading; using System.Threading.Tasks; +using System.Diagnostics; namespace Keyden; @@ -155,7 +156,7 @@ private async void MainAppPipeThread(CancellationToken ct) public override void OnFrameworkInitializationCompleted() { Exception? singleInstanceEx = null; - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime && !Debugger.IsAttached) { try { diff --git a/src/Keyden.App/GenericSystemServices.cs b/src/Keyden.App/GenericSystemServices.cs index 24bcde9..bdda447 100644 --- a/src/Keyden.App/GenericSystemServices.cs +++ b/src/Keyden.App/GenericSystemServices.cs @@ -78,4 +78,25 @@ public void NotifyPreauthorizedKey(ClientInfo clientInfo, SshKey key) } public string AuthenticationBranding => "None"; + + private readonly static string[] MacNativeMessagingPaths = + [ + "~/Library/Application Support/Google/Chrome/NativeMessagingHosts", + "~/Library/Application Support/Chromium/NativeMessagingHosts", + "/Library/Google/Chrome/NativeMessagingHosts", + "/Library/Application Support/Chromium/NativeMessagingHosts", + ]; + private readonly static string[] LinuxNativeMessagingPaths = + [ + "~/.config/google-chrome/NativeMessagingHosts", + "~/.config/chromium/NativeMessagingHosts", + "/etc/opt/chrome/native-messaging-hosts", + "/etc/chromium/native-messaging-hosts", + ]; + public string? GetNativeClientManifest(string id) + { + // throw new NotImplementedException(); + return null; + } + } diff --git a/src/Keyden.App/KeyStoreController.cs b/src/Keyden.App/KeyStoreController.cs index e183ac9..24416cb 100644 --- a/src/Keyden.App/KeyStoreController.cs +++ b/src/Keyden.App/KeyStoreController.cs @@ -33,6 +33,7 @@ public sealed class KeyStoreController : ISshKeyStore, ISshKeyOptionsStore private readonly DeveloperTestKeyStore DevTestStore; private readonly OnePassCliSshKeyStore OnePassCliStore; + private readonly OnePassSdkSshKeyStore OnePassSdkStore; private KeyStoreControllerData Data { get; } private const string DataLocation = "keystore-controller-data.json"; @@ -46,6 +47,7 @@ public KeyStoreController( DevTestStore = App.GetKeyedService("devtest"); OnePassCliStore = App.GetKeyedService("op"); + OnePassSdkStore = App.GetKeyedService("opsdk"); if (FileSystem.TryReadBytes(DataLocation, out var contents)) { @@ -63,7 +65,11 @@ public KeyStoreController( else Data = new(); + // assign current settings UpdateBackend(); + OnePassSdkStore.AccountName = Settings.OnepassAccountName; + + // listen for new settings Settings.PropertyChanged += Settings_PropertyChanged; } @@ -71,10 +77,14 @@ private void UpdateBackend() { switch (Settings.KeystoreBackend) { - case KeystoreBackend.OnePassCLI: + case KeystoreBackend.OnePassCli: BaseKeyStore = OnePassCliStore; BaseOptionsStore = OnePassCliStore; break; + case KeystoreBackend.OnePassSdk: + BaseKeyStore = OnePassSdkStore; + BaseOptionsStore = OnePassSdkStore; + break; case KeystoreBackend.DeveloperTest: BaseKeyStore = DevTestStore; BaseOptionsStore = DevTestStore; @@ -91,6 +101,8 @@ private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs e { if (e.PropertyName == nameof(Settings.KeystoreBackend)) UpdateBackend(); + if (e.PropertyName == nameof(Settings.OnepassAccountName)) + OnePassSdkStore.AccountName = Settings.OnepassAccountName; } public IReadOnlyList PublicKeys => diff --git a/src/Keyden.App/Keyden.App.csproj b/src/Keyden.App/Keyden.App.csproj index bf28c61..f5425fe 100644 --- a/src/Keyden.App/Keyden.App.csproj +++ b/src/Keyden.App/Keyden.App.csproj @@ -1,9 +1,11 @@ - + - net9.0 + net10.0 true True + + true diff --git a/src/Keyden.App/KeydenSettings.cs b/src/Keyden.App/KeydenSettings.cs index 77c225f..af3a362 100644 --- a/src/Keyden.App/KeydenSettings.cs +++ b/src/Keyden.App/KeydenSettings.cs @@ -3,7 +3,6 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json.Serialization; -using System.Text.Unicode; namespace Keyden; @@ -16,6 +15,13 @@ public KeystoreBackend KeystoreBackend set => SetProperty(ref _KeystoreBackend, value); } + private string _OnepassAccountName = string.Empty; + public string OnepassAccountName + { + get => _OnepassAccountName; + set => SetProperty(ref _OnepassAccountName, value); + } + private float _AuthButtonEnableDelay = 0.5f; public float AuthButtonEnableDelay { @@ -109,7 +115,8 @@ public enum KeystoreBackend { None, DeveloperTest, - OnePassCLI, + OnePassCli, + OnePassSdk, } public enum AuthenticationMode diff --git a/src/Keyden.App/ServiceCollectionExtensions.cs b/src/Keyden.App/ServiceCollectionExtensions.cs index b16a4c4..65a6b67 100644 --- a/src/Keyden.App/ServiceCollectionExtensions.cs +++ b/src/Keyden.App/ServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ public static void AddKeydenServices(this IServiceCollection collection) collection.AddKeyedSingleton("devtest"); collection.AddKeyedSingleton("op"); + collection.AddKeyedSingleton("opsdk"); collection.AddSingleton(); collection.AddSingletonAlias(); diff --git a/src/Keyden.App/ViewModels/SettingsViewModel.cs b/src/Keyden.App/ViewModels/SettingsViewModel.cs index 1b682b7..aa10f0f 100644 --- a/src/Keyden.App/ViewModels/SettingsViewModel.cs +++ b/src/Keyden.App/ViewModels/SettingsViewModel.cs @@ -25,6 +25,14 @@ public SettingsViewModel( { SystemServices = systemServices; Settings = settings; + + Settings.PropertyChanged += Settings_PropertyChanged; + } + + private void Settings_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Settings.KeystoreBackend)) + this.RaisePropertyChanged(nameof(UsingOnepassSdk)); } private int _TabIndexSelected = 0; @@ -53,11 +61,12 @@ public int TabIndexSelected public bool IsSecurityTabSelectedAndLocked => TabIndexSelected == 2 && !SecurityUnlocked; public bool IsSecurityTabSelectedAndUnlocked => TabIndexSelected == 2 && SecurityUnlocked; public bool IsAdvancedTabSelected => TabIndexSelected == 1; + public bool UsingOnepassSdk => Settings.KeystoreBackend == KeystoreBackend.OnePassSdk; public IReadOnlyList KeystoreBackends => Settings.DeveloperMode - ? [KeystoreBackend.None, KeystoreBackend.OnePassCLI, KeystoreBackend.DeveloperTest] - : [KeystoreBackend.None, KeystoreBackend.OnePassCLI]; + ? [KeystoreBackend.None, KeystoreBackend.OnePassSdk, KeystoreBackend.OnePassCli, KeystoreBackend.DeveloperTest] + : [KeystoreBackend.None, KeystoreBackend.OnePassSdk, KeystoreBackend.OnePassCli]; public IReadOnlyList AuthenticationModes { get; } = [AuthenticationMode.System, AuthenticationMode.InternalPIN]; diff --git a/src/Keyden.App/Views/AuthPrompt.axaml.cs b/src/Keyden.App/Views/AuthPrompt.axaml.cs index 3c9a121..e0f5077 100644 --- a/src/Keyden.App/Views/AuthPrompt.axaml.cs +++ b/src/Keyden.App/Views/AuthPrompt.axaml.cs @@ -178,6 +178,13 @@ private void Pin_AttachedToVisualTree(object? sender, VisualTreeAttachmentEventA Pin.Focus(); } + protected override void OnOpened(EventArgs e) + { + base.OnOpened(e); + + Pin.Focus(); + } + private void AuthPrompt_Closed(object? sender, EventArgs e) { AuthButtonEnabled = false; diff --git a/src/Keyden.App/Views/SettingsWindow.axaml b/src/Keyden.App/Views/SettingsWindow.axaml index 6ff2ffb..6df332e 100644 --- a/src/Keyden.App/Views/SettingsWindow.axaml +++ b/src/Keyden.App/Views/SettingsWindow.axaml @@ -86,10 +86,11 @@ - - - Key storage backend: - + Key storage backend: + @@ -97,7 +98,12 @@ - + + + + 1Password account + + Automatically start diff --git a/src/Keyden.App/Views/SettingsWindow.axaml.cs b/src/Keyden.App/Views/SettingsWindow.axaml.cs index 6f11e33..23017d6 100644 --- a/src/Keyden.App/Views/SettingsWindow.axaml.cs +++ b/src/Keyden.App/Views/SettingsWindow.axaml.cs @@ -94,7 +94,8 @@ object IValueConverter.Convert(object? value, Type targetType, object? parameter { KeystoreBackend.None => "None", KeystoreBackend.DeveloperTest => "Developer Test", - KeystoreBackend.OnePassCLI => "1Password CLI", + KeystoreBackend.OnePassCli => "1Password CLI", + KeystoreBackend.OnePassSdk => "1Password SDK", _ => backend.ToString(), }, AuthenticationMode authMode => authMode switch diff --git a/src/Keyden.App/Views/TitleBarButtons.axaml.cs b/src/Keyden.App/Views/TitleBarButtons.axaml.cs index c0cf21a..e1e92a3 100644 --- a/src/Keyden.App/Views/TitleBarButtons.axaml.cs +++ b/src/Keyden.App/Views/TitleBarButtons.axaml.cs @@ -41,7 +41,10 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) if (e.Root is not Window window) return; - bool usingClientChrome = window.IsExtendedIntoWindowDecorations; + bool usingClientChrome = + window.IsExtendedIntoWindowDecorations || + (window.PlatformImpl?.IsClientAreaExtendedToDecorations ?? false); + MinimizeButton.IsVisible = usingClientChrome; MaximizeButton.IsVisible = usingClientChrome; CloseButton.IsVisible = usingClientChrome; diff --git a/src/Keyden.Core/AgentProtocol/AgentClient.cs b/src/Keyden.Core/AgentProtocol/AgentClient.cs index d6b9852..ea406ca 100644 --- a/src/Keyden.Core/AgentProtocol/AgentClient.cs +++ b/src/Keyden.Core/AgentProtocol/AgentClient.cs @@ -7,6 +7,7 @@ namespace Keyden.AgentProtocol; // https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#name-private-key-operations +// https://datatracker.ietf.org/doc/html/draft-ietf-sshm-ssh-agent // https://www.rfc-editor.org/rfc/rfc4251.html internal enum AgentMessageType : byte { diff --git a/src/Keyden.Core/AgentProtocol/AgentProtocolPrimitiveExtensions.cs b/src/Keyden.Core/AgentProtocol/AgentProtocolPrimitiveExtensions.cs index 098983a..e97cabf 100644 --- a/src/Keyden.Core/AgentProtocol/AgentProtocolPrimitiveExtensions.cs +++ b/src/Keyden.Core/AgentProtocol/AgentProtocolPrimitiveExtensions.cs @@ -16,6 +16,16 @@ public static void WriteByte(this BufferWriter self, byte value) self.NextBlock(1).Span[0] = value; } + public static Memory ReadBytes(this BufferWriter self, int length) + { + return self.NextBlock(length); + } + public static void WriteBytes(this BufferWriter self, ReadOnlySpan value) + { + var block = self.NextBlock(value.Length); + value.CopyTo(block.Span); + } + public static bool ReadBool(this BufferReader self) { return self.ReadByte() != 0; diff --git a/src/Keyden.Core/ISystemServices.cs b/src/Keyden.Core/ISystemServices.cs index 5f03b2b..919c23b 100644 --- a/src/Keyden.Core/ISystemServices.cs +++ b/src/Keyden.Core/ISystemServices.cs @@ -25,6 +25,8 @@ Task TryAuthUser( Process? GetPipeClientProcess(NamedPipeServerStream pipeServer); Process? GetParentProcess(Process process); + + string? GetNativeClientManifest(string id); } public struct AuthResult diff --git a/src/Keyden.Core/KeyOptionsJson.cs b/src/Keyden.Core/KeyOptionsJson.cs new file mode 100644 index 0000000..27aeb4b --- /dev/null +++ b/src/Keyden.Core/KeyOptionsJson.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Keyden; + +file static class JsonExtensionMethods +{ + public static JsonValue AsJsonValue(this TimeSpan value) + { + return JsonValue.Create(value.ToString(null, CultureInfo.InvariantCulture)); + } + public static JsonValue AsJsonValue(this bool value) + { + return JsonValue.Create(value); + } + public static JsonArray AsJsonValue(this IEnumerable value) + { + return [.. value.Select(x => JsonValue.Create(x))]; + } + public static bool? GetBool(this JsonNode node) + { + if (node.AsValue() is not JsonValue value) + return null; + return value.GetValue(); + } + public static TimeSpan? GetTimeSpan(this JsonNode node) + { + if (node.AsValue() is not JsonValue value) + return null; + var str = value.GetValue(); + return TimeSpan.Parse(str, CultureInfo.InvariantCulture); + } + public static IReadOnlyList? GetStringList(this JsonNode node) + { + if (node.AsArray() is not JsonArray array) + return null; + return array + .Select(x => x?.GetValue()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + } +} + +public static class KeyOptionsJson +{ + public static SshKeyOptions ReadOptionsNode(JsonObject obj) + { + var @default = new SshKeyOptions(); + return new SshKeyOptions() + { + EnableForMachines = obj["EnableForMachines"]?.GetStringList() ?? @default.EnableForMachines, + RequireAuthorization = obj["RequireAuthorization"]?.GetBool() ?? @default.RequireAuthentication, + RemainAuthorized = obj["RemainAuthorized"]?.GetBool() ?? @default.RemainAuthorized, + RemainAuthorizedFor = obj["RemainAuthorizedFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedFor, + RemainAuthorizedUntilKeyInactivity = obj["RemainAuthorizedUntilKeyInactivity"]?.GetBool() ?? @default.RemainAuthorizedUntilKeyInactivity, + RemainAuthorizedUntilKeyInactivityFor = obj["RemainAuthorizedUntilKeyInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedUntilKeyInactivityFor, + RemainAuthorizedUntilUserInactivity = obj["RemainAuthorizedUntilUserInactivity"]?.GetBool() ?? @default.RemainAuthorizedUntilUserInactivity, + RemainAuthorizedUntilUserInactivityFor = obj["RemainAuthorizedUntilUserInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedUntilUserInactivityFor, + RemainAuthorizedUntilLocked = obj["RemainAuthorizedUntilLocked"]?.GetBool() ?? @default.RemainAuthorizedUntilLocked, + RequireAuthentication = obj["RequireAuthentication"]?.GetBool() ?? @default.RequireAuthentication, + RemainAuthenticated = obj["RemainAuthenticated"]?.GetBool() ?? @default.RemainAuthenticated, + RemainAuthenticatedFor = obj["RemainAuthenticatedFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedFor, + RemainAuthenticatedUntilKeyInactivity = obj["RemainAuthenticatedUntilKeyInactivity"]?.GetBool() ?? @default.RemainAuthenticatedUntilKeyInactivity, + RemainAuthenticatedUntilKeyInactivityFor = obj["RemainAuthenticatedUntilKeyInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedUntilKeyInactivityFor, + RemainAuthenticatedUntilUserInactivity = obj["RemainAuthenticatedUntilUserInactivity"]?.GetBool() ?? @default.RemainAuthenticatedUntilUserInactivity, + RemainAuthenticatedUntilUserInactivityFor = obj["RemainAuthenticatedUntilUserInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedUntilUserInactivityFor, + RemainAuthenticatedUntilLocked = obj["RemainAuthenticatedUntilLocked"]?.GetBool() ?? @default.RemainAuthenticatedUntilLocked, + }; + } + + public static void WriteOptionsNode(JsonObject obj, SshKeyOptions opts) + { + obj["EnableForMachines"] = opts.EnableForMachines.AsJsonValue(); + obj["RequireAuthorization"] = opts.RequireAuthorization.AsJsonValue(); + obj["RemainAuthorized"] = opts.RemainAuthorized.AsJsonValue(); + obj["RemainAuthorizedFor"] = opts.RemainAuthorizedFor.AsJsonValue(); + obj["RemainAuthorizedUntilKeyInactivity"] = opts.RemainAuthorizedUntilKeyInactivity.AsJsonValue(); + obj["RemainAuthorizedUntilKeyInactivityFor"] = opts.RemainAuthorizedUntilKeyInactivityFor.AsJsonValue(); + obj["RemainAuthorizedUntilUserInactivity"] = opts.RemainAuthorizedUntilUserInactivity.AsJsonValue(); + obj["RemainAuthorizedUntilUserInactivityFor"] = opts.RemainAuthorizedUntilUserInactivityFor.AsJsonValue(); + obj["RemainAuthorizedUntilLocked"] = opts.RemainAuthorizedUntilLocked.AsJsonValue(); + obj["RequireAuthentication"] = opts.RequireAuthentication.AsJsonValue(); + obj["RemainAuthenticated"] = opts.RemainAuthenticated.AsJsonValue(); + obj["RemainAuthenticatedFor"] = opts.RemainAuthenticatedFor.AsJsonValue(); + obj["RemainAuthenticatedUntilKeyInactivity"] = opts.RemainAuthenticatedUntilKeyInactivity.AsJsonValue(); + obj["RemainAuthenticatedUntilKeyInactivityFor"] = opts.RemainAuthenticatedUntilKeyInactivityFor.AsJsonValue(); + obj["RemainAuthenticatedUntilUserInactivity"] = opts.RemainAuthenticatedUntilUserInactivity.AsJsonValue(); + obj["RemainAuthenticatedUntilUserInactivityFor"] = opts.RemainAuthenticatedUntilUserInactivityFor.AsJsonValue(); + obj["RemainAuthenticatedUntilLocked"] = opts.RemainAuthenticatedUntilLocked.AsJsonValue(); + } +} diff --git a/src/Keyden.Core/Keyden.Core.csproj b/src/Keyden.Core/Keyden.Core.csproj index 5fa3091..d2f18c3 100644 --- a/src/Keyden.Core/Keyden.Core.csproj +++ b/src/Keyden.Core/Keyden.Core.csproj @@ -1,7 +1,7 @@ - + - net9.0 + net10.0 Keyden True @@ -11,4 +11,8 @@ + + + + diff --git a/src/Keyden.Core/OnePassCliSshKeyStore.cs b/src/Keyden.Core/OnePassCliSshKeyStore.cs index c0f0e66..363779a 100644 --- a/src/Keyden.Core/OnePassCliSshKeyStore.cs +++ b/src/Keyden.Core/OnePassCliSshKeyStore.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -12,48 +10,6 @@ namespace Keyden; -file static class JsonExtensionMethods -{ - public static JsonValue AsJsonValue(this TimeSpan value) - { - return JsonValue.Create(value.ToString(null, CultureInfo.InvariantCulture)); - } - public static JsonValue AsJsonValue(this bool value) - { - return JsonValue.Create(value); - } - public static JsonArray AsJsonValue(this IReadOnlyList value) - { - var array = new JsonNode[value.Count]; - for (int i = 0; i < value.Count; i++) - array[i] = JsonValue.Create(value[i]); - return new JsonArray(array); - } - public static bool? GetBool(this JsonNode node) - { - if (node.AsValue() is not JsonValue value) - return null; - return value.GetValue(); - } - public static TimeSpan? GetTimeSpan(this JsonNode node) - { - if (node.AsValue() is not JsonValue value) - return null; - var str = value.GetValue(); - return TimeSpan.Parse(str, CultureInfo.InvariantCulture); - } - public static IReadOnlyList? GetStringList(this JsonNode node) - { - if (node.AsArray() is not JsonArray array) - return null; - return array - .Select(x => x?.GetValue()) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Select(x => x!) - .ToList(); - } -} - public sealed class OnePassCliSshKeyStore : ISshKeyStore, ISshKeyOptionsStore { private readonly static UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false); @@ -244,51 +200,6 @@ void ISshKeyOptionsStore.SetKeyOptions(string id, SshKeyOptions? options) } private const string KewardenOptionsEntryName = "\"Keyden Options\""; - private static SshKeyOptions ReadOptionsNode(JsonObject obj) - { - var @default = new SshKeyOptions(); - return new SshKeyOptions() - { - EnableForMachines = obj["EnableForMachines"]?.GetStringList() ?? @default.EnableForMachines, - RequireAuthorization = obj["RequireAuthorization"]?.GetBool() ?? @default.RequireAuthentication, - RemainAuthorized = obj["RemainAuthorized"]?.GetBool() ?? @default.RemainAuthorized, - RemainAuthorizedFor = obj["RemainAuthorizedFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedFor, - RemainAuthorizedUntilKeyInactivity = obj["RemainAuthorizedUntilKeyInactivity"]?.GetBool() ?? @default.RemainAuthorizedUntilKeyInactivity, - RemainAuthorizedUntilKeyInactivityFor = obj["RemainAuthorizedUntilKeyInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedUntilKeyInactivityFor, - RemainAuthorizedUntilUserInactivity = obj["RemainAuthorizedUntilUserInactivity"]?.GetBool() ?? @default.RemainAuthorizedUntilUserInactivity, - RemainAuthorizedUntilUserInactivityFor = obj["RemainAuthorizedUntilUserInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthorizedUntilUserInactivityFor, - RemainAuthorizedUntilLocked = obj["RemainAuthorizedUntilLocked"]?.GetBool() ?? @default.RemainAuthorizedUntilLocked, - RequireAuthentication = obj["RequireAuthentication"]?.GetBool() ?? @default.RequireAuthentication, - RemainAuthenticated = obj["RemainAuthenticated"]?.GetBool() ?? @default.RemainAuthenticated, - RemainAuthenticatedFor = obj["RemainAuthenticatedFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedFor, - RemainAuthenticatedUntilKeyInactivity = obj["RemainAuthenticatedUntilKeyInactivity"]?.GetBool() ?? @default.RemainAuthenticatedUntilKeyInactivity, - RemainAuthenticatedUntilKeyInactivityFor = obj["RemainAuthenticatedUntilKeyInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedUntilKeyInactivityFor, - RemainAuthenticatedUntilUserInactivity = obj["RemainAuthenticatedUntilUserInactivity"]?.GetBool() ?? @default.RemainAuthenticatedUntilUserInactivity, - RemainAuthenticatedUntilUserInactivityFor = obj["RemainAuthenticatedUntilUserInactivityFor"]?.GetTimeSpan() ?? @default.RemainAuthenticatedUntilUserInactivityFor, - RemainAuthenticatedUntilLocked = obj["RemainAuthenticatedUntilLocked"]?.GetBool() ?? @default.RemainAuthenticatedUntilLocked, - }; - } - - private static void WriteOptionsNode(JsonObject obj, SshKeyOptions opts) - { - obj["EnableForMachines"] = opts.EnableForMachines.AsJsonValue(); - obj["RequireAuthorization"] = opts.RequireAuthorization.AsJsonValue(); - obj["RemainAuthorized"] = opts.RemainAuthorized.AsJsonValue(); - obj["RemainAuthorizedFor"] = opts.RemainAuthorizedFor.AsJsonValue(); - obj["RemainAuthorizedUntilKeyInactivity"] = opts.RemainAuthorizedUntilKeyInactivity.AsJsonValue(); - obj["RemainAuthorizedUntilKeyInactivityFor"] = opts.RemainAuthorizedUntilKeyInactivityFor.AsJsonValue(); - obj["RemainAuthorizedUntilUserInactivity"] = opts.RemainAuthorizedUntilUserInactivity.AsJsonValue(); - obj["RemainAuthorizedUntilUserInactivityFor"] = opts.RemainAuthorizedUntilUserInactivityFor.AsJsonValue(); - obj["RemainAuthorizedUntilLocked"] = opts.RemainAuthorizedUntilLocked.AsJsonValue(); - obj["RequireAuthentication"] = opts.RequireAuthentication.AsJsonValue(); - obj["RemainAuthenticated"] = opts.RemainAuthenticated.AsJsonValue(); - obj["RemainAuthenticatedFor"] = opts.RemainAuthenticatedFor.AsJsonValue(); - obj["RemainAuthenticatedUntilKeyInactivity"] = opts.RemainAuthenticatedUntilKeyInactivity.AsJsonValue(); - obj["RemainAuthenticatedUntilKeyInactivityFor"] = opts.RemainAuthenticatedUntilKeyInactivityFor.AsJsonValue(); - obj["RemainAuthenticatedUntilUserInactivity"] = opts.RemainAuthenticatedUntilUserInactivity.AsJsonValue(); - obj["RemainAuthenticatedUntilUserInactivityFor"] = opts.RemainAuthenticatedUntilUserInactivityFor.AsJsonValue(); - obj["RemainAuthenticatedUntilLocked"] = opts.RemainAuthenticatedUntilLocked.AsJsonValue(); - } async Task ISshKeyOptionsStore.SyncKeyOptions(CancellationToken ct) { @@ -375,14 +286,14 @@ async Task ISshKeyOptionsStore.SyncKeyOptions(CancellationToken ct) continue; var keyId = field.Key; - var upstreamOptions = ReadOptionsNode(obj); + var upstreamOptions = KeyOptionsJson.ReadOptionsNode(obj); unprocessedKeys.Remove(keyId); if (DirtyOptions.Contains(keyId)) { if (Options.TryGetValue(keyId, out var downstreamOptions)) - WriteOptionsNode(obj, downstreamOptions); + KeyOptionsJson.WriteOptionsNode(obj, downstreamOptions); else toRemoveKeys.Add(keyId); } @@ -397,7 +308,7 @@ async Task ISshKeyOptionsStore.SyncKeyOptions(CancellationToken ct) changed = true; var obj = new JsonObject(); var downstreamOptions = Options[unprocessedKey]; - WriteOptionsNode(obj, downstreamOptions); + KeyOptionsJson.WriteOptionsNode(obj, downstreamOptions); jsonDoc[unprocessedKey] = obj; } diff --git a/src/Keyden.Core/OnePassSdkSshKeyStore.cs b/src/Keyden.Core/OnePassSdkSshKeyStore.cs new file mode 100644 index 0000000..66e4a2e --- /dev/null +++ b/src/Keyden.Core/OnePassSdkSshKeyStore.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +using Keyden.OnePassword.SDK; +using Keyden.OnePassword.SDK.Types; + +namespace Keyden; + +// https://developer.1password.com/docs/sdks/desktop-app-integrations +public sealed partial class OnePassSdkSshKeyStore : ISshKeyStore, ISshKeyOptionsStore +{ + [JsonSerializable(typeof(PrivateKeyInfo))] + internal partial class JsonContext : JsonSerializerContext + { + } + + internal record struct PrivateKeyInfo() + { + [JsonPropertyName("publicKey")] + public required string PublicKey { get; set; } + + [JsonPropertyName("fingerprint")] + public required string Fingerprint { get; set; } + + [JsonPropertyName("keyType")] + public required string KeyType { get; set; } + } + + private ISystemServices SystemServices { get; } + private OnePassSdk Sdk { get; } + public string AccountName + { + get => Sdk.AccountName ?? string.Empty; + set => Sdk.AccountName = value; + } + + public OnePassSdkSshKeyStore(ISystemServices systemServices) + { + SystemServices = systemServices; + Sdk = new OnePassSdk(new() + { + IntegrationName = "Keyden", + IntegrationVersion = Verlite.Version.Full, + }); + } + + private readonly static UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false); + + private List PublicKeys { get; } = []; + private List PrivateKeys { get; } = []; + + IReadOnlyList ISshKeyStore.PublicKeys => PublicKeys; + + ValueTask ISshKeyStore.GetPrivateKey(SshKey publicKey, CancellationToken ct) + { + var matchingKey = PrivateKeys + .Where(k => k.PublicKey.Span.SequenceEqual(publicKey.PublicKey.Span)) + .FirstOrDefault(); + + return new(matchingKey); + } + + async Task ISshKeyStore.SyncKeys(CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + + var newIds = new List(); + var newKeys = new List(); + + foreach (var vault in await Sdk.GetAllVaultsAsync()) + { + var itemIds = new List(); + foreach (var itemOverview in await Sdk.ListItemsAsync(vault.Id, [])) + if (itemOverview.Category == ItemCategory.SshKey) + itemIds.Add(itemOverview.Id); + + if (itemIds.Count == 0) + continue; + + var items = await Sdk.GetItemsAsync(vault.Id, itemIds); + + foreach (var itemResponse in items.IndividualResponses) + { + if (itemResponse.Content is null) + continue; + + var item = itemResponse.Content.Value; + string? fingerprint = null; + string? publicKey = null; + string? privateKey = null; + + foreach (var field in item.Fields) + { + if (field.FieldType != ItemFieldType.SshKey) + continue; + + if (field.Id is "public_key") + publicKey = field.Value; + else if (field.Id is "private_key") + { + var privateKeySecretRef = $"op://{vault.Id}/{item.Id}/{field.Id}?ssh-format=openssh"; + privateKey = await Sdk.ResolveSecretAsync(privateKeySecretRef).WaitAsync(ct); + + if (field.Details.HasValue && field.Details.Value.Type is "SshKey") + { + try + { + var keyInfo = JsonSerializer.Deserialize(field.Details.Value.Content, JsonContext.Default.PrivateKeyInfo); + publicKey = keyInfo.PublicKey; + fingerprint = keyInfo.Fingerprint; + } + catch (JsonException) + { + break; + } + } + } + } + + if (publicKey is null || privateKey is null || fingerprint is null) + continue; + + newIds.Add(item.Id); + newKeys.Add(new() + { + Id = item.Id, + Name = item.Title, + Fingerprint = fingerprint, + PublicKeyText = publicKey, + PublicKey = Convert.FromBase64String(publicKey[publicKey.IndexOf(' ')..]), + PrivateKey = Utf8.GetBytes(privateKey), + }); + } + } + + PrivateKeys.Clear(); + PublicKeys.Clear(); + PrivateKeys.AddRange(newKeys); + PublicKeys.AddRange(newKeys.Select(k => k with { PrivateKey = default })); + + Debug.WriteLine($"Synced with op in {sw.Elapsed.TotalSeconds} seconds"); + } + + private readonly HashSet DirtyOptions = []; + private readonly Dictionary Options = []; + private const string KeydenOptionsEntryName = "Keyden Options"; + + SshKeyOptions? ISshKeyOptionsStore.GetKeyOptions(string id) + { + if (Options.TryGetValue(id, out var options)) + return options; + else + return null; + } + + void ISshKeyOptionsStore.SetKeyOptions(string id, SshKeyOptions? options) + { + if (options is null) + { + if (Options.Remove(id)) + DirtyOptions.Add(id); + } + else + { + + if (Options.TryGetValue(id, out var oldOptions) && options.Value != oldOptions) + DirtyOptions.Add(id); + else if (options.Value != new SshKeyOptions()) + DirtyOptions.Add(id); + + Options[id] = options.Value; + } + } + + async Task ISshKeyOptionsStore.SyncKeyOptions(CancellationToken ct) + { + var jsonSerializerOptions = new JsonSerializerOptions() + { + WriteIndented = true, + }; + + + var personalVaultId = string.Empty; + foreach (var vault in await Sdk.GetAllVaultsAsync().WaitAsync(ct)) + { + if (vault.VaultType == "personal") + { + personalVaultId = vault.Id; + break; + } + } + if (personalVaultId is null) + throw new BackendException("SyncKeyOptions(): Could not find personal vault"); + + Item? optionsItem = null; + foreach (var item in await Sdk.ListItemsAsync(personalVaultId, []).WaitAsync(ct)) + { + if (item.Category == ItemCategory.SecureNote && item.Title is KeydenOptionsEntryName) + { + optionsItem = await Sdk.GetItemAsync(personalVaultId, item.Id); + break; + } + } + + if (!optionsItem.HasValue) + { + optionsItem = Sdk.CreateItem(new() + { + Title = KeydenOptionsEntryName, + VaultId = personalVaultId, + Category = ItemCategory.SecureNote, + Fields = [ + new ItemField + { + Id = "json", + Title = "json", + FieldType = ItemFieldType.Text, + Value = "{}", + SectionId = "", + } + ], + Sections = [ + new() { Id = "", Title = "" } + ] + }); + } + + var optionsJson = string.Empty; + var jsonFieldIndex = -1; + + for (int i = 0; i < optionsItem.Value.Fields.Count; i++) + { + var field = optionsItem.Value.Fields[i]; + + optionsJson = field.Value; + jsonFieldIndex = i; + break; + } + + if (jsonFieldIndex < 0) + throw new BackendException("Options \"Keyden Options\" -> \"json\" does not exist, automatic creation not yet supported. Please create manually"); + + bool changed = DirtyOptions.Count > 0; + JsonObject jsonDoc; + + if (string.IsNullOrEmpty(optionsJson)) + jsonDoc = new JsonObject(); + else + jsonDoc = JsonNode.Parse(optionsJson)?.AsObject() + ?? throw new BackendException($"Failed to parse options json"); + + var unprocessedKeys = new HashSet(Options.Keys); + var toRemoveKeys = new HashSet(); + + foreach (var field in jsonDoc) + { + if (field.Value?.AsObject() is not JsonObject obj) + continue; + + var keyId = field.Key; + var upstreamOptions = KeyOptionsJson.ReadOptionsNode(obj); + + unprocessedKeys.Remove(keyId); + + if (DirtyOptions.Contains(keyId)) + { + if (Options.TryGetValue(keyId, out var downstreamOptions)) + KeyOptionsJson.WriteOptionsNode(obj, downstreamOptions); + else + toRemoveKeys.Add(keyId); + } + else + Options[keyId] = upstreamOptions; + } + + foreach (var removedKey in toRemoveKeys) + jsonDoc.Remove(removedKey); + foreach (var unprocessedKey in unprocessedKeys) + { + changed = true; + var obj = new JsonObject(); + var downstreamOptions = Options[unprocessedKey]; + KeyOptionsJson.WriteOptionsNode(obj, downstreamOptions); + jsonDoc[unprocessedKey] = obj; + } + + if (changed) + { + var fieldsClone = optionsItem.Value.Fields.ToArray(); + fieldsClone[jsonFieldIndex].Value = jsonDoc.ToJsonString(jsonSerializerOptions); + optionsItem = optionsItem.Value with { Fields = fieldsClone }; + + DirtyOptions.Clear(); + await Sdk.UpdateItemAsync(optionsItem.Value); + } + } +} diff --git a/src/Keyden.Core/SshAgent.cs b/src/Keyden.Core/SshAgent.cs index 296f47b..b5051a3 100644 --- a/src/Keyden.Core/SshAgent.cs +++ b/src/Keyden.Core/SshAgent.cs @@ -7,11 +7,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.IO.Pipes; using System.Linq; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Threading; using System.Threading.Channels; diff --git a/src/Keyden.Desktop.Unix/Keyden.Desktop.Unix.csproj b/src/Keyden.Desktop.Unix/Keyden.Desktop.Unix.csproj index e49cf7c..8f22382 100644 --- a/src/Keyden.Desktop.Unix/Keyden.Desktop.Unix.csproj +++ b/src/Keyden.Desktop.Unix/Keyden.Desktop.Unix.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 WinExe GPLv3 diff --git a/src/Keyden.Desktop.Unix/Program.cs b/src/Keyden.Desktop.Unix/Program.cs index 492ef91..0d8f1fd 100644 --- a/src/Keyden.Desktop.Unix/Program.cs +++ b/src/Keyden.Desktop.Unix/Program.cs @@ -1,3 +1,5 @@ +using System; + using Avalonia; using Avalonia.ReactiveUI; @@ -5,9 +7,6 @@ using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.MaterialDesign; -using System; -using System.Runtime.InteropServices; - namespace Keyden.Desktop.Any; internal sealed class Program @@ -16,8 +15,10 @@ internal sealed class Program // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static int Main(string[] args) + { + return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() diff --git a/src/Keyden.Desktop.Windows/Keyden.Desktop.Windows.csproj b/src/Keyden.Desktop.Windows/Keyden.Desktop.Windows.csproj index 0ea0b9d..eede2bc 100644 --- a/src/Keyden.Desktop.Windows/Keyden.Desktop.Windows.csproj +++ b/src/Keyden.Desktop.Windows/Keyden.Desktop.Windows.csproj @@ -1,7 +1,7 @@ - + - net9.0-windows10.0.20348.0 + net10.0-windows10.0.20348.0 WinExe GPLv3 en diff --git a/src/Keyden.Desktop.Windows/Program.cs b/src/Keyden.Desktop.Windows/Program.cs index 05f9349..c95b14d 100644 --- a/src/Keyden.Desktop.Windows/Program.cs +++ b/src/Keyden.Desktop.Windows/Program.cs @@ -1,12 +1,14 @@ +using System; + using Avalonia; using Avalonia.ReactiveUI; +using Keyden.OnePassword.SDK; + using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.MaterialDesign; -using System; - namespace Keyden.Desktop.Windows; internal sealed class Program @@ -15,8 +17,10 @@ internal sealed class Program // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static int Main(string[] args) + { + return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() diff --git a/src/Keyden.Desktop.Windows/WindowsSystemServices.cs b/src/Keyden.Desktop.Windows/WindowsSystemServices.cs index 4b161e8..508c676 100644 --- a/src/Keyden.Desktop.Windows/WindowsSystemServices.cs +++ b/src/Keyden.Desktop.Windows/WindowsSystemServices.cs @@ -290,5 +290,43 @@ public void NotifyPreauthorizedKey(ClientInfo clientInfo, SshKey key) // TODO: popup a notification if the setting is enabled } + private readonly static string[] WindowsNativeMessagingPaths = + [ + @"SOFTWARE\Mozilla\NativeMessagingHosts", + @"SOFTWARE\Google\Chrome\NativeMessagingHosts", + ]; + + public string? GetNativeClientManifest(string id) + { + foreach (var path in WindowsNativeMessagingPaths) + { + RegistryKey? key = null; + + try + { + key = Registry.CurrentUser.OpenSubKey(@$"{path}\{id}", false)!; + if (key is not null && key.GetValue(null) is string keyValue) + return keyValue; + } + finally + { + key?.Dispose(); + } + + try + { + key = Registry.LocalMachine.OpenSubKey(@$"{path}\{id}", false)!; + if (key is not null && key.GetValue(null) is string keyValue) + return keyValue; + } + finally + { + key?.Dispose(); + } + } + + return null; + } + public string AuthenticationBranding => "Windows Hello"; } diff --git a/tests/SshAgentTest/SshAgentTest.csproj b/tests/SshAgentTest/SshAgentTest.csproj index 69d7bde..9b0fdc1 100644 --- a/tests/SshAgentTest/SshAgentTest.csproj +++ b/tests/SshAgentTest/SshAgentTest.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 Exe diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj index 66a2a2f..274656e 100644 --- a/tests/UnitTests/UnitTests.csproj +++ b/tests/UnitTests/UnitTests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 false $(NoWarn);1701;1702;IDE0051;IDE0058; @@ -27,12 +27,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all