sp-rtk-portal-protocol 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sp_rtk_portal_protocol-0.9.0/PKG-INFO +90 -0
- sp_rtk_portal_protocol-0.9.0/README.md +62 -0
- sp_rtk_portal_protocol-0.9.0/pyproject.toml +40 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/__init__.py +125 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/doorbell.py +233 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/messages.py +321 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/proof.py +259 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/py.typed +0 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/tunnel.py +320 -0
- sp_rtk_portal_protocol-0.9.0/src/sp_rtk_portal_protocol/version.py +22 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sp-rtk-portal-protocol
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Agent-to-Portal protocol shared by sp-rtk-portal and sp-rtk-base's Portal agent
|
|
5
|
+
Keywords: rtk,gnss,base-station,dpop,jws,ed25519,tunnel
|
|
6
|
+
Author: John Roden
|
|
7
|
+
Author-email: John Roden <rodenj@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: Homepage, https://github.com/rodenj1/sp-rtk-portal/tree/main/protocol
|
|
24
|
+
Project-URL: Repository, https://github.com/rodenj1/sp-rtk-portal
|
|
25
|
+
Project-URL: Issues, https://github.com/rodenj1/sp-rtk-portal/issues
|
|
26
|
+
Project-URL: Used by, https://github.com/rodenj1/sp-rtk-base
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# sp-rtk-portal-protocol
|
|
30
|
+
|
|
31
|
+
The agent-to-Portal protocol, published from the `sp-rtk-portal` repo so the Portal and sp-rtk-base's Portal agent share one definition: message shapes, request signing and verification, tunnel framing, and the protocol version.
|
|
32
|
+
|
|
33
|
+
## What's in it
|
|
34
|
+
|
|
35
|
+
- `PROTOCOL_VERSION` (an integer, starting at 1, independent of either side's release version) and `VERSION_HEADER` (`SP-RTK-Protocol-Version`), sent on every agent request. Additive changes don't bump it: both sides ignore fields they don't know.
|
|
36
|
+
- **Request proofs** (`proof.py`). The agent's Ed25519 **base key** (`BaseKey`) signs every request with a DPoP-style JWS (RFC 9449), sent as `Authorization: Signature <proof>`: header `{"typ": "dpop+jwt", "alg": "EdDSA", "jwk": <public key>}`, claims `htm`, `htu` (URL without query), `iat` (seconds), `jti`, `bh` (base64url SHA-256 of the body, when there is one) and optional `nonce`. `verify_proof` checks the signature, the request it was made for, the body and that `iat` is within 60 s; `ReplayCache` refuses a `jti` seen before. The Portal identifies a base by the key's RFC 7638 thumbprint and stores only the public key.
|
|
37
|
+
- **Message shapes** (`messages.py`): frozen dataclasses with `to_json()` / `from_json()`. `from_json` ignores unknown fields and raises `MessageError` for a missing or mistyped one.
|
|
38
|
+
- Claim (RFC 8628): `POST /agent/claims` with `ClaimStart` → `ClaimStarted`; `POST /agent/claims/token` with `ClaimToken` → `Claimed`, or HTTP 400 `ErrorReply` (`authorization_pending`, `slow_down`, `expired_token`, `access_denied`, `invalid_grant`). Both requests are signed with the new key, so only the key that started a Claim can collect it.
|
|
39
|
+
- Polls and Check-ins (a Claimed base, signed with its key): `POST /agent/poll` with `Poll(state)` → `PollReply(config, metered, notices)`; `POST /agent/check-in` with `CheckIn(software_version, doorbell, lan_addresses, paused)` (`paused` from 0.9; `None` from older agents) → `CheckInReply(config, metered, notices)`. `config` is the revisioned `AgentConfig` block (poll intervals, Check-in interval, Doorbell host and port, tunnel URL, tunnel timeouts; the agent applies it only on a new revision). An unknown key gets 401 `ErrorReply("unclaimed")`.
|
|
40
|
+
- Release (a local Un-claim, signed with the base key): `POST /agent/release` with `Release()` → `Released(base_id)`, or 401 `unclaimed` if the Portal already forgot the key. Best effort: the agent wipes its key either way.
|
|
41
|
+
- An agent below the Portal's minimum protocol version gets HTTP 426 `UpgradeRequired(min_version, message)` on any request.
|
|
42
|
+
- Messages may nest messages, lists of them and optional (`X | None`) fields.
|
|
43
|
+
- A refused proof is HTTP 401 `ErrorReply("invalid_proof")`.
|
|
44
|
+
- A Wake: `PollReply.wake` carries a one-time nonce (and nothing else).
|
|
45
|
+
- `PollReply.sessions`: the base's live Remote sessions (`LiveSession(who, since, shared)`), for its local Portal page. `Poll.state` is `paused` while remote access is paused on site; a paused agent ignores Wakes.
|
|
46
|
+
- **Doorbell** (`doorbell.py`). UDP packets between a base and a Doorbell server: a keepalive signed by the base key (`keepalive`, `read_keepalive`, `verify_keepalive`; 109 bytes) and a ring carrying a Wake's nonce, authenticated with the base's ring key (`ring`, `open_ring`, `new_ring_key`); both refused more than 60 s from now. `DOORBELL_INTERVAL` (20 s) and `DOORBELL_JITTER` (5 s). `CheckInReply.doorbell_heard` and `.ring_key` tell the agent the Portal hears its keepalives and give it the ring key. The Doorbell server's HTTPS interface on the Portal: `KEEPALIVES_PATH` (`KeepalivesReport` → `KeepalivesReply`) and `RINGS_PATH` (→ `Rings`); see the Portal README.
|
|
47
|
+
- **Tunnel** (`tunnel.py`). The agent opens `wss://<Portal host>/agent/tunnel` with a GET signed for `https://<Portal host>/agent/tunnel` (`signed_url`) whose proof's `nonce` claim echoes the Wake. Each binary websocket message is one frame: a type byte, a big-endian 4-byte stream id, then the payload. Types: `NEW` (1, no payload ever: a stream never names a destination), `DATA` (2, 1..64 KiB), `CLOSE` (3), `PING`/`PONG` (4/5, stream 0), `PAUSE`/`RESUME` (6/7, flow control). An unknown type, a payload where none belongs, or a `NEW` from the agent raises `FrameError`, and the receiver closes the whole tunnel. `Tunnel` is one end of it (independent of the websocket library: give it a `send` coroutine and `feed` it each message); only the Portal end opens streams (`open_stream()` -> `Stream` with `read`/`write`/`close`).
|
|
48
|
+
|
|
49
|
+
The package version (`pyproject.toml`) is bumped on every change, so a pinned consumer can tell which shapes it has.
|
|
50
|
+
|
|
51
|
+
It is published to PyPI, and sp-rtk-base depends on it from there:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[project]
|
|
55
|
+
dependencies = ["sp-rtk-portal-protocol>=0.9.0,<1"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Raise the lower bound when the agent needs newer shapes. The Portal uses this directory directly, as a uv workspace member.
|
|
59
|
+
|
|
60
|
+
For local development of both sides at once, point sp-rtk-base at a checkout next to it with `[tool.uv.sources] sp-rtk-portal-protocol = { path = "../sp-rtk-portal/protocol", editable = true }`, and don't commit that.
|
|
61
|
+
|
|
62
|
+
## Releasing
|
|
63
|
+
|
|
64
|
+
`.github/workflows/protocol-release.yml` publishes this package to PyPI when a `protocol-v<version>` tag is pushed. It checks the tag against `version` in this `pyproject.toml`, runs these tests on Python 3.10 to 3.13, builds with `uv build`, runs `twine check`, then uploads with PyPI Trusted Publishing (OIDC, no token). Portal image tags (`v*`) and protocol tags don't trigger each other's workflow.
|
|
65
|
+
|
|
66
|
+
Once, before the first release:
|
|
67
|
+
|
|
68
|
+
1. On pypi.org, under Your account > Publishing > Add a new pending publisher > GitHub, enter:
|
|
69
|
+
- PyPI project name: `sp-rtk-portal-protocol`
|
|
70
|
+
- Owner: `rodenj1`
|
|
71
|
+
- Repository name: `sp-rtk-portal`
|
|
72
|
+
- Workflow name: `protocol-release.yml`
|
|
73
|
+
- Environment name: `pypi`
|
|
74
|
+
2. On GitHub, in the sp-rtk-portal repo's Settings > Environments, create an environment named `pypi` (optionally with required reviewers, to approve each upload).
|
|
75
|
+
|
|
76
|
+
GitHub offers environments in a private repo only on a paid plan (Pro, Team or Enterprise). If Settings has no Environments page, leave the environment name blank in step 1, and delete the `environment:` block from the workflow's `publish-pypi` job.
|
|
77
|
+
|
|
78
|
+
Each release:
|
|
79
|
+
|
|
80
|
+
1. Bump `version` in this `pyproject.toml` on `main` (PyPI never accepts the same version twice).
|
|
81
|
+
2. Tag that commit and push the tag:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
git tag protocol-v0.9.0
|
|
85
|
+
git push origin protocol-v0.9.0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
3. Once it's on PyPI, raise sp-rtk-base's lower bound if the agent needs the new version, then run `uv lock` there.
|
|
89
|
+
|
|
90
|
+
To rerun a failed release for an existing tag, run the workflow by hand (Actions > protocol-release > Run workflow) with the tag.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# sp-rtk-portal-protocol
|
|
2
|
+
|
|
3
|
+
The agent-to-Portal protocol, published from the `sp-rtk-portal` repo so the Portal and sp-rtk-base's Portal agent share one definition: message shapes, request signing and verification, tunnel framing, and the protocol version.
|
|
4
|
+
|
|
5
|
+
## What's in it
|
|
6
|
+
|
|
7
|
+
- `PROTOCOL_VERSION` (an integer, starting at 1, independent of either side's release version) and `VERSION_HEADER` (`SP-RTK-Protocol-Version`), sent on every agent request. Additive changes don't bump it: both sides ignore fields they don't know.
|
|
8
|
+
- **Request proofs** (`proof.py`). The agent's Ed25519 **base key** (`BaseKey`) signs every request with a DPoP-style JWS (RFC 9449), sent as `Authorization: Signature <proof>`: header `{"typ": "dpop+jwt", "alg": "EdDSA", "jwk": <public key>}`, claims `htm`, `htu` (URL without query), `iat` (seconds), `jti`, `bh` (base64url SHA-256 of the body, when there is one) and optional `nonce`. `verify_proof` checks the signature, the request it was made for, the body and that `iat` is within 60 s; `ReplayCache` refuses a `jti` seen before. The Portal identifies a base by the key's RFC 7638 thumbprint and stores only the public key.
|
|
9
|
+
- **Message shapes** (`messages.py`): frozen dataclasses with `to_json()` / `from_json()`. `from_json` ignores unknown fields and raises `MessageError` for a missing or mistyped one.
|
|
10
|
+
- Claim (RFC 8628): `POST /agent/claims` with `ClaimStart` → `ClaimStarted`; `POST /agent/claims/token` with `ClaimToken` → `Claimed`, or HTTP 400 `ErrorReply` (`authorization_pending`, `slow_down`, `expired_token`, `access_denied`, `invalid_grant`). Both requests are signed with the new key, so only the key that started a Claim can collect it.
|
|
11
|
+
- Polls and Check-ins (a Claimed base, signed with its key): `POST /agent/poll` with `Poll(state)` → `PollReply(config, metered, notices)`; `POST /agent/check-in` with `CheckIn(software_version, doorbell, lan_addresses, paused)` (`paused` from 0.9; `None` from older agents) → `CheckInReply(config, metered, notices)`. `config` is the revisioned `AgentConfig` block (poll intervals, Check-in interval, Doorbell host and port, tunnel URL, tunnel timeouts; the agent applies it only on a new revision). An unknown key gets 401 `ErrorReply("unclaimed")`.
|
|
12
|
+
- Release (a local Un-claim, signed with the base key): `POST /agent/release` with `Release()` → `Released(base_id)`, or 401 `unclaimed` if the Portal already forgot the key. Best effort: the agent wipes its key either way.
|
|
13
|
+
- An agent below the Portal's minimum protocol version gets HTTP 426 `UpgradeRequired(min_version, message)` on any request.
|
|
14
|
+
- Messages may nest messages, lists of them and optional (`X | None`) fields.
|
|
15
|
+
- A refused proof is HTTP 401 `ErrorReply("invalid_proof")`.
|
|
16
|
+
- A Wake: `PollReply.wake` carries a one-time nonce (and nothing else).
|
|
17
|
+
- `PollReply.sessions`: the base's live Remote sessions (`LiveSession(who, since, shared)`), for its local Portal page. `Poll.state` is `paused` while remote access is paused on site; a paused agent ignores Wakes.
|
|
18
|
+
- **Doorbell** (`doorbell.py`). UDP packets between a base and a Doorbell server: a keepalive signed by the base key (`keepalive`, `read_keepalive`, `verify_keepalive`; 109 bytes) and a ring carrying a Wake's nonce, authenticated with the base's ring key (`ring`, `open_ring`, `new_ring_key`); both refused more than 60 s from now. `DOORBELL_INTERVAL` (20 s) and `DOORBELL_JITTER` (5 s). `CheckInReply.doorbell_heard` and `.ring_key` tell the agent the Portal hears its keepalives and give it the ring key. The Doorbell server's HTTPS interface on the Portal: `KEEPALIVES_PATH` (`KeepalivesReport` → `KeepalivesReply`) and `RINGS_PATH` (→ `Rings`); see the Portal README.
|
|
19
|
+
- **Tunnel** (`tunnel.py`). The agent opens `wss://<Portal host>/agent/tunnel` with a GET signed for `https://<Portal host>/agent/tunnel` (`signed_url`) whose proof's `nonce` claim echoes the Wake. Each binary websocket message is one frame: a type byte, a big-endian 4-byte stream id, then the payload. Types: `NEW` (1, no payload ever: a stream never names a destination), `DATA` (2, 1..64 KiB), `CLOSE` (3), `PING`/`PONG` (4/5, stream 0), `PAUSE`/`RESUME` (6/7, flow control). An unknown type, a payload where none belongs, or a `NEW` from the agent raises `FrameError`, and the receiver closes the whole tunnel. `Tunnel` is one end of it (independent of the websocket library: give it a `send` coroutine and `feed` it each message); only the Portal end opens streams (`open_stream()` -> `Stream` with `read`/`write`/`close`).
|
|
20
|
+
|
|
21
|
+
The package version (`pyproject.toml`) is bumped on every change, so a pinned consumer can tell which shapes it has.
|
|
22
|
+
|
|
23
|
+
It is published to PyPI, and sp-rtk-base depends on it from there:
|
|
24
|
+
|
|
25
|
+
```toml
|
|
26
|
+
[project]
|
|
27
|
+
dependencies = ["sp-rtk-portal-protocol>=0.9.0,<1"]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Raise the lower bound when the agent needs newer shapes. The Portal uses this directory directly, as a uv workspace member.
|
|
31
|
+
|
|
32
|
+
For local development of both sides at once, point sp-rtk-base at a checkout next to it with `[tool.uv.sources] sp-rtk-portal-protocol = { path = "../sp-rtk-portal/protocol", editable = true }`, and don't commit that.
|
|
33
|
+
|
|
34
|
+
## Releasing
|
|
35
|
+
|
|
36
|
+
`.github/workflows/protocol-release.yml` publishes this package to PyPI when a `protocol-v<version>` tag is pushed. It checks the tag against `version` in this `pyproject.toml`, runs these tests on Python 3.10 to 3.13, builds with `uv build`, runs `twine check`, then uploads with PyPI Trusted Publishing (OIDC, no token). Portal image tags (`v*`) and protocol tags don't trigger each other's workflow.
|
|
37
|
+
|
|
38
|
+
Once, before the first release:
|
|
39
|
+
|
|
40
|
+
1. On pypi.org, under Your account > Publishing > Add a new pending publisher > GitHub, enter:
|
|
41
|
+
- PyPI project name: `sp-rtk-portal-protocol`
|
|
42
|
+
- Owner: `rodenj1`
|
|
43
|
+
- Repository name: `sp-rtk-portal`
|
|
44
|
+
- Workflow name: `protocol-release.yml`
|
|
45
|
+
- Environment name: `pypi`
|
|
46
|
+
2. On GitHub, in the sp-rtk-portal repo's Settings > Environments, create an environment named `pypi` (optionally with required reviewers, to approve each upload).
|
|
47
|
+
|
|
48
|
+
GitHub offers environments in a private repo only on a paid plan (Pro, Team or Enterprise). If Settings has no Environments page, leave the environment name blank in step 1, and delete the `environment:` block from the workflow's `publish-pypi` job.
|
|
49
|
+
|
|
50
|
+
Each release:
|
|
51
|
+
|
|
52
|
+
1. Bump `version` in this `pyproject.toml` on `main` (PyPI never accepts the same version twice).
|
|
53
|
+
2. Tag that commit and push the tag:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
git tag protocol-v0.9.0
|
|
57
|
+
git push origin protocol-v0.9.0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
3. Once it's on PyPI, raise sp-rtk-base's lower bound if the agent needs the new version, then run `uv lock` there.
|
|
61
|
+
|
|
62
|
+
To rerun a failed release for an existing tag, run the workflow by hand (Actions > protocol-release > Run workflow) with the tag.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sp-rtk-portal-protocol"
|
|
3
|
+
version = "0.9.0"
|
|
4
|
+
description = "Agent-to-Portal protocol shared by sp-rtk-portal and sp-rtk-base's Portal agent"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "John Roden", email = "rodenj@gmail.com" }]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
# sp-rtk-base supports Python 3.10, so this package must too.
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = ["rtk", "gnss", "base-station", "dpop", "jws", "ed25519", "tunnel"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
22
|
+
"Topic :: System :: Networking",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["cryptography>=42"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/rodenj1/sp-rtk-portal/tree/main/protocol"
|
|
29
|
+
Repository = "https://github.com/rodenj1/sp-rtk-portal"
|
|
30
|
+
Issues = "https://github.com/rodenj1/sp-rtk-portal/issues"
|
|
31
|
+
"Used by" = "https://github.com/rodenj1/sp-rtk-base"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
extend = "../pyproject.toml"
|
|
39
|
+
# sp-rtk-base still runs on Python 3.10.
|
|
40
|
+
target-version = "py310"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The agent-to-Portal protocol shared by sp-rtk-portal and sp-rtk-base."""
|
|
2
|
+
|
|
3
|
+
from sp_rtk_portal_protocol import doorbell
|
|
4
|
+
from sp_rtk_portal_protocol.doorbell import (
|
|
5
|
+
DOORBELL_INTERVAL,
|
|
6
|
+
DOORBELL_JITTER,
|
|
7
|
+
DoorbellError,
|
|
8
|
+
HeardKeepalive,
|
|
9
|
+
KeepalivesReply,
|
|
10
|
+
KeepalivesReport,
|
|
11
|
+
Ring,
|
|
12
|
+
Rings,
|
|
13
|
+
)
|
|
14
|
+
from sp_rtk_portal_protocol.messages import (
|
|
15
|
+
CHECK_IN_PATH,
|
|
16
|
+
CLAIM_TOKEN_PATH,
|
|
17
|
+
CLAIMS_PATH,
|
|
18
|
+
DOORBELL_IGNORED,
|
|
19
|
+
DOORBELL_OFF,
|
|
20
|
+
DOORBELL_OK,
|
|
21
|
+
DOORBELL_UNAVAILABLE,
|
|
22
|
+
POLL_PATH,
|
|
23
|
+
RELEASE_PATH,
|
|
24
|
+
TUNNEL_PATH,
|
|
25
|
+
AgentConfig,
|
|
26
|
+
CheckIn,
|
|
27
|
+
CheckInReply,
|
|
28
|
+
Claimed,
|
|
29
|
+
ClaimStart,
|
|
30
|
+
ClaimStarted,
|
|
31
|
+
ClaimToken,
|
|
32
|
+
ErrorReply,
|
|
33
|
+
LanAddress,
|
|
34
|
+
LiveSession,
|
|
35
|
+
Message,
|
|
36
|
+
MessageError,
|
|
37
|
+
Notice,
|
|
38
|
+
Poll,
|
|
39
|
+
PollReply,
|
|
40
|
+
Release,
|
|
41
|
+
Released,
|
|
42
|
+
UpgradeRequired,
|
|
43
|
+
)
|
|
44
|
+
from sp_rtk_portal_protocol.proof import (
|
|
45
|
+
AUTHORIZATION_SCHEME,
|
|
46
|
+
MAX_CLOCK_SKEW,
|
|
47
|
+
BaseKey,
|
|
48
|
+
ProofError,
|
|
49
|
+
ReplayCache,
|
|
50
|
+
VerifiedProof,
|
|
51
|
+
jwk_thumbprint,
|
|
52
|
+
proof_from_authorization,
|
|
53
|
+
request_url,
|
|
54
|
+
verify_proof,
|
|
55
|
+
)
|
|
56
|
+
from sp_rtk_portal_protocol.tunnel import (
|
|
57
|
+
FrameError,
|
|
58
|
+
Stream,
|
|
59
|
+
StreamClosed,
|
|
60
|
+
Tunnel,
|
|
61
|
+
TunnelClosed,
|
|
62
|
+
)
|
|
63
|
+
from sp_rtk_portal_protocol.version import (
|
|
64
|
+
PROTOCOL_VERSION,
|
|
65
|
+
VERSION_HEADER,
|
|
66
|
+
version_from_header,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AUTHORIZATION_SCHEME",
|
|
71
|
+
"CHECK_IN_PATH",
|
|
72
|
+
"CLAIMS_PATH",
|
|
73
|
+
"CLAIM_TOKEN_PATH",
|
|
74
|
+
"DOORBELL_IGNORED",
|
|
75
|
+
"DOORBELL_INTERVAL",
|
|
76
|
+
"DOORBELL_JITTER",
|
|
77
|
+
"DOORBELL_OFF",
|
|
78
|
+
"DOORBELL_OK",
|
|
79
|
+
"DOORBELL_UNAVAILABLE",
|
|
80
|
+
"MAX_CLOCK_SKEW",
|
|
81
|
+
"POLL_PATH",
|
|
82
|
+
"PROTOCOL_VERSION",
|
|
83
|
+
"RELEASE_PATH",
|
|
84
|
+
"TUNNEL_PATH",
|
|
85
|
+
"VERSION_HEADER",
|
|
86
|
+
"AgentConfig",
|
|
87
|
+
"BaseKey",
|
|
88
|
+
"CheckIn",
|
|
89
|
+
"CheckInReply",
|
|
90
|
+
"ClaimStart",
|
|
91
|
+
"ClaimStarted",
|
|
92
|
+
"ClaimToken",
|
|
93
|
+
"Claimed",
|
|
94
|
+
"DoorbellError",
|
|
95
|
+
"ErrorReply",
|
|
96
|
+
"FrameError",
|
|
97
|
+
"HeardKeepalive",
|
|
98
|
+
"KeepalivesReply",
|
|
99
|
+
"KeepalivesReport",
|
|
100
|
+
"LanAddress",
|
|
101
|
+
"LiveSession",
|
|
102
|
+
"Message",
|
|
103
|
+
"MessageError",
|
|
104
|
+
"Notice",
|
|
105
|
+
"Poll",
|
|
106
|
+
"PollReply",
|
|
107
|
+
"ProofError",
|
|
108
|
+
"Release",
|
|
109
|
+
"Released",
|
|
110
|
+
"ReplayCache",
|
|
111
|
+
"Ring",
|
|
112
|
+
"Rings",
|
|
113
|
+
"Stream",
|
|
114
|
+
"StreamClosed",
|
|
115
|
+
"Tunnel",
|
|
116
|
+
"TunnelClosed",
|
|
117
|
+
"UpgradeRequired",
|
|
118
|
+
"VerifiedProof",
|
|
119
|
+
"doorbell",
|
|
120
|
+
"jwk_thumbprint",
|
|
121
|
+
"proof_from_authorization",
|
|
122
|
+
"request_url",
|
|
123
|
+
"verify_proof",
|
|
124
|
+
"version_from_header",
|
|
125
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""The Doorbell (ADR 0002): UDP packets between a base and a Doorbell server,
|
|
2
|
+
and the HTTPS interface the Doorbell server uses on the Portal.
|
|
3
|
+
|
|
4
|
+
A base whose agent config names a usable Doorbell host sends a **keepalive**
|
|
5
|
+
to it every :data:`DOORBELL_INTERVAL` seconds (± :data:`DOORBELL_JITTER`),
|
|
6
|
+
from one UDP socket, which holds a return path open through its router. To
|
|
7
|
+
Wake the base the Portal hands the Doorbell server a **ring**, which it sends
|
|
8
|
+
back down that path. Packets are binary, big-endian:
|
|
9
|
+
|
|
10
|
+
- keepalive (109 bytes): ``MAGIC`` (4) | kind 1 | time (8, Unix seconds) |
|
|
11
|
+
key thumbprint (32, the raw SHA-256 behind the RFC 7638 thumbprint) |
|
|
12
|
+
Ed25519 signature by the base key over everything before it (64).
|
|
13
|
+
- ring: ``MAGIC`` (4) | kind 2 | time (8) | nonce length (1) | nonce (ASCII,
|
|
14
|
+
the Wake's one-time nonce) | HMAC-SHA256 over everything before it with the
|
|
15
|
+
base's **ring key** (32).
|
|
16
|
+
|
|
17
|
+
The ring key is a per-base secret the Portal gives the agent in each
|
|
18
|
+
Check-in reply (over HTTPS). A ring carries only a Wake's nonce: the agent
|
|
19
|
+
then opens its tunnel exactly as for a polled Wake, echoing the nonce in a
|
|
20
|
+
request signed by its base key, so even a forged ring could only cause a
|
|
21
|
+
refused tunnel attempt. Both packets are refused more than
|
|
22
|
+
:data:`~sp_rtk_portal_protocol.proof.MAX_CLOCK_SKEW` away from now.
|
|
23
|
+
|
|
24
|
+
The Doorbell server holds no keys. It relays each keepalive it receives, with
|
|
25
|
+
the address and port it came from, to :data:`KEEPALIVES_PATH`; the Portal
|
|
26
|
+
checks the signature, records the sign of life and remembers the return
|
|
27
|
+
path. It collects rings from :data:`RINGS_PATH` (a long poll) and sends each
|
|
28
|
+
packet to the address and port given. Both requests carry
|
|
29
|
+
``Authorization: Bearer <the Doorbell token>``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import dataclasses
|
|
36
|
+
import hashlib
|
|
37
|
+
import hmac
|
|
38
|
+
import secrets
|
|
39
|
+
import struct
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import datetime, timezone
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from cryptography.exceptions import InvalidSignature
|
|
45
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
46
|
+
|
|
47
|
+
from sp_rtk_portal_protocol.messages import Message
|
|
48
|
+
from sp_rtk_portal_protocol.proof import MAX_CLOCK_SKEW, BaseKey
|
|
49
|
+
|
|
50
|
+
DOORBELL_INTERVAL = 20
|
|
51
|
+
"""Seconds between a base's keepalives (before jitter)."""
|
|
52
|
+
DOORBELL_JITTER = 5
|
|
53
|
+
"""Each gap is ``DOORBELL_INTERVAL`` ± up to this many seconds."""
|
|
54
|
+
|
|
55
|
+
MAGIC = b"SPDB"
|
|
56
|
+
KEEPALIVE = 1
|
|
57
|
+
RING = 2
|
|
58
|
+
|
|
59
|
+
_HEAD = struct.Struct(">4sBQ") # magic, kind, time
|
|
60
|
+
_SIGNATURE = 64
|
|
61
|
+
_THUMBPRINT = 32
|
|
62
|
+
_MAC = 32
|
|
63
|
+
KEEPALIVE_SIZE = _HEAD.size + _THUMBPRINT + _SIGNATURE
|
|
64
|
+
MAX_NONCE = 255
|
|
65
|
+
|
|
66
|
+
KEEPALIVES_PATH = "/doorbell/keepalives"
|
|
67
|
+
"""POST (Doorbell server -> Portal): :class:`KeepalivesReport` ->
|
|
68
|
+
:class:`KeepalivesReply`."""
|
|
69
|
+
RINGS_PATH = "/doorbell/rings"
|
|
70
|
+
"""GET ``?wait=<seconds>`` (Doorbell server -> Portal), a long poll: ->
|
|
71
|
+
:class:`Rings` as soon as there are any, or empty after ``wait``."""
|
|
72
|
+
|
|
73
|
+
KEEPALIVE_OK = "ok"
|
|
74
|
+
KEEPALIVE_UNKNOWN = "unknown"
|
|
75
|
+
"""No Claimed base has this key."""
|
|
76
|
+
KEEPALIVE_INVALID = "invalid"
|
|
77
|
+
"""Not a keepalive, a bad signature, or too old or new."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DoorbellError(ValueError):
|
|
81
|
+
"""A packet that must be ignored. The message says why (for logs)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _b64(data: bytes) -> str:
|
|
85
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _unb64(text: str) -> bytes:
|
|
89
|
+
try:
|
|
90
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
91
|
+
except (ValueError, TypeError) as exc:
|
|
92
|
+
raise DoorbellError("not base64url") from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def encode_packet(packet: bytes) -> str:
|
|
96
|
+
"""A packet as the HTTPS interface carries it (base64url, no padding)."""
|
|
97
|
+
return _b64(packet)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decode_packet(text: str) -> bytes:
|
|
101
|
+
return _unb64(text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _when(at: int) -> datetime:
|
|
105
|
+
try:
|
|
106
|
+
return datetime.fromtimestamp(at, tz=timezone.utc)
|
|
107
|
+
except (ValueError, OverflowError, OSError) as exc:
|
|
108
|
+
raise DoorbellError("packet time is out of range") from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_time(at: int, now: datetime) -> datetime:
|
|
112
|
+
when = _when(at)
|
|
113
|
+
if abs(now - when) > MAX_CLOCK_SKEW:
|
|
114
|
+
raise DoorbellError("packet time is too far from now")
|
|
115
|
+
return when
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- Keepalives ------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Keepalive:
|
|
123
|
+
thumbprint: str
|
|
124
|
+
"""The base key's RFC 7638 thumbprint (find the base by it)."""
|
|
125
|
+
at: datetime
|
|
126
|
+
"""When the base sent it (its clock)."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def keepalive(key: BaseKey, *, now: datetime) -> bytes:
|
|
130
|
+
"""A keepalive signed by the base key."""
|
|
131
|
+
body = _HEAD.pack(MAGIC, KEEPALIVE, int(now.timestamp())) + _unb64(key.thumbprint)
|
|
132
|
+
return body + key.sign(body)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _split_keepalive(packet: bytes) -> tuple[bytes, Keepalive, bytes]:
|
|
136
|
+
if len(packet) != KEEPALIVE_SIZE:
|
|
137
|
+
raise DoorbellError("not a keepalive")
|
|
138
|
+
magic, kind, at = _HEAD.unpack_from(packet)
|
|
139
|
+
if magic != MAGIC or kind != KEEPALIVE:
|
|
140
|
+
raise DoorbellError("not a keepalive")
|
|
141
|
+
body = packet[:-_SIGNATURE]
|
|
142
|
+
thumbprint = _b64(body[_HEAD.size :])
|
|
143
|
+
return body, Keepalive(thumbprint, _when(at)), packet[-_SIGNATURE:]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_keepalive(packet: bytes) -> Keepalive:
|
|
147
|
+
"""The keepalive's claims, NOT verified: only to look the base up."""
|
|
148
|
+
return _split_keepalive(packet)[1]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def verify_keepalive(packet: bytes, jwk: dict[str, Any], *, now: datetime) -> Keepalive:
|
|
152
|
+
"""The keepalive, if the base key ``jwk`` signed it recently."""
|
|
153
|
+
body, heard, signature = _split_keepalive(packet)
|
|
154
|
+
try:
|
|
155
|
+
public = Ed25519PublicKey.from_public_bytes(_unb64(str(jwk.get("x", ""))))
|
|
156
|
+
public.verify(signature, body)
|
|
157
|
+
except (InvalidSignature, ValueError) as exc:
|
|
158
|
+
raise DoorbellError("bad signature") from exc
|
|
159
|
+
_check_time(int(heard.at.timestamp()), now)
|
|
160
|
+
return heard
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- Rings ----------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def new_ring_key() -> str:
|
|
167
|
+
"""A fresh ring key (256 bits, base64url)."""
|
|
168
|
+
return _b64(secrets.token_bytes(32))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ring(ring_key: str, nonce: str, *, now: datetime) -> bytes:
|
|
172
|
+
"""A ring carrying a Wake's ``nonce``, for the holder of ``ring_key``."""
|
|
173
|
+
raw = nonce.encode("ascii")
|
|
174
|
+
if not 0 < len(raw) <= MAX_NONCE:
|
|
175
|
+
raise ValueError("nonce must be 1..255 ASCII characters")
|
|
176
|
+
body = _HEAD.pack(MAGIC, RING, int(now.timestamp())) + bytes([len(raw)]) + raw
|
|
177
|
+
return body + hmac.new(_unb64(ring_key), body, hashlib.sha256).digest()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def open_ring(ring_key: str, packet: bytes, *, now: datetime) -> str:
|
|
181
|
+
"""The nonce a genuine, recent ring carries."""
|
|
182
|
+
if len(packet) < _HEAD.size + 1 + 1 + _MAC:
|
|
183
|
+
raise DoorbellError("not a ring")
|
|
184
|
+
magic, kind, at = _HEAD.unpack_from(packet)
|
|
185
|
+
length = packet[_HEAD.size]
|
|
186
|
+
if magic != MAGIC or kind != RING or len(packet) != _HEAD.size + 1 + length + _MAC:
|
|
187
|
+
raise DoorbellError("not a ring")
|
|
188
|
+
body, mac = packet[:-_MAC], packet[-_MAC:]
|
|
189
|
+
expected = hmac.new(_unb64(ring_key), body, hashlib.sha256).digest()
|
|
190
|
+
if not hmac.compare_digest(mac, expected):
|
|
191
|
+
raise DoorbellError("bad ring signature")
|
|
192
|
+
_check_time(at, now)
|
|
193
|
+
try:
|
|
194
|
+
return body[_HEAD.size + 1 :].decode("ascii")
|
|
195
|
+
except UnicodeDecodeError as exc:
|
|
196
|
+
raise DoorbellError("not a ring") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- The Doorbell server's HTTPS interface -------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class HeardKeepalive(Message):
|
|
204
|
+
packet: str
|
|
205
|
+
"""The datagram, base64url (:func:`encode_packet`)."""
|
|
206
|
+
address: str
|
|
207
|
+
"""The source IP it came from (the base's router)."""
|
|
208
|
+
port: int
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class KeepalivesReport(Message):
|
|
213
|
+
keepalives: list[HeardKeepalive] = dataclasses.field(default_factory=list)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class KeepalivesReply(Message):
|
|
218
|
+
results: list[str] = dataclasses.field(default_factory=list)
|
|
219
|
+
"""One per reported keepalive, in order: ``KEEPALIVE_*``."""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class Ring(Message):
|
|
224
|
+
address: str
|
|
225
|
+
port: int
|
|
226
|
+
packet: str
|
|
227
|
+
"""Send this datagram (base64url) to ``address``:``port``, from the
|
|
228
|
+
socket the keepalives arrived on."""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class Rings(Message):
|
|
233
|
+
rings: list[Ring] = dataclasses.field(default_factory=list)
|