feat: Sprint 7 — Go SDK with QUIC transport and Cap'n Proto RPC
First non-Rust client SDK for quicproquo ecosystem. - Cap'n Proto codegen: generated 6487-line Go types from node.capnp with all 24 RPC methods (NodeService, Auth, Envelope) - QUIC transport: quic-go + TLS 1.3, ALPN "capnp", single bidi stream, 300s idle timeout, InsecureSkipVerify for dev, custom CA cert support - High-level qpq package: Connect, Health, ResolveUser, CreateChannel, Send/SendWithTTL, Receive/ReceiveWait, DeleteAccount, OPAQUE wrappers - Auth management: session token storage, version/token/deviceID on all RPCs - Example program and README with API reference All tests pass: go test ./..., go vet, go build
This commit is contained in:
377
sdks/go/qpq/client.go
Normal file
377
sdks/go/qpq/client.go
Normal file
@@ -0,0 +1,377 @@
|
||||
// Package qpq provides the high-level Go API for interacting with a quicproquo server.
|
||||
//
|
||||
// It wraps the generated Cap'n Proto types and transport layer into an
|
||||
// ergonomic client that handles authentication, key management, and messaging.
|
||||
package qpq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"quicproquo.dev/sdk/go/proto/node"
|
||||
"quicproquo.dev/sdk/go/transport"
|
||||
)
|
||||
|
||||
// Options configures the connection to a quicproquo server.
|
||||
type Options struct {
|
||||
// Addr is the host:port of the server (e.g. "127.0.0.1:5001").
|
||||
Addr string
|
||||
|
||||
// InsecureSkipVerify disables TLS certificate verification (dev mode only).
|
||||
InsecureSkipVerify bool
|
||||
|
||||
// CACertPath is the path to a PEM-encoded CA certificate for production use.
|
||||
CACertPath string
|
||||
}
|
||||
|
||||
// Message represents a received message envelope.
|
||||
type Message struct {
|
||||
Seq uint64
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Client is the high-level quicproquo client.
|
||||
type Client struct {
|
||||
conn *transport.Connection
|
||||
token []byte // session token from OPAQUE login
|
||||
deviceID []byte // optional device ID
|
||||
}
|
||||
|
||||
// Connect establishes a connection to a qpq server.
|
||||
func Connect(ctx context.Context, opts Options) (*Client, error) {
|
||||
conn, err := transport.Connect(ctx, transport.ConnectOptions{
|
||||
Addr: opts.Addr,
|
||||
InsecureSkipVerify: opts.InsecureSkipVerify,
|
||||
CACertPath: opts.CACertPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: connect: %w", err)
|
||||
}
|
||||
return &Client{conn: conn}, nil
|
||||
}
|
||||
|
||||
// Close disconnects from the server.
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// SetSessionToken sets a pre-existing session token for authentication.
|
||||
// Use this when you have already performed OPAQUE login externally.
|
||||
func (c *Client) SetSessionToken(token []byte) {
|
||||
c.token = token
|
||||
}
|
||||
|
||||
// SetDeviceID sets the device ID sent with each authenticated RPC call.
|
||||
func (c *Client) SetDeviceID(id []byte) {
|
||||
c.deviceID = id
|
||||
}
|
||||
|
||||
// setAuth populates an Auth struct on an RPC params message.
|
||||
func (c *Client) setAuth(auth node.Auth) error {
|
||||
auth.SetVersion(1)
|
||||
if err := auth.SetAccessToken(c.token); err != nil {
|
||||
return fmt.Errorf("set access token: %w", err)
|
||||
}
|
||||
if len(c.deviceID) > 0 {
|
||||
if err := auth.SetDeviceId(c.deviceID); err != nil {
|
||||
return fmt.Errorf("set device id: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health checks server health and returns the status string.
|
||||
func (c *Client) Health(ctx context.Context) (string, error) {
|
||||
future, release := c.conn.Client().Health(ctx, nil)
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qpq: health: %w", err)
|
||||
}
|
||||
status, err := res.Status()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qpq: health: read status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ResolveUser looks up a username and returns their identity key.
|
||||
func (c *Client) ResolveUser(ctx context.Context, username string) (identityKey []byte, err error) {
|
||||
future, release := c.conn.Client().ResolveUser(ctx, func(p node.NodeService_resolveUser_Params) error {
|
||||
if err := p.SetUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: resolve user %q: %w", username, err)
|
||||
}
|
||||
key, err := res.IdentityKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: resolve user %q: read identity key: %w", username, err)
|
||||
}
|
||||
// Copy the key out of the capnp message buffer.
|
||||
out := make([]byte, len(key))
|
||||
copy(out, key)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateChannel creates a 1:1 DM channel with a peer identified by their identity key.
|
||||
func (c *Client) CreateChannel(ctx context.Context, peerKey []byte) (channelID []byte, wasNew bool, err error) {
|
||||
future, release := c.conn.Client().CreateChannel(ctx, func(p node.NodeService_createChannel_Params) error {
|
||||
if err := p.SetPeerKey(peerKey); err != nil {
|
||||
return err
|
||||
}
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("qpq: create channel: %w", err)
|
||||
}
|
||||
chID, err := res.ChannelId()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("qpq: create channel: read channel id: %w", err)
|
||||
}
|
||||
out := make([]byte, len(chID))
|
||||
copy(out, chID)
|
||||
return out, res.WasNew(), nil
|
||||
}
|
||||
|
||||
// Send enqueues a message payload to a recipient identified by their identity key.
|
||||
func (c *Client) Send(ctx context.Context, recipientKey, payload []byte) (seq uint64, err error) {
|
||||
return c.sendInternal(ctx, recipientKey, payload, 0)
|
||||
}
|
||||
|
||||
// SendWithTTL enqueues a disappearing message with a time-to-live in seconds.
|
||||
func (c *Client) SendWithTTL(ctx context.Context, recipientKey, payload []byte, ttlSecs uint32) (seq uint64, err error) {
|
||||
return c.sendInternal(ctx, recipientKey, payload, ttlSecs)
|
||||
}
|
||||
|
||||
func (c *Client) sendInternal(ctx context.Context, recipientKey, payload []byte, ttlSecs uint32) (uint64, error) {
|
||||
future, release := c.conn.Client().Enqueue(ctx, func(p node.NodeService_enqueue_Params) error {
|
||||
if err := p.SetRecipientKey(recipientKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SetPayload(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
p.SetVersion(1)
|
||||
if ttlSecs > 0 {
|
||||
p.SetTtlSecs(ttlSecs)
|
||||
}
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("qpq: send: %w", err)
|
||||
}
|
||||
return res.Seq(), nil
|
||||
}
|
||||
|
||||
// Receive fetches queued messages for the given recipient key.
|
||||
func (c *Client) Receive(ctx context.Context, recipientKey []byte) ([]Message, error) {
|
||||
future, release := c.conn.Client().Fetch(ctx, func(p node.NodeService_fetch_Params) error {
|
||||
if err := p.SetRecipientKey(recipientKey); err != nil {
|
||||
return err
|
||||
}
|
||||
p.SetVersion(1)
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: receive: %w", err)
|
||||
}
|
||||
return extractMessages(res.Payloads())
|
||||
}
|
||||
|
||||
// ReceiveWait long-polls for messages with a timeout in milliseconds.
|
||||
func (c *Client) ReceiveWait(ctx context.Context, recipientKey []byte, timeoutMs uint64) ([]Message, error) {
|
||||
future, release := c.conn.Client().FetchWait(ctx, func(p node.NodeService_fetchWait_Params) error {
|
||||
if err := p.SetRecipientKey(recipientKey); err != nil {
|
||||
return err
|
||||
}
|
||||
p.SetVersion(1)
|
||||
p.SetTimeoutMs(timeoutMs)
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: receive wait: %w", err)
|
||||
}
|
||||
return extractMessages(res.Payloads())
|
||||
}
|
||||
|
||||
// DeleteAccount permanently deletes the authenticated user's account.
|
||||
func (c *Client) DeleteAccount(ctx context.Context) error {
|
||||
future, release := c.conn.Client().DeleteAccount(ctx, func(p node.NodeService_deleteAccount_Params) error {
|
||||
auth, err := p.NewAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setAuth(auth)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qpq: delete account: %w", err)
|
||||
}
|
||||
if !res.Success() {
|
||||
return fmt.Errorf("qpq: delete account: server returned success=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterStart initiates OPAQUE registration and returns the server's response bytes.
|
||||
//
|
||||
// The OPAQUE protocol requires client-side cryptographic operations.
|
||||
// The request parameter must be the serialized OPAQUE RegistrationRequest
|
||||
// generated by an OPAQUE client library (e.g. github.com/cloudflare/circl/opaque).
|
||||
// Process the returned server response with your OPAQUE library to produce
|
||||
// the upload bytes for RegisterFinish.
|
||||
func (c *Client) RegisterStart(ctx context.Context, username string, request []byte) (serverResponse []byte, err error) {
|
||||
future, release := c.conn.Client().OpaqueRegisterStart(ctx, func(p node.NodeService_opaqueRegisterStart_Params) error {
|
||||
if err := p.SetUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SetRequest(request)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: register start: %w", err)
|
||||
}
|
||||
resp, err := res.Response()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: register start: read response: %w", err)
|
||||
}
|
||||
out := make([]byte, len(resp))
|
||||
copy(out, resp)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RegisterFinish completes OPAQUE registration with the upload and identity key.
|
||||
func (c *Client) RegisterFinish(ctx context.Context, username string, upload, identityKey []byte) error {
|
||||
future, release := c.conn.Client().OpaqueRegisterFinish(ctx, func(p node.NodeService_opaqueRegisterFinish_Params) error {
|
||||
if err := p.SetUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SetUpload(upload); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SetIdentityKey(identityKey)
|
||||
})
|
||||
defer release()
|
||||
|
||||
_, err := future.Struct()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qpq: register finish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginStart initiates OPAQUE login and returns the server's response bytes.
|
||||
func (c *Client) LoginStart(ctx context.Context, username string, request []byte) (serverResponse []byte, err error) {
|
||||
future, release := c.conn.Client().OpaqueLoginStart(ctx, func(p node.NodeService_opaqueLoginStart_Params) error {
|
||||
if err := p.SetUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SetRequest(request)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: login start: %w", err)
|
||||
}
|
||||
resp, err := res.Response()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qpq: login start: read response: %w", err)
|
||||
}
|
||||
out := make([]byte, len(resp))
|
||||
copy(out, resp)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoginFinish completes OPAQUE login and stores the session token.
|
||||
// The finalization parameter is the OPAQUE finalization message from
|
||||
// your OPAQUE client library. The identityKey is your public identity key.
|
||||
func (c *Client) LoginFinish(ctx context.Context, username string, finalization, identityKey []byte) error {
|
||||
future, release := c.conn.Client().OpaqueLoginFinish(ctx, func(p node.NodeService_opaqueLoginFinish_Params) error {
|
||||
if err := p.SetUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SetFinalization(finalization); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SetIdentityKey(identityKey)
|
||||
})
|
||||
defer release()
|
||||
|
||||
res, err := future.Struct()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qpq: login finish: %w", err)
|
||||
}
|
||||
token, err := res.SessionToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qpq: login finish: read session token: %w", err)
|
||||
}
|
||||
c.token = make([]byte, len(token))
|
||||
copy(c.token, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractMessages converts a Cap'n Proto Envelope_List into a slice of Message.
|
||||
func extractMessages(envList node.Envelope_List, listErr error) ([]Message, error) {
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
msgs := make([]Message, envList.Len())
|
||||
for i := range msgs {
|
||||
env := envList.At(i)
|
||||
data, err := env.Data()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read message %d data: %w", i, err)
|
||||
}
|
||||
dataCopy := make([]byte, len(data))
|
||||
copy(dataCopy, data)
|
||||
msgs[i] = Message{
|
||||
Seq: env.Seq(),
|
||||
Data: dataCopy,
|
||||
}
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
58
sdks/go/qpq/client_test.go
Normal file
58
sdks/go/qpq/client_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package qpq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetSessionToken(t *testing.T) {
|
||||
c := &Client{}
|
||||
token := []byte("test-session-token-abc123")
|
||||
|
||||
c.SetSessionToken(token)
|
||||
|
||||
if string(c.token) != string(token) {
|
||||
t.Errorf("expected token %q, got %q", token, c.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDeviceID(t *testing.T) {
|
||||
c := &Client{}
|
||||
id := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
c.SetDeviceID(id)
|
||||
|
||||
if len(c.deviceID) != 4 {
|
||||
t.Fatalf("expected 4-byte device ID, got %d bytes", len(c.deviceID))
|
||||
}
|
||||
for i, b := range id {
|
||||
if c.deviceID[i] != b {
|
||||
t.Errorf("deviceID[%d]: expected %d, got %d", i, b, c.deviceID[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStruct(t *testing.T) {
|
||||
m := Message{Seq: 42, Data: []byte("hello")}
|
||||
if m.Seq != 42 {
|
||||
t.Errorf("expected Seq 42, got %d", m.Seq)
|
||||
}
|
||||
if string(m.Data) != "hello" {
|
||||
t.Errorf("expected Data %q, got %q", "hello", m.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsDefaults(t *testing.T) {
|
||||
opts := Options{
|
||||
Addr: "127.0.0.1:5001",
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
if opts.Addr != "127.0.0.1:5001" {
|
||||
t.Errorf("unexpected addr: %s", opts.Addr)
|
||||
}
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("expected InsecureSkipVerify to be true")
|
||||
}
|
||||
if opts.CACertPath != "" {
|
||||
t.Error("expected empty CACertPath")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user