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