Newer
Older
#!/usr/bin/env python
#
# Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2017 New Vector Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Script for signing and sending federation requests.
Some tips on doing the join dance with this:
room_id=...
user_id=...
# make_join
federation_client.py "/_matrix/federation/v1/make_join/$room_id/$user_id?ver=5" > make_join.json
# sign
jq -M .event make_join.json | sign_json --sign-event-room-version=$(jq -r .room_version make_join.json) -o signed-join.json
# send_join
federation_client.py -X PUT "/_matrix/federation/v2/send_join/$room_id/x" --body $(<signed-join.json) > send_join.json
"""
Amber Brown
committed
import base64
import json
import sys
from typing import Any, Dict, Optional, Tuple
Dagfinn Ilmari Mannsåker
committed
from urllib import parse as urlparse
import signedjson.key
Richard van der Hoff
committed
import signedjson.types
Amber Brown
committed
from requests.adapters import HTTPAdapter
from urllib3 import HTTPConnectionPool
# uncomment the following to enable debug logging of http requests
Amber Brown
committed
# from httplib import HTTPConnection
# HTTPConnection.debuglevel = 1
def encode_base64(input_bytes: bytes) -> str:
"""Encode bytes as a base64 string without any padding."""
input_len = len(input_bytes)
output_len = 4 * ((input_len + 2) // 3) + (input_len + 2) % 3 - 2
output_bytes = base64.b64encode(input_bytes)
output_string = output_bytes[:output_len].decode("ascii")
return output_string
def encode_canonical_json(value: object) -> bytes:
Amber Brown
committed
value,
# Encode code-points outside of ASCII as UTF-8 rather than \u escapes
ensure_ascii=False,
# Remove unecessary white space.
Amber Brown
committed
# Sort the keys of dictionaries.
sort_keys=True,
# Encode the resulting unicode as UTF-8 bytes.
).encode("UTF-8")
Richard van der Hoff
committed
def sign_json(
json_object: Any, signing_key: signedjson.types.SigningKey, signing_name: str
) -> Any:
signatures = json_object.pop("signatures", {})
unsigned = json_object.pop("unsigned", None)
signed = signing_key.sign(encode_canonical_json(json_object))
signature_base64 = encode_base64(signed.signature)
key_id = "%s:%s" % (signing_key.alg, signing_key.version)
signatures.setdefault(signing_name, {})[key_id] = signature_base64
json_object["signatures"] = signatures
if unsigned is not None:
json_object["unsigned"] = unsigned
return json_object
Richard van der Hoff
committed
def request(
method: Optional[str],
origin_name: str,
origin_key: signedjson.types.SigningKey,
destination: str,
path: str,
content: Optional[str],
) -> requests.Response:
if method is None:
if content is None:
method = "GET"
else:
method = "POST"
json_to_sign = {
"method": method,
"uri": path,
"origin": origin_name,
"destination": destination,
}
if content is not None:
json_to_sign["content"] = json.loads(content)
signed_json = sign_json(json_to_sign, origin_key, origin_name)
authorization_headers = []
for key, sig in signed_json["signatures"][origin_name].items():
Jan Christian Grünhage
committed
header = 'X-Matrix origin=%s,key="%s",sig="%s",destination="%s"' % (
origin_name,
key,
sig,
destination,
)
authorization_headers.append(header)
Amber Brown
committed
print("Authorization: %s" % header, file=sys.stderr)
dest = "matrix://%s%s" % (destination, path)
Amber Brown
committed
print("Requesting %s" % dest, file=sys.stderr)
s = requests.Session()
s.mount("matrix://", MatrixConnectionAdapter())
headers: Dict[str, str] = {
"Host": destination,
"Authorization": authorization_headers[0],
}
if method == "POST":
headers["Content-Type"] = "application/json"
Richard van der Hoff
committed
return s.request(
method=method,
url=dest,
headers=headers,
verify=False,
data=content,
stream=True,
def main() -> None:
parser = argparse.ArgumentParser(
Amber Brown
committed
description="Signs and sends a federation request to a matrix homeserver"
parser.add_argument(
Amber Brown
committed
"-N",
"--server-name",
help="Name to give as the local homeserver. If unspecified, will be "
Amber Brown
committed
"read from the config file.",
)
parser.add_argument(
Amber Brown
committed
"-k",
"--signing-key-path",
help="Path to the file containing the private ed25519 key to sign the "
Amber Brown
committed
"request with.",
)
Amber Brown
committed
"-c",
"--config",
default="homeserver.yaml",
help="Path to server config file. Ignored if --server-name and "
Amber Brown
committed
"--signing-key-path are both given.",
)
parser.add_argument(
Amber Brown
committed
"-d",
"--destination",
default="matrix.org",
help="name of the remote homeserver. We will do SRV lookups and "
Amber Brown
committed
"connect appropriately.",
parser.add_argument(
Amber Brown
committed
"-X",
"--method",
help="HTTP method to use for the request. Defaults to GET if --body is"
Amber Brown
committed
"unspecified, POST if it is.",
Amber Brown
committed
parser.add_argument("--body", help="Data to send as the body of the HTTP request")
"path", help="request path, including the '/_matrix/federation/...' prefix."
)
args = parser.parse_args()
args.signing_key = None
if args.signing_key_path:
with open(args.signing_key_path) as f:
args.signing_key = f.readline()
if not args.server_name or not args.signing_key:
read_args_from_config(args)
assert isinstance(args.signing_key, str)
algorithm, version, key_base64 = args.signing_key.split()
key = signedjson.key.decode_signing_key_base64(algorithm, version, key_base64)
Richard van der Hoff
committed
result = request(
args.method,
Amber Brown
committed
args.server_name,
key,
args.destination,
content=args.body,
Richard van der Hoff
committed
sys.stderr.write("Status Code: %d\n" % (result.status_code,))
for chunk in result.iter_content():
# we write raw utf8 to stdout.
sys.stdout.buffer.write(chunk)
Amber Brown
committed
print("")
def read_args_from_config(args: argparse.Namespace) -> None:
config = yaml.safe_load(fh)
if not args.server_name:
if not args.signing_key:
if "signing_key" in config:
args.signing_key = config["signing_key"]
else:
with open(config["signing_key_path"]) as f:
args.signing_key = f.readline()
class MatrixConnectionAdapter(HTTPAdapter):
@staticmethod
def lookup(s: str, skip_well_known: bool = False) -> Tuple[str, int]:
# ipv6 literal (with no port)
return s, 8448
if ":" in s:
Amber Brown
committed
out = s.rsplit(":", 1)
try:
port = int(out[1])
except ValueError:
raise ValueError("Invalid host:port '%s'" % s)
return out[0], port
# try a .well-known lookup
if not skip_well_known:
well_known = MatrixConnectionAdapter.get_well_known(s)
if well_known:
return MatrixConnectionAdapter.lookup(well_known, skip_well_known=True)
try:
srv = srvlookup.lookup("matrix", "tcp", s)[0]
return srv.host, srv.port
Amber Brown
committed
except Exception:
def get_well_known(server_name: str) -> Optional[str]:
uri = "https://%s/.well-known/matrix/server" % (server_name,)
print("fetching %s" % (uri,), file=sys.stderr)
try:
resp = requests.get(uri)
if resp.status_code != 200:
print("%s gave %i" % (uri, resp.status_code), file=sys.stderr)
return None
parsed_well_known = resp.json()
if not isinstance(parsed_well_known, dict):
raise Exception("not a dict")
if "m.server" not in parsed_well_known:
raise Exception("Missing key 'm.server'")
new_name = parsed_well_known["m.server"]
print("well-known lookup gave %s" % (new_name,), file=sys.stderr)
return new_name
except Exception as e:
print("Invalid response from %s: %s" % (uri, e), file=sys.stderr)
def get_connection(
self, url: str, proxies: Optional[Dict[str, str]] = None
) -> HTTPConnectionPool:
parsed = urlparse.urlparse(url)
(host, port) = self.lookup(parsed.netloc)
netloc = "%s:%d" % (host, port)
print("Connecting to %s" % (netloc,), file=sys.stderr)
url = urlparse.urlunparse(
Amber Brown
committed
("https", netloc, parsed.path, parsed.params, parsed.query, parsed.fragment)
)
return super().get_connection(url, proxies)