Skip to content

Commit

Permalink
wip account creation
Browse files Browse the repository at this point in the history
  • Loading branch information
DavidBuchanan314 committed Mar 3, 2024
1 parent a80ce16 commit 07fd95d
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 14 deletions.
3 changes: 3 additions & 0 deletions millipds_dev.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ RUN python3 -m pip install -v src/
# init the db with dev presets
RUN python3 -m millipds init millipds.test --dev

# create a test user
RUN python3 -m millipds account create bob.test did:web:bob.test --unsafe_password=hunter2

# do the thing
CMD python3 -m millipds run --listen_host=0.0.0.0 --listen_port=8123

Expand Down
43 changes: 41 additions & 2 deletions src/millipds/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Usage:
millipds init <hostname> [--dev|--sandbox]
millipds config [--pds_pfx=URL] [--pds_did=DID] [--bsky_appview_pfx=URL] [--bsky_appview_did=DID]
millipds account create <did> <handle> [--unsafe_password=PW]
millipds run [--sock_path=PATH] [--listen_host=HOST] [--listen_port=PORT]
millipds (-h | --help)
millipds --version
Expand All @@ -19,11 +20,22 @@
Any options not specified will be left at their previous values. Once changes
have been made (or even if they haven't), the new config will be printed.
Do not change the config while the PDS is running (TODO: enforce this in code (or make sure it's harmless?))
--pds_pfx=URL The HTTP URL prefix that this PDS is publicly accessible at (e.g. mypds.example)
--pds_did=DID This PDS's DID (e.g. did:web:mypds.example)
--bsky_appview_pfx=URL AppView URL prefix e.g. "https://api.bsky-sandbox.dev"
--bsky_appview_did=DID AppView DID e.g. did:web:api.bsky-sandbox.dev
Account create:
Create a new user account on the PDS. Bring your own DID and corresponding
handle - millipds will not (yet?) attempt to validate either.
You'll be prompted for a password interactively.
TODO: consider bring-your-own signing key?
--unsafe_password=PW Specify password non-iteractively, for use in test scripts etc.
Run:
Launch the service (in the foreground)
Expand All @@ -36,12 +48,21 @@
--version Show version.
"""

from docopt import docopt
import importlib.metadata
import asyncio
import sys
import logging
from getpass import getpass

from docopt import docopt

from . import service
from . import database
from . import crypto


logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?


"""
This is the entrypoint for the `millipds` command (declared in project.scripts)
Expand Down Expand Up @@ -75,7 +96,7 @@ def main():
bsky_appview_pfx="https://api.bsky-sandbox.dev",
bsky_appview_did="did:web:api.bsky-sandbox.dev",
)
else:
else: # "prod" presets
db.update_config(
pds_pfx=f'https://{args["<hostname>"]}',
pds_did=f'did:web:{args["<hostname>"]}',
Expand All @@ -98,6 +119,24 @@ def main():
bsky_appview_did=args["--bsky_appview_did"],
)
db.print_config()
elif args["account"]:
if args["create"]:
pw = args["--unsafe_password"]
if pw:
# rationale: only allow non-iteractive password input from scripts etc.
if sys.stdin.buffer.isatty():
print("error: --unsafe_password can't be used from an interactive shell")
return
else:
pw = getpass(f"Password for new account: ")
db.account_create(
did=args["<did>"],
handle=args["<handle>"],
password=pw,
privkey=crypto.keygen_p256() # TODO: supply from arg
)
else:
print("CLI arg parse error?!")
elif args["run"]:
asyncio.run(service.run(
sock_path=args["--sock_path"],
Expand Down
22 changes: 22 additions & 0 deletions src/millipds/crypto.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
from cryptography.exceptions import InvalidSignature

Expand Down Expand Up @@ -47,3 +48,24 @@ def raw_sign(privkey: ec.EllipticCurvePrivateKey, data: bytes) -> bytes:
)
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return signature


def keygen_p256() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())


def privkey_to_pem(privkey: ec.EllipticCurvePrivateKey) -> str:
return privkey.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode()


def privkey_from_pem(pem: str) -> ec.EllipticCurvePrivateKey:
privkey = serialization.load_pem_private_key(pem.encode(), password=None)
if not isinstance(privkey, ec.EllipticCurvePrivateKey):
raise TypeError("unsupported key type")
if not isinstance(privkey.curve, (ec.SECP256R1, ec.SECP256K1)):
raise TypeError("unsupported key type")
return privkey
104 changes: 99 additions & 5 deletions src/millipds/database.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
"""
Ideally, all SQL statements are contained within this file
Ideally, all SQL statements are contained within this file.
Password hashing also happens in here, because it doesn't make much sense to do
it anywhere else.
"""

from typing import Optional, Dict
from functools import cached_property
import secrets
import os
import logging

from argon2 import PasswordHasher # maybe this should come from .crypto?
import apsw
import apsw.bestpractice

from atmst.blockstore import BlockStore

from . import static_config
from . import util
from . import crypto

logger = logging.getLogger(__name__)

# https://rogerbinns.github.io/apsw/bestpractice.html
apsw.bestpractice.apply(apsw.bestpractice.recommended)

class Database:
def __init__(self, path: str=static_config.MAIN_DB_PATH) -> None:
util.mkdirs_for_file(path)
self.con = apsw.Connection(path)
self.pw_hasher = PasswordHasher()

try:
if self.config["db_version"] != static_config.MILLIPDS_DB_VERSION:
Expand All @@ -27,6 +43,7 @@ def __init__(self, path: str=static_config.MAIN_DB_PATH) -> None:
self._init_central_tables()

def _init_central_tables(self):
logger.info("initing central tables")
self.con.execute(
"""
CREATE TABLE config(
Expand All @@ -42,7 +59,7 @@ def _init_central_tables(self):

self.con.execute(
"""
INSERT INTO config (
INSERT INTO config(
db_version,
jwt_access_secret
) VALUES (?, ?)
Expand All @@ -54,6 +71,7 @@ def _init_central_tables(self):
"""
CREATE TABLE user(
did TEXT PRIMARY KEY NOT NULL,
handle TEXT NOT NULL,
prefs BLOB NOT NULL,
pw_hash TEXT NOT NULL,
repo_path TEXT NOT NULL,
Expand All @@ -62,6 +80,8 @@ def _init_central_tables(self):
"""
)

self.con.execute("CREATE UNIQUE INDEX user_by_handle ON user(handle)")

self.con.execute(
"""
CREATE TABLE firehose(
Expand All @@ -84,11 +104,20 @@ def update_config(self,
if pds_did is not None:
self.con.execute("UPDATE config SET pds_did=?", (pds_did,))
if bsky_appview_pfx is not None:
self.con.execute("UPDATE config SET bsky_appview_pfx=?", (bsky_appview_pfx,))
self.con.execute(
"UPDATE config SET bsky_appview_pfx=?",
(bsky_appview_pfx,)
)
if bsky_appview_did is not None:
self.con.execute("UPDATE config SET bsky_appview_did=?", (bsky_appview_did,))
self.con.execute(
"UPDATE config SET bsky_appview_did=?",
(bsky_appview_did,)
)

del self.config # invalidate the cached value
try:
del self.config # invalidate the cached value
except AttributeError:
pass

@cached_property
def config(self) -> Dict[str, object]:
Expand Down Expand Up @@ -117,3 +146,68 @@ def print_config(self, redact_secrets: bool=True) -> None:
if redact_secrets and "secret" in k:
v = "[REDACTED]"
print(f"{k:<{maxlen}} : {v!r}")

def account_create(self,
did: str,
handle: str,
password: str,
privkey: crypto.ec.EllipticCurvePrivateKey
) -> None:
pw_hash = self.pw_hasher.hash(password)
privkey_pem = crypto.privkey_to_pem(privkey)
repo_path = f"{static_config.REPOS_DIR}/{util.did_to_safe_filename(did)}.sqlite3"
logger.info(
f"creating account for did={did}, handle={handle} at {repo_path}"
)
with self.con:
self.con.execute(
"""
INSERT INTO user(
did,
handle,
prefs,
pw_hash,
repo_path,
signing_key
) VALUES (?, ?, ?, ?, ?, ?)
""",
(did, handle, b"{}", pw_hash, repo_path, privkey_pem)
)
UserDatabase.init_tables(self.con, did, repo_path)
self.con.execute("DETACH spoke")


class UserDBBlockStore(BlockStore):
pass # TODO


class UserDatabase:
def __init__(self, wcon: apsw.Connection, did: str, path: str) -> None:
self.wcon = wcon # writes go via the hub database connection, using ATTACH
self.rcon = apsw.Connection(path, flags=apsw.SQLITE_OPEN_READONLY)

# TODO: check db version and did match

@staticmethod
def init_tables(wcon: apsw.Connection, did: str, path: str) -> None:
util.mkdirs_for_file(path)
wcon.execute("ATTACH ? AS spoke", (path,))

wcon.execute(
"""
CREATE TABLE spoke.repo(
db_version INTEGER NOT NULL,
did TEXT NOT NULL
)
"""
)

wcon.execute(
"INSERT INTO spoke.repo(db_version, did) VALUES (?, ?)",
(static_config.MILLIPDS_DB_VERSION, did)
)

# TODO: the other tables

# nb: caller is responsible for running "DETACH spoke", after the end
# of the transaction
4 changes: 1 addition & 3 deletions src/millipds/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@

from . import static_config

logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?


async def hello(request: web.Request):
version = importlib.metadata.version("millipds")
Expand Down Expand Up @@ -46,7 +44,7 @@ async def server_describe_server(request: web.Request):
This gets invoked via millipds.__main__.py
"""
async def run(sock_path: Optional[str], host: str, port: int):
runner = web.AppRunner(app, access_log_format=static_config.LOG_FMT)
runner = web.AppRunner(app, access_log_format=static_config.HTTP_LOG_FMT)
await runner.setup()

if sock_path is None:
Expand Down
8 changes: 4 additions & 4 deletions src/millipds/static_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
(some of this stuff might want to be broken out into a proper config file, eventually)
"""

LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'
HTTP_LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'

GROUPNAME = "millipds-sock"

MILLIPDS_DB_VERSION = 1 # this gets bumped if we make breaking changes to the db schema

DATA_DIR = "./data/"
MAIN_DB_PATH = DATA_DIR + "millipds.sqlite3"
REPOS_DIR = DATA_DIR + "repos/"
DATA_DIR = "./data"
MAIN_DB_PATH = DATA_DIR + "/millipds.sqlite3"
REPOS_DIR = DATA_DIR + "/repos"
16 changes: 16 additions & 0 deletions src/millipds/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import os
import hashlib

def mkdirs_for_file(path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)

FILANEME_SAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"

def did_to_safe_filename(did: str) -> str:
"""
The format is <sha256(did)>_<filtered_did>
The former guarantees uniqueness, and the latter makes it human-recognizeable (ish)
"""

hexdigest = hashlib.sha256(did.encode()).hexdigest()
filtered = "".join(char for char in did if char in FILANEME_SAFE_CHARS)

# Truncate to make sure we're staying within PATH_MAX
# (with room to spare, in case the caller appends a file extension)
return f"{hexdigest}_{filtered}"[:200]

0 comments on commit 07fd95d

Please sign in to comment.