Repository: WebOfTrust/keria
Issue category: Feature Request

Issue:
Provide a way to protect the boot endpoint
### Feature request description/rationale

I suggest there should be a way to protect the boot endpoint with some simple authentication. In my opinion, this is convenient even if the boot port is not exposed publicly on the internet. It would also make it easier for people to spin up a KERIA instance publicly without having to worry about unauthorized people booting agents.

# Suggestion

Implement a [Basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) check by configuring the keria boot endpoint with a `username` and a `password`. Like this:

```
keria start --boot-username username --boot-password password --boot 3903
```

Or, using environment variables

```
export KERIA_BOOT_USERNAME=username
export KERIA_BOOT_PASSWORD=password
keria start --boot 3903
```

To boot an agent, you can then either:

- Use basic auth in the Boot URL: `http://username:password@localhost:3903/boot`, most http clients will set the authorization header for you.
- Set the Authorization header in the boot request manually to `Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=`

We can also add a flag to completely disable the boot endpoint (https://github.com/WebOfTrust/keria/issues/203). E.g.

```
keria start --no-boot-endpoint
```

Or, 

```
export KERIA_DISABLE_BOOT_ENDPOINT=1
```

# Considerations

There are probably much more sophisticated ways to protect the boot endpoint. Please share your thoughts. In my opinion, this could be sufficient and will allow users to more easily deploy keria instances on the internet for testing purposes.

I have code and unit tests for this, so I can open a PR if we can agree this is a good idea.


Hints:
This makes sense to me for test servers deployed publicly. Would just want to make sure a simple username/pass doesn't end up becoming the norm.

I wrote a simple Spring Cloud Gateway service in Java that provisions one-time use boot URLs and emails them to clients. Used it to protect our infrastructure in an alpha program of our wallet, and it worked quite well. I can ask for it to be open sourced.

There are probably even safer ways to do it but it's a start.
> Would just want to make sure a simple username/pass doesn't end up becoming the norm.

True. I think that can be handled by some clever naming of the configuration option. For example, a flag `--enable-test-boot-authentication` that must be set when `--boot-password` is set. Or, `--dangerously-set-boot-password <password>`  or something like that. :)
Another note. It is quite common to password protect resources within a private network anyway. You would normally have password protection on your database servers for example. So perhaps it shouldn't be too inconvenient to protect it like this.

Patch:
diff --git a/src/keria/app/agenting.py b/src/keria/app/agenting.py
index 3d6f96a4..1fc8a210 100644
--- a/src/keria/app/agenting.py
+++ b/src/keria/app/agenting.py
@@ -4,6 +4,7 @@
 keria.app.agenting module
 
 """
+from base64 import b64decode
 import json
 import os
 import datetime
@@ -53,7 +54,8 @@
 
 
 def setup(name, bran, adminPort, bootPort, base='', httpPort=None, configFile=None, configDir=None,
-          keypath=None, certpath=None, cafilepath=None, cors=False, releaseTimeout=None, curls=None, iurls=None, durls=None):
+          keypath=None, certpath=None, cafilepath=None, cors=False, releaseTimeout=None, curls=None,
+          iurls=None, durls=None, bootUsername=None, bootPassword=None):
     """ Set up an ahab in Signify mode """
 
     agency = Agency(name=name, base=base, bran=bran, configFile=configFile, configDir=configDir, releaseTimeout=releaseTimeout, curls=curls, iurls=iurls, durls=durls)
@@ -66,7 +68,7 @@ def setup(name, bran, adminPort, bootPort, base='', httpPort=None, configFile=No
     if not bootServer.reopen():
         raise RuntimeError(f"cannot create boot http server on port {bootPort}")
     bootServerDoer = http.ServerDoer(server=bootServer)
-    bootEnd = BootEnd(agency)
+    bootEnd = BootEnd(agency, username=bootUsername, password=bootPassword)
     bootApp.add_route("/boot", bootEnd)
     bootApp.add_route("/health", HealthEnd())
 
@@ -871,17 +873,44 @@ def loadEnds(app):
 class BootEnd:
     """ Resource class for creating datastore in cloud ahab """
 
-    def __init__(self, agency):
+    def __init__(self, agency: Agency, username: str | None = None, password: str | None = None):
         """ Provides endpoints for initializing and unlocking an agent
