Pigeonhole services
This document explains how three components work together to let a client reach a pigeonhole service it has never talked to. Two are programs that mixnet operators run; the third is a library for writing pigeonhole services.
- The service directory (capability
service_directory) holds a descriptor for each pigeonhole service, saying how to reach it. It is theservice-directoryprogram in katzenpost, undercmd/service-directory, built on thepigeonservice/directorypackage. Operators run it as a plugin on a service node. - The voucher router (capability
voucher_router) relays a client’s first message to a pigeonhole service. It is thevoucher-routerprogram in katzenpost, undercmd/voucher-router, built on thepigeonservice/routerpackage. Operators run it as a plugin on a service node. - The pigeonhole service library is for writing pigeonhole services, and
the clients that reach them, in Go or Rust. A pigeonhole service is the
responder side of an initiator–responder protocol over Pigeonhole: it
accepts new connections from clients and then speaks its own protocol over
each one, such as a bot that joins group chats, or a binary protocol no
human reads. The Go library is the
pigeonservicepackage in katzenpost; the Rust library is theservicesmodule of thepigeonhole_protocolscrate in pigeonhole_protocols, which also has ELIZA, a pigeonhole service written with it. The two speak the same wire format.
| Part | Where | Status |
|---|---|---|
| This specification | website #246 | in review |
| Service directory | katzenpost cmd/service-directory #1247 |
in review |
| Voucher router | katzenpost cmd/voucher-router #1247 |
in review |
| Go library | katzenpost pigeonservice #1247 |
in review |
| Rust library | pigeonhole_protocols src/services #11 |
in review |
| ELIZA | pigeonhole_protocols src/bin/eliza.rs #11 |
in review |
| Rust thin client | thin_client #100 | in review |
A pigeonhole service has no address and no open port; everything it sends or receives is a box on the storage replicas. The directory and the router bootstrap the first contact.
Many operators should run the directory and the router. If one operator ran the only instance of each, that operator would be a single point of failure for every pigeonhole service. With two or more there is none: a pigeonhole service registers with every router and advertises in every directory the PKI document lists, and a client can look it up in any directory and route through any of a service’s routers. A pigeonhole service notices when a new PKI document lists more routers or directories, and starts using them.
Afterwards the client and the service share a connection of two Pigeonhole streams, one written by each, and each holds the other’s read cap. The router and the directory take no further part. Every party reaches the mixnet through a client daemon, kpclientd, which does all the cryptography that involves keys.
Contact vouchers
Every connection starts from a contact voucher: one side mints it, the other uses it, and both end up holding each other’s read cap. A voucher can be used once. The protocol is specified in Contact voucher and narrated in Contact voucher protocol narration. Here it matters only who mints the voucher and how it travels.
A client could instead send a service a read cap and a write cap directly. Vouchers are used because:
- Forward secrecy. Routers keep forwarded messages for days, sealed to the service’s long-term key. Caps in them would stay valid for anyone who later gets that key; a used voucher is worthless, since its payload is tombstoned and its reply is sealed to a key only the client holds.
- Each side makes its own stream. No one holds a write cap to the other’s stream.
- The client knows it was accepted. The voucher reply confirms the connection; with bare caps the client cannot tell an idle service from one that never got them.
- One token for every path. The same short, single-use token is routed, offered, or pasted into a chat app.
The cost is a few extra round trips before the first message.
Identity
A service is known by its NIKE keypair, x25519-ctidh1024, kept in its data
directory so it survives restarts. Its ServiceID is the BLAKE2b-256 hash of
the public key, written as 64 hex digits.
The router and the directory publish their NIKE public keys in the PKI
document, as the parameters nikeScheme and publicKey of their service
node entry. A client trusts a router or a directory because the PKI lists
it, and trusts nothing a directory says without checking it against the PKI.
Registering with routers
A service mints a voucher and sends it to each voucher router in the PKI in
a RegisterRequest, with its public key and a MAC. The router uses the
voucher, and the connection that results is the service’s link with that
router. The router writes every sealed voucher addressed to the service to
the link as a LinkMessage, and keeps them for seven days, so a service
that was offline still receives them.
The authorities publish a new PKI document once an epoch, and the client daemon tells its thin clients about each one. On each new document a service registers with any router listed in it that it has no link with. A registration that fails, and a link whose reader fails, are forgotten, so the next document registers with that router again.
Advertising and discovery
Once a service holds a link, it advertises its ServiceDescriptor in every
directory in the PKI: its public key and the routers it holds links with. An
AdvertiseRequest carries a MAC and a sequence number, the time in
nanoseconds; the directory keeps only the descriptor with the highest. A
WithdrawRequest removes it, and its sequence number stays, so a replayed
older advertisement cannot bring it back.
A client finds one service with a LookupRequest, asking each directory in
turn and checking that the descriptor it gets is the service’s. A
ListRequest instead returns the read cap of a snapshot stream, where the
directory writes all its descriptors whenever one changes; a client merges
the lists of every directory, so no single directory can hide a service’s
routers.
Dialing a service
A client mints a voucher, seals it to the service’s public key, and sends it
to one of the service’s routers in a RouteRequest. It tries only routers
the PKI lists, five minutes each. The router forwards the sealed voucher
over its link without being able to open it; the service opens it, uses it,
and answers in the voucher reply. The router learns which service is being
contacted and when, but not by whom, since the request travels over Sphinx
with a reply SURB.
A chat app that cannot route vouchers itself can mint one and hand it to
pigeonhole-voucher send SERVICE_IDENTITY VOUCHER_TOKEN.
The other direction needs no router or directory: a service mints a voucher and offers it, for a client to use.
Connections
Each side writes its messages, one per box, to its own stream, and reads the peer’s stream box by box. A message is at most one box. Replicas keep a box for one to two replica epochs, so a connection rewrites everything it sent in the last seven days once a replica epoch, and saves its state after every send and receive: either side can be offline for days, or restart.
Request authentication
RegisterRequest, AdvertiseRequest and WithdrawRequest carry a MAC that
proves the request comes from the holder of the service’s private key,
without a challenge round:
ss = DH(servicePriv, pluginPub) = DH(pluginPriv, servicePub)
key = HKDF-BLAKE2b-256(ss, salt = none, info = "katzenpost-pigeonservice-mac-v1" || pluginPub || servicePub)
mac = BLAKE2b-256(key = key, msg = canonical CBOR of the request with MAC null)
pluginPub is the router’s or the directory’s public key from the PKI. An
all-zero shared secret is rejected, and MACs are compared in constant time.
Messages
Requests are the payloads of Sphinx queries to the plugin’s endpoint, and replies come back in the reply SURB. Every message is canonical CBOR, a map keyed by the Go field names. A decoder accepts the zero padding Sphinx adds after the item. The daemon retransmits a request until a reply arrives, so the router and the directory treat a repeated request as one.
const (
RouterCapability = "voucher_router"
DirectoryCapability = "service_directory"
ParamNIKEScheme = "nikeScheme"
ParamPublicKey = "publicKey"
)
const (
StatusOK uint8 = iota
StatusInvalidRequest
StatusBadMAC
StatusUnknownService
StatusStale
StatusBusy
StatusInternalError
)
type ServiceID [32]byte
type RouterRef struct {
NodeIDHash [32]byte
Endpoint string
}
type ServiceDescriptor struct {
NIKEScheme string
PublicKey []byte
Routers []RouterRef
}
// To a voucher router; exactly one field is set.
type RouterRequest struct {
Route *RouteRequest `cbor:",omitempty"`
Register *RegisterRequest `cbor:",omitempty"`
}
type RouteRequest struct {
ServiceID ServiceID
Ciphertext []byte // the voucher, sealed to the service
}
type RegisterRequest struct {
NIKEScheme string
PublicKey []byte
Voucher []byte
MAC []byte
}
type RouterReply struct {
Status uint8
}
// One box on the router's stream of a link.
type LinkMessage struct {
Forward []byte `cbor:",omitempty"` // a client's sealed voucher
}
// To a service directory; exactly one field is set.
type DirectoryRequest struct {
Advertise *AdvertiseRequest `cbor:",omitempty"`
Withdraw *WithdrawRequest `cbor:",omitempty"`
Lookup *LookupRequest `cbor:",omitempty"`
List *ListRequest `cbor:",omitempty"`
}
type AdvertiseRequest struct {
Descriptor ServiceDescriptor
Seq uint64
MAC []byte
}
type WithdrawRequest struct {
ServiceID ServiceID
Seq uint64
MAC []byte
}
type LookupRequest struct {
ServiceID ServiceID
}
type ListRequest struct{}
type DirectoryReply struct {
Status uint8
Descriptor *ServiceDescriptor `cbor:",omitempty"` // for Lookup
ReadCap []byte `cbor:",omitempty"` // for List: the snapshot stream
StartIndex []byte `cbor:",omitempty"`
}
// A snapshot stream holds a 4-byte big-endian length, then this.
type Snapshot struct {
Seq uint64
Descriptors []ServiceDescriptor
}
// The voucher reply, in the shape of katzenqt's ReplyWho with one member.
type whoReply struct {
PleaseAdds []pleaseAdd `cbor:"please_adds"`
}
type pleaseAdd struct {
DisplayName string `cbor:"display_name"`
ReadCap []byte `cbor:"read_cap"`
}pub const ROUTER_CAPABILITY: &str = "voucher_router";
pub const DIRECTORY_CAPABILITY: &str = "service_directory";
pub const PARAM_NIKE_SCHEME: &str = "nikeScheme";
pub const PARAM_PUBLIC_KEY: &str = "publicKey";
#[repr(u8)]
pub enum Status { Ok = 0, InvalidRequest, BadMac, UnknownService, Stale, Busy, InternalError }
pub struct ServiceId(pub [u8; 32]);
pub struct RouterRef {
pub node_id_hash: [u8; 32],
pub endpoint: String,
}
pub struct ServiceDescriptor {
pub nike_scheme: String,
pub public_key: Vec<u8>,
pub routers: Vec<RouterRef>,
}
pub enum RouterRequest {
Route(RouteRequest),
Register(RegisterRequest),
}
pub struct RouteRequest {
pub service_id: ServiceId,
pub ciphertext: Vec<u8>, // the voucher, sealed to the service
}
pub struct RegisterRequest {
pub nike_scheme: String,
pub public_key: Vec<u8>,
pub voucher: Vec<u8>,
pub mac: Option<Vec<u8>>,
}
pub enum DirectoryRequest {
Advertise(AdvertiseRequest),
Withdraw(WithdrawRequest),
Lookup(ServiceId),
List,
}
pub struct AdvertiseRequest {
pub descriptor: ServiceDescriptor,
pub seq: u64,
pub mac: Option<Vec<u8>>,
}
pub struct WithdrawRequest {
pub service_id: ServiceId,
pub seq: u64,
pub mac: Option<Vec<u8>>,
}
pub struct DirectoryReply {
pub status: u8,
pub descriptor: Option<ServiceDescriptor>, // for Lookup
pub read_cap: Vec<u8>, // for List: the snapshot stream
pub start_index: Vec<u8>,
}
pub struct Snapshot {
pub seq: u64,
pub descriptors: Vec<ServiceDescriptor>,
}
// The router reply, the link message and the voucher reply have no type of
// their own; these encode and decode them.
pub fn encode_router_reply(status: Status) -> Vec<u8>;
pub fn decode_status(raw: &[u8]) -> Result<u8, Error>;
pub fn encode_link_message(forward: &[u8]) -> Vec<u8>;
pub fn decode_link_message(raw: &[u8]) -> Result<Vec<u8>, Error>;
pub(crate) fn encode_who_reply(display_name: &str, read_cap: &[u8]) -> Vec<u8>;Interfaces
The mixnet
A service reaches the mixnet through one interface, which the thin client implements and tests replace with an in-memory network.
type Pigeonhole interface {
NewStream() (*bacap.WriteCap, *bacap.ReadCap, *bacap.MessageBoxIndex, error)
Write(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex, plaintext []byte) (*bacap.MessageBoxIndex, error)
Read(ctx context.Context, readCap *bacap.ReadCap, idx *bacap.MessageBoxIndex) ([]byte, *bacap.MessageBoxIndex, error)
Tombstone(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex) error
WritePayload(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex, payload []byte) (*bacap.MessageBoxIndex, error)
MaxPlaintext() int
VoucherMint(messageWriteCap []byte, displayName string) (*MintResult, error)
VoucherInduct(voucher, payload, whoReply []byte) (*InductResult, error)
VoucherOpen(secretKey, sealedReply, messageWriteCap []byte) (*OpenResult, error)
VoucherDeriveStream(voucher []byte) (writeCap, readCap []byte, err error)
BoxSeal(nikeScheme string, publicKey, plaintext []byte) ([]byte, error)
Request(ctx context.Context, node *[32]byte, endpoint []byte, payload []byte) ([]byte, error)
Services(capability string) ([]ServiceNode, error)
}
func NewThinPigeonhole(c *thin.ThinClient) Pigeonholepub trait Backend: Send + Sync + 'static {
fn new_keypair(&self) -> impl Future<Output = Result<Keypair>> + Send;
fn write_box(&self, write_cap: &[u8], index: &[u8], plaintext: &[u8]) -> impl Future<Output = Result<()>> + Send;
fn read_box(&self, read_cap: &[u8], index: &[u8]) -> impl Future<Output = Result<Read>> + Send;
fn replica_epoch(&self) -> impl Future<Output = Result<u64>> + Send;
fn max_plaintext(&self) -> impl Future<Output = Result<usize>> + Send;
fn voucher_mint(&self, write_cap: &[u8], name: &str) -> impl Future<Output = Result<Minted>> + Send;
fn voucher_derive_stream(&self, voucher: &[u8]) -> impl Future<Output = Result<(Secret, Secret)>> + Send;
fn voucher_induct(&self, voucher: &[u8], payload: &[u8], who_reply: &[u8]) -> impl Future<Output = Result<Inducted>> + Send;
fn voucher_open(&self, secret_key: &[u8], sealed_reply: &[u8], write_cap: &[u8]) -> impl Future<Output = Result<Opened>> + Send;
fn request(&self, node: &[u8; 32], endpoint: &[u8], payload: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
fn services(&self, capability: &str) -> impl Future<Output = Result<Vec<ServiceNode>>> + Send;
fn nike_generate_key_pair(&self, scheme: &str) -> impl Future<Output = Result<(Vec<u8>, Secret)>> + Send;
fn nike_derive_secret(&self, scheme: &str, private: &[u8], public: &[u8]) -> impl Future<Output = Result<Secret>> + Send;
fn box_seal(&self, scheme: &str, public: &[u8], plaintext: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
fn box_open(&self, scheme: &str, private: &[u8], ciphertext: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
}
// `daemon` is `@name` for an abstract unix socket, a socket path, or `host:port`.
impl ThinClientBackend {
pub async fn dial(daemon: &str) -> Result<ThinClientBackend, Box<dyn std::error::Error>>;
}Writing a service
A service runs a handler for each client’s session. A session is a connection, and ends when its handler returns. Closing the service keeps its sessions on disk for the next start.
func LoadServiceKey(dataDir string) (*ServiceKey, error)
func NewService(cfg Config, ph Pigeonhole) (*Service, error)
func (s *Service) ID() ServiceID
func (s *Service) Descriptor() *ServiceDescriptor
func (s *Service) Run(ctx context.Context, h Handler) error
func (s *Service) Close() error
type Handler interface {
OnSession(ctx context.Context, s *Session)
}
type Session struct {
*Conn
// ...
}
func (c *Conn) Send(ctx context.Context, msg []byte) error
func (c *Conn) Recv(ctx context.Context) ([]byte, error)impl<B: Backend> Service<B> {
pub fn new(cfg: Config, ph: Arc<B>) -> Result<Self, Error>;
pub fn id(&self) -> ServiceId;
pub fn descriptor(&self) -> ServiceDescriptor;
pub async fn run<F, Fut>(&self, handler: F) -> Result<(), Error>
where
F: Fn(Session<B>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static;
pub async fn offer(&self) -> Result<Vec<u8>, Error>;
pub async fn close(&self);
}
// A Session derefs to its Conn.
impl<B: Backend> Conn<B> {
pub async fn send(&self, msg: &[u8]) -> Result<(), Error>;
pub async fn recv(&self) -> Result<Vec<u8>, Error>;
}Reaching a service
type Discoverer interface {
Lookup(ctx context.Context, id ServiceID) (*ServiceDescriptor, error)
List(ctx context.Context) ([]ServiceDescriptor, error)
}
type Advertiser interface {
Advertise(ctx context.Context, d *ServiceDescriptor) error
Withdraw(ctx context.Context, id ServiceID) error
}
// Package directory: both, over every directory in the PKI.
func NewAll(ph ps.Pigeonhole, key *ps.ServiceKey, pollInterval time.Duration) (*All, error)
// Mints a voucher, routes it to the service and waits for its reply.
func Dial(ctx context.Context, ph Pigeonhole, d *ServiceDescriptor, displayName string, pollInterval time.Duration) (*Conn, error)
// Routes a voucher someone else minted.
func Route(ctx context.Context, ph Pigeonhole, d *ServiceDescriptor, voucher []byte) error
// Uses a voucher a service offered.
func Induct(ctx context.Context, ph Pigeonhole, voucher []byte, displayName string, pollInterval time.Duration) (*Conn, error)// One per directory in the PKI.
impl<B: Backend> Directory<B> {
pub async fn all(ph: Arc<B>, key: Option<Keypair>, poll: Duration) -> Result<Vec<Self>, Error>;
pub async fn lookup(&self, id: ServiceId) -> Result<ServiceDescriptor, Error>;
pub async fn list(&self) -> Result<Vec<ServiceDescriptor>, Error>;
pub async fn advertise(&self, d: &ServiceDescriptor) -> Result<(), Error>;
pub async fn withdraw(&self, id: ServiceId) -> Result<(), Error>;
}
/// Mints a voucher, routes it to the service and waits for its reply.
pub async fn dial<B: Backend>(ph: Arc<B>, d: &ServiceDescriptor, display_name: &str, poll: Duration) -> Result<Conn<B>, Error>;
/// Routes a voucher someone else minted.
pub async fn route<B: Backend>(ph: &B, d: &ServiceDescriptor, voucher: &[u8]) -> Result<(), Error>;
/// Uses a voucher a service offered.
pub async fn induct<B: Backend>(ph: Arc<B>, voucher: &[u8], display_name: &str, poll: Duration) -> Result<Conn<B>, Error>;Programs
| Program | Where | What it does |
|---|---|---|
voucher-router |
katzenpost cmd/voucher-router |
the voucher router plugin |
service-directory |
katzenpost cmd/service-directory |
the service directory plugin |
pigeonhole-voucher |
katzenpost cmd/pigeonhole-voucher |
list the services; send a service a voucher |
pigeonhole-echo |
katzenpost cmd/pigeonhole-echo |
a service that sends back each message |
eliza |
pigeonhole_protocols src/bin/eliza.rs |
ELIZA, in katzenqt’s chat framing |
motd-service |
pigeonhole_protocols cmd/motd-service |
a Go service that answers each request with the message of the day, read from a file |
motd |
pigeonhole_protocols cmd/motd |
fetches the message of the day from a motd-service |
Talking to ELIZA
ELIZA can be dialed from any of our chat clients:
katzenqt,
jam,
frank_chat or
hushphone. Generate a voucher in
the client, find ELIZA’s service identity among the services
pigeonhole-voucher list shows, and send ELIZA the voucher token:
pigeonhole-voucher --thin_config thinclient.toml list
pigeonhole-voucher --thin_config thinclient.toml send SERVICE_IDENTITY VOUCHER_TOKEN
ELIZA inducts you into a new chat, opens it with a greeting, and answers each message you send.
Running the router and the directory
The voucher router and the service directory run on a service node: a
special kind of mix node in a Katzenpost mix network that delivers Sphinx
packet payloads to services and sends their answers back in SURB replies.
The node runs each service as a plugin, beside others such as the courier.
An operator adds the two plugins to the [ServiceNode] section of the
node’s configuration:
[[ServiceNode.CBORPluginKaetzchen]]
Capability = "voucher_router"
Endpoint = "voucher_router"
Command = "/usr/local/bin/voucher-router"
MaxConcurrency = 1
[ServiceNode.CBORPluginKaetzchen.Config]
data_dir = "/var/lib/katzenpost-servicenode/voucher_router"
log_dir = "/var/log/katzenpost/voucher_router"
thin_config = "/etc/katzenpost-servicenode/pigeonservice-thinclient.toml"
[[ServiceNode.CBORPluginKaetzchen]]
Capability = "service_directory"
Endpoint = "service_directory"
Command = "/usr/local/bin/service-directory"
MaxConcurrency = 1
[ServiceNode.CBORPluginKaetzchen.Config]
data_dir = "/var/lib/katzenpost-servicenode/service_directory"
log_dir = "/var/log/katzenpost/service_directory"
thin_config = "/etc/katzenpost-servicenode/pigeonservice-thinclient.toml"
Capability is the name the plugin is listed under in the PKI document, and
Endpoint the name a client addresses its requests to. Each plugin has its
own data directory, for its key and database, and its own log directory.
Because the router and the directory also read and write Pigeonhole streams,
each reaches the mixnet through a client daemon, kpclientd, named by its
thin client configuration. On first start a plugin generates its NIKE
keypair, and the node publishes the public key in the PKI document.