Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Added

- **The Python reference server for the local API.**
`offline_protocol_sdk.local_api` and the `offline-protocol-service` command
front one engine for any number of local applications over JSON-RPC 2.0 on
a WebSocket, on an owner-only Unix domain socket by default or on loopback
TCP with a per-launch token. The server owns the run loop and the drain;
a client declares its application id once in `hello`, every send is
stamped with it, and every event is relayed as the engine serialised it to
the clients the chapter's rules select (by application id, by an
identifier the server issued, or to everyone), with the stamped inbound
events held for an application whose client is away. The method table is
generated from the interface definition and checked in
(`local_api/table.py`), every declaration is classified as exposed or
platform-only in `dispatch.py`, and a Rust guard in the FFI crate holds
the chapter, the definition and that classification to one another, so
an unclassified method is a failing test rather than a method every local
application can reach. Optional rules from one JSON file: a space
allow-list and method denials per application id, which once configured
refuse a `hello` under an id no rule names. A test over two servers on
one host exchanges a message over the peer-stream transport.
- **The local API chapter.** `docs/spec/local-api.md` specifies how one
server process fronts one engine for several local applications: JSON-RPC
2.0 over a WebSocket on a Unix domain socket by default (TCP on loopback
Expand Down
41 changes: 41 additions & 0 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,47 @@ sealed protocol-state record. Install a real secret service (gnome-keyring,
kwallet) for any deployment where that matters, supply your own
`MlsStorageProvider`, or use the built-in file stores below.

### Run as a service: the local API

One process can own the engine and serve several local applications at once,
over JSON-RPC 2.0 on a WebSocket. The contract is
[the local API chapter](../../docs/spec/local-api.md); the reference server
ships in this package as `offline_protocol_sdk.local_api` and as the
`offline-protocol-service` command:

```bash
export OFFLINE_PROTOCOL_STORE_KEY="$(openssl rand -hex 32)" # once; keep it
offline-protocol-service --config config.json \
--mls-root /var/lib/example/keys --state-root /var/lib/example/state \
--socket /run/example/api.sock --listen 0.0.0.0:7878 --peer 10.0.0.2:7878
```

`config.json` holds the `ProtocolConfig` fields by name; the socket is
created owner-only. A client opens the socket, sends `hello` with its
application id, and calls the engine's own methods by name:

```python
import asyncio, json
from websockets.asyncio.client import unix_connect

async def main():
async with unix_connect("/run/example/api.sock", uri="ws://localhost/") as ws:
await ws.send(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "hello",
"params": {"app_id": "notes"}}))
print(json.loads(await ws.recv())["result"]["local_address"])

asyncio.run(main())
```

Every message a `notes` client sends is stamped with that id, and a
`message_received` for `notes` reaches only `notes` clients; what the server
holds for an application whose client is away, and what it never puts on the
wire, is the chapter's. `--policy policy.json` adds the optional rules
(`spaces`, `denied`); `--tcp PORT --token-file PATH` serves loopback TCP with
a per-launch token instead of the socket. See
[the bridge contract](../../docs/bridges/local-api.md) for what the server
owes.

### Headless hosts: the built-in file stores

A server or container usually has no secret service at all. Pass a store key
Expand Down
34 changes: 34 additions & 0 deletions bindings/python/offline_protocol_sdk/local_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""The reference server for the local API (``docs/spec/local-api.md``).

One process owns one engine and serves any number of local applications over
JSON-RPC 2.0 on a WebSocket, on a Unix domain socket by default. See
:class:`LocalApiServer`, and ``offline-protocol-service`` for the shell entry
point.
"""

from .authz import METHOD_GROUPS, Policy, ServiceOwnership # noqa: F401
from .codec import RpcError # noqa: F401
from .dispatch import BEFORE_HELLO, EXPOSED, ID_RESULTS, PLATFORM, SESSION_METHODS # noqa: F401
from .mux import HELD_TAGS, HOLD_CAPACITY, EventRouter # noqa: F401
from .server import API_VERSION, MAX_MESSAGE_SIZE, SERVER_NAME, LocalApiServer # noqa: F401
from .session import Session # noqa: F401

__all__ = [
"API_VERSION",
"BEFORE_HELLO",
"EXPOSED",
"EventRouter",
"HELD_TAGS",
"HOLD_CAPACITY",
"ID_RESULTS",
"LocalApiServer",
"MAX_MESSAGE_SIZE",
"METHOD_GROUPS",
"PLATFORM",
"Policy",
"RpcError",
"SERVER_NAME",
"SESSION_METHODS",
"ServiceOwnership",
"Session",
]
180 changes: 180 additions & 0 deletions bindings/python/offline_protocol_sdk/local_api/authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""The three server-side rules that separate the applications behind one
identity: service ownership, space scoping and method groups.

Each is applied before a call reaches the engine, each refuses with the
engine's ``PermissionDenied``, and none has any representation on the mesh
(chapter, "Server-side rules"; bridge rule L3). The engine sees one identity
registering services and syncing spaces, exactly as it would from one
application.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from fnmatch import fnmatchcase
from typing import Any

from .codec import taxonomy_error

#: The named method groups an operator may deny to an application id. A
#: policy entry that is not a group name is a single method's wire name.
METHOD_GROUPS: dict[str, frozenset[str]] = {
"sign_data": frozenset({"sign_data"}),
"manual_mls": frozenset(
{
"mls_generate_key_package",
"mls_get_or_create_key_package",
"mls_import_key_package",
"mls_get_pending_key_packages",
"mls_mark_key_package_synced",
"mls_create_session",
"mls_join_session",
"mls_encrypt_for_user",
"mls_decrypt_from_user",
"mls_get_pending_welcome",
"mls_clear_pending_welcome",
"mls_decrypt",
"mls_process_welcome",
}
),
"tuning": frozenset(
{
"set_relay_priority",
"update_relay_config",
"force_transport",
"release_transport_lock",
"update_dors_config",
"update_ack_config",
"update_retry_config",
"update_dedup_config",
}
),
}


@dataclass
class Policy:
"""The operator's configuration of the optional rules.

``spaces`` maps an application id to the glob patterns over space ids it
may open; an application with no entry may open every space. ``denied``
maps an application id to method group names and single method names it
may not call; nothing is denied by default. ``applications`` lists ids
that have no rule of their own but are still admitted once a rule exists;
on its own it configures nothing.

An application id is self-declared per connection, so a rule keyed by
id separates applications from each other's mistakes, never from a
hostile local process, which is inside the socket boundary already.
What keeps a rule from being stepped around by a reconnect under
another name is the chapter's unlisted-id rule: once a space allow-list
or a method deny is configured, ``hello`` with an id no entry names is
refused. With neither configured, every id is admitted. Service
ownership is a runtime shadow, never a configured rule, and never counts.
"""

spaces: dict[str, list[str]] = field(default_factory=dict)
denied: dict[str, list[str]] = field(default_factory=dict)
applications: list[str] = field(default_factory=list)

@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "Policy":
raw = raw or {}
unknown = sorted(set(raw) - {"spaces", "denied", "applications"})
if unknown:
raise ValueError(f"policy has no section {unknown[0]!r}")
spaces = {str(k): [str(p) for p in v] for k, v in (raw.get("spaces") or {}).items()}
denied = {str(k): [str(m) for m in v] for k, v in (raw.get("denied") or {}).items()}
applications = [str(a) for a in (raw.get("applications") or [])]
# Imported here: dispatch imports this module. A deny that names
# nothing on the wire would otherwise deny nothing and say nothing,
# and an operator who misspelled `sign_data` would believe the
# signing oracle denied while every application still held it.
from .dispatch import EXPOSED

for app_id, names in denied.items():
for name in names:
if name not in METHOD_GROUPS and name not in EXPOSED:
raise ValueError(
f"policy denies {name!r} for {app_id!r}: not a method group "
f"({', '.join(sorted(METHOD_GROUPS))}) or an exposed method"
)
return cls(spaces=spaces, denied=denied, applications=applications)

def restricts(self) -> bool:
"""Whether a space allow-list or a method deny is configured."""
return bool(self.spaces or self.denied)

def admits(self, app_id: str) -> bool:
"""Whether ``hello`` may declare ``app_id``."""
if not self.restricts():
return True
return app_id in self.spaces or app_id in self.denied or app_id in self.applications

def check_admission(self, app_id: str) -> None:
if not self.admits(app_id):
raise taxonomy_error(
"PermissionDenied",
f"no rule names application {app_id!r}; rules are configured, so unnamed ids are refused",
)

def allows_space(self, app_id: str, space_id: str) -> bool:
patterns = self.spaces.get(app_id)
if patterns is None:
return True
return any(fnmatchcase(space_id, pattern) for pattern in patterns)

def denies(self, app_id: str, method: str) -> bool:
for name in self.denied.get(app_id, ()):
if name == method or method in METHOD_GROUPS.get(name, ()):
return True
return False

def check_space(self, app_id: str, space_id: str) -> None:
if not self.allows_space(app_id, space_id):
raise taxonomy_error(
"PermissionDenied",
f"space {space_id!r} is outside what {app_id!r} may open",
)

def check_method(self, app_id: str, method: str) -> None:
if self.denies(app_id, method):
raise taxonomy_error("PermissionDenied", f"{method} is denied to {app_id!r}")


class ServiceOwnership:
"""A shadow of the engine's service registry, keyed by application id.

The engine has no owner on a registration and no way to enumerate them,
so this is rebuilt from the clients' calls and is empty after a restart
until they register again. An id another application already holds is
refused, which is the collision the registry exists to catch: without it
the second registration silently replaces the first in the engine and
the first application's requests start arriving at the second.
"""

def __init__(self) -> None:
self._owner: dict[str, str] = {}

def owner(self, service_id: str) -> str | None:
return self._owner.get(service_id)

def claim(self, service_id: str, app_id: str) -> None:
holder = self._owner.get(service_id)
if holder is not None and holder != app_id:
raise taxonomy_error(
"PermissionDenied",
f"service {service_id!r} is registered by another application",
)
self._owner[service_id] = app_id

def release(self, service_id: str) -> None:
self._owner.pop(service_id, None)

def check(self, service_id: str, app_id: str) -> None:
holder = self._owner.get(service_id)
if holder is not None and holder != app_id:
raise taxonomy_error(
"PermissionDenied",
f"service {service_id!r} belongs to another application",
)
Loading
Loading