-
         Parameters:
             agency (Agency): Agency for managing agents
-
+            username (str): username for boot request
+            password (str): password for boot request
         """
-        self.authn = authing.Authenticater(agency=agency)
+        self.username = username
+        self.password = password
         self.agency = agency
 
-    def on_post(self, req, rep):
+    def authenticate(self, req: falcon.Request):
+        # Username AND Password is not set, so no need to authenticate
+        if self.username is None and self.password is None:
+            return
+
+        if req.auth is None:
+            raise falcon.HTTPUnauthorized(title="Unauthorized")
+
+        scheme, token = req.auth.split(' ')
+        if scheme != 'Basic':
+            raise falcon.HTTPUnauthorized(title="Unauthorized")
+
+        try:
+            username, password = b64decode(token).decode('utf-8').split(':')
+
+            if username is None or password is None:
+                raise falcon.HTTPUnauthorized(title="Unauthorized")
+
+            if username == self.username and password == self.password:
+                return
+
+        except Exception:
+            raise falcon.HTTPUnauthorized(title="Unauthorized")
+
+        raise falcon.HTTPUnauthorized(title="Unauthorized")
+
+    def on_post(self, req: falcon.Request, rep: falcon.Response):
         """ Inception event POST endpoint
 
         Give me a new Agent.  Create Habery using ctrlPRE as database name, agentHab that anchors the caid and
@@ -893,6 +922,8 @@ def on_post(self, req, rep):
 
         """
 
+        self.authenticate(req)
+
         body = req.get_media()
         if "icp" not in body:
             raise falcon.HTTPBadRequest(title="invalid inception",
diff --git a/src/keria/app/cli/commands/start.py b/src/keria/app/cli/commands/start.py
index 21272d48..c7f21661 100644
--- a/src/keria/app/cli/commands/start.py
+++ b/src/keria/app/cli/commands/start.py
@@ -68,6 +68,14 @@
                     help="Set log level to DEBUG | INFO | WARNING | ERROR | CRITICAL. Default is CRITICAL")
 parser.add_argument("--logfile", action="store", required=False, default=None,
                     help="path of the log file. If not defined, logs will not be written to the file.")
+parser.add_argument("--experimental-boot-password",
+                    help="Experimental password for boot endpoint. Enables HTTP Basic Authentication for the boot endpoint. Only meant to be used for testing purposes.",
+                    dest="bootPassword",
+                    default=os.getenv("KERIA_EXPERIMENTAL_BOOT_PASSWORD"))
+parser.add_argument("--experimental-boot-username",
+                    help="Experimental username for boot endpoint. Enables HTTP Basic Authentication for the boot endpoint. Only meant to be used for testing purposes.",
+                    dest="bootUsername",
+                    default=os.getenv("KERIA_EXPERIMENTAL_BOOT_USERNAME"))
 
 def getListVariable(name):
     value = os.getenv(name)
@@ -99,7 +107,9 @@ def launch(args):
                             releaseTimeout=int(os.getenv("KERIA_RELEASER_TIMEOUT", "86400")),
                             curls=getListVariable("KERIA_CURLS"),
                             iurls=getListVariable("KERIA_IURLS"),
-                            durls=getListVariable("KERIA_DURLS"))
+                            durls=getListVariable("KERIA_DURLS"),
+                            bootPassword=args.bootPassword,
+                            bootUsername=args.bootUsername)
 
     directing.runController(doers=agency, expire=0.0)
 


Test patch:
diff --git a/tests/app/test_agenting.py b/tests/app/test_agenting.py
index 6f4cf910..e0338f38 100644
--- a/tests/app/test_agenting.py
+++ b/tests/app/test_agenting.py
@@ -5,6 +5,7 @@
 
 Testing the Mark II Agent
 """
