diff --git a/sdks/java/README.md b/sdks/java/README.md new file mode 100644 index 0000000..c593735 --- /dev/null +++ b/sdks/java/README.md @@ -0,0 +1,50 @@ +# QuicProQuo Java SDK + +Java wrapper over `libquicproquo_ffi` via JNI for JVM and Android. + +## Prerequisites + +- JDK 17+ +- `libquicproquo_ffi` built for the target platform +- JNI bridge compiled (shared with Kotlin SDK: `../kotlin/jni/`) + +## Building + +```sh +# Build Rust FFI library +cargo build --release -p quicproquo-ffi + +# Build Java SDK +./gradlew build +``` + +## Usage + +```java +import dev.quicproquo.QpqClient; + +try (QpqClient client = new QpqClient("127.0.0.1:5001", "ca.pem")) { + client.login("alice", "secret"); + client.send("bob", "hello".getBytes()); + + var messages = client.receive(5000); + messages.forEach(msg -> System.out.println("Received: " + msg)); +} +``` + +## API + +| Method | Description | +|---|---| +| `new QpqClient(server, caCertPath)` | Connect to server | +| `client.login(username, password)` | OPAQUE authentication | +| `client.send(recipient, message)` | Send message by username | +| `client.receive(timeoutMs)` | Receive pending messages | +| `client.close()` / `client.disconnect()` | Disconnect | +| `client.isConnected()` | Connection status | + +## Structure + +- `src/main/java/dev/quicproquo/QpqClient.java` -- High-level client +- `src/main/java/dev/quicproquo/NativeBridge.java` -- JNI declarations +- JNI C bridge shared with Kotlin SDK at `../kotlin/jni/` diff --git a/sdks/java/build.gradle.kts b/sdks/java/build.gradle.kts new file mode 100644 index 0000000..e3745b2 --- /dev/null +++ b/sdks/java/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + java +} + +group = "dev.quicproquo" +version = "0.1.0" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.google.code.gson:gson:2.10.1") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/sdks/java/src/main/java/dev/quicproquo/NativeBridge.java b/sdks/java/src/main/java/dev/quicproquo/NativeBridge.java new file mode 100644 index 0000000..c4ff28b --- /dev/null +++ b/sdks/java/src/main/java/dev/quicproquo/NativeBridge.java @@ -0,0 +1,30 @@ +package dev.quicproquo; + +/** + * JNI bridge to libquicproquo_ffi native library. + * + *

Load the library with: + *

