Expose the pre-constructed keypair functionality to the HTTP API

This commit is contained in:
Jean-Paul Calderone 2023-01-03 11:31:29 -05:00
parent c7bb190290
commit 3423bfb351
4 changed files with 138 additions and 19 deletions

View File

@ -32,6 +32,7 @@ from allmydata.storage.server import StorageServer, FoolscapStorageServer
from allmydata import storage_client
from allmydata.immutable.upload import Uploader
from allmydata.immutable.offloaded import Helper
from allmydata.mutable.filenode import MutableFileNode
from allmydata.introducer.client import IntroducerClient
from allmydata.util import (
hashutil, base32, pollmixin, log, idlib,
@ -1086,9 +1087,38 @@ class _Client(node.Node, pollmixin.PollMixin):
def create_immutable_dirnode(self, children, convergence=None):
return self.nodemaker.create_immutable_directory(children, convergence)
def create_mutable_file(self, contents=None, version=None):
def create_mutable_file(
self,
contents: bytes | None = None,
version: int | None = None,
*,
unique_keypair: tuple[rsa.PublicKey, rsa.PrivateKey] | None = None,
) -> MutableFileNode:
"""
Create *and upload* a new mutable object.
:param contents: If given, the initial contents for the new object.
:param version: If given, the mutable file format for the new object
(otherwise a format will be chosen automatically).
:param unique_keypair: **Warning** This valuely independently
determines the identity of the mutable object to create. There
cannot be two different mutable objects that share a keypair.
They will merge into one object (with undefined contents).
It is not common to pass a non-None value for this parameter. If
None is given then a new random keypair will be generated.
If non-None, the given public/private keypair will be used for the
new object.
:return: A Deferred which will fire with a representation of the new
mutable object after it has been uploaded.
"""
return self.nodemaker.create_mutable_file(contents,
version=version)
version=version,
keypair=unique_keypair)
def upload(self, uploadable, reactor=None):
uploader = self.getServiceNamed("uploader")

View File

