diff --git a/.github/workflows/build-rust-cross-platform.yml b/.github/workflows/build-rust-cross-platform.yml new file mode 100644 index 00000000..845a13c3 --- /dev/null +++ b/.github/workflows/build-rust-cross-platform.yml @@ -0,0 +1,127 @@ +name: Build Rust Cross Platform + +on: + workflow_call: + +jobs: + build_rust: + name: Build for ${{ matrix.settings.os }} ${{ matrix.settings.target }} + runs-on: ${{ matrix.settings.os }} + strategy: + fail-fast: false + matrix: + settings: + - os: macos-13 + target: x86_64-apple-darwin + dotnet_rid: osx-x64 + - os: macos-13 + target: aarch64-apple-darwin + dotnet_rid: osx-arm64 + - os: windows-2022 + target: i686-pc-windows-msvc + dotnet_rid: win-x86 + - os: windows-2022 + target: x86_64-pc-windows-msvc + dotnet_rid: win-x64 + - os: windows-2022 + target: aarch64-pc-windows-msvc + dotnet_rid: win-arm64 + # caution: updating the linux runner OS version for GNU + # targets will likely break the library for older OS versions. + # prefer using oldest supported runner for for these targets + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + dotnet_rid: linux-x64 + - os: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + dotnet_rid: linux-arm64 + use_cross: true + - os: ubuntu-22.04 + target: armv7-unknown-linux-gnueabihf + dotnet_rid: linux-arm + use_cross: true + - os: ubuntu-22.04 + target: arm-unknown-linux-gnueabihf + dotnet_rid: linux-armv6 + use_cross: true + - os: ubuntu-22.04 + target: armv5te-unknown-linux-gnueabi + dotnet_rid: linux-armel + use_cross: true + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install rust + uses: dtolnay/rust-toolchain@c5a29ddb4d9d194e7c84ec8c3fba61b1c31fee8c # stable + with: + toolchain: stable + + - name: Cache cargo registry + uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5 + + - name: Install cross + if: ${{ startsWith(matrix.settings.os, 'ubuntu') && !startsWith(matrix.settings.target, 'x86_64') }} + run: cargo install cross --git https://github.com/cross-rs/cross --rev 36c0d7810ddde073f603c82d896c2a6c886ff7a4 + + - name: Add build architecture + run: rustup target add ${{ matrix.settings.target }} + + # Build Rust natively + - name: Build Rust native for - ${{ matrix.settings.target }} + if: ${{ matrix.settings.use_cross != true }} + env: + RUSTFLAGS: "-D warnings" + MACOSX_DEPLOYMENT_TARGET: "10.14" # allows using new macos runner versions while still supporting older systems + run: cargo build --target ${{ matrix.settings.target }} --release + working-directory: extensions/Bitwarden.Opaque/rust + + # Build Rust using cross + - name: Build Rust cross for - ${{ matrix.settings.target }} + if: ${{ matrix.settings.use_cross == true }} + env: + RUSTFLAGS: "-D warnings" + run: cross build --target ${{ matrix.settings.target }} --release + working-directory: extensions/Bitwarden.Opaque/rust + + - name: Upload Artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: libopaque_ke_binding_files-${{ matrix.settings.dotnet_rid }} + # We only need these until the next step, so delete them as soon as possible + retention-days: 1 + if-no-files-found: error + path: | + extensions/Bitwarden.Opaque/rust/target/${{ matrix.settings.target }}/release/opaque_ke_binding.dll + extensions/Bitwarden.Opaque/rust/target/${{ matrix.settings.target }}/release/libopaque_ke_binding.so + extensions/Bitwarden.Opaque/rust/target/${{ matrix.settings.target }}/release/libopaque_ke_binding.dylib + + collect_artifacts: + name: Collect and Upload All Artifacts + runs-on: ubuntu-22.04 + needs: build_rust + steps: + - name: Download all artifacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + pattern: libopaque_ke_binding_files-* + path: downloaded_runtimes/ + + - name: Move files to the correct directory + run: | + for file in downloaded_runtimes/libopaque_ke_binding_files-*; do + echo "Processing $file" + platform="${file#downloaded_runtimes/libopaque_ke_binding_files-}" + echo "Platform: $platform" + mkdir -p runtimes/${platform}/native + mv $file/* runtimes/${platform}/native + done + + - name: Upload Combined Artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: libopaque_ke_binding_all_files + if-no-files-found: error + path: | + runtimes/**/* diff --git a/.github/workflows/pack-and-release.yml b/.github/workflows/pack-and-release.yml index 649814b6..3ea66727 100644 --- a/.github/workflows/pack-and-release.yml +++ b/.github/workflows/pack-and-release.yml @@ -11,8 +11,14 @@ on: description: Token used to publish packages jobs: + build_rust: + name: Build Rust Cross Platform + uses: ./.github/workflows/build-rust-cross-platform.yml + release: name: Release + needs: + - build_rust runs-on: ubuntu-22.04 outputs: package: ${{ steps.parse-package.outputs.result }} @@ -47,6 +53,12 @@ jobs: return refParts[3]; + - name: Download cross compiled library files + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: libopaque_ke_binding_all_files + path: extensions/Bitwarden.Opaque/rust/dist/ + - name: Pack run: dotnet pack -c Release -p:IsPreRelease=$IS_PRERELEASE working-directory: "./extensions/${{ steps.parse-package.outputs.result }}/src" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index c20496f7..393a57ac 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,7 +8,7 @@ on: jobs: prerelease: name: Do prerelease - uses: bitwarden/dotnet-extensions/.github/workflows/pack-and-release.yml@main + uses: ./.github/workflows/pack-and-release.yml with: prerelease: true secrets: diff --git a/.github/workflows/start-release.yml b/.github/workflows/start-release.yml index e9df2f28..4221847e 100644 --- a/.github/workflows/start-release.yml +++ b/.github/workflows/start-release.yml @@ -11,6 +11,7 @@ on: options: - Bitwarden.Server.Sdk - Bitwarden.Server.Sdk.Features + - Bitwarden.Opaque permissions: pull-requests: write diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b7ef8653 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.linkedProjects": ["extensions/Bitwarden.Opaque/rust/Cargo.toml"], +} diff --git a/bitwarden-dotnet.sln b/bitwarden-dotnet.sln index 7e7e4498..3cd6ee85 100644 --- a/bitwarden-dotnet.sln +++ b/bitwarden-dotnet.sln @@ -45,6 +45,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4949B721 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bitwarden.Server.Sdk.Features.Tests", "extensions\Bitwarden.Server.Sdk.Features\tests\Bitwarden.Server.Sdk.Features.Tests\Bitwarden.Server.Sdk.Features.Tests.csproj", "{1789F567-87B3-4313-80CF-E3CCFA1B6D5E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Bitwarden.Opaque", "Bitwarden.Opaque", "{023418F1-43B7-4D56-AFA1-67D8FE9B7EC1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bitwarden.Opaque", "extensions\Bitwarden.Opaque\src\Bitwarden.Opaque.csproj", "{B49E33DF-A672-4361-BECA-C3DA423BD7A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bitwarden.Opaque.Tests", "extensions\Bitwarden.Opaque\tests\Bitwarden.Opaque.Tests.csproj", "{DC9DAB81-5ED7-4756-BF20-D594D87D2865}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bitwarden.Opaque.Benchmarks", "extensions\Bitwarden.Opaque\perf\Bitwarden.Opaque.Benchmarks.csproj", "{96631E35-695B-4BAF-B1E7-4446437A5FC8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +110,18 @@ Global {1789F567-87B3-4313-80CF-E3CCFA1B6D5E}.Debug|Any CPU.Build.0 = Debug|Any CPU {1789F567-87B3-4313-80CF-E3CCFA1B6D5E}.Release|Any CPU.ActiveCfg = Release|Any CPU {1789F567-87B3-4313-80CF-E3CCFA1B6D5E}.Release|Any CPU.Build.0 = Release|Any CPU + {B49E33DF-A672-4361-BECA-C3DA423BD7A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B49E33DF-A672-4361-BECA-C3DA423BD7A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B49E33DF-A672-4361-BECA-C3DA423BD7A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B49E33DF-A672-4361-BECA-C3DA423BD7A9}.Release|Any CPU.Build.0 = Release|Any CPU + {DC9DAB81-5ED7-4756-BF20-D594D87D2865}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC9DAB81-5ED7-4756-BF20-D594D87D2865}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC9DAB81-5ED7-4756-BF20-D594D87D2865}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC9DAB81-5ED7-4756-BF20-D594D87D2865}.Release|Any CPU.Build.0 = Release|Any CPU + {96631E35-695B-4BAF-B1E7-4446437A5FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96631E35-695B-4BAF-B1E7-4446437A5FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96631E35-695B-4BAF-B1E7-4446437A5FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96631E35-695B-4BAF-B1E7-4446437A5FC8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5EC8B943-2E9E-437D-9FFC-D18B5DB4D7D0} = {695C76EF-1102-4805-970F-7C995EE54930} @@ -124,5 +144,9 @@ Global {DF914CD1-F916-4A58-B749-625DB67FAAA7} = {026589E0-5AAA-44EB-B973-3CFFF5B54AFC} {4949B721-5C7F-4D85-AB35-F57B54D7A6E6} = {026589E0-5AAA-44EB-B973-3CFFF5B54AFC} {1789F567-87B3-4313-80CF-E3CCFA1B6D5E} = {4949B721-5C7F-4D85-AB35-F57B54D7A6E6} + {023418F1-43B7-4D56-AFA1-67D8FE9B7EC1} = {695C76EF-1102-4805-970F-7C995EE54930} + {B49E33DF-A672-4361-BECA-C3DA423BD7A9} = {023418F1-43B7-4D56-AFA1-67D8FE9B7EC1} + {DC9DAB81-5ED7-4756-BF20-D594D87D2865} = {023418F1-43B7-4D56-AFA1-67D8FE9B7EC1} + {96631E35-695B-4BAF-B1E7-4446437A5FC8} = {023418F1-43B7-4D56-AFA1-67D8FE9B7EC1} EndGlobalSection EndGlobal diff --git a/extensions/Bitwarden.Opaque/perf/Bitwarden.Opaque.Benchmarks.csproj b/extensions/Bitwarden.Opaque/perf/Bitwarden.Opaque.Benchmarks.csproj new file mode 100644 index 00000000..4a6876aa --- /dev/null +++ b/extensions/Bitwarden.Opaque/perf/Bitwarden.Opaque.Benchmarks.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + false + enable + enable + + + + + + + + + + + diff --git a/extensions/Bitwarden.Opaque/perf/OpaqueBench.cs b/extensions/Bitwarden.Opaque/perf/OpaqueBench.cs new file mode 100644 index 00000000..2162f83d --- /dev/null +++ b/extensions/Bitwarden.Opaque/perf/OpaqueBench.cs @@ -0,0 +1,108 @@ +using BenchmarkDotNet.Attributes; + +namespace Bitwarden.Opaque.Benchmarks; + +// dotnet run --project extensions/Bitwarden.Opaque/perf/Bitwarden.Opaque.Benchmarks.csproj -c Release -p:BuildOpaqueLib=true + +[MemoryDiagnoser] +public class OpaqueBench +{ + public BitwardenOpaqueServer server = new(); + public BitwardenOpaqueClient client = new(); + public CipherConfiguration config = CipherConfiguration.Default; + + public string username = "demo_username"; + public string password = "demo_password"; + + public byte[] serverSetup = null!; + public byte[] serverRegistration = null!; + + public byte[] clientRegistrationRequest = null!; + public byte[] clientRegistrationUpload = null!; + + public byte[] clientLoginCredentialRequest = null!; + public byte[] serverLoginState = null!; + public byte[] clientLoginCredentialFinalization = null!; + + [GlobalSetup] + public void Setup() + { + // Use the complete benchmarks to extract the data for the partial benchmarks + var registration = CompleteRegistration(); + serverSetup = registration.Item1; + serverRegistration = registration.Item2; + clientRegistrationRequest = registration.Item3; + clientRegistrationUpload = registration.Item4; + + var login = CompleteLogin(); + clientLoginCredentialRequest = login.Item1; + serverLoginState = login.Item2; + clientLoginCredentialFinalization = login.Item3; + } + + [Benchmark] + public (byte[], byte[]) SeededFakeRegistration() + { + var seed = new byte[32]; + return server.SeededFakeRegistration(seed); + } + + [Benchmark] + public (byte[], byte[], byte[], byte[]) CompleteRegistration() + { + var clientRegisterStartResult = client.StartRegistration(config, password); + var serverRegisterStartResult = server.StartRegistration(config, null, clientRegisterStartResult.registrationRequest, username); + var clientRegisterFinishResult = client.FinishRegistration(config, clientRegisterStartResult.state, serverRegisterStartResult.registrationResponse, password); + var serverRegisterFinishResult = server.FinishRegistration(config, clientRegisterFinishResult.registrationUpload); + return ( + serverRegisterStartResult.serverSetup, + serverRegisterFinishResult.serverRegistration, + clientRegisterStartResult.registrationRequest, + clientRegisterFinishResult.registrationUpload + ); + } + + [Benchmark] + public (byte[], byte[], byte[], byte[]) CompleteLogin() + { + var clientLoginStartResult = client.StartLogin(config, password); + var serverLoginStartResult = server.StartLogin(config, serverSetup, serverRegistration, clientLoginStartResult.credentialRequest, username); + var clientLoginFinishResult = client.FinishLogin(config, clientLoginStartResult.state, serverLoginStartResult.credentialResponse, password); + var serverLoginFinishResult = server.FinishLogin(config, serverLoginStartResult.state, clientLoginFinishResult.credentialFinalization); + return ( + clientLoginStartResult.credentialRequest, + serverLoginStartResult.state, + clientLoginFinishResult.credentialFinalization, + serverLoginFinishResult.sessionKey + ); + } + + [Benchmark] + public (byte[], byte[]) StartServerRegistration() + { + var result = server.StartRegistration(config, null, clientRegistrationRequest, username); + return (result.registrationResponse, result.serverSetup); + } + + [Benchmark] + public byte[] FinishServerRegistration() + { + var result = server.FinishRegistration(config, clientRegistrationUpload); + return result.serverRegistration; + } + + + [Benchmark] + public (byte[], byte[]) StartServerLogin() + { + var result = server.StartLogin(config, serverSetup, serverRegistration, clientLoginCredentialRequest, username); + return (result.credentialResponse, result.state); + } + + [Benchmark] + public byte[] FinishServerLogin() + { + var result = server.FinishLogin(config, serverLoginState, clientLoginCredentialFinalization); + return result.sessionKey; + } +} diff --git a/extensions/Bitwarden.Opaque/perf/Program.cs b/extensions/Bitwarden.Opaque/perf/Program.cs new file mode 100644 index 00000000..ae6c326d --- /dev/null +++ b/extensions/Bitwarden.Opaque/perf/Program.cs @@ -0,0 +1,4 @@ +using System.Reflection; +using BenchmarkDotNet.Running; + +BenchmarkRunner.Run(Assembly.GetExecutingAssembly()); diff --git a/extensions/Bitwarden.Opaque/rust/.gitignore b/extensions/Bitwarden.Opaque/rust/.gitignore new file mode 100644 index 00000000..580f45e9 --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/.gitignore @@ -0,0 +1,2 @@ +target +dist diff --git a/extensions/Bitwarden.Opaque/rust/Cargo.lock b/extensions/Bitwarden.Opaque/rust/Cargo.lock new file mode 100644 index 00000000..8bc6d84a --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/Cargo.lock @@ -0,0 +1,643 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive-where" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "serde", + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "once_cell" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "opaque-ke" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb31f7b2c5760d8ffbc39652043f5cff14029b6249a2280b28ae6f0cd61f5098" +dependencies = [ + "argon2", + "curve25519-dalek", + "derive-where", + "digest", + "displaydoc", + "elliptic-curve", + "generic-array", + "getrandom", + "hkdf", + "hmac", + "rand", + "serde", + "subtle", + "voprf", + "zeroize", +] + +[[package]] +name = "opaque-ke-binding" +version = "0.0.0" +dependencies = [ + "argon2", + "hex", + "opaque-ke", + "rand", + "rand_chacha", + "serde", + "serde_json", + "zeroizing-alloc", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zerocopy" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroizing-alloc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebff5e6b81c1c7dca2d0bd333b2006da48cb37dbcae5a8da888f31fcb3c19934" diff --git a/extensions/Bitwarden.Opaque/rust/Cargo.toml b/extensions/Bitwarden.Opaque/rust/Cargo.toml new file mode 100644 index 00000000..1e6a144c --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "opaque-ke-binding" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +argon2 = "0.5.3" +hex = "0.4.3" +opaque-ke = { version = "3.0.0", features = ["std", "argon2"] } +rand = "0.8.5" +rand_chacha = "0.3.1" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +zeroizing-alloc = "0.1.0" + +# We want panic unwinding to be enabled, which would ensure the process it's not aborted +# when a panic occurs. This the default value, but we want to be explicit about it. + +[profile.dev] +panic = "unwind" + +[profile.release] +panic = "unwind" +# Turn on LTO on release mode +lto = true +codegen-units = 1 diff --git a/extensions/Bitwarden.Opaque/rust/rust-toolchain.toml b/extensions/Bitwarden.Opaque/rust/rust-toolchain.toml new file mode 100644 index 00000000..73cb934d --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/extensions/Bitwarden.Opaque/rust/src/ffi/mod.rs b/extensions/Bitwarden.Opaque/rust/src/ffi/mod.rs new file mode 100644 index 00000000..90b9d403 --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/src/ffi/mod.rs @@ -0,0 +1,317 @@ +use std::{ffi::c_char, panic::UnwindSafe, str::FromStr}; + +use crate::{ + Error, + opaque::{CipherConfiguration, OpaqueImpl}, + try_ffi, +}; + +mod types; + +use types::*; + +/// # Safety +/// All the limitations of [`std::ffi::CStr::from_ptr`] apply, mainly: +/// - The pointer must be valid and point to a null-terminated byte string. +/// - The memory must be valid for the duration of the call and not modified by other threads. +unsafe fn parse_str<'a>(input: *const c_char, name: &'static str) -> Result<&'a str, Error> { + if input.is_null() { + return Err(Error::InvalidInput("Input string is null".into())); + } + unsafe { std::ffi::CStr::from_ptr(input) } + .to_str() + .map_err(|_| Error::InvalidInput(name.into())) +} + +fn catch(f: impl FnOnce() -> Response + UnwindSafe) -> Response { + match std::panic::catch_unwind(f) { + Ok(r) => r, + Err(e) => Response::error(Error::InternalError(format!("Panic: {e:?}"))), + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn register_seeded_fake_config(seed: Buffer) -> Response { + catch(|| { + let seed = try_ffi!(unsafe { seed.as_slice() }); + let seed = try_ffi!( + <[u8; 32]>::try_from(seed) + .map_err(|_| Error::InvalidInput("Seed must be 32 bytes".into())) + ); + + let (server_setup, server_registration) = + try_ffi!(crate::opaque::register_seeded_fake_config(seed)); + Response::ok([server_setup, server_registration]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn start_client_registration( + config: *const c_char, + password: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let password: &str = try_ffi!(unsafe { parse_str(password, "password") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let result = try_ffi!(config.start_client_registration(password)); + Response::ok([result.registration_request, result.state]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn start_server_registration( + config: *const c_char, + server_setup: Buffer, + registration_request: Buffer, + username: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let server_setup = try_ffi!(unsafe { server_setup.as_slice_optional() }); + let registration_request = try_ffi!(unsafe { registration_request.as_slice() }); + let username = try_ffi!(unsafe { parse_str(username, "username") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.start_server_registration( + server_setup, + registration_request, + username + )); + Response::ok([response.registration_response, response.server_setup]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn finish_client_registration( + config: *const c_char, + state: Buffer, + registration_response: Buffer, + password: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let registration_response = try_ffi!(unsafe { registration_response.as_slice() }); + let state = try_ffi!(unsafe { state.as_slice() }); + let password = try_ffi!(unsafe { parse_str(password, "password") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = + try_ffi!(config.finish_client_registration(state, registration_response, password)); + Response::ok([ + response.registration_upload, + response.export_key, + response.server_s_pk, + ]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn finish_server_registration( + config: *const c_char, + registration_upload: Buffer, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let registration_upload = try_ffi!(unsafe { registration_upload.as_slice() }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.finish_server_registration(registration_upload)); + Response::ok([response.server_registration]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn start_client_login( + config: *const c_char, + password: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let password = try_ffi!(unsafe { parse_str(password, "password") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.start_client_login(password)); + Response::ok([response.credential_request, response.state]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn start_server_login( + config: *const c_char, + server_setup: Buffer, + server_registration: Buffer, + credential_request: Buffer, + username: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let server_setup = try_ffi!(unsafe { server_setup.as_slice() }); + let server_registration = try_ffi!(unsafe { server_registration.as_slice() }); + let credential_request = try_ffi!(unsafe { credential_request.as_slice() }); + let username = try_ffi!(unsafe { parse_str(username, "username") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.start_server_login( + server_setup, + server_registration, + credential_request, + username, + )); + Response::ok([response.credential_response, response.state]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn finish_client_login( + config: *const c_char, + state: Buffer, + credential_response: Buffer, + password: *const c_char, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let state = try_ffi!(unsafe { state.as_slice() }); + let credential_response = try_ffi!(unsafe { credential_response.as_slice() }); + let password = try_ffi!(unsafe { parse_str(password, "password") }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.finish_client_login(state, credential_response, password)); + Response::ok([ + response.credential_finalization, + response.session_key, + response.export_key, + response.server_s_pk, + ]) + }) +} + +/// # Safety +/// This function must follow the same safety rules as [`parse_str`] and [`Buffer::as_slice`]. +/// The caller must ensure that the [Response] is correctly freed after use. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn finish_server_login( + config: *const c_char, + state: Buffer, + credential_finalization: Buffer, +) -> Response { + catch(|| { + let config: &str = try_ffi!(unsafe { parse_str(config, "config") }); + let state = try_ffi!(unsafe { state.as_slice() }); + let credential_finalization = try_ffi!(unsafe { credential_finalization.as_slice() }); + + let mut config = try_ffi!(CipherConfiguration::from_str(config)); + + let response = try_ffi!(config.finish_server_login(state, credential_finalization)); + Response::ok([response.session_key]) + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::{CString, c_char}; + + use crate::opaque::*; + + use super::{types::Buffer, *}; + + // Test for possible panics and/or undefined behavior. + // Ideally run using: + // + // cargo +nightly miri test + // RUSTFLAGS="-Z sanitizer=address" cargo +nightly test --target aarch64-apple-darwin --release + // RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test --target aarch64-apple-darwin --release + #[test] + fn test_ffi_no_panic() { + let user = "username"; + let user = CString::new(user).unwrap(); + let pass = "password"; + let pass = CString::new(pass).unwrap(); + let cfg = serde_json::to_string(&CipherConfiguration::default()).unwrap(); + let cfg = CString::new(cfg.as_str()).unwrap(); + + let _buf = Buffer::from_vec(vec![0; 32]); + let buf = || _buf.duplicate(); + + let _null_buf = Buffer::null(); + let null_buf = || _null_buf.duplicate(); + + let null_str_ptr: *const c_char = std::ptr::null(); + + unsafe { + start_client_registration(cfg.as_ptr(), pass.as_ptr()); + start_client_registration(null_str_ptr, pass.as_ptr()); + start_client_registration(null_str_ptr, null_str_ptr); + + start_server_registration(cfg.as_ptr(), buf(), buf(), user.as_ptr()); + start_server_registration(null_str_ptr, buf(), buf(), user.as_ptr()); + start_server_registration(cfg.as_ptr(), buf(), buf(), user.as_ptr()); + start_server_registration(cfg.as_ptr(), null_buf(), buf(), user.as_ptr()); + start_server_registration(cfg.as_ptr(), buf(), null_buf(), null_str_ptr); + + finish_client_registration(cfg.as_ptr(), buf(), buf(), pass.as_ptr()); + finish_client_registration(null_str_ptr, buf(), buf(), pass.as_ptr()); + finish_client_registration(cfg.as_ptr(), null_buf(), buf(), pass.as_ptr()); + finish_client_registration(cfg.as_ptr(), buf(), null_buf(), pass.as_ptr()); + finish_client_registration(cfg.as_ptr(), buf(), buf(), null_str_ptr); + + finish_server_registration(cfg.as_ptr(), buf()); + finish_server_registration(null_str_ptr, buf()); + finish_server_registration(cfg.as_ptr(), null_buf()); + + start_client_login(cfg.as_ptr(), pass.as_ptr()); + start_client_login(null_str_ptr, pass.as_ptr()); + start_client_login(cfg.as_ptr(), null_str_ptr); + + start_server_login(cfg.as_ptr(), buf(), buf(), buf(), user.as_ptr()); + start_server_login(null_str_ptr, buf(), buf(), buf(), user.as_ptr()); + start_server_login(cfg.as_ptr(), null_buf(), buf(), buf(), user.as_ptr()); + start_server_login(cfg.as_ptr(), buf(), null_buf(), buf(), user.as_ptr()); + start_server_login(cfg.as_ptr(), buf(), buf(), null_buf(), user.as_ptr()); + start_server_login(cfg.as_ptr(), buf(), buf(), buf(), null_str_ptr); + + finish_client_login(cfg.as_ptr(), buf(), buf(), pass.as_ptr()); + finish_client_login(null_str_ptr, buf(), buf(), pass.as_ptr()); + finish_client_login(cfg.as_ptr(), null_buf(), buf(), pass.as_ptr()); + finish_client_login(cfg.as_ptr(), buf(), null_buf(), pass.as_ptr()); + finish_client_login(cfg.as_ptr(), buf(), buf(), null_str_ptr); + + finish_server_login(cfg.as_ptr(), buf(), buf()); + finish_server_login(null_str_ptr, buf(), buf()); + finish_server_login(cfg.as_ptr(), null_buf(), buf()); + finish_server_login(cfg.as_ptr(), buf(), null_buf()); + + buf().free(); + null_buf().free(); + } + } +} diff --git a/extensions/Bitwarden.Opaque/rust/src/ffi/types.rs b/extensions/Bitwarden.Opaque/rust/src/ffi/types.rs new file mode 100644 index 00000000..38238338 --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/src/ffi/types.rs @@ -0,0 +1,160 @@ +use crate::Error; + +/// Free the buffer memory. +/// +/// # Safety +/// This function should ONLY be used with [Buffer]s initialized from the Rust side. +/// Calling it with a [Buffer] initialized from the C# side is undefined behavior. +/// The caller is responsible for ensuring that the memory is not used after this call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_buffer(buf: Buffer) { + std::panic::catch_unwind(|| { + unsafe { buf.free() }; + }) + .ok(); +} + +#[repr(C)] +pub struct Buffer { + data: *mut u8, + len: usize, +} + +/// A struct to represent a buffer of data. +/// Important: The structure of this type must match the structure +/// of the Buffer type in the C# `BitwardenLibrary`, both in field type and order. +impl Buffer { + pub fn null() -> Self { + Buffer { + data: std::ptr::null_mut(), + len: 0, + } + } + + pub fn from_vec(mut vec: Vec) -> Self { + // Important: Ensure that capacity and length are the same. + vec.shrink_to_fit(); + + let len = vec.len(); + let data = vec.as_mut_ptr(); + std::mem::forget(vec); + Buffer { data, len } + } + + /// # Safety + /// All the limitations of [`std::slice::from_raw_parts`] apply, mainly: + /// - The pointer must be either null or valid for reads up to [`Buffer::len`] bytes. + /// - The memory must be valid for the duration of the call and not modified by other threads. + pub unsafe fn as_slice_optional(&self) -> Result, Error> { + if self.data.is_null() { + return Ok(None); + } + if self.len >= isize::MAX as usize { + return Err(Error::InvalidInput("Buffer length is too large".into())); + } + if (self.data.addr()) + .overflowing_add_signed(self.len as isize) + .1 + { + return Err(Error::InvalidInput("Buffer data overflows".into())); + } + Ok(Some(unsafe { + std::slice::from_raw_parts(self.data, self.len) + })) + } + + /// # Safety + /// All the limitations of [`std::slice::from_raw_parts`] apply, mainly: + /// - The pointer must be either null or valid for reads up to [`Buffer::len`] bytes. + /// - The memory must be valid for the duration of the call and not modified by other threads. + pub unsafe fn as_slice(&self) -> Result<&[u8], Error> { + unsafe { self.as_slice_optional() }? + .ok_or_else(|| Error::InvalidInput("Buffer data is null".into())) + } + + /// # Safety + /// This function should ONLY be used with [Buffer]s initialized from the Rust side. + /// Calling it with a [Buffer] initialized from the C# side is undefined behavior. + /// The caller is responsible for ensuring that the memory is not used after this call. + pub unsafe fn free(self) { + if !self.data.is_null() { + let _ = unsafe { Vec::from_raw_parts(self.data, self.len, self.len) }; + } + } + + #[cfg(test)] + pub fn duplicate(&self) -> Buffer { + Buffer { + data: self.data, + len: self.len, + } + } +} + +#[macro_export] +macro_rules! try_ffi { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => return Response::error(e), + } + }; +} + +/// A struct to represent a response from the rust library. +/// Important: The structure of this type must match the structure of the +/// Response type in the C# `BitwardenLibrary`, both in field type and order. +#[repr(C)] +pub struct Response { + pub error: usize, + pub error_message: Buffer, + + // This is a way of returning multiple values without having different return FFI types. + // Currently the API only returns byte arrays so this is a good fit for now. + // Important: The length of this array must match exactly with the length of + // the C# `BitwardenLibrary.Response` struct. It also must be greater than or + // equal than the implementations of the AllowedSize trait below. + pub data: [Buffer; 4], +} + +pub(crate) trait AllowedSize {} +impl AllowedSize for [T; 1] {} +impl AllowedSize for [T; 2] {} +impl AllowedSize for [T; 3] {} +impl AllowedSize for [T; 4] {} + +impl Response { + pub(crate) fn ok(data: [Vec; N]) -> Self + where + [Vec; N]: AllowedSize, + { + let mut iter = data.into_iter().fuse(); + let data = std::array::from_fn(|_| match iter.next() { + Some(vec) => Buffer::from_vec(vec), + None => Buffer::null(), + }); + + debug_assert!(iter.next().is_none()); + Response { + error: 0, + error_message: Buffer::null(), + data, + } + } + + pub fn error(error: Error) -> Self { + // Important: The error codes need to be kept in sync with the BitwardenException in C#. + let (error, message) = match error { + Error::InvalidInput(name) => (1, name), + Error::InvalidConfig(error) => (2, error), + Error::Protocol(e) => (3, format!("{e:?}")), + Error::InternalError(error) => (4, error), + }; + + Response { + error, + error_message: Buffer::from_vec(message.into_bytes()), + data: std::array::from_fn(|_| Buffer::null()), + } + } +} diff --git a/extensions/Bitwarden.Opaque/rust/src/lib.rs b/extensions/Bitwarden.Opaque/rust/src/lib.rs new file mode 100644 index 00000000..987a3c41 --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/src/lib.rs @@ -0,0 +1,110 @@ +#![forbid(unsafe_op_in_unsafe_fn)] + +use zeroizing_alloc::ZeroAlloc; + +#[global_allocator] +static ALLOC: ZeroAlloc = ZeroAlloc(std::alloc::System); + +mod ffi; +mod opaque; + +#[derive(Debug)] +pub enum Error { + InvalidInput(String), + InvalidConfig(String), + Protocol(opaque_ke::errors::ProtocolError), + InternalError(String), +} + +impl From for Error { + fn from(error: opaque_ke::errors::ProtocolError) -> Self { + Self::Protocol(error) + } +} + +#[cfg(test)] +mod tests { + use crate::opaque::*; + + #[test] + fn test() { + let password = "password"; + let username = "username"; + + let mut config = CipherConfiguration::default(); + + // Registration + + let registration_request = config.start_client_registration(password).unwrap(); + + let server_start_result = config + .start_server_registration(None, ®istration_request.registration_request, username) + .unwrap(); + + let client_finish_result = config + .finish_client_registration( + ®istration_request.state, + &server_start_result.registration_response, + password, + ) + .unwrap(); + + let server_finish_result = config + .finish_server_registration(&client_finish_result.registration_upload) + .unwrap(); + + // Login + + let login_request = config.start_client_login(password).unwrap(); + + let server_login_result = config + .start_server_login( + &server_start_result.server_setup, + &server_finish_result.server_registration, + &login_request.credential_request, + username, + ) + .unwrap(); + + let client_login_result = config + .finish_client_login( + &login_request.state, + &server_login_result.credential_response, + password, + ) + .unwrap(); + + let server_login_finish_result = config + .finish_server_login( + &server_login_result.state, + &client_login_result.credential_finalization, + ) + .unwrap(); + + let _ = server_login_finish_result.session_key; + } + + #[test] + fn test_seeded() { + let seed = [0u8; 32]; + let (server_setup, password_file) = + super::opaque::register_seeded_fake_config(seed).unwrap(); + assert_eq!(server_setup.len(), 128); + assert_eq!(password_file.len(), 192); + + let password = "password"; + let username = "username"; + let mut config = CipherConfiguration::default(); + let res = config.start_client_login(password).unwrap(); + let server_res = config + .start_server_login( + &server_setup, + &password_file, + &res.credential_request, + username, + ) + .unwrap(); + let res = config.finish_client_login(&res.state, &server_res.credential_response, password); + assert!(res.is_err()); + } +} diff --git a/extensions/Bitwarden.Opaque/rust/src/opaque/mod.rs b/extensions/Bitwarden.Opaque/rust/src/opaque/mod.rs new file mode 100644 index 00000000..30dabead --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/src/opaque/mod.rs @@ -0,0 +1,392 @@ +use opaque_ke::*; +use rand_chacha::ChaCha20Rng; + +use crate::Error; + +mod types; + +pub(crate) use types::*; + +// This trait exists to extract the differences between all the OpaqueImpl implementations. +pub trait OpaqueUtil<'a>: opaque_ke::CipherSuite + OpaqueImpl + Sized { + fn as_variant(config: &'a mut CipherConfiguration) -> Option; + fn get_ksf(&self) -> Result; + fn get_rng(&mut self) -> &mut ChaCha20Rng; +} + +fn invalid_config(config: &CipherConfiguration) -> Error { + Error::InvalidConfig(serde_json::to_string(config).unwrap_or_default()) +} + +// Define the cipher suites and implement the required traits on them (opaque_ke::CipherSuite+OpaqueUtil) +struct RistrettoTripleDhArgonSuite<'a>(&'a mut ChaCha20Rng, Argon2id); +impl opaque_ke::CipherSuite for RistrettoTripleDhArgonSuite<'_> { + type OprfCs = opaque_ke::Ristretto255; + type KeGroup = opaque_ke::Ristretto255; + type KeyExchange = opaque_ke::key_exchange::tripledh::TripleDh; + type Ksf = argon2::Argon2<'static>; +} +impl<'a> OpaqueUtil<'a> for RistrettoTripleDhArgonSuite<'a> { + fn as_variant(config: &'a mut CipherConfiguration) -> Option { + match config { + CipherConfiguration { + opaque_version: 3, + oprf_cs: OprfCs::Ristretto255, + ke_group: KeGroup::Ristretto255, + key_exchange: KeyExchange::TripleDh, + ksf: types::Ksf::Argon2id(argon), + rng, + } => Some(Self(rng, *argon)), + _ => None, + } + } + fn get_ksf(&self) -> Result { + Ok(argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(self.1.memory, self.1.iterations, self.1.parallelism, None) + .map_err(|_| Error::InvalidConfig("Invalid Argon2 parameters".into()))?, + )) + } + + fn get_rng(&mut self) -> &mut ChaCha20Rng { + self.0 + } +} + +struct RistrettoTripleDhIdentitySuite<'a>(&'a mut ChaCha20Rng); +impl opaque_ke::CipherSuite for RistrettoTripleDhIdentitySuite<'_> { + type OprfCs = opaque_ke::Ristretto255; + type KeGroup = opaque_ke::Ristretto255; + type KeyExchange = opaque_ke::key_exchange::tripledh::TripleDh; + type Ksf = ksf::Identity; +} +impl<'a> OpaqueUtil<'a> for RistrettoTripleDhIdentitySuite<'a> { + fn as_variant(config: &'a mut CipherConfiguration) -> Option { + match config { + CipherConfiguration { + opaque_version: 3, + oprf_cs: OprfCs::Ristretto255, + ke_group: KeGroup::Ristretto255, + key_exchange: KeyExchange::TripleDh, + ksf: types::Ksf::Identity, + rng, + } => Some(Self(rng)), + _ => None, + } + } + fn get_ksf(&self) -> Result { + Ok(ksf::Identity) + } + + fn get_rng(&mut self) -> &mut ChaCha20Rng { + self.0 + } +} + +// This generic utility function is used to dynamically dispatch to the correct cipher suite +fn with_variants( + config: &mut CipherConfiguration, + func: impl FnOnce(&mut dyn OpaqueImpl) -> Result, +) -> Result { + if let Some(mut suite) = RistrettoTripleDhArgonSuite::as_variant(config) { + return func(&mut suite); + }; + if let Some(mut suite) = RistrettoTripleDhIdentitySuite::as_variant(config) { + return func(&mut suite); + }; + Err(invalid_config(config)) +} + +pub fn register_seeded_fake_config(seed: [u8; 32]) -> Result<(Vec, Vec), Error> { + use rand::RngCore as _; + + let mut config = CipherConfiguration::fake_from_seed(seed); + + let mut password: [u8; 32] = [0; 32]; + let mut username: [u8; 32] = [0; 32]; + config.rng.fill_bytes(&mut password); + config.rng.fill_bytes(&mut username); + let password = hex::encode(password); + let username = hex::encode(username); + + let start = config.start_client_registration(password.as_str())?; + let server_start = + config.start_server_registration(None, &start.registration_request, &username)?; + let client_finish = config.finish_client_registration( + &start.state, + &server_start.registration_response, + &password, + )?; + let server_finish = config.finish_server_registration(&client_finish.registration_upload)?; + Ok((server_start.server_setup, server_finish.server_registration)) +} + +// The opaque-ke crate uses a lot of generic traits, which are difficult to handle in FFI. +// This trait implements dynamic dispatch to allow using opaque-ke without generics. +pub trait OpaqueImpl { + fn start_client_registration( + &mut self, + password: &str, + ) -> Result; + fn start_server_registration( + &mut self, + server_setup: Option<&[u8]>, + registration_request: &[u8], + username: &str, + ) -> Result; + fn finish_client_registration( + &mut self, + state: &[u8], + registration_response: &[u8], + password: &str, + ) -> Result; + fn finish_server_registration( + &mut self, + registration_upload: &[u8], + ) -> Result; + + fn start_client_login( + &mut self, + password: &str, + ) -> Result; + fn start_server_login( + &mut self, + server_setup: &[u8], + server_registration: &[u8], + credential_request: &[u8], + username: &str, + ) -> Result; + fn finish_client_login( + &mut self, + state: &[u8], + credential_response: &[u8], + password: &str, + ) -> Result; + fn finish_server_login( + &mut self, + state: &[u8], + credential_finalization: &[u8], + ) -> Result; +} + +// Implement OpaqueImpl for the shared type, and dynamically dispatch to the correct cipher suite +impl OpaqueImpl for CipherConfiguration { + fn start_client_registration( + &mut self, + password: &str, + ) -> Result { + with_variants(self, |suite| suite.start_client_registration(password)) + } + fn start_server_registration( + &mut self, + server_setup: Option<&[u8]>, + registration_request: &[u8], + username: &str, + ) -> Result { + with_variants(self, |suite| { + suite.start_server_registration(server_setup, registration_request, username) + }) + } + fn finish_client_registration( + &mut self, + state: &[u8], + registration_response: &[u8], + password: &str, + ) -> Result { + with_variants(self, |suite| { + suite.finish_client_registration(state, registration_response, password) + }) + } + fn finish_server_registration( + &mut self, + registration_upload: &[u8], + ) -> Result { + with_variants(self, |suite| { + suite.finish_server_registration(registration_upload) + }) + } + + fn start_client_login( + &mut self, + password: &str, + ) -> Result { + with_variants(self, |suite| suite.start_client_login(password)) + } + fn start_server_login( + &mut self, + server_setup: &[u8], + server_registration: &[u8], + credential_request: &[u8], + username: &str, + ) -> Result { + with_variants(self, |suite| { + suite.start_server_login( + server_setup, + server_registration, + credential_request, + username, + ) + }) + } + fn finish_client_login( + &mut self, + state: &[u8], + credential_response: &[u8], + password: &str, + ) -> Result { + with_variants(self, |suite| { + suite.finish_client_login(state, credential_response, password) + }) + } + fn finish_server_login( + &mut self, + state: &[u8], + credential_finalization: &[u8], + ) -> Result { + with_variants(self, |suite| { + suite.finish_server_login(state, credential_finalization) + }) + } +} + +// Implement OpaqueImpl for each cipher suite. The code is entirely the same except for the impl OpaqueImpl for +macro_rules! implement_cipher_suite { + ( $type:ty ) => { + impl crate::opaque::OpaqueImpl for $type { + fn start_client_registration( + &mut self, + password: &str, + ) -> Result { + let result = + ClientRegistration::::start(self.get_rng(), password.as_bytes())?; + Ok(crate::opaque::types::ClientRegistrationStartResult { + registration_request: result.message.serialize().to_vec(), + state: result.state.serialize().to_vec(), + }) + } + fn start_server_registration( + &mut self, + server_setup: Option<&[u8]>, + registration_request: &[u8], + username: &str, + ) -> Result { + let server_setup = match server_setup { + Some(server_setup) => ServerSetup::::deserialize(server_setup)?, + None => ServerSetup::::new(self.get_rng()), + }; + let result = ServerRegistration::start( + &server_setup, + RegistrationRequest::deserialize(registration_request)?, + username.as_bytes(), + )?; + Ok(crate::opaque::types::ServerRegistrationStartResult { + registration_response: result.message.serialize().to_vec(), + server_setup: server_setup.serialize().to_vec(), + }) + } + fn finish_client_registration( + &mut self, + state: &[u8], + registration_response: &[u8], + password: &str, + ) -> Result { + let state = ClientRegistration::::deserialize(state)?; + let ksf = self.get_ksf()?; + let response = RegistrationResponse::deserialize(registration_response)?; + let params = + ClientRegistrationFinishParameters::new(Identifiers::default(), Some(&ksf)); + let result = state.finish(self.get_rng(), password.as_bytes(), response, params)?; + Ok(crate::opaque::types::ClientRegistrationFinishResult { + registration_upload: result.message.serialize().to_vec(), + export_key: result.export_key.to_vec(), + server_s_pk: result.server_s_pk.serialize().to_vec(), + }) + } + fn finish_server_registration( + &mut self, + registration_upload: &[u8], + ) -> Result { + let upload = RegistrationUpload::::deserialize(registration_upload)?; + let registration = ServerRegistration::finish(upload); + Ok(crate::opaque::types::ServerRegistrationFinishResult { + server_registration: registration.serialize().to_vec(), + }) + } + + fn start_client_login( + &mut self, + password: &str, + ) -> Result { + let result = ClientLogin::::start(self.get_rng(), password.as_bytes())?; + Ok(crate::opaque::types::ClientLoginStartResult { + credential_request: result.message.serialize().to_vec(), + state: result.state.serialize().to_vec(), + }) + } + fn start_server_login( + &mut self, + server_setup: &[u8], + server_registration: &[u8], + credential_request: &[u8], + username: &str, + ) -> Result { + let server_setup = ServerSetup::::deserialize(server_setup)?; + let server_registration = + ServerRegistration::::deserialize(server_registration)?; + let credential_request = CredentialRequest::deserialize(credential_request)?; + + let result = ServerLogin::start( + self.get_rng(), + &server_setup, + Some(server_registration), + credential_request, + username.as_bytes(), + ServerLoginStartParameters::default(), + )?; + Ok(crate::opaque::types::ServerLoginStartResult { + credential_response: result.message.serialize().to_vec(), + state: result.state.serialize().to_vec(), + }) + } + fn finish_client_login( + &mut self, + state: &[u8], + credential_response: &[u8], + password: &str, + ) -> Result { + let client_login = ClientLogin::::deserialize(state)?; + let ksf = self.get_ksf()?; + let params = + ClientLoginFinishParameters::new(None, Identifiers::default(), Some(&ksf)); + let result = client_login.finish( + password.as_bytes(), + CredentialResponse::deserialize(credential_response)?, + params, + )?; + Ok(crate::opaque::types::ClientLoginFinishResult { + credential_finalization: result.message.serialize().to_vec(), + session_key: result.session_key.to_vec(), + export_key: result.export_key.to_vec(), + server_s_pk: result.server_s_pk.serialize().to_vec(), + }) + } + fn finish_server_login( + &mut self, + state: &[u8], + credential_finalization: &[u8], + ) -> Result { + let server_login = ServerLogin::::deserialize(state)?; + let result = server_login.finish(CredentialFinalization::deserialize( + credential_finalization, + )?)?; + Ok(crate::opaque::types::ServerLoginFinishResult { + session_key: result.session_key.to_vec(), + }) + } + } + }; +} + +implement_cipher_suite!(RistrettoTripleDhArgonSuite<'_>); +implement_cipher_suite!(RistrettoTripleDhIdentitySuite<'_>); diff --git a/extensions/Bitwarden.Opaque/rust/src/opaque/types.rs b/extensions/Bitwarden.Opaque/rust/src/opaque/types.rs new file mode 100644 index 00000000..b842b308 --- /dev/null +++ b/extensions/Bitwarden.Opaque/rust/src/opaque/types.rs @@ -0,0 +1,142 @@ +use std::str::FromStr; + +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; + +use crate::Error; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OprfCs { + Ristretto255, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeGroup { + Ristretto255, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeyExchange { + #[serde(alias = "tripleDH", alias = "triple-dh")] + TripleDh, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "algorithm", content = "parameters")] +pub enum Ksf { + Argon2id(Argon2id), + #[serde(skip)] + Identity, + __NonExhaustive(()), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Argon2id { + pub memory: u32, + pub iterations: u32, + pub parallelism: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherConfiguration { + pub opaque_version: u32, + #[serde(alias = "oprfCS")] + pub oprf_cs: OprfCs, + pub ke_group: KeGroup, + pub key_exchange: KeyExchange, + pub ksf: Ksf, + + #[serde(skip, default = "default_rng")] + pub(crate) rng: ChaCha20Rng, +} + +fn default_rng() -> ChaCha20Rng { + ChaCha20Rng::from_entropy() +} + +impl CipherConfiguration { + pub(crate) fn fake_from_seed(seed: [u8; 32]) -> Self { + Self { + ksf: Ksf::Identity, + rng: ChaCha20Rng::from_seed(seed), + ..Default::default() + } + } +} + +impl Default for CipherConfiguration { + fn default() -> Self { + Self { + opaque_version: 3, + oprf_cs: OprfCs::Ristretto255, + ke_group: KeGroup::Ristretto255, + key_exchange: KeyExchange::TripleDh, + ksf: Ksf::Argon2id(Argon2id { + memory: 65536, + iterations: 4, + parallelism: 4, + }), + rng: default_rng(), + } + } +} + +impl FromStr for CipherConfiguration { + type Err = Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| Error::InvalidConfig(e.to_string())) + } +} + +pub(crate) struct ClientRegistrationStartResult { + // The message is sent to the server for the next step of the registration protocol. + pub(crate) registration_request: Vec, + // The state is stored temporarily by the client and used in the next step of the registration protocol. + pub(crate) state: Vec, +} + +pub(crate) struct ServerRegistrationStartResult { + pub(crate) registration_response: Vec, + pub(crate) server_setup: Vec, +} + +pub(crate) struct ClientRegistrationFinishResult { + // The message is sent to the server for the last step of the registration protocol. + pub(crate) registration_upload: Vec, + pub(crate) export_key: Vec, + pub(crate) server_s_pk: Vec, +} + +pub(crate) struct ServerRegistrationFinishResult { + pub(crate) server_registration: Vec, +} + +//////////////////////////////////////// + +pub(crate) struct ClientLoginStartResult { + pub(crate) credential_request: Vec, + pub(crate) state: Vec, +} + +pub(crate) struct ServerLoginStartResult { + pub(crate) credential_response: Vec, + pub(crate) state: Vec, +} + +pub(crate) struct ClientLoginFinishResult { + pub(crate) credential_finalization: Vec, + pub(crate) session_key: Vec, + pub(crate) export_key: Vec, + pub(crate) server_s_pk: Vec, +} + +pub(crate) struct ServerLoginFinishResult { + pub(crate) session_key: Vec, +} diff --git a/extensions/Bitwarden.Opaque/src/Bitwarden.Opaque.csproj b/extensions/Bitwarden.Opaque/src/Bitwarden.Opaque.csproj new file mode 100644 index 00000000..d7d2207d --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/Bitwarden.Opaque.csproj @@ -0,0 +1,88 @@ + + + + net8.0 + true + enable + enable + + true + + + + Bitwarden Inc. + OPAQUE-KE bindings for .NET and C# by Bitwarden + + https://github.com/bitwarden/dotnet-extensions + https://github.com/bitwarden/dotnet-extensions/releases + GPL-3.0-only + false + README.md + true + true + true + + Bitwarden.Opaque + Bitwarden OPAQUE-KE library + Bitwarden Inc. + Opaque + + Bitwarden.Opaque + bitwarden.png + Bitwarden;Opaque;PAKE;.NET + + 0.1.0 + beta + 3 + $(PreReleaseVersionLabel).$(PreReleaseVersionIteration) + + + + + + + + + + + + true + false + $(BuildOpaqueLib) + + $(NETCoreSdkRuntimeIdentifier) + + + opaque_ke_binding.dll + + + libopaque_ke_binding.so + + + libopaque_ke_binding.dylib + + + + + + + + + + + + + + + + + + + $(RustLibraryFile) + Always + true + runtimes/ + + + + \ No newline at end of file diff --git a/extensions/Bitwarden.Opaque/src/BitwardenException.cs b/extensions/Bitwarden.Opaque/src/BitwardenException.cs new file mode 100644 index 00000000..e44a45cb --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/BitwardenException.cs @@ -0,0 +1,26 @@ +namespace Bitwarden.Opaque; + +/// +/// A class to represent an exception thrown by the Bitwarden OPAQUE library. +/// +/// A numeric error code, to separate different error types +/// The error message +public class BitwardenException(int errorCode, string message) : Exception($"Error {getCodeName(errorCode)} - {message}") +{ + private static string getCodeName(int code) + { + // Important: This needs to be kept in sync with the error codes in the rust library. + return code switch + { + 0 => "OK", + 1 => "INVALID_INPUT", + 2 => "INVALID_CONFIG", + 3 => "PROTOCOL_ERROR", + 4 => "INTERNAL_ERRROR", + + // This is a special case and it's only used in the C# code. + 100 => "UNEXPECTED_RETURN", + _ => "UNKNOWN", + }; + } +} diff --git a/extensions/Bitwarden.Opaque/src/BitwardenLibrary.cs b/extensions/Bitwarden.Opaque/src/BitwardenLibrary.cs new file mode 100644 index 00000000..fa4ef3a6 --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/BitwardenLibrary.cs @@ -0,0 +1,175 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Bitwarden.Opaque; + +internal static partial class BitwardenLibrary +{ + + /// + /// A struct to represent a buffer of data. + /// Important: The structure of this type must match the structure + /// of the Buffer type in the rust crate, both in field type and order. + /// + [StructLayout(LayoutKind.Sequential)] + internal struct Buffer + { + internal IntPtr data; + internal nint size; + } + + /// + /// A struct to represent a response from the rust library. + /// Important: The structure of this type must match the structure + /// of the Response type in the rust crate, both in field type and order. + /// + [StructLayout(LayoutKind.Sequential)] + internal struct Response + { + internal nint error; + internal Buffer error_message; + + internal Buffer data1; + internal Buffer data2; + internal Buffer data3; + internal Buffer data4; + + // Utility function to get all buffers as a list + internal readonly List GetAllBuffers() + { + return [data1, data2, data3, data4]; + } + } + + // These are all the functions defined in the rust library. + // Important: The function signatures must always match the signatures in the rust library. + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial void free_buffer(Buffer buf); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response register_seeded_fake_config(Buffer seed); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response start_client_registration(string config, string password); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response start_server_registration(string config, Buffer server_setup, Buffer registration_request, string username); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response finish_client_registration(string config, Buffer state, Buffer registration_response, string password); + + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response finish_server_registration(string config, Buffer registration_upload); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response start_client_login(string config, string password); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response start_server_login(string config, Buffer server_setup, Buffer server_registration, Buffer credential_request, string username); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response finish_client_login(string config, Buffer state, Buffer credential_response, string password); + + [LibraryImport("opaque_ke_binding", StringMarshalling = StringMarshalling.Utf8)] + internal static partial Response finish_server_login(string config, Buffer state, Buffer credential_finalization); + + // This is an internal class to improve the FFI handling. It should not be created directly, + // and should be used only from inside the ExecuteFFIFunction callback. + internal class FFIHandler + { + private FFIHandler() { } + + private static readonly JsonSerializerOptions _serializerOptions = new() + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, // Converts enums to strings + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + private readonly List _handles = []; + + private void FreeHandles() + { + foreach (var handle in _handles) handle.Free(); + _handles.Clear(); + } + /// Create an FFI buffer from the provided byte array. + /// The buffer will be freed when the FFIHandler calls FreeHandles. + /// Important: You should never use free_buffer on the buffer returned by this function, + /// as it is only to be used with Rust allocated buffers. Doing otherwise is undefined behavior. + public Buffer Buf(byte[]? data) + { + var handle = GCHandle.Alloc(data, GCHandleType.Pinned); + _handles.Add(handle); + return new Buffer + { + data = handle.AddrOfPinnedObject(), + size = data?.Length ?? 0 + }; + } + /// Serialize the provided configuration to a FFI string. + public string Cfg(CipherConfiguration config) + { + return JsonSerializer.Serialize(config, _serializerOptions); + } + + + internal static List ExecuteFFIFunction(Func function, int expectedValues) + { + + static byte[]? CopyAndFreeBuffer(Buffer buffer) + { + if (buffer.data == IntPtr.Zero) return null; + if (buffer.size == 0) return []; + + var data = new byte[buffer.size]; + Marshal.Copy(buffer.data, data, 0, (int)buffer.size); + free_buffer(buffer); + return data; + } + + + var ffi = new FFIHandler(); + try + { + // Execute the function and get the response + var response = function(ffi); + + // If we receive an error, parse the message and throw an exception + if (response.error != 0) + { + var message = CopyAndFreeBuffer(response.error_message); + string messageStr; + try { messageStr = Encoding.UTF8.GetString(message!); } catch { messageStr = ""; } + throw new BitwardenException((int)response.error, messageStr); + } + + // If we don't receive an error, parse all the return types + var arrays = new List { }; + foreach (var buffer in response.GetAllBuffers()) + { + var data = CopyAndFreeBuffer(buffer); + if (data == null) break; + arrays.Add(data); + } + + // If we receive a different number of return values than expected, something must have gone wrong, throw an exception + if (arrays.Count != expectedValues) + { + throw new BitwardenException(100, $"Invalid number of return values. Expected {expectedValues}, got {arrays.Count}"); + } + + return arrays; + } + finally + { + ffi.FreeHandles(); + } + } + } + + // Just a wrapper to simplify calling + internal static List ExecuteFFIFunction(Func function, int expectedValues) => FFIHandler.ExecuteFFIFunction(function, expectedValues); +} diff --git a/extensions/Bitwarden.Opaque/src/BitwardenOpaqueClient.cs b/extensions/Bitwarden.Opaque/src/BitwardenOpaqueClient.cs new file mode 100644 index 00000000..682fb83a --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/BitwardenOpaqueClient.cs @@ -0,0 +1,134 @@ +namespace Bitwarden.Opaque; +#pragma warning disable CA1822 // Mark members as static + +/// The result of +public struct ClientRegistrationStartResult +{ + /// The registration response which is then passed to . + public byte[] registrationRequest; + + /// The client state, which must be kept on the client for . + public byte[] state; +} + +/// The result of +public struct ClientRegistrationFinishResult +{ + /// The registration upload which is then passed to . + public byte[] registrationUpload; + /// The export key output by client registration + public byte[] exportKey; + /// The server's static public key + public byte[] serverSPKey; +} + +/// The result of +public struct ClientLoginStartResult +{ + /// The credential request which is then passed to . + public byte[] credentialRequest; + /// The state generated during the login start, which must be kept on the client for . + public byte[] state; +} + +/// The result of +public struct ClientLoginFinishResult +{ + /// The credential finalization which is then passed to . + public byte[] credentialFinalization; + /// The session key generated after a successful login. + public byte[] sessionKey; + /// The export key output by client login. + public byte[] exportKey; + /// The server's static public key. + public byte[] serverSPKey; +} + +/// A class to represent client side functionality the Bitwarden OPAQUE library. +public sealed partial class BitwardenOpaqueClient +{ + + /// + /// Start the client registration process. This is the first step in the registration process. + /// + /// The Cipher configuration, must be the same for all the operation + /// The password to register + /// + public ClientRegistrationStartResult StartRegistration(CipherConfiguration config, string password) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.start_client_registration(ffi.Cfg(config), password); + }, 2); + return new ClientRegistrationStartResult + { + registrationRequest = result[0], + state = result[1] + }; + } + + /// + /// Finish the server registration process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The state obtained from the client start operation, + /// The server registration response, + /// The password to register + /// + public ClientRegistrationFinishResult FinishRegistration(CipherConfiguration config, byte[] state, byte[] registrationResponse, string password) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.finish_client_registration(ffi.Cfg(config), ffi.Buf(state), ffi.Buf(registrationResponse), password); + }, 3); + return new ClientRegistrationFinishResult + { + registrationUpload = result[0], + exportKey = result[1], + serverSPKey = result[2] + }; + } + + /// + /// Start the client login process. This is the first step in the login process. + /// + /// The Cipher configuration, must be the same for all the operation + /// The password to login + /// + public ClientLoginStartResult StartLogin(CipherConfiguration config, string password) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.start_client_login(ffi.Cfg(config), password); + }, 2); + return new ClientLoginStartResult + { + credentialRequest = result[0], + state = result[1] + }; + + } + + /// + /// Finish the client login process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The state obtained from the client start operation, + /// The server credential response, + /// The password to login + /// + public ClientLoginFinishResult FinishLogin(CipherConfiguration config, byte[] state, byte[] credentialResponse, string password) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.finish_client_login(ffi.Cfg(config), ffi.Buf(state), ffi.Buf(credentialResponse), password); + }, 4); + return new ClientLoginFinishResult + { + credentialFinalization = result[0], + sessionKey = result[1], + exportKey = result[2], + serverSPKey = result[3] + }; + } +} diff --git a/extensions/Bitwarden.Opaque/src/BitwardenOpaqueServer.cs b/extensions/Bitwarden.Opaque/src/BitwardenOpaqueServer.cs new file mode 100644 index 00000000..73d27000 --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/BitwardenOpaqueServer.cs @@ -0,0 +1,133 @@ +namespace Bitwarden.Opaque; +#pragma warning disable CA1822 // Mark members as static + + +/// The result of +public struct ServerRegistrationStartResult +{ + /// The registration response which is then passed to . + public byte[] registrationResponse; + /// The server setup, which needs to be persisted on the server for future logins. + public byte[] serverSetup; +} + +/// The result of +public struct ServerRegistrationFinishResult +{ + /// The server registration, which needs to be persisted on the server for future logins. + public byte[] serverRegistration; +} + +/// The result of +public struct ServerLoginStartResult +{ + /// The credential response which is then passed to . + public byte[] credentialResponse; + /// The state generated during the login start, which needs to be stored until the login finish. + public byte[] state; +} + +/// The result of +public struct ServerLoginFinishResult +{ + /// The session key generated after a successful login. + public byte[] sessionKey; +} + +/// A class to represent server side functionality the Bitwarden OPAQUE library. +public sealed partial class BitwardenOpaqueServer +{ + /// + /// Start the server registration process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The server setup. Use null to let the library create a new random one + /// The client registration request, + /// The username to register + /// + public ServerRegistrationStartResult StartRegistration(CipherConfiguration config, byte[]? serverSetup, byte[] registrationRequest, string username) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.start_server_registration(ffi.Cfg(config), ffi.Buf(serverSetup), ffi.Buf(registrationRequest), username); + }, 2); + + return new ServerRegistrationStartResult + { + registrationResponse = result[0], + serverSetup = result[1] + }; + } + + /// + /// Finish the server registration process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The client registration upload, + /// + public ServerRegistrationFinishResult FinishRegistration(CipherConfiguration config, byte[] registrationUpload) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.finish_server_registration(ffi.Cfg(config), ffi.Buf(registrationUpload)); + }, 1); + return new ServerRegistrationFinishResult + { + serverRegistration = result[0] + }; + } + + /// + /// Start the server login process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The server setup, previously generated or supplied during registration + /// The server registration, previously generated during registration + /// The client credential request, + /// The username to login + /// + public ServerLoginStartResult StartLogin(CipherConfiguration config, byte[] serverSetup, byte[] serverRegistration, byte[] credentialRequest, string username) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.start_server_login(ffi.Cfg(config), ffi.Buf(serverSetup), ffi.Buf(serverRegistration), ffi.Buf(credentialRequest), username); + }, 2); + return new ServerLoginStartResult + { + credentialResponse = result[0], + state = result[1] + }; + } + + /// + /// Finish the server login process. This must happen after + /// + /// The Cipher configuration, must be the same for all the operation + /// The state generated during the login start, + /// The client credential finalization, + /// + public ServerLoginFinishResult FinishLogin(CipherConfiguration config, byte[] state, byte[] credentialFinalization) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.finish_server_login(ffi.Cfg(config), ffi.Buf(state), ffi.Buf(credentialFinalization)); + }, 1); + return new ServerLoginFinishResult + { + sessionKey = result[0] + }; + } + /// + /// Generate a seeded fake registration. This can be returned for unenrolled users to avoid account enumeration issues. + /// + /// The seed to use for the fake registration. This should be consistent between multiple calls to the same user + public (byte[] serverSetup, byte[] serverRegistration) SeededFakeRegistration(byte[] seed) + { + var result = BitwardenLibrary.ExecuteFFIFunction((ffi) => + { + return BitwardenLibrary.register_seeded_fake_config(ffi.Buf(seed)); + }, 2); + return (result[0], result[1]); + } + +} diff --git a/extensions/Bitwarden.Opaque/src/CipherConfiguration.cs b/extensions/Bitwarden.Opaque/src/CipherConfiguration.cs new file mode 100644 index 00000000..588d3679 --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/CipherConfiguration.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Serialization; + +namespace Bitwarden.Opaque; + +/// A VOPRF ciphersuite +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum OprfCs +{ + /// The Ristretto255 ciphersuite + Ristretto255 = 0, +} + +/// A `Group` used for the `KeyExchange`. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum KeGroup +{ + /// The Ristretto255 group + Ristretto255 +} + +/// The key exchange protocol to use in the login step +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum KeyExchange +{ + /// The Triple Diffie-Hellman key exchange implementation + TripleDH +} + +/// A key stretching algorithm +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum KsfAlgorithm +{ + /// The Argon2id key stretching function + Argon2id +} + +/// Key stretching function parameters +public class KsfParameters +{ + /// The number of iterations to use + public int Iterations { get; set; } + /// The amount of memory to use in KiB + public int Memory { get; set; } + /// The number of threads to use + public int Parallelism { get; set; } +} + +/// A key stretching function, typically used for password hashing +public class Ksf +{ + /// The key stretching function to use + public KsfAlgorithm Algorithm { get; set; } + /// The parameters for the key stretching function + public required KsfParameters Parameters { get; set; } +} + +/// Configures the underlying primitives used in OPAQUE +public class CipherConfiguration +{ + /// The version of the OPAQUE-ke protocol to use + public int OpaqueVersion { get; set; } + /// A VOPRF ciphersuite + public OprfCs OprfCs { get; set; } + /// A `Group` used for the `KeyExchange`. + public KeGroup KeGroup { get; set; } + /// The key exchange protocol to use in the login step + public KeyExchange KeyExchange { get; set; } + /// A key stretching function, typically used for password hashing + public required Ksf Ksf { get; set; } + + /// The default configuration for the OPAQUE protocol + public static readonly CipherConfiguration Default = new CipherConfiguration + { + OpaqueVersion = 3, + OprfCs = OprfCs.Ristretto255, + KeGroup = KeGroup.Ristretto255, + KeyExchange = KeyExchange.TripleDH, + Ksf = new Ksf + { + Algorithm = KsfAlgorithm.Argon2id, + Parameters = new KsfParameters + { + Iterations = 4, + Memory = 65536, + Parallelism = 4 + } + } + }; +} diff --git a/extensions/Bitwarden.Opaque/src/README.md b/extensions/Bitwarden.Opaque/src/README.md new file mode 100644 index 00000000..c70f97ec --- /dev/null +++ b/extensions/Bitwarden.Opaque/src/README.md @@ -0,0 +1,3 @@ +# Bitwarden OPAQUE-KE library + +OPAQUE-KE bindings for .NET and C# by Bitwarden diff --git a/extensions/Bitwarden.Opaque/src/bitwarden.png b/extensions/Bitwarden.Opaque/src/bitwarden.png new file mode 100644 index 00000000..681629a2 Binary files /dev/null and b/extensions/Bitwarden.Opaque/src/bitwarden.png differ diff --git a/extensions/Bitwarden.Opaque/tests/Bitwarden.Opaque.Tests.csproj b/extensions/Bitwarden.Opaque/tests/Bitwarden.Opaque.Tests.csproj new file mode 100644 index 00000000..182d1a39 --- /dev/null +++ b/extensions/Bitwarden.Opaque/tests/Bitwarden.Opaque.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/extensions/Bitwarden.Opaque/tests/Tests.cs b/extensions/Bitwarden.Opaque/tests/Tests.cs new file mode 100644 index 00000000..aa2941dc --- /dev/null +++ b/extensions/Bitwarden.Opaque/tests/Tests.cs @@ -0,0 +1,131 @@ +namespace Bitwarden.Opaque.Tests; + +using Xunit; + +public class OpaqueTests +{ + // Lower the config values from default so the tests run fast + public readonly CipherConfiguration config = new() + { + OpaqueVersion = 3, + OprfCs = OprfCs.Ristretto255, + KeGroup = KeGroup.Ristretto255, + KeyExchange = KeyExchange.TripleDH, + Ksf = new Ksf + { + Algorithm = KsfAlgorithm.Argon2id, + Parameters = new KsfParameters + { + Iterations = 1, + Memory = 1024, + Parallelism = 1 + } + } + }; + + [Fact] + public void TestSeededRegistration() + { + var server = new BitwardenOpaqueServer(); + + var seed = new byte[32]; + var (serverSetup1, serverRegistration1) = server.SeededFakeRegistration(seed); + + Assert.NotNull(serverSetup1); + Assert.NotNull(serverRegistration1); + + var (serverSetup2, serverRegistration2) = server.SeededFakeRegistration(seed); + Assert.NotNull(serverSetup2); + Assert.NotNull(serverRegistration2); + + Assert.Equal(serverSetup1, serverSetup2); + Assert.Equal(serverRegistration1, serverRegistration2); + } + + + [Fact] + public void TestRegistration() + { + var username = "demo_username"; + var password = "demo_password"; + + // Create the OPAQUE Clients + var server = new BitwardenOpaqueServer(); + var client = new BitwardenOpaqueClient(); + + // Start the client registration + var clientRegisterStartResult = client.StartRegistration(config, password); + + // Client sends reg_start to server + var serverRegisterStartResult = server.StartRegistration(config, null, clientRegisterStartResult.registrationRequest, username); + + // Server sends server_start_result to client + var clientRegisterFinishResult = client.FinishRegistration(config, clientRegisterStartResult.state, serverRegisterStartResult.registrationResponse, password); + + // Client sends client_finish_result to server + var serverRegisterFinishResult = server.FinishRegistration(config, clientRegisterFinishResult.registrationUpload); + + // These two need to be stored in the server for future logins + Assert.NotNull(serverRegisterStartResult.serverSetup); + Assert.NotNull(serverRegisterFinishResult.serverRegistration); + + Assert.NotNull(clientRegisterFinishResult.exportKey); + Assert.NotNull(clientRegisterFinishResult.serverSPKey); + } + [Fact] + public void TestLogin() + { + var username = "demo_username"; + var password = "demo_password"; + + // These values have been obtained from a previous registration with the same user/pass + var serverSetup = Convert.FromBase64String("i1mHwGvcVd5iYedbbgYFnFNLOSbotw+Ltgvr+xkNaGp1exkmDOjmFlr5McxjGAff2zermIpPezwCzq1C95Tot+gKuJqwWJOJ6jMXIrg7dSx6+H1IvZnR7LFtI7ylYoMFTvOWPyMyfoPTHK/+IlzgB10bKYcuPb+W4vH224qrXAk="); + var serverRegistration = Convert.FromBase64String("ECHaam+JiZMa+lO8Rn6f5G4polvgvi468qUy1i6IaSu2L0Rh7XiQ5hm3KSu9doCGKIgfgeju/A5i8aefKZvxPtduytVRtaJm57+5jX7YYW1lv53jDIrvdgDwBt/xBO8Sghm8yzo/BUDDcYvClRx1N7rqk9CfaSQxkKQwKvgFeDtiXVWDj0i7MvVs6bBAFq9fprI8ahfdfeiQWx1Qcx5itCx7hlnzzvL4XwnNc3otFtz60PnYVsUpO+Mbe86ZGrNX"); + var serverSPKey = Convert.FromBase64String("8C9MWibFiO5PSCDXGQc2/jxTLCGBv6PC0jje9BOUhmk="); + + byte[]? previousSessionKey = null; + byte[]? previousExportKey = null; + + for (var i = 0; i < 2; i++) + { + // Create the OPAQUE Clients + var server = new BitwardenOpaqueServer(); + var client = new BitwardenOpaqueClient(); + + // Start the client login + var clientLoginStartResult = client.StartLogin(config, password); + + // Client sends login_start to server + var serverLoginStartResult = server.StartLogin(config, serverSetup, serverRegistration, clientLoginStartResult.credentialRequest, username); + + // Server sends login_start_result to client + var clientLoginFinishResult = client.FinishLogin(config, clientLoginStartResult.state, serverLoginStartResult.credentialResponse, password); + + // Client sends login_finish_result to server + var serverLoginFinishResult = server.FinishLogin(config, serverLoginStartResult.state, clientLoginFinishResult.credentialFinalization); + + // Session key must be the same in both client and server + Assert.NotNull(serverLoginFinishResult.sessionKey); + Assert.Equal(serverLoginFinishResult.sessionKey, clientLoginFinishResult.sessionKey); + + // SPKey must be the same as during registration + Assert.Equal(clientLoginFinishResult.serverSPKey, serverSPKey); + + if (i == 0) + { + previousSessionKey = serverLoginFinishResult.sessionKey; + previousExportKey = clientLoginFinishResult.exportKey; + } + else + { + // Session key must be different for each login + Assert.NotNull(serverLoginFinishResult.sessionKey); + Assert.NotEqual(previousSessionKey, serverLoginFinishResult.sessionKey); + + // Export key must be the same for all logins + Assert.NotNull(clientLoginFinishResult.exportKey); + Assert.Equal(previousExportKey, clientLoginFinishResult.exportKey); + } + } + } +}