Skip to content
Open
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
87 changes: 87 additions & 0 deletions gh/wg_spec_submission_plan.md
Original file line number Diff line number Diff line change
@@ -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: <https://github.com/multiformats/multicodec>

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: <https://github.com/multiformats/multiaddr>

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}/<host>/udp/<port>/wg/<key>`.

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
75 changes: 75 additions & 0 deletions multiaddr/codecs/wg.py
Original file line number Diff line number Diff line change
@@ -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)}")
2 changes: 2 additions & 0 deletions multiaddr/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
P_WEBTRANSPORT = 0x01D1
P_WEBRTC_DIRECT = 0x118
P_WEBRTC = 0x119
P_WG = 0x01C7
P_MEMORY = 0x309
P_CERTHASH = 0x1D2

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions newsfragments/107.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions tests/test_multiaddr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand Down
98 changes: 97 additions & 1 deletion tests/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Loading