+from base64 import b64encode
 import json
 import os
 import shutil
@@ -248,7 +249,7 @@ def test_agency_with_urls_from_arguments():
         assert agent.hby.cf.get()["iurls"] == iurls
         assert agent.hby.cf.get()["durls"] == durls
 
-def test_boot_ends(helpers):
+def test_unprotected_boot_ends(helpers):
     agency = agenting.Agency(name="agency", bran=None, temp=True)
     doist = doing.Doist(limit=1.0, tock=0.03125, real=True)
     doist.enter(doers=[agency])
@@ -281,6 +282,85 @@ def test_boot_ends(helpers):
         'description': 'agent for controller EK35JRNdfVkO4JwhXaSTdV4qzB_ibk_tGJmSVcY4pZqx already exists'
     }
 
+def test_protected_boot_ends(helpers):
+    agency = agenting.Agency(name="agency", bran=None, temp=True)
+    doist = doing.Doist(limit=1.0, tock=0.03125, real=True)
+    doist.enter(doers=[agency])
+
+    serder, sigers = helpers.controller()
+    assert serder.pre == helpers.controllerAID
+
+    app = falcon.App()
+    client = testing.TestClient(app)
+
+    username = "user"
+    password = "secret"
+
+    bootEnd = agenting.BootEnd(agency, username=username, password=password)
+    app.add_route("/boot", bootEnd)
+
+    body = dict(
+        icp=serder.ked,
+        sig=sigers[0].qb64,
+        salty=dict(
+            stem='signify:aid', pidx=0, tier='low', sxlt='OBXYZ',
+            icodes=[MtrDex.Ed25519_Seed], ncodes=[MtrDex.Ed25519_Seed]
+        )
+    )
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"))
+    assert rep.status_code == 401
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": "Something test"})
+    assert rep.status_code == 401
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": "Basic user:secret"})
+    assert rep.status_code == 401
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": f"Basic {b64encode(b'test:secret').decode('utf-8')}"} )
+    assert rep.status_code == 401
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": f"Basic {b64encode(b'user').decode('utf-8')}"} )
+    assert rep.status_code == 401
+
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": f"Basic {b64encode(b'user:test').decode('utf-8')}"} )
+    assert rep.status_code == 401
+
+    authorization = f"Basic {b64encode(b'user:secret').decode('utf-8')}"
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": authorization})
+    assert rep.status_code == 202
+
+def test_misconfigured_protected_boot_ends(helpers):
+    agency = agenting.Agency(name="agency", bran=None, temp=True)
+    doist = doing.Doist(limit=1.0, tock=0.03125, real=True)
+    doist.enter(doers=[agency])
+
+    serder, sigers = helpers.controller()
+    assert serder.pre == helpers.controllerAID
+
+    app = falcon.App()
+    client = testing.TestClient(app)
+
+    # No password set, should return 401
+    bootEnd = agenting.BootEnd(agency, username="user", password=None)
+    app.add_route("/boot", bootEnd)
+
+    body = dict(
+        icp=serder.ked,
+        sig=sigers[0].qb64,
+        salty=dict(
+            stem='signify:aid', pidx=0, tier='low', sxlt='OBXYZ',
+            icodes=[MtrDex.Ed25519_Seed], ncodes=[MtrDex.Ed25519_Seed]
+        )
+    )
+
+    authorization = f"Basic {b64encode(b'user').decode('utf-8')}"
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": authorization})
+    assert rep.status_code == 401
+
+    authorization = f"Basic {b64encode(b'user:secret').decode('utf-8')}"
+    rep = client.simulate_post("/boot", body=json.dumps(body).encode("utf-8"), headers={"Authorization": authorization})
+    assert rep.status_code == 401
 
 def test_witnesser(helpers):
     salt = b'0123456789abcdef'