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
42 changes: 42 additions & 0 deletions docs/enrollment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Enrollment and owner issuer trust

Fresh client enrollment uses the existing `arbiter enrol` command and local
operator approval. Registering an owner issuer establishes public trust only;
it does not enroll a client or grant account access. Selecting an existing
portable identity for another server remains unavailable in this revision.

Use `arbiter-server authority owner-issuer` through the same authenticated local
OS account as server administration. Commands work while the instance is stopped
or through its authenticated local control while running. Remote administration
uses verified SSH to run these commands on the server host.

```sh
arbiter-server authority owner-issuer list --state-dir ROOT --json
arbiter-server authority owner-issuer export --state-dir ROOT --issuer-sha256 FINGERPRINT > owner.der
arbiter-server authority owner-issuer register --state-dir ROOT --certificate-file owner.der --json
arbiter-server authority owner-issuer remove --state-dir ROOT --issuer-sha256 FINGERPRINT --json
```

Obtain public owner DER from an independently trusted operator. Registration
requires the exact valid Arbiter owner certificate. Keep private issuer and
recovery keys private. The fingerprint is the lowercase SHA-256 of the exact
canonical DER. Distinct issuer keys can share an owner ID; each certificate
requires its own explicit registration.

List returns at most 32 summaries and a `next_cursor`. Pass that value with
`list --cursor FINGERPRINT` to continue. Export returns one public DER certificate
as binary stdout. Register reads at most 4,096 bytes from the local CLI file;
it sends certificate bytes, not a file path for the server to open.

Removing trust retains an inactive record. Repeating the same registration or
removal is idempotent; reactivating a removed certificate advances its trust
revision. Restart does not reactivate removed trust. Removal blocks new
activation under that issuer and does not revoke an already activated access
credential or change its historical enrollment result.

The first upgraded startup performs an atomic, one-way authority schema upgrade.
It preserves existing credentials, exact enrollment results, denial and inactivity
state, and binds each existing agent to its enrollment. Invalid or duplicate
historical bindings stop startup and preserve the old database. Preserve the
state and resolve the reported error; do not delete authority files to retry.
New fresh enrollments maintain that binding in the same activation transaction.
3 changes: 3 additions & 0 deletions server/news/+portable-issuer-controls.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add authenticated local public owner-issuer registration, paginated listing,
export and removal. Upgrade existing authority state atomically and preserve
unique agent-to-enrollment bindings during fresh activation and exact replay.
24 changes: 20 additions & 4 deletions server/src/arbiter_server/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
DATABASE = "authority.sqlite3"
_PENDING = ".authority.pending.sqlite3"
_LOCK = ".authority.lock"
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
# Windows Modules Installer is part of the trusted host OS. This exception is
# for ancestors only; authority-root and file ACL allowlists remain unchanged.
_TRUSTED_INSTALLER = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
Expand Down Expand Up @@ -268,6 +268,9 @@ def _write_database(path: Path, record: AuthorityRecord) -> None:
record.server_id,
datetime.fromisoformat(record.created_at),
)
from .owner_issuers import create_tables

create_tables(db, record)
db.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
db.commit()
with path.open("r+b") as handle:
Expand Down Expand Up @@ -379,7 +382,13 @@ def _current_record(root: Path) -> AuthorityRecord | None:
raise AuthorityError(
"authority journal recovery failed; preserve state"
) from exc
return _read_record(path) if path is not None else None
if path is None:
return None
record = _read_record(path, allow_previous=True)
from .owner_issuers import upgrade

upgrade(path, record)
return _read_record(path)


@contextmanager
Expand All @@ -390,7 +399,7 @@ def running_instance(state_dir: Path) -> Iterator[AuthorityRecord | None]:
yield _current_record(root)


def _read_record(path: Path) -> AuthorityRecord:
def _read_record(path: Path, *, allow_previous: bool = False) -> AuthorityRecord:
from .credentials import verify_setup_credentials
from .recovery import verify_registration

Expand All @@ -402,8 +411,15 @@ def _read_record(path: Path) -> AuthorityRecord:
sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=0)
) as db:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version != SCHEMA_VERSION:
if version != SCHEMA_VERSION and not (allow_previous and version == 3):
raise AuthorityError("unsupported authority database schema version")
if version == SCHEMA_VERSION:
db.execute(
"SELECT issuer_sha256, owner_id, certificate_der, active, revision FROM owner_issuers LIMIT 0"
)
db.execute(
"SELECT agent_id, enrollment_id FROM agent_enrollments LIMIT 0"
)
rows = db.execute(
"SELECT singleton, owner_id, server_id, created_at FROM authority"
).fetchall()
Expand Down
14 changes: 14 additions & 0 deletions server/src/arbiter_server/enrollment_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import closing
from datetime import datetime
import json
import hashlib
from pathlib import Path
import sqlite3

