From 8af8da0ba1d7c21e0ce6006581e56b1ec1ed4a38 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 14 Apr 2026 20:40:31 -0400 Subject: [PATCH 1/2] Add `wg` (WireGuard) protocol support Introduce a new `wg` multiaddr protocol using draft code `0x01C7` (unassigned slot between `noise` 0x01C6 and `shs` 0x01C8 in the multicodec table). Deats, - `multiaddr/codecs/wg.py`: fixed 256-bit codec for Curve25519 public keys using standard base64 (matching `wg(8)` tooling conventions) - `P_WG = 0x01C7` constant and `Protocol` entry registered in the default `PROTOCOLS` list - codec unit tests: roundtrip, validation, error paths, registry lookup, full `Multiaddr` integration - valid/invalid multiaddr string test cases for both IPv4 and IPv6 stacks (e.g. `/ip4/1.2.3.4/udp/51820/wg/`) Further, the `0x01C7` allocation is not yet in the upstream multicodec table; an addition PR per https://github.com/multiformats/multicodec?tab=readme-ov-file#adding-new-multicodecs-to-the-table is needed to make it official. Prompt-IO: ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260415T002453Z_0b6493a_prompt_io.md | 48 ++++++ .../20260415T002453Z_0b6493a_prompt_io.raw.md | 154 ++++++++++++++++++ ai/prompt-io/claude/README.md | 27 +++ multiaddr/codecs/wg.py | 63 +++++++ multiaddr/protocols.py | 2 + tests/test_multiaddr.py | 5 + tests/test_protocols.py | 83 +++++++++- 7 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.md create mode 100644 ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.raw.md create mode 100644 ai/prompt-io/claude/README.md create mode 100644 multiaddr/codecs/wg.py diff --git a/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.md b/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.md new file mode 100644 index 0000000..4ec6f70 --- /dev/null +++ b/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.md @@ -0,0 +1,48 @@ +--- +model: claude-opus-4-6 +service: claude +session: py-multiaddr-wg-protocol +timestamp: 2026-04-15T00:24:53Z +git_ref: 0b6493a +scope: code +substantive: true +raw_file: 20260415T002453Z_0b6493a_prompt_io.raw.md +--- + +## Prompt + +The user requested adding full support for a new "wireguard" protocol +denoted as `wg` into the py-multiaddr codebase. They referenced the +upstream spec repo links: + +- https://github.com/multiformats/multiaddr/blob/master/protocols.csv +- https://multiformats.io/multiaddr/ + +And noted the multicodec addition process: + +- https://github.com/multiformats/multicodec +- https://github.com/multiformats/multicodec?tab=readme-ov-file#adding-new-multicodecs-to-the-table +- https://github.com/multiformats/multicodec/blob/master/table.csv#L130 + +## Response summary + +Add WireGuard (`wg`) as a new multiaddr protocol with draft code +`0x01C7` (an unassigned slot between `noise` 0x01C6 and `shs` 0x01C8 +in the multicodec table). The codec handles 32-byte Curve25519 public +keys encoded as standard base64, matching `wg(8)` tooling conventions. +Example multiaddr: `/ip4/1.2.3.4/udp/51820/wg/`. + +## Files changed + +- `multiaddr/protocols.py` — Add `P_WG = 0x01C7` constant and + `Protocol(P_WG, "wg", "wg")` entry in `PROTOCOLS` list +- `multiaddr/codecs/wg.py` — New codec for 32-byte Curve25519 + public keys (SIZE=256, standard base64 encoding) +- `tests/test_protocols.py` — Add `wg` codec unit tests (roundtrip, + validation, error paths, registry lookup, integration) +- `tests/test_multiaddr.py` — Add valid and invalid WireGuard + multiaddr string test cases + +## Human edits + +None — committed as generated. diff --git a/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.raw.md b/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.raw.md new file mode 100644 index 0000000..f5b1db9 --- /dev/null +++ b/ai/prompt-io/claude/20260415T002453Z_0b6493a_prompt_io.raw.md @@ -0,0 +1,154 @@ +--- +model: claude-opus-4-6 +service: claude +timestamp: 2026-04-15T00:24:53Z +git_ref: 0b6493a +--- + +Let me explore the codebase and check the upstream spec simultaneously. + +[Launched Explore agent to thoroughly explore py-multiaddr codebase structure, protocol definitions, codec patterns, and tests] + +[Fetched https://github.com/multiformats/multiaddr/blob/master/protocols.csv — no WireGuard/wg entry found in upstream spec] + +The upstream `protocols.csv` doesn't yet include a `wg` entry, so we'll need to pick a code. Let me read the key files to understand the exact patterns before implementing. + +[Read multiaddr/protocols.py, multiaddr/codecs/__init__.py, multiaddr/codecs/uint16be.py] + +Now let me also check the upstream multicodec table for any existing WireGuard code assignment. + +[Fetched https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv — no wireguard/wg entry found. Available slots in 0x01C0-0x01D0 range: 0x01c2, 0x01c3, 0x01c4, 0x01c5, 0x01c7, 0x01c9, 0x01ca, 0x01cb, 0x01ce, 0x01cf, 0x01d0] + +[Read tests/test_protocols.py, tests/test_multiaddr.py] + +I have a clear picture. Here's the plan: + +- **Protocol code**: `0x01C7` (available slot between `noise` 0x01C6 and `shs` 0x01C8 in the multicodec table) +- **Codec**: 32-byte (256-bit) Curve25519 public key, base64 encoded (standard WireGuard key format) +- **Multiaddr example**: `/ip4/1.2.3.4/udp/51820/wg/` + +Let me implement all three pieces. + +### Edit 1: multiaddr/protocols.py — Add P_WG constant + +Added `P_WG = 0x01C7` before `P_MEMORY`. + +### Edit 2: multiaddr/protocols.py — Add Protocol entry + +Added `Protocol(P_WG, "wg", "wg"),` after the `noise` entry in the PROTOCOLS list. + +### New file: multiaddr/codecs/wg.py + +```python +""" +WireGuard protocol codec. + +Encode/decode a 32-byte Curve25519 public key as standard +base64 (the canonical format used by `wg(8)` tooling). + +The protocol code `0x01C7` is a draft allocation not yet +present in the upstream multicodec table: +- https://github.com/multiformats/multicodec/blob/master/table.csv +- https://github.com/multiformats/multiaddr/blob/master/protocols.csv +- https://multiformats.io/multiaddr/ + +See also the upstream multicodec addition process: +- https://github.com/multiformats/multicodec?tab=readme-ov-file#adding-new-multicodecs-to-the-table +""" + +import base64 +from typing import Any + +from ..codecs import CodecBase +from ..exceptions import BinaryParseError + +SIZE = 256 # 32 bytes * 8 bits +IS_PATH = False + +WG_KEY_LENGTH = 32 # Curve25519 public key + + +class Codec(CodecBase): + SIZE = SIZE + IS_PATH = IS_PATH + + def to_bytes(self, proto: Any, string: str) -> bytes: + try: + raw = base64.b64decode(string, validate=True) + except Exception as exc: + raise ValueError( + f"invalid base64 WireGuard public key: {exc}" + ) from exc + + if len(raw) != WG_KEY_LENGTH: + raise ValueError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(raw)}" + ) + return raw + + def to_string(self, proto: Any, buf: bytes) -> str: + if len(buf) != WG_KEY_LENGTH: + raise BinaryParseError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(buf)}", + buf, + "wg", + ) + return base64.b64encode(buf).decode("ascii") + + def validate(self, b: bytes) -> None: + if len(b) != WG_KEY_LENGTH: + raise ValueError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(b)}" + ) +``` + +### Edit 3: tests/test_protocols.py — Add wg import and tests + +Added `wg` to codec imports. + +Added test functions: +- `test_wg_valid_roundtrip` — base64 encode/decode of random 32-byte key +- `test_wg_zero_key_roundtrip` — all-zeros key roundtrip +- `test_wg_invalid_base64_raises` — rejects invalid base64 +- `test_wg_wrong_length_string_raises` — rejects 16-byte and 64-byte keys +- `test_wg_wrong_length_bytes_raises` — rejects wrong-length binary buffers +- `test_wg_validate` — direct validate() method testing +- `test_wg_protocol_lookup` — registry lookup by name +- `test_wg_integration` — full Multiaddr roundtrip with `/ip4/1.2.3.4/udp/51820/wg/` + +### Edit 4: tests/test_multiaddr.py — Add invalid cases + +Added to invalid test params: +- `/wg` — missing key +- `/wg/not-valid-base64` — invalid base64 +- `/ip4/1.2.3.4/udp/51820/wg` — missing key after full address + +### Edit 5: tests/test_multiaddr.py — Add valid cases + +Added to valid test params: +- `/ip4/1.2.3.4/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=` +- `/ip6/::1/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=` + +### Test run + +All 269 tests pass. + +### Summary + +**3 files touched:** + +1. **`multiaddr/protocols.py`** — Added `P_WG = 0x01C7` constant and `Protocol(P_WG, "wg", "wg")` entry in the `PROTOCOLS` list (placed after `noise`). + +2. **`multiaddr/codecs/wg.py`** (new) — Codec for 32-byte Curve25519 public keys using standard base64 encoding (the canonical format from `wg(8)` tooling). Fixed size of 256 bits. Includes docstring referencing the upstream multicodec/multiaddr spec repos and the addition process. + +3. **`tests/test_protocols.py`** + **`tests/test_multiaddr.py`** — Added codec roundtrip, validation, error-path tests, protocol registry lookup, integration tests, and valid/invalid multiaddr string cases. + +**Protocol details:** +- Code: `0x01C7` (draft, between `noise` 0x01C6 and `shs` 0x01C8 — not yet allocated upstream) +- Codec: fixed 256-bit (32-byte) Curve25519 public key, standard base64 +- Example multiaddr: `/ip4/1.2.3.4/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=` + +Regarding the upstream multicodec table — `0x01C7` is currently unassigned. To make this official, a PR to multiformats/multicodec adding `wg,multiaddr,0x01c7,draft` to `table.csv` would be needed per their adding new multicodecs process (similar to how `shs`/Secure Scuttlebutt got `0x01c8`). diff --git a/ai/prompt-io/claude/README.md b/ai/prompt-io/claude/README.md new file mode 100644 index 0000000..b93e8ad --- /dev/null +++ b/ai/prompt-io/claude/README.md @@ -0,0 +1,27 @@ +# AI Prompt I/O Log — claude + +This directory tracks prompt inputs and model +outputs for AI-assisted development using +`claude` (Claude Code). + +## Policy + +Prompt logging follows the +[NLNet generative AI policy][nlnet-ai]. +All substantive AI contributions are logged +with: +- Model name and version +- Timestamps +- The prompts that produced the output +- Unedited model output (`.raw.md` files) + +[nlnet-ai]: https://nlnet.nl/foundation/policies/generativeAI/ + +## Usage + +Entries are created by the `/prompt-io` skill +or automatically via `/commit-msg` integration. + +Human contributors remain accountable for all +code decisions. AI-generated content is never +presented as human-authored work. diff --git a/multiaddr/codecs/wg.py b/multiaddr/codecs/wg.py new file mode 100644 index 0000000..d8df787 --- /dev/null +++ b/multiaddr/codecs/wg.py @@ -0,0 +1,63 @@ +""" +WireGuard protocol codec. + +Encode/decode a 32-byte Curve25519 public key as standard +base64 (the canonical format used by `wg(8)` tooling). + +The protocol code `0x01C7` is a draft allocation not yet +present in the upstream multicodec table: +- https://github.com/multiformats/multicodec/blob/master/table.csv +- https://github.com/multiformats/multiaddr/blob/master/protocols.csv +- https://multiformats.io/multiaddr/ + +See also the upstream multicodec addition process: +- https://github.com/multiformats/multicodec?tab=readme-ov-file#adding-new-multicodecs-to-the-table +""" + +import base64 +from typing import Any + +from ..codecs import CodecBase +from ..exceptions import BinaryParseError + +SIZE = 256 # 32 bytes * 8 bits +IS_PATH = False + +WG_KEY_LENGTH = 32 # Curve25519 public key + + +class Codec(CodecBase): + SIZE = SIZE + IS_PATH = IS_PATH + + def to_bytes(self, proto: Any, string: str) -> bytes: + try: + raw = base64.b64decode(string, validate=True) + except Exception as exc: + raise ValueError( + f"invalid base64 WireGuard public key: {exc}" + ) from exc + + if len(raw) != WG_KEY_LENGTH: + raise ValueError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(raw)}" + ) + return raw + + def to_string(self, proto: Any, buf: bytes) -> str: + if len(buf) != WG_KEY_LENGTH: + raise BinaryParseError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(buf)}", + buf, + "wg", + ) + return base64.b64encode(buf).decode("ascii") + + def validate(self, b: bytes) -> None: + if len(b) != WG_KEY_LENGTH: + raise ValueError( + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " + f"got {len(b)}" + ) diff --git a/multiaddr/protocols.py b/multiaddr/protocols.py index 43c9ffa..37d7bf9 100644 --- a/multiaddr/protocols.py +++ b/multiaddr/protocols.py @@ -83,6 +83,7 @@ P_WEBTRANSPORT = 0x01D1 P_WEBRTC_DIRECT = 0x118 P_WEBRTC = 0x119 +P_WG = 0x01C7 P_MEMORY = 0x309 P_CERTHASH = 0x1D2 @@ -157,6 +158,7 @@ def __repr__(self) -> str: Protocol(P_DNSADDR, "dnsaddr", "domain"), Protocol(P_SNI, "sni", "domain"), Protocol(P_NOISE, "noise", None), + Protocol(P_WG, "wg", "wg"), Protocol(P_SCTP, "sctp", "uint16be"), Protocol(P_UDT, "udt", None), Protocol(P_UTP, "utp", None), diff --git a/tests/test_multiaddr.py b/tests/test_multiaddr.py index da402e5..af8023e 100644 --- a/tests/test_multiaddr.py +++ b/tests/test_multiaddr.py @@ -83,6 +83,9 @@ "/dns4", "/dns6", "/cancer", + "/wg", + "/wg/not-valid-base64", + "/ip4/1.2.3.4/udp/51820/wg", ], ) def test_invalid(addr_str): @@ -139,6 +142,8 @@ def test_invalid(addr_str): "/ip4/127.0.0.1/tcp/9090/http/p2p-webrtc-direct", "/ip4/127.0.0.1/tcp/127/webrtc-direct", "/ip4/127.0.0.1/tcp/127/webrtc", + "/ip4/1.2.3.4/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "/ip6/::1/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g" "/ip4/127.0.0.1/udp/9090/webrtc-direct/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g", "/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw", diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 3d9c515..615ce2b 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -7,7 +7,7 @@ import varint from multiaddr import Multiaddr, exceptions, protocols -from multiaddr.codecs import certhash, garlic32, garlic64, http_path, ipcidr, memory +from multiaddr.codecs import certhash, garlic32, garlic64, http_path, ipcidr, memory, wg from multiaddr.exceptions import BinaryParseError, StringParseError @@ -625,3 +625,84 @@ def test_certhash_validate_function(): # Invalid bytes should raise a ValueError with pytest.raises(ValueError): codec.validate(INVALID_BYTES) + + +# --- WireGuard (wg) Tests --- +# A valid 32-byte Curve25519 public key (random) +VALID_WG_KEY_BYTES = os.urandom(32) +VALID_WG_KEY_STRING = base64.b64encode(VALID_WG_KEY_BYTES).decode("ascii") + +# A well-known test key (all zeros) +ZERO_WG_KEY_BYTES = b"\x00" * 32 +ZERO_WG_KEY_STRING = base64.b64encode(ZERO_WG_KEY_BYTES).decode("ascii") + + +def test_wg_valid_roundtrip(): + codec = wg.Codec() + + b = codec.to_bytes(None, VALID_WG_KEY_STRING) + assert isinstance(b, bytes) + assert len(b) == 32 + assert b == VALID_WG_KEY_BYTES + + s_out = codec.to_string(None, b) + assert s_out == VALID_WG_KEY_STRING + + +def test_wg_zero_key_roundtrip(): + codec = wg.Codec() + + b = codec.to_bytes(None, ZERO_WG_KEY_STRING) + assert b == ZERO_WG_KEY_BYTES + assert codec.to_string(None, b) == ZERO_WG_KEY_STRING + + +def test_wg_invalid_base64_raises(): + codec = wg.Codec() + with pytest.raises(ValueError): + codec.to_bytes(None, "not-valid-base64!!!") + + +def test_wg_wrong_length_string_raises(): + codec = wg.Codec() + # 16 bytes encoded as base64 (too short) + short_key = base64.b64encode(os.urandom(16)).decode("ascii") + with pytest.raises(ValueError): + codec.to_bytes(None, short_key) + + # 64 bytes encoded as base64 (too long) + long_key = base64.b64encode(os.urandom(64)).decode("ascii") + with pytest.raises(ValueError): + codec.to_bytes(None, long_key) + + +def test_wg_wrong_length_bytes_raises(): + codec = wg.Codec() + with pytest.raises(BinaryParseError): + codec.to_string(None, os.urandom(16)) + with pytest.raises(BinaryParseError): + codec.to_string(None, os.urandom(64)) + + +def test_wg_validate(): + codec = wg.Codec() + codec.validate(VALID_WG_KEY_BYTES) + + with pytest.raises(ValueError): + codec.validate(os.urandom(31)) + with pytest.raises(ValueError): + codec.validate(os.urandom(33)) + + +def test_wg_protocol_lookup(): + proto = protocols.protocol_with_name("wg") + assert proto.name == "wg" + assert proto.code == protocols.P_WG + assert proto.codec == "wg" + assert proto.size == 256 + + +def test_wg_integration(): + ma = Multiaddr(f"/ip4/1.2.3.4/udp/51820/wg/{VALID_WG_KEY_STRING}") + assert str(ma) == f"/ip4/1.2.3.4/udp/51820/wg/{VALID_WG_KEY_STRING}" + assert ma.value_for_protocol(protocols.P_WG) == VALID_WG_KEY_STRING From edf9cfe27d013f2627cffcea8e90f590fa56080e Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 23 Jun 2026 16:41:54 -0400 Subject: [PATCH 2/2] Use URL-safe base64 for `wg` keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard base64's alphabet includes `/`, which collides with multiaddr's `/` segment delimiter — so any key whose encoding contained `/` was unparsable and could emit an ambiguous multiaddr. Switch the `wg` codec to URL-safe base64 (RFC 4648 §5, padded), matching the alt-alphabet approach already used by `garlic64`. - `to_string()` now encodes via `base64.urlsafe_b64encode()` - `to_bytes()` decodes with `altchars=b"-_"` and explicitly rejects the std alphabet (`+`/`/`), hinting at the `tr '+/' '-_'` transliteration from `wg(8)` output - swap the flaky import-time `os.urandom(32)` test keys for deterministic patterns (`bytes(range(32))`, all-zeros, all-ones); the random keys were tripping CI when an encoding hit a `/` - add roundtrip + full-`Multiaddr` integration tests using an all-ones key (std-base64 form is 42 `/` chars) plus std-alphabet rejection cases Also stage `gh/wg_spec_submission_plan.md`, the upstream multicodec/multiaddr spec-registration plan for code `0x01c7`. Review: PR #108 (Copilot) https://github.com/multiformats/py-multiaddr/pull/108#pullrequestreview-4110117964 (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- gh/wg_spec_submission_plan.md | 87 +++++++++++++++++++++++++++++++++++ multiaddr/codecs/wg.py | 48 ++++++++++++------- tests/test_multiaddr.py | 4 ++ tests/test_protocols.py | 64 ++++++++++++++++++++++---- 4 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 gh/wg_spec_submission_plan.md diff --git a/gh/wg_spec_submission_plan.md b/gh/wg_spec_submission_plan.md new file mode 100644 index 0000000..9fda133 --- /dev/null +++ b/gh/wg_spec_submission_plan.md @@ -0,0 +1,87 @@ +# `wg` multiaddr protocol: upstream spec submission plan + +Plan for registering the `wg` (WireGuard) protocol upstream +once the `py-multiaddr` impl (PR +[multiformats/py-multiaddr#108][pr108]) is settled. + +## Settled impl semantics (post-review) + +- protocol name: `wg` +- code: `0x01c7` (decimal `455`) — the unassigned slot + between `noise` (`0x01c6`) and `shs` (`0x01c8`) in the + `multiaddr` tag range. +- binary form: exactly 32 bytes — a raw Curve25519 public + key; fixed size `256` bits. +- string form: URL-safe base64 (RFC 4648 section 5) **with + padding** — 44 chars ending in `=`. + * standard base64 (as printed by `wg(8)`) is rejected + since `/` collides with the multiaddr delimiter and `+` + is reserved to the std alphabet; transliterate via + `tr '+/' '-_'`. + * precedent: `garlic64` uses alt-alphabet base64 + (`-~`) for the same delimiter-collision reason; + `certhash` uses multibase `base64url`. +- example: + `/ip4/1.2.3.4/udp/51820/wg/__________________________________________8=` + +## Step 1: multicodec PR (reserves the code) + +Repo: + +Add one row to `table.csv` (cols: +`name, tag, code, status, description`), keeping the file's +column alignment, sorted by code next to `noise`/`shs`: + +```csv +wg, multiaddr, 0x01c7, draft, WireGuard tunnel endpoint - 32-byte Curve25519 public key +``` + +- run the repo's table validation locally before pushing + (`make` / `npm test` per their CONTRIBUTING docs). +- per their addition process, `draft` status entries for + unclaimed codes are routinely accepted via small PRs. +- PR body: link WireGuard cryptokey-routing docs, py-multiaddr + PR #108 as the first implementation, and the overlay-network + use case from issue #107. + +## Step 2: multiaddr spec PR (defines the protocol) + +Repo: + +1. Add a row to `protocols.csv` (cols: + `code,\tsize,\tname,\tcomment`), sorted by code after + `noise` (`454`): + + ```csv + 455, 256, wg, WireGuard tunnel endpoint (Curve25519 public key) + ``` + +2. If requested by maintainers, add a short protocol + description to the README/spec covering: + * value = 32-byte Curve25519 public key (binary), + padded URL-safe base64 (string). + * rationale for the URL-safe alphabet (delimiter + collision) + the `tr '+/' '-_'` conversion from + `wg(8)` output. + * canonical composition: `/ip{4,6}//udp//wg/`. + +Open this PR referencing the multicodec PR from step 1 so +both land with the same code. + +## Step 3: circle back to py-multiaddr + +After (or alongside) the upstream PRs: + +- update `multiaddr/codecs/wg.py` module docstring + PR #108 + body to point at the upstream PRs instead of calling the + code a speculative draft. +- tick the "Submit a draft multicodec addition PR upstream" + TODO checkbox in the PR #108 description. + +## Follow-ups (optional) + +- propose the same protocol to `go-multiaddr` / + `js-multiaddr` once the spec rows land, so the string/binary + forms stay interoperable across impls. + +[pr108]: https://github.com/multiformats/py-multiaddr/pull/108 diff --git a/multiaddr/codecs/wg.py b/multiaddr/codecs/wg.py index d8df787..adbea40 100644 --- a/multiaddr/codecs/wg.py +++ b/multiaddr/codecs/wg.py @@ -1,8 +1,20 @@ """ WireGuard protocol codec. -Encode/decode a 32-byte Curve25519 public key as standard -base64 (the canonical format used by `wg(8)` tooling). +Encode/decode a 32-byte Curve25519 public key as URL-safe +base64 (RFC 4648 section 5, with padding). + +`wg(8)` tooling renders keys as *standard* base64 whose +alphabet includes '/', a char which collides with the +multiaddr protocol delimiter and thus can't appear in a +multiaddr string segment. The URL-safe alphabet differs +only by two chars ('+' -> '-', '/' -> '_') so converting a +`wg(8)` key is a simple transliteration: + + tr '+/' '-_' <<< "$WG_PUBKEY" + +The same alternate-alphabet approach is used by the +`garlic64` codec for i2p addrs. The protocol code `0x01C7` is a draft allocation not yet present in the upstream multicodec table: @@ -31,33 +43,35 @@ class Codec(CodecBase): IS_PATH = IS_PATH def to_bytes(self, proto: Any, string: str) -> bytes: + # explicitly reject the standard-base64 alphabet: + # '/' collides with the multiaddr delimiter, so keys + # copied from `wg(8)` output must be transliterated + # to the URL-safe alphabet first. + if "+" in string or "/" in string: + raise ValueError( + "WireGuard public key must be URL-safe base64 " + "(RFC 4648 section 5): replace '+' with '-' " + "and '/' with '_'" + ) + try: - raw = base64.b64decode(string, validate=True) + raw = base64.b64decode(string, altchars=b"-_", validate=True) except Exception as exc: - raise ValueError( - f"invalid base64 WireGuard public key: {exc}" - ) from exc + raise ValueError(f"invalid base64 WireGuard public key: {exc}") from exc if len(raw) != WG_KEY_LENGTH: - raise ValueError( - f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " - f"got {len(raw)}" - ) + raise ValueError(f"WireGuard public key must be {WG_KEY_LENGTH} bytes, got {len(raw)}") return raw def to_string(self, proto: Any, buf: bytes) -> str: if len(buf) != WG_KEY_LENGTH: raise BinaryParseError( - f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " - f"got {len(buf)}", + f"WireGuard public key must be {WG_KEY_LENGTH} bytes, got {len(buf)}", buf, "wg", ) - return base64.b64encode(buf).decode("ascii") + return base64.urlsafe_b64encode(buf).decode("ascii") def validate(self, b: bytes) -> None: if len(b) != WG_KEY_LENGTH: - raise ValueError( - f"WireGuard public key must be {WG_KEY_LENGTH} bytes, " - f"got {len(b)}" - ) + raise ValueError(f"WireGuard public key must be {WG_KEY_LENGTH} bytes, got {len(b)}") diff --git a/tests/test_multiaddr.py b/tests/test_multiaddr.py index af8023e..7e1ae51 100644 --- a/tests/test_multiaddr.py +++ b/tests/test_multiaddr.py @@ -86,6 +86,7 @@ "/wg", "/wg/not-valid-base64", "/ip4/1.2.3.4/udp/51820/wg", + "/ip4/1.2.3.4/udp/51820/wg/AAAAAAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", ], ) def test_invalid(addr_str): @@ -144,6 +145,9 @@ def test_invalid(addr_str): "/ip4/127.0.0.1/tcp/127/webrtc", "/ip4/1.2.3.4/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "/ip6/::1/udp/51820/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + # all-ones key: std-base64 form would be '/' chars, + # the URL-safe alphabet maps those to '_' + "/ip6/::1/udp/51820/wg/__________________________________________8=", "/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g" "/ip4/127.0.0.1/udp/9090/webrtc-direct/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g", "/ip4/127.0.0.1/udp/1234/quic-v1/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw", diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 615ce2b..b41d569 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -628,13 +628,20 @@ def test_certhash_validate_function(): # --- WireGuard (wg) Tests --- -# A valid 32-byte Curve25519 public key (random) -VALID_WG_KEY_BYTES = os.urandom(32) -VALID_WG_KEY_STRING = base64.b64encode(VALID_WG_KEY_BYTES).decode("ascii") +# Deterministic 32-byte keys: random keys made these tests +# flaky since a standard-base64 encoding containing '/' +# can't survive multiaddr's '/'-delimited string form. +VALID_WG_KEY_BYTES = bytes(range(32)) +VALID_WG_KEY_STRING = base64.urlsafe_b64encode(VALID_WG_KEY_BYTES).decode("ascii") # A well-known test key (all zeros) ZERO_WG_KEY_BYTES = b"\x00" * 32 -ZERO_WG_KEY_STRING = base64.b64encode(ZERO_WG_KEY_BYTES).decode("ascii") +ZERO_WG_KEY_STRING = base64.urlsafe_b64encode(ZERO_WG_KEY_BYTES).decode("ascii") + +# All-ones key: its standard-base64 form is 42 '/' chars, so +# it exercises the URL-safe '_' alphabet end-to-end. +HIGH_WG_KEY_BYTES = b"\xff" * 32 +HIGH_WG_KEY_STRING = base64.urlsafe_b64encode(HIGH_WG_KEY_BYTES).decode("ascii") def test_wg_valid_roundtrip(): @@ -657,21 +664,50 @@ def test_wg_zero_key_roundtrip(): assert codec.to_string(None, b) == ZERO_WG_KEY_STRING +def test_wg_urlsafe_key_roundtrip(): + codec = wg.Codec() + + # the standard-base64 form of this key contains '/' + std_b64 = base64.b64encode(HIGH_WG_KEY_BYTES).decode("ascii") + assert "/" in std_b64 + assert "_" in HIGH_WG_KEY_STRING + + b = codec.to_bytes(None, HIGH_WG_KEY_STRING) + assert b == HIGH_WG_KEY_BYTES + assert codec.to_string(None, b) == HIGH_WG_KEY_STRING + + def test_wg_invalid_base64_raises(): codec = wg.Codec() with pytest.raises(ValueError): codec.to_bytes(None, "not-valid-base64!!!") +def test_wg_std_base64_alphabet_raises(): + codec = wg.Codec() + + # std-base64 of the all-ones key is mostly '/' chars + std_b64 = base64.b64encode(HIGH_WG_KEY_BYTES).decode("ascii") + with pytest.raises(ValueError): + codec.to_bytes(None, std_b64) + + # '+' is likewise reserved to the std alphabet + with pytest.raises(ValueError): + codec.to_bytes( + None, + "AAAAAAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ) + + def test_wg_wrong_length_string_raises(): codec = wg.Codec() # 16 bytes encoded as base64 (too short) - short_key = base64.b64encode(os.urandom(16)).decode("ascii") + short_key = base64.urlsafe_b64encode(b"\x01" * 16).decode("ascii") with pytest.raises(ValueError): codec.to_bytes(None, short_key) # 64 bytes encoded as base64 (too long) - long_key = base64.b64encode(os.urandom(64)).decode("ascii") + long_key = base64.urlsafe_b64encode(b"\x01" * 64).decode("ascii") with pytest.raises(ValueError): codec.to_bytes(None, long_key) @@ -679,9 +715,9 @@ def test_wg_wrong_length_string_raises(): def test_wg_wrong_length_bytes_raises(): codec = wg.Codec() with pytest.raises(BinaryParseError): - codec.to_string(None, os.urandom(16)) + codec.to_string(None, b"\x01" * 16) with pytest.raises(BinaryParseError): - codec.to_string(None, os.urandom(64)) + codec.to_string(None, b"\x01" * 64) def test_wg_validate(): @@ -689,9 +725,9 @@ def test_wg_validate(): codec.validate(VALID_WG_KEY_BYTES) with pytest.raises(ValueError): - codec.validate(os.urandom(31)) + codec.validate(b"\x01" * 31) with pytest.raises(ValueError): - codec.validate(os.urandom(33)) + codec.validate(b"\x01" * 33) def test_wg_protocol_lookup(): @@ -706,3 +742,11 @@ def test_wg_integration(): ma = Multiaddr(f"/ip4/1.2.3.4/udp/51820/wg/{VALID_WG_KEY_STRING}") assert str(ma) == f"/ip4/1.2.3.4/udp/51820/wg/{VALID_WG_KEY_STRING}" assert ma.value_for_protocol(protocols.P_WG) == VALID_WG_KEY_STRING + + +def test_wg_integration_urlsafe_key(): + # a key whose standard-base64 form would contain '/' and + # thus break '/'-delimited multiaddr string parsing + ma = Multiaddr(f"/ip6/::1/udp/51820/wg/{HIGH_WG_KEY_STRING}") + assert str(ma) == f"/ip6/::1/udp/51820/wg/{HIGH_WG_KEY_STRING}" + assert ma.value_for_protocol(protocols.P_WG) == HIGH_WG_KEY_STRING