{@code
+ * System.loadLibrary("quicproquo_ffi");
+ * }
+ */ +final class NativeBridge { + static final int QPQ_OK = 0; + static final int QPQ_ERROR = 1; + static final int QPQ_AUTH_FAILED = 2; + static final int QPQ_TIMEOUT = 3; + static final int QPQ_NOT_CONNECTED = 4; + + static { + System.loadLibrary("quicproquo_ffi"); + } + + static native long nativeConnect(String server, String caCert, String serverName); + static native int nativeLogin(long handle, String username, String password); + static native int nativeSend(long handle, String recipient, byte[] message); + static native String nativeReceive(long handle, int timeoutMs); + static native void nativeDisconnect(long handle); + static native String nativeLastError(long handle); + + private NativeBridge() {} +} diff --git a/sdks/java/src/main/java/dev/quicproquo/QpqAuthException.java b/sdks/java/src/main/java/dev/quicproquo/QpqAuthException.java new file mode 100644 index 0000000..f84680d --- /dev/null +++ b/sdks/java/src/main/java/dev/quicproquo/QpqAuthException.java @@ -0,0 +1,6 @@ +package dev.quicproquo; + +/** OPAQUE authentication failed (bad credentials). */ +public class QpqAuthException extends QpqException { + public QpqAuthException(String message) { super(message); } +} diff --git a/sdks/java/src/main/java/dev/quicproquo/QpqClient.java b/sdks/java/src/main/java/dev/quicproquo/QpqClient.java new file mode 100644 index 0000000..46f0871 --- /dev/null +++ b/sdks/java/src/main/java/dev/quicproquo/QpqClient.java @@ -0,0 +1,127 @@ +package dev.quicproquo; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.List; + +/** + * High-level quicproquo client for JVM. + * + *

Wraps {@code libquicproquo_ffi} via JNI. + * + *

{@code
+ * try (QpqClient client = new QpqClient("127.0.0.1:5001", "ca.pem")) {
+ *     client.login("alice", "secret");
+ *     client.send("bob", "hello".getBytes());
+ *     List messages = client.receive(5000);
+ * }
+ * }
+ */ +public final class QpqClient implements AutoCloseable { + private volatile long handle; + private final Gson gson = new Gson(); + private static final Type STRING_LIST_TYPE = + new TypeToken>() {}.getType(); + + /** + * Connect to a quicproquo server. + * + * @param server server address as {@code host:port} + * @param caCertPath path to PEM-encoded CA certificate + */ + public QpqClient(String server, String caCertPath) { + this(server, caCertPath, server.split(":")[0]); + } + + /** + * Connect to a quicproquo server with explicit TLS server name. + * + * @param server server address as {@code host:port} + * @param caCertPath path to PEM-encoded CA certificate + * @param serverName TLS SNI server name + */ + public QpqClient(String server, String caCertPath, String serverName) { + long h = NativeBridge.nativeConnect(server, caCertPath, serverName); + if (h == 0) { + throw new QpqException("qpq_connect failed for " + server); + } + this.handle = h; + } + + /** Whether the client is connected. */ + public boolean isConnected() { + return handle != 0; + } + + /** + * Authenticate with OPAQUE credentials. + * + * @throws QpqAuthException on bad credentials + */ + public void login(String username, String password) { + checkConnected(); + int code = NativeBridge.nativeLogin(handle, username, password); + checkStatus(code); + } + + /** + * Send a message to a recipient by username. + * + * @param recipient recipient username + * @param message message payload (arbitrary bytes) + */ + public void send(String recipient, byte[] message) { + checkConnected(); + int code = NativeBridge.nativeSend(handle, recipient, message); + checkStatus(code); + } + + /** + * Receive pending messages, blocking up to {@code timeoutMs} milliseconds. + * + * @return list of message strings (UTF-8) + */ + public List receive(int timeoutMs) { + checkConnected(); + String json = NativeBridge.nativeReceive(handle, timeoutMs); + if (json == null) return Collections.emptyList(); + return gson.fromJson(json, STRING_LIST_TYPE); + } + + /** Disconnect from the server and release resources. */ + @Override + public void close() { + long h = handle; + if (h != 0) { + handle = 0; + NativeBridge.nativeDisconnect(h); + } + } + + /** Alias for {@link #close()}. */ + public void disconnect() { + close(); + } + + private void checkConnected() { + if (handle == 0) throw new QpqException("not connected"); + } + + private void checkStatus(int code) { + if (code == NativeBridge.QPQ_OK) return; + String msg = NativeBridge.nativeLastError(handle); + if (msg == null) msg = "unknown error"; + switch (code) { + case NativeBridge.QPQ_AUTH_FAILED: + throw new QpqAuthException(msg); + case NativeBridge.QPQ_TIMEOUT: + throw new QpqTimeoutException(msg); + case NativeBridge.QPQ_NOT_CONNECTED: + throw new QpqException("not connected: " + msg); + default: + throw new QpqException(msg); + } + } +} diff --git a/sdks/java/src/main/java/dev/quicproquo/QpqException.java b/sdks/java/src/main/java/dev/quicproquo/QpqException.java new file mode 100644 index 0000000..e4c3c98 --- /dev/null +++ b/sdks/java/src/main/java/dev/quicproquo/QpqException.java @@ -0,0 +1,7 @@ +package dev.quicproquo; + +/** Base exception for quicproquo SDK errors. */ +public class QpqException extends RuntimeException { + public QpqException(String message) { super(message); } + public QpqException(String message, Throwable cause) { super(message, cause); } +} diff --git a/sdks/java/src/main/java/dev/quicproquo/QpqTimeoutException.java b/sdks/java/src/main/java/dev/quicproquo/QpqTimeoutException.java new file mode 100644 index 0000000..73ef04c --- /dev/null +++ b/sdks/java/src/main/java/dev/quicproquo/QpqTimeoutException.java @@ -0,0 +1,6 @@ +package dev.quicproquo; + +/** Operation timed out. */ +public class QpqTimeoutException extends QpqException { + public QpqTimeoutException(String message) { super(message); } +} diff --git a/sdks/ruby/README.md b/sdks/ruby/README.md new file mode 100644 index 0000000..e558881 --- /dev/null +++ b/sdks/ruby/README.md @@ -0,0 +1,82 @@ +# QuicProQuo Ruby SDK + +Ruby FFI gem wrapping `libquicproquo_ffi` for the quicproquo E2E encrypted messenger. + +## Prerequisites + +- Ruby 3.1+ +- `libquicproquo_ffi` built for the target platform + +## Installation + +```sh +gem install quicproquo +``` + +Or add to your Gemfile: + +```ruby +gem "quicproquo" +``` + +## Building the Native Library + +```sh +cargo build --release -p quicproquo-ffi +``` + +Set `QPQ_LIB_PATH` if the library is not in the default search path. + +## Usage + +```ruby +require "quicproquo" + +# Block form (auto-disconnect) +QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |client| + client.login("alice", "secret") + client.send("bob", "hello from Ruby!") + + messages = client.receive(timeout_ms: 5000) + messages.each { |msg| puts msg } +end + +# Manual lifecycle +client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem") +client.login("alice", "secret") +client.send("bob", "hello") +client.disconnect +``` + +## API + +| Method | Description | +|---|---| +| `Client.new(server, ca_cert:, server_name:)` | Connect to server | +| `Client.open(server, **opts) { \|c\| ... }` | Connect with auto-disconnect | +| `client.login(username, password)` | OPAQUE authentication | +| `client.send(recipient, message)` | Send message by username | +| `client.receive(timeout_ms: 5000)` | Receive pending messages | +| `client.disconnect` | Disconnect | +| `client.connected?` | Connection status | + +## Error Handling + +```ruby +begin + client.login("alice", "wrong") +rescue QuicProQuo::AuthError => e + puts "Auth failed: #{e.message}" +rescue QuicProQuo::TimeoutError => e + puts "Timeout: #{e.message}" +rescue QuicProQuo::Error => e + puts "Error: #{e.message}" +end +``` + +## Structure + +- `lib/quicproquo/client.rb` -- High-level client +- `lib/quicproquo/ffi_bindings.rb` -- FFI function declarations +- `lib/quicproquo/errors.rb` -- Exception classes +- `examples/demo.rb` -- Usage example diff --git a/sdks/ruby/examples/demo.rb b/sdks/ruby/examples/demo.rb new file mode 100644 index 0000000..16de1fc --- /dev/null +++ b/sdks/ruby/examples/demo.rb @@ -0,0 +1,43 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example: quicproquo Ruby SDK demo. +# +# Usage: +# ruby demo.rb --server 127.0.0.1:5001 --ca-cert ca.pem \ +# --user alice --pass secret --recipient bob --message "hello" + +require "optparse" +require_relative "../lib/quicproquo" + +options = { + server: "127.0.0.1:5001", + ca_cert: "ca.pem", + message: "hello from Ruby SDK!", +} + +OptionParser.new do |opts| + opts.banner = "Usage: demo.rb [options]" + opts.on("--server ADDR", "Server address") { |v| options[:server] = v } + opts.on("--ca-cert PATH", "CA certificate") { |v| options[:ca_cert] = v } + opts.on("--user NAME", "Username") { |v| options[:user] = v } + opts.on("--pass PASSWORD", "Password") { |v| options[:pass] = v } + opts.on("--recipient NAME", "Recipient") { |v| options[:recipient] = v } + opts.on("--message TEXT", "Message") { |v| options[:message] = v } +end.parse! + +QuicProQuo::Client.open(options[:server], ca_cert: options[:ca_cert]) do |client| + puts "Connected to #{options[:server]}" + + client.login(options[:user], options[:pass]) + puts "Logged in as #{options[:user]}" + + client.send(options[:recipient], options[:message]) + puts "Sent message to #{options[:recipient]}" + + puts "Waiting for messages (5s)..." + messages = client.receive(timeout_ms: 5000) + messages.each { |msg| puts " received: #{msg}" } + + puts "Done." +end diff --git a/sdks/ruby/lib/quicproquo.rb b/sdks/ruby/lib/quicproquo.rb new file mode 100644 index 0000000..6ac8598 --- /dev/null +++ b/sdks/ruby/lib/quicproquo.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "quicproquo/ffi_bindings" +require_relative "quicproquo/client" +require_relative "quicproquo/errors" +require_relative "quicproquo/version" + +# Ruby SDK for the quicproquo E2E encrypted messenger. +# +# Two usage patterns: +# +# # Block form (auto-disconnect) +# QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |client| +# client.login("alice", "secret") +# client.send("bob", "hello") +# end +# +# # Manual lifecycle +# client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem") +# client.login("alice", "secret") +# client.disconnect +module QuicProQuo +end diff --git a/sdks/ruby/lib/quicproquo/client.rb b/sdks/ruby/lib/quicproquo/client.rb new file mode 100644 index 0000000..f7992f4 --- /dev/null +++ b/sdks/ruby/lib/quicproquo/client.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "json" + +module QuicProQuo + # High-level quicproquo client for Ruby. + # + # Wraps +libquicproquo_ffi+ via the +ffi+ gem. + # + # client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem") + # client.login("alice", "secret") + # client.send("bob", "hello") + # messages = client.receive(timeout_ms: 5000) + # client.disconnect + # + # Or use the block form for automatic cleanup: + # + # QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |c| + # c.login("alice", "secret") + # c.send("bob", "hello") + # end + class Client + # Connect to a quicproquo server. + # + # @param server [String] Server address as +host:port+. + # @param ca_cert [String] Path to PEM-encoded CA certificate. + # @param server_name [String] TLS SNI server name (defaults to host). + # @raise [ConnectionError] if the connection fails. + def initialize(server, ca_cert:, server_name: nil) + sn = server_name || server.split(":").first + @handle = FFIBindings.qpq_connect(server, ca_cert, sn) + raise ConnectionError, "qpq_connect failed for #{server}" if @handle.null? + end + + # Connect and yield the client, disconnecting when the block returns. + def self.open(server, **opts) + client = new(server, **opts) + begin + yield client + ensure + client.disconnect + end + end + + # Authenticate with OPAQUE credentials. + # + # @param username [String] + # @param password [String] + # @raise [AuthError] on bad credentials. + def login(username, password) + check_connected! + code = FFIBindings.qpq_login(@handle, username, password) + check_status!(code) + end + + # Send a message to a recipient by username. + # + # @param recipient [String] Recipient username. + # @param message [String] Message payload (UTF-8 string or binary). + def send(recipient, message) + check_connected! + msg_bytes = message.encode("BINARY") + buf = FFI::MemoryPointer.new(:uint8, msg_bytes.bytesize) + buf.put_bytes(0, msg_bytes) + code = FFIBindings.qpq_send(@handle, recipient, buf, msg_bytes.bytesize) + check_status!(code) + end + + # Receive pending messages, blocking up to +timeout_ms+ milliseconds. + # + # @param timeout_ms [Integer] Timeout in milliseconds (default 5000). + # @return [Array] Message strings (UTF-8). + def receive(timeout_ms: 5000) + check_connected! + out_ptr = FFI::MemoryPointer.new(:pointer) + code = FFIBindings.qpq_receive(@handle, timeout_ms, out_ptr) + check_status!(code) + + json_ptr = out_ptr.read_pointer + return [] if json_ptr.null? + + json_str = json_ptr.read_string + FFIBindings.qpq_free_string(json_ptr) + JSON.parse(json_str) + end + + # Disconnect from the server and release resources. + def disconnect + return if @handle.nil? || @handle.null? + + FFIBindings.qpq_disconnect(@handle) + @handle = nil + end + + # Whether the client is connected. + def connected? + !@handle.nil? && !@handle.null? + end + + private + + def check_connected! + raise NotConnectedError, "not connected" unless connected? + end + + def check_status!(code) + return if code == FFIBindings::QPQ_OK + + msg = FFIBindings.qpq_last_error(@handle) || "unknown error" + case code + when FFIBindings::QPQ_AUTH_FAILED + raise AuthError, msg + when FFIBindings::QPQ_TIMEOUT + raise TimeoutError, msg + when FFIBindings::QPQ_NOT_CONNECTED + raise NotConnectedError, msg + else + raise Error, msg + end + end + end +end diff --git a/sdks/ruby/lib/quicproquo/errors.rb b/sdks/ruby/lib/quicproquo/errors.rb new file mode 100644 index 0000000..a4416d8 --- /dev/null +++ b/sdks/ruby/lib/quicproquo/errors.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module QuicProQuo + # Base error for quicproquo SDK. + class Error < StandardError; end + + # OPAQUE authentication failed (bad credentials). + class AuthError < Error; end + + # Operation timed out. + class TimeoutError < Error; end + + # Client is not connected. + class NotConnectedError < Error; end + + # Connection to the server failed. + class ConnectionError < Error; end +end diff --git a/sdks/ruby/lib/quicproquo/ffi_bindings.rb b/sdks/ruby/lib/quicproquo/ffi_bindings.rb new file mode 100644 index 0000000..ce13f0f --- /dev/null +++ b/sdks/ruby/lib/quicproquo/ffi_bindings.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "ffi" + +module QuicProQuo + # Low-level FFI bindings to libquicproquo_ffi. + # + # The library is located via: + # 1. QPQ_LIB_PATH environment variable + # 2. Common cargo build output paths + # 3. System library path + module FFIBindings + extend FFI::Library + + # Status codes (mirrors crates/quicproquo-ffi/src/lib.rs). + QPQ_OK = 0 + QPQ_ERROR = 1 + QPQ_AUTH_FAILED = 2 + QPQ_TIMEOUT = 3 + QPQ_NOT_CONNECTED = 4 + + # Locate and load the shared library. + LIB_SEARCH_PATHS = [ + ENV.fetch("QPQ_LIB_PATH", nil), + File.expand_path("../../../../target/release/libquicproquo_ffi.so", __dir__), + File.expand_path("../../../../target/debug/libquicproquo_ffi.so", __dir__), + File.expand_path("../../../../target/release/libquicproquo_ffi.dylib", __dir__), + File.expand_path("../../../../target/debug/libquicproquo_ffi.dylib", __dir__), + "quicproquo_ffi", + ].compact.freeze + + lib_path = LIB_SEARCH_PATHS.find { |p| File.exist?(p) } || "quicproquo_ffi" + ffi_lib lib_path + + # QpqHandle* qpq_connect(const char*, const char*, const char*) + attach_function :qpq_connect, %i[string string string], :pointer + + # int qpq_login(QpqHandle*, const char*, const char*) + attach_function :qpq_login, %i[pointer string string], :int + + # int qpq_send(QpqHandle*, const char*, :pointer, size_t) + attach_function :qpq_send, %i[pointer string pointer size_t], :int + + # int qpq_receive(QpqHandle*, uint32, :pointer) + attach_function :qpq_receive, %i[pointer uint32 pointer], :int + + # void qpq_disconnect(QpqHandle*) + attach_function :qpq_disconnect, [:pointer], :void + + # const char* qpq_last_error(const QpqHandle*) + attach_function :qpq_last_error, [:pointer], :string + + # void qpq_free_string(char*) + attach_function :qpq_free_string, [:pointer], :void + end +end diff --git a/sdks/ruby/lib/quicproquo/version.rb b/sdks/ruby/lib/quicproquo/version.rb new file mode 100644 index 0000000..cd63822 --- /dev/null +++ b/sdks/ruby/lib/quicproquo/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module QuicProQuo + VERSION = "0.1.0" +end diff --git a/sdks/ruby/quicproquo.gemspec b/sdks/ruby/quicproquo.gemspec new file mode 100644 index 0000000..d31c6a0 --- /dev/null +++ b/sdks/ruby/quicproquo.gemspec @@ -0,0 +1,20 @@ +Gem::Specification.new do |s| + s.name = "quicproquo" + s.version = "0.1.0" + s.summary = "Ruby SDK for quicproquo E2E encrypted messenger" + s.description = "Ruby FFI bindings to libquicproquo_ffi for the quicproquo " \ + "end-to-end encrypted messaging system." + s.authors = ["quicproquo contributors"] + s.license = "MIT" + s.homepage = "https://github.com/nicholasgasior/quicproquo" + + s.required_ruby_version = ">= 3.1" + + s.files = Dir["lib/**/*.rb", "README.md", "LICENSE"] + + s.add_dependency "ffi", "~> 1.16" + s.add_dependency "json", "~> 2.7" + + s.add_development_dependency "rspec", "~> 3.13" + s.add_development_dependency "rubocop", "~> 1.60" +end