Expand Down Expand Up @@ -99,7 +100,20 @@ def activate(
or existing[2] <= now.timestamp()
):
raise ValueError("Enrollment activation unavailable")
if db.execute(
"SELECT enrollment_id FROM agent_enrollments WHERE agent_id=?",
(ids[2],),
).fetchone() != (ids[3],):
raise ValueError("Enrollment binding unavailable")
else:
if db.execute(
"SELECT active FROM owner_issuers WHERE issuer_sha256=? AND owner_id=? AND certificate_der=?",
(hashlib.sha256(fields[2]).hexdigest(), ids[1], fields[2]),
).fetchone() != (1,):
raise ValueError("Enrollment issuer unavailable")
db.execute(
"INSERT INTO agent_enrollments VALUES (?, ?)", (ids[2], ids[3])
)
db.execute(
"INSERT INTO enrollments VALUES (?, ?, 0, ?, ?, ?)",
(
Expand Down
2 changes: 1 addition & 1 deletion server/src/arbiter_server/generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def reconcile(root: Path) -> Path | None:
raise a.AuthorityError("pending generation digest mismatch")
# An already durable selector is authoritative. Its live DB can change
# after activation; a stale intent must never roll those changes back.
a._read_record(database)
a._read_record(database, allow_previous=True)
if source == staged:
a._publish(staged, final)
if active is None:
Expand Down
62 changes: 62 additions & 0 deletions server/src/arbiter_server/issuer_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Bounded public-issuer commands over the existing authenticated local control."""

import base64
import json
from pathlib import Path
import sqlite3
import sys

from . import authority as a
from .cli_errors import print_cli_error
from .local_control import _exchange
from .owner_issuers import control


def issuer_cli(
command: str,
state_dir: Path,
*,
certificate_file: Path | None = None,
issuer_sha256: str | None = None,
cursor: str | None = None,
as_json: bool = False,
) -> int:
try:
arguments: dict[str, object] = {}
if command == "register":
if certificate_file is None:
raise ValueError("select --certificate-file with public owner DER")
with certificate_file.open("rb") as handle:
der = handle.read(4097)
if not 1 <= len(der) <= 4096:
raise ValueError("owner DER must contain 1–4096 bytes")
arguments["certificate_der"] = base64.b64encode(der).decode()
elif command in {"export", "remove"}:
arguments["issuer_sha256"] = issuer_sha256
elif cursor is not None:
arguments["cursor"] = cursor
root = a._root(state_dir, create=False)
try:
with a._locked(root):
result = control(root, command, arguments)
except a.AuthorityBusy:
result = _exchange(
root,
{
"root": str(root),
"action": "owner-issuer",
"command": command,
"arguments": arguments,
},
)
if command == "export":
encoded = result.get("certificate_der")
if not isinstance(encoded, str) or len(encoded) > 5464:
raise ValueError("invalid public issuer export")
sys.stdout.buffer.write(base64.b64decode(encoded, validate=True))
else:
print(json.dumps(result, sort_keys=True, indent=None if as_json else 2))
return 0
except (OSError, ValueError, sqlite3.Error) as exc:
print_cli_error(str(exc), area="owner-issuer")
return 1
11 changes: 11 additions & 0 deletions server/src/arbiter_server/local_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ def __init__(

def dispatch(self, value: dict[str, Any]) -> dict[str, Any]:
action = value.get("action")
if action == "owner-issuer":
from .owner_issuers import control

if (
set(value) != {"root", "action", "command", "arguments"}
or value["root"] != str(self.root)
or not isinstance(value["command"], str)
or not isinstance(value["arguments"], dict)
):
raise a.AuthorityError("invalid owner-issuer control request")
return control(self.root, value["command"], value["arguments"])
if action == "enrollment":
if (
set(value) != {"root", "action", "command", "arguments"}
Expand Down
29 changes: 29 additions & 0 deletions server/src/arbiter_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4574,6 +4574,24 @@ def _build_parser() -> argparse.ArgumentParser:
help="reuse existing recovery keys and register this server's backup verifier",
)

issuer = authority_commands.add_parser(
"owner-issuer", help="manage independently registered public owner issuers"
)
issuer_commands = issuer.add_subparsers(dest="owner_issuer_command", required=True)
for action in ("register", "list", "export", "remove"):
command = issuer_commands.add_parser(action)
command.add_argument("--state-dir", type=Path, required=True)
if action != "export":
command.add_argument("--json", action="store_true")
if action == "register":
command.add_argument("--certificate-file", type=Path, required=True)
elif action in {"export", "remove"}:
command.add_argument("--issuer-sha256", required=True)
else:
command.add_argument(
"--cursor", help="continue after this exact issuer fingerprint"
)

enrollment = subcommands.add_parser(
"enrollment",
help="open or approve enrollment through authenticated local control",
Expand Down Expand Up @@ -4813,6 +4831,17 @@ def main(argv: Sequence[str] | None = None) -> int:
as_json=namespace.json,
)
if namespace.command == "authority":
if namespace.authority_command == "owner-issuer":
from .issuer_admin import issuer_cli

return issuer_cli(
namespace.owner_issuer_command,
namespace.state_dir,
certificate_file=getattr(namespace, "certificate_file", None),
issuer_sha256=getattr(namespace, "issuer_sha256", None),
cursor=getattr(namespace, "cursor", None),
as_json=getattr(namespace, "json", False),
)
from .authority import authority_cli

return authority_cli(
Expand Down
Loading
Loading