@ -105,6 +105,7 @@ from allmydata.scripts.common import (
from ..crypto import (
ed25519,
rsa,
)
from .eliotutil import (
EliotLoggedRunTest,
@ -622,15 +623,28 @@ class FakeMutableFileNode(object): # type: ignore # incomplete implementation
MUTABLE_SIZELIMIT = 10000
def __init__(self, storage_broker, secret_holder,
default_encoding_parameters, history, all_contents):
_public_key: rsa.PublicKey | None
_private_key: rsa.PrivateKey | None
def __init__(self,
storage_broker,
secret_holder,
default_encoding_parameters,
history,
all_contents,
keypair: tuple[rsa.PublicKey, rsa.PrivateKey] | None
):
self.all_contents = all_contents
self.file_types = {} # storage index => MDMF_VERSION or SDMF_VERSION
self.init_from_cap(make_mutable_file_cap())
self.init_from_cap(make_mutable_file_cap(keypair))
self._k = default_encoding_parameters['k']
self._segsize = default_encoding_parameters['max_segment_size']
def create(self, contents, key_generator=None, keysize=None,
version=SDMF_VERSION):
if keypair is None:
self._public_key = self._private_key = None
else:
self._public_key, self._private_key = keypair
def create(self, contents, version=SDMF_VERSION):
if version == MDMF_VERSION and \
isinstance(self.my_uri, (uri.ReadonlySSKFileURI,
uri.WriteableSSKFileURI)):
@ -826,9 +840,28 @@ class FakeMutableFileNode(object): # type: ignore # incomplete implementation
return defer.succeed(consumer)
def make_mutable_file_cap():
return uri.WriteableSSKFileURI(writekey=os.urandom(16),
fingerprint=os.urandom(32))
def make_mutable_file_cap(
keypair: tuple[rsa.PublicKey, rsa.PrivateKey] | None = None,
) -> uri.WriteableSSKFileURI:
"""
Create a local representation of a mutable object.
:param keypair: If None, a random keypair will be generated for the new
object. Otherwise, this is the keypair for that object.
"""
if keypair is None:
writekey = os.urandom(16)
fingerprint = os.urandom(32)
else:
pubkey, privkey = keypair
pubkey_s = rsa.der_string_from_verifying_key(pubkey)
privkey_s = rsa.der_string_from_signing_key(privkey)
writekey = hashutil.ssk_writekey_hash(privkey_s)
fingerprint = hashutil.ssk_pubkey_fingerprint_hash(pubkey_s)
return uri.WriteableSSKFileURI(
writekey=writekey, fingerprint=fingerprint,
)
def make_mdmf_mutable_file_cap():
return uri.WriteableMDMFFileURI(writekey=os.urandom(16),
@ -858,7 +891,7 @@ def create_mutable_filenode(contents, mdmf=False, all_contents=None):
encoding_params['max_segment_size'] = 128*1024
filenode = FakeMutableFileNode(None, None, encoding_params, None,
all_contents)
all_contents, None)
filenode.init_from_cap(cap)
if mdmf:
filenode.create(MutableData(contents), version=MDMF_VERSION)

View File

@ -8,6 +8,7 @@ from six import ensure_binary
import os.path, re, time
import treq
from urllib.parse import quote as urlquote, unquote as urlunquote
from base64 import urlsafe_b64encode
from bs4 import BeautifulSoup
@ -32,6 +33,7 @@ from allmydata.util import fileutil, base32, hashutil, jsonbytes as json
from allmydata.util.consumer import download_to_data
from allmydata.util.encodingutil import to_bytes
from ...util.connection_status import ConnectionStatus
from ...crypto.rsa import PublicKey, PrivateKey, create_signing_keypair, der_string_from_signing_key
from ..common import (
EMPTY_CLIENT_CONFIG,
FakeCHKFileNode,
@ -59,6 +61,7 @@ from allmydata.interfaces import (
MustBeReadonlyError,
)
from allmydata.mutable import servermap, publish, retrieve
from allmydata.mutable.common import derive_mutable_keys
from .. import common_util as testutil
from ..common_util import TimezoneMixin
from ..common_web import (
@ -94,14 +97,19 @@ class FakeNodeMaker(NodeMaker):
def _create_mutable(self, cap):
return FakeMutableFileNode(None, None,
self.encoding_params, None,
self.all_contents).init_from_cap(cap)
def create_mutable_file(self, contents=b"", keysize=None,
version=SDMF_VERSION,
keypair=None,
self.all_contents, None).init_from_cap(cap)
def create_mutable_file(self,
contents=None,
version=None,
keypair: tuple[PublicKey, PrivateKey] | None=None,
):
assert keypair is None, "FakeNodeMaker does not support externally supplied keypairs"
if contents is None:
contents = b""
if version is None:
version = SDMF_VERSION
n = FakeMutableFileNode(None, None, self.encoding_params, None,
self.all_contents)
self.all_contents, keypair)
return n.create(contents, version=version)
class FakeUploader(service.Service):
@ -2865,6 +2873,37 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi
"Unknown format: foo",
method="post", data=body, headers=headers)
async def test_POST_upload_keypair(self) -> None:
"""
A *POST* creating a new mutable object may include a *private-key*
query argument giving a urlsafe-base64-encoded RSA private key to use
as the "signature key". The given signature key is used, rather than
a new one being generated.
"""
format = "sdmf"
priv, pub = create_signing_keypair(2048)
encoded_privkey = urlsafe_b64encode(der_string_from_signing_key(priv)).decode("ascii")
filename = "predetermined-sdmf"
actual_cap = uri.from_string(await self.POST(
self.public_url +
f"/foo?t=upload&format={format}&private-key={encoded_privkey}",
file=(filename, self.NEWFILE_CONTENTS * 100),
))
# Ideally we would inspect the private ("signature") and public
# ("verification") keys but they are not made easily accessible here
# (ostensibly because we have a FakeMutableFileNode instead of a real
# one).
#
# So, instead, re-compute the writekey and fingerprint and compare
# those against the capability string.
expected_writekey, _, expected_fingerprint = derive_mutable_keys((pub, priv))
self.assertEqual(
(expected_writekey, expected_fingerprint),
(actual_cap.writekey, actual_cap.fingerprint),
)
def test_POST_upload_format(self):
def _check_upload(ign, format, uri_prefix, fn=None):
filename = format + ".txt"

View File

@ -3,8 +3,10 @@ Ported to Python 3.
"""
from __future__ import annotations
from base64 import urlsafe_b64decode
from twisted.web import http, static
from twisted.web.iweb import IRequest
from twisted.internet import defer
from twisted.web.resource import (
Resource,
@ -45,6 +47,19 @@ from allmydata.web.check_results import (
)
from allmydata.web.info import MoreInfo
from allmydata.util import jsonbytes as json
from allmydata.crypto.rsa import PrivateKey, PublicKey, create_signing_keypair_from_string
def get_keypair(request: IRequest) -> tuple[PublicKey, PrivateKey] | None:
"""
Load a keypair from a urlsafe-base64-encoded RSA private key in the
**private-key** argument of the given request, if there is one.
"""
privkey_der = get_arg(request, "private-key", None)
if privkey_der is None:
return None
privkey, pubkey = create_signing_keypair_from_string(urlsafe_b64decode(privkey_der))
return pubkey, privkey
class ReplaceMeMixin(object):
@ -54,7 +69,8 @@ class ReplaceMeMixin(object):
mutable_type = get_mutable_type(file_format)
if mutable_type is not None:
data = MutableFileHandle(req.content)
d = client.create_mutable_file(data, version=mutable_type)
keypair = get_keypair(req)
d = client.create_mutable_file(data, version=mutable_type, unique_keypair=keypair)
def _uploaded(newnode):
d2 = self.parentnode.set_node(self.name, newnode,
overwrite=replace)
@ -96,7 +112,8 @@ class ReplaceMeMixin(object):
if file_format in ("SDMF", "MDMF"):
mutable_type = get_mutable_type(file_format)
uploadable = MutableFileHandle(contents.file)
d = client.create_mutable_file(uploadable, version=mutable_type)
keypair = get_keypair(req)
d = client.create_mutable_file(uploadable, version=mutable_type, unique_keypair=keypair)
def _uploaded(newnode):
d2 = self.parentnode.set_node(self.name, newnode,
overwrite=replace)