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 new file mode 100644 index 0000000..ac657cd --- /dev/null +++ b/multiaddr/codecs/wg.py @@ -0,0 +1,75 @@ +""" +WireGuard protocol codec. + +Encode/decode a 32-byte Curve25519 public key as multibase base64url +(the ``u`` prefix form used by ``/certhash`` in go-multiaddr and +py-multiaddr). Standard base64 from ``wg(8)`` tooling may contain ``/`` +and cannot be used directly in ``/``-delimited multiaddr strings. + +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 + +To convert a ``wg(8)`` public key for use in a multiaddr string:: + + import base64 + import multibase + + raw = base64.b64decode(wg_tooling_key) + safe = multibase.encode("base64url", raw).decode("ascii") + # /ip4/1.2.3.4/udp/51820/wg/{safe} + +See also the upstream multicodec addition process: +https://github.com/multiformats/multicodec?tab=readme-ov-file#adding-new-multicodecs-to-the-table +""" + +from typing import Any + +import multibase + +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: + if not string.startswith("u"): + raise ValueError("wg public key must use base64url multibase prefix 'u'") + + try: + decoded = multibase.decode(string) + except Exception as exc: + raise ValueError(f"invalid multibase WireGuard public key: {exc}") from exc + + decoded_bytes = decoded[1] if isinstance(decoded, tuple) else decoded + if not isinstance(decoded_bytes, (bytes, bytearray)): + raise ValueError("failed to decode multibase string to bytes") + raw = bytes(decoded_bytes) + + if len(raw) != WG_KEY_LENGTH: + 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, got {len(buf)}", + buf, + "wg", + ) + encoded_string = multibase.encode("base64url", buf) + return encoded_string.decode("utf-8") + + def validate(self, b: bytes) -> None: + if len(b) != WG_KEY_LENGTH: + raise ValueError(f"WireGuard public key must be {WG_KEY_LENGTH} bytes, 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/newsfragments/107.feature.rst b/newsfragments/107.feature.rst new file mode 100644 index 0000000..280fd12 --- /dev/null +++ b/newsfragments/107.feature.rst @@ -0,0 +1,4 @@ +Add draft WireGuard (``wg``) multiaddr protocol support. Public keys are +encoded as 32-byte Curve25519 values using multibase base64url (``u`` prefix) +in textual multiaddrs. Protocol code ``0x01C7`` is provisional until +registered in the upstream multicodec table. diff --git a/tests/test_multiaddr.py b/tests/test_multiaddr.py index da402e5..5acdc2c 100644 --- a/tests/test_multiaddr.py +++ b/tests/test_multiaddr.py @@ -83,6 +83,10 @@ "/dns4", "/dns6", "/cancer", + "/wg", + "/wg/not-valid-base64", + "/wg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "/ip4/1.2.3.4/udp/51820/wg", ], ) def test_invalid(addr_str): @@ -139,6 +143,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/uAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "/ip6/::1/udp/51820/wg/uAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "/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..88c5dad 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,99 @@ def test_certhash_validate_function(): # Invalid bytes should raise a ValueError with pytest.raises(ValueError): codec.validate(INVALID_BYTES) + + +# --- WireGuard (wg) Tests --- +# Fixed key whose standard base64 contains '/' (unsafe in multiaddr strings) +SLASH_WG_KEY_STD_B64 = "rCzgw/sszhsg+SpR8V5IPmVnYPg2PliWW739/SWemwY=" +SLASH_WG_KEY_BYTES = base64.b64decode(SLASH_WG_KEY_STD_B64) +SLASH_WG_KEY_STRING = multibase.encode("base64url", SLASH_WG_KEY_BYTES).decode("utf-8") + +# A well-known test key (all zeros) +ZERO_WG_KEY_BYTES = b"\x00" * 32 +ZERO_WG_KEY_STRING = multibase.encode("base64url", ZERO_WG_KEY_BYTES).decode("utf-8") + + +def test_wg_valid_roundtrip(): + codec = wg.Codec() + + b = codec.to_bytes(None, SLASH_WG_KEY_STRING) + assert isinstance(b, bytes) + assert len(b) == 32 + assert b == SLASH_WG_KEY_BYTES + + s_out = codec.to_string(None, b) + assert s_out == SLASH_WG_KEY_STRING + + +def test_wg_slash_key_multibase_roundtrip(): + codec = wg.Codec() + assert "/" not in SLASH_WG_KEY_STRING + assert "/" in SLASH_WG_KEY_STD_B64 + + b = codec.to_bytes(None, SLASH_WG_KEY_STRING) + assert b == SLASH_WG_KEY_BYTES + assert codec.to_string(None, b) == SLASH_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_standard_base64_without_u_rejected(): + codec = wg.Codec() + with pytest.raises(ValueError, match="multibase prefix 'u'"): + codec.to_bytes(None, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + +def test_wg_invalid_multibase_raises(): + codec = wg.Codec() + with pytest.raises(ValueError): + codec.to_bytes(None, "not-valid-multibase!!!") + + +def test_wg_wrong_length_string_raises(): + codec = wg.Codec() + short_key = multibase.encode("base64url", os.urandom(16)).decode("utf-8") + with pytest.raises(ValueError): + codec.to_bytes(None, short_key) + + long_key = multibase.encode("base64url", os.urandom(64)).decode("utf-8") + 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(SLASH_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/{SLASH_WG_KEY_STRING}") + assert str(ma) == f"/ip4/1.2.3.4/udp/51820/wg/{SLASH_WG_KEY_STRING}" + assert ma.value_for_protocol(protocols.P_WG) == SLASH_WG_KEY_STRING