diff --git a/server/news/+authority-credential-profiles.feature.md b/server/news/+authority-credential-profiles.feature.md new file mode 100644 index 0000000..f10d789 --- /dev/null +++ b/server/news/+authority-credential-profiles.feature.md @@ -0,0 +1,6 @@ +Offline authority initialization now creates separate owner-issuer, server-authority +and server-TLS credentials atomically with the private identity database. Strict +role, issuer, identity, size and validity checks reject mixed-purpose credentials; +private keys are never included in status output. Runtime caller authentication +remains a separate milestone. The development authority schema is now version 2; +older experimental state is rejected without modification. diff --git a/server/src/arbiter_server/authority.py b/server/src/arbiter_server/authority.py index 5a47fc2..5c3843b 100644 --- a/server/src/arbiter_server/authority.py +++ b/server/src/arbiter_server/authority.py @@ -16,7 +16,7 @@ DATABASE = "authority.sqlite3" _PENDING = ".authority.pending.sqlite3" _LOCK = ".authority.lock" -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 # 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" @@ -246,6 +246,8 @@ def _remove_pending(root: Path) -> None: def _write_database(path: Path, record: AuthorityRecord) -> None: + from .credentials import store_setup_credentials + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) os.close(fd) _check_path(path) @@ -261,6 +263,12 @@ def _write_database(path: Path, record: AuthorityRecord) -> None: "INSERT INTO authority VALUES (1, ?, ?, ?)", (record.owner_id, record.server_id, record.created_at), ) + store_setup_credentials( + db, + record.owner_id, + record.server_id, + datetime.fromisoformat(record.created_at), + ) db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") db.commit() with path.open("r+b") as handle: @@ -303,6 +311,8 @@ def initialize_authority(state_dir: Path) -> AuthorityRecord: def authority_status(state_dir: Path) -> AuthorityRecord | None: """Inspect committed records without creating or repairing a database.""" + from .credentials import verify_setup_credentials + root = _root(state_dir, create=False) if not root.exists(): return None @@ -323,6 +333,9 @@ def authority_status(state_dir: Path) -> AuthorityRecord | None: rows = db.execute( "SELECT singleton, owner_id, server_id, created_at FROM authority" ).fetchall() + credentials = db.execute( + "SELECT role, certificate_der, private_key_der FROM credentials" + ).fetchall() if len(rows) != 1: raise ValueError("expected one authority record") singleton, owner, server, created = rows[0] @@ -336,6 +349,9 @@ def authority_status(state_dir: Path) -> AuthorityRecord | None: when = datetime.fromisoformat(created) if when.tzinfo is None or when.utcoffset() != timezone.utc.utcoffset(None): raise ValueError("invalid creation timestamp") + # Offline inspection checks the initial setup at its recorded time. + # Network verifiers must use the current time, independently. + verify_setup_credentials(credentials, owner, server, when) return AuthorityRecord(owner, server, created) except (sqlite3.Error, ValueError) as exc: if isinstance(exc, AuthorityError): diff --git a/server/src/arbiter_server/credentials.py b/server/src/arbiter_server/credentials.py new file mode 100644 index 0000000..baa193b --- /dev/null +++ b/server/src/arbiter_server/credentials.py @@ -0,0 +1,402 @@ +"""Version-one Arbiter certificate profiles; parsing does not register trust.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from enum import Enum +import sqlite3 +from uuid import UUID + +from cryptography import x509 +from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID, SignatureAlgorithmOID + +MAX_CERTIFICATE_BYTES = 4096 +MAX_PRIVATE_KEY_BYTES = 512 +ISSUER_LIFETIME = timedelta(days=3650) +LEAF_LIFETIME = timedelta(days=365) +BACKDATE = timedelta(minutes=5) +ISSUER_RENEW_BEFORE = timedelta(days=366) +LEAF_RENEW_BEFORE = timedelta(days=30) + + +class CredentialError(ValueError): + """Credential bytes do not satisfy the selected registered purpose.""" + + +class Role(str, Enum): + OWNER = "owner" + SERVER_AUTHORITY = "server-authority" + PORTABLE = "portable" + ACCESS = "access" + SERVER_TLS = "server" + + +@dataclass(frozen=True) +class Identity: + role: Role + subject_id: str + server_id: str | None = None + enrollment_id: str | None = None + + def __post_init__(self) -> None: + self.uris() + + def uris(self) -> tuple[str, ...]: + if not isinstance(self.role, Role): + raise CredentialError("unknown credential role") + pairs: list[tuple[str, str | None]] = [(self.role.value, self.subject_id)] + if self.role is Role.ACCESS: + pairs.extend( + (("server", self.server_id), ("enrollment", self.enrollment_id)) + ) + elif self.server_id is not None or self.enrollment_id is not None: + raise CredentialError("unexpected credential identity binding") + ids = [value for _, value in pairs] + for value in ids: + try: + valid = isinstance(value, str) and str(UUID(value, version=4)) == value + except ValueError: + valid = False + if not valid: + raise CredentialError( + "credential IDs must be canonical version-four UUIDs" + ) + if len(set(ids)) != len(ids): + raise CredentialError("duplicate credential identity") + return tuple(f"urn:arbiter:{role}:{value}" for role, value in pairs) + + +@dataclass(frozen=True) +class SigningCredential: + identity: Identity + certificate_der: bytes + private_key: ec.EllipticCurvePrivateKey = field(repr=False) + + +def _utc(now: datetime | None) -> datetime: + value = now if now is not None else datetime.now(timezone.utc) + if value.tzinfo is None or value.utcoffset() != timedelta(0): + raise CredentialError("credential time must be UTC") + return value + + +def _ca(role: Role) -> bool: + return role in {Role.OWNER, Role.SERVER_AUTHORITY} + + +def _key_usage(ca: bool) -> x509.KeyUsage: + return x509.KeyUsage(not ca, False, False, False, False, ca, False, False, False) + + +def _public(key: object) -> ec.EllipticCurvePublicKey: + if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance( + key.curve, ec.SECP256R1 + ): + raise CredentialError("credential requires a P-256 public key") + return key + + +def _spki(key: ec.EllipticCurvePublicKey) -> bytes: + return key.public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + + +def parse_certificate(data: bytes) -> x509.Certificate: + if not isinstance(data, bytes) or not 0 < len(data) <= MAX_CERTIFICATE_BYTES: + raise CredentialError("certificate exceeds the DER size bound") + try: + cert = x509.load_der_x509_certificate(data) + if cert.public_bytes(serialization.Encoding.DER) != data: + raise ValueError("noncanonical or trailing DER") + # Materialize extensions now so duplicate or malformed extensions fail. + list(cert.extensions) + return cert + except ( + ValueError, + x509.InvalidVersion, + x509.DuplicateExtension, + x509.UnsupportedGeneralNameType, + ) as exc: + raise CredentialError("invalid certificate DER") from exc + + +def _profile(cert: x509.Certificate, identity: Identity, now: datetime) -> None: + ca = _ca(identity.role) + expected: dict[x509.ObjectIdentifier, x509.ExtensionType] = { + x509.ExtensionOID.BASIC_CONSTRAINTS: x509.BasicConstraints( + ca, 0 if ca else None + ), + x509.ExtensionOID.KEY_USAGE: _key_usage(ca), + x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: x509.SubjectAlternativeName( + [x509.UniformResourceIdentifier(uri) for uri in identity.uris()] + ), + } + if not ca: + purpose = ( + ExtendedKeyUsageOID.SERVER_AUTH + if identity.role is Role.SERVER_TLS + else ExtendedKeyUsageOID.CLIENT_AUTH + ) + expected[x509.ExtensionOID.EXTENDED_KEY_USAGE] = x509.ExtendedKeyUsage( + [purpose] + ) + for oid, value in expected.items(): + try: + extension = cert.extensions.get_extension_for_oid(oid) + except x509.ExtensionNotFound as exc: + raise CredentialError("missing credential profile extension") from exc + if not extension.critical or extension.value != value: + raise CredentialError("wrong credential role, identity or key usage") + if ca and any( + e.oid == x509.ExtensionOID.EXTENDED_KEY_USAGE for e in cert.extensions + ): + raise CredentialError("issuer must not have an ordinary leaf purpose") + if any(e.critical and e.oid not in expected for e in cert.extensions): + raise CredentialError("unknown critical certificate extension") + try: + public_key = cert.public_key() + except (UnsupportedAlgorithm, ValueError) as exc: + raise CredentialError("credential requires a supported public key") from exc + _public(public_key) + if ( + cert.version is not x509.Version.v3 + or cert.signature_algorithm_oid != SignatureAlgorithmOID.ECDSA_WITH_SHA256 + ): + raise CredentialError("unsupported certificate version or signature algorithm") + lifetime = ISSUER_LIFETIME if ca else LEAF_LIFETIME + if not cert.not_valid_before_utc <= now <= cert.not_valid_after_utc: + raise CredentialError("certificate is not currently valid") + if ( + not timedelta(0) + < cert.not_valid_after_utc - cert.not_valid_before_utc + <= lifetime + BACKDATE + ): + raise CredentialError("certificate validity exceeds its role profile") + + +def verify_certificate( + data: bytes, + identity: Identity, + *, + issuer_der: bytes | None = None, + issuer_id: str | None = None, + now: datetime | None = None, +) -> x509.Certificate: + """Verify a purpose against trusted caller-selected issuer bytes and IDs. + + Self-signed issuer validation checks structure only. It never registers that + issuer. Leaf callers must obtain issuer_der from their registered trust store. + """ + when = _utc(now) + cert = parse_certificate(data) + _profile(cert, identity, when) + if _ca(identity.role): + if issuer_der is not None or issuer_id is not None: + raise CredentialError("Arbiter issuers must be self-signed roots") + issuer = cert + else: + if issuer_der is None or issuer_id is None: + raise CredentialError("a registered issuer and its identity are required") + issuer_role = ( + Role.OWNER if identity.role is Role.PORTABLE else Role.SERVER_AUTHORITY + ) + if identity.role is not Role.PORTABLE: + server = ( + identity.server_id + if identity.role is Role.ACCESS + else identity.subject_id + ) + if issuer_id != server: + raise CredentialError("wrong server authority binding") + issuer = verify_certificate( + issuer_der, Identity(issuer_role, issuer_id), now=when + ) + if ( + not issuer.not_valid_before_utc <= cert.not_valid_before_utc + or cert.not_valid_after_utc > issuer.not_valid_after_utc + ): + raise CredentialError("leaf validity exceeds its issuer") + if _spki(_public(cert.public_key())) == _spki(_public(issuer.public_key())): + raise CredentialError("issuer and leaf keys must be separate") + try: + cert.verify_directly_issued_by(issuer) + except (InvalidSignature, ValueError) as exc: + raise CredentialError( + "certificate was not signed by the registered issuer" + ) from exc + return cert + + +def _build( + identity: Identity, + key: ec.EllipticCurvePublicKey, + signer: ec.EllipticCurvePrivateKey, + issuer: x509.Certificate | None, + now: datetime, +) -> bytes: + ca = _ca(identity.role) + subject = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, f"Arbiter {identity.role.value}")] + ) + start = now.replace(microsecond=0) - BACKDATE + end = now.replace(microsecond=0) + (ISSUER_LIFETIME if ca else LEAF_LIFETIME) + if issuer is not None and ( + start < issuer.not_valid_before_utc or end > issuer.not_valid_after_utc + ): + raise CredentialError("renew the issuer before issuing a full-lifetime leaf") + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer.subject if issuer is not None else subject) + .public_key(_public(key)) + .serial_number(x509.random_serial_number()) + .not_valid_before(start) + .not_valid_after(end) + .add_extension(x509.BasicConstraints(ca, 0 if ca else None), critical=True) + .add_extension(_key_usage(ca), critical=True) + .add_extension( + x509.SubjectAlternativeName( + [x509.UniformResourceIdentifier(uri) for uri in identity.uris()] + ), + critical=True, + ) + ) + if not ca: + purpose = ( + ExtendedKeyUsageOID.SERVER_AUTH + if identity.role is Role.SERVER_TLS + else ExtendedKeyUsageOID.CLIENT_AUTH + ) + builder = builder.add_extension(x509.ExtendedKeyUsage([purpose]), critical=True) + return builder.sign(signer, hashes.SHA256()).public_bytes( + serialization.Encoding.DER + ) + + +def create_issuer( + identity: Identity, *, now: datetime | None = None +) -> SigningCredential: + if not _ca(identity.role): + raise CredentialError("only owner/server authority roles are issuers") + key = ec.generate_private_key(ec.SECP256R1()) + data = _build(identity, key.public_key(), key, None, _utc(now)) + verify_certificate(data, identity, now=now) + return SigningCredential(identity, data, key) + + +def issue_certificate( + identity: Identity, + public_key: ec.EllipticCurvePublicKey, + issuer: SigningCredential, + *, + now: datetime | None = None, +) -> bytes: + if _ca(identity.role): + raise CredentialError("issuers cannot create subordinate CAs") + when = _utc(now) + ca = verify_certificate(issuer.certificate_der, issuer.identity, now=when) + if not _ca(issuer.identity.role) or _spki(_public(ca.public_key())) != _spki( + issuer.private_key.public_key() + ): + raise CredentialError("issuer signing key or purpose mismatch") + data = _build(identity, public_key, issuer.private_key, ca, when) + verify_certificate( + data, + identity, + issuer_der=issuer.certificate_der, + issuer_id=issuer.identity.subject_id, + now=when, + ) + return data + + +def renewal_due(cert: x509.Certificate, *, now: datetime | None = None) -> bool: + ca = cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca + return _utc(now) >= cert.not_valid_after_utc - ( + ISSUER_RENEW_BEFORE if ca else LEAF_RENEW_BEFORE + ) + + +def store_setup_credentials( + db: sqlite3.Connection, owner_id: str, server_id: str, now: datetime +) -> None: + """Called inside the unpublished authority DB transaction; no key files.""" + owner = create_issuer(Identity(Role.OWNER, owner_id), now=now) + server = create_issuer(Identity(Role.SERVER_AUTHORITY, server_id), now=now) + leaf_key = ec.generate_private_key(ec.SECP256R1()) + identity = Identity(Role.SERVER_TLS, server_id) + leaf = SigningCredential( + identity, + issue_certificate(identity, leaf_key.public_key(), server, now=now), + leaf_key, + ) + db.execute( + "CREATE TABLE credentials (role TEXT PRIMARY KEY, certificate_der BLOB NOT NULL, private_key_der BLOB NOT NULL)" + ) + for item in (owner, server, leaf): + private = item.private_key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + db.execute( + "INSERT INTO credentials VALUES (?, ?, ?)", + (item.identity.role.value, item.certificate_der, private), + ) + + +def verify_setup_credentials( + rows: list[tuple[str, bytes, bytes]], owner_id: str, server_id: str, now: datetime +) -> None: + material = {role: (cert, key) for role, cert, key in rows} + if len(rows) != 3 or set(material) != { + Role.OWNER.value, + Role.SERVER_AUTHORITY.value, + Role.SERVER_TLS.value, + }: + raise CredentialError("incomplete or duplicate setup credential roles") + keys = set() + for role, identifier in ( + (Role.OWNER, owner_id), + (Role.SERVER_AUTHORITY, server_id), + (Role.SERVER_TLS, server_id), + ): + cert_data, key_data = material[role.value] + if ( + not isinstance(key_data, bytes) + or not 0 < len(key_data) <= MAX_PRIVATE_KEY_BYTES + ): + raise CredentialError("invalid private credential size") + try: + key = serialization.load_der_private_key(key_data, password=None) + except (TypeError, ValueError, UnsupportedAlgorithm) as exc: + raise CredentialError("invalid private credential encoding") from exc + if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance( + key.curve, ec.SECP256R1 + ): + raise CredentialError("private credential requires P-256") + if ( + key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + != key_data + ): + raise CredentialError("private credential must use canonical PKCS8 DER") + cert = verify_certificate( + cert_data, + Identity(role, identifier), + now=now, + issuer_der=None if _ca(role) else material[Role.SERVER_AUTHORITY.value][0], + issuer_id=None if _ca(role) else server_id, + ) + public = _spki(key.public_key()) + if public != _spki(_public(cert.public_key())) or public in keys: + raise CredentialError("setup credential key mismatch or key reuse") + keys.add(public) diff --git a/server/tests/unit/conftest.py b/server/tests/unit/conftest.py new file mode 100644 index 0000000..866400a --- /dev/null +++ b/server/tests/unit/conftest.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + +import pytest + + +@pytest.fixture +def private_windows_test_parent(tmp_path: Path) -> None: + if os.name != "nt": + return + from arbiter_server.file_protection import ensure_runtime_config_permissions + from arbiter_server.file_protection.windows import _windows_file_security + + security = _windows_file_security(tmp_path) + owner_sid = security.owner_sid + assert owner_sid is not None + allowed = {owner_sid, "S-1-5-18", "S-1-5-32-544"} + # Use real, inheritable ACLs. Python/pytest's special 0o700 Owner Rights ACE + # is not an operator-prepared private parent under Arbiter's allowlist. + subprocess.run( + [ + "icacls", + str(tmp_path), + "/inheritance:r", + "/grant:r", + f"*{owner_sid}:(OI)(CI)F", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + ], + check=True, + capture_output=True, + text=True, + ) + for sid in sorted({ace.sid for ace in security.access_aces} - allowed): + subprocess.run( + ["icacls", str(tmp_path), "/remove:g", f"*{sid}"], + check=True, + capture_output=True, + text=True, + ) + ensure_runtime_config_permissions(config_dir=tmp_path, env_file=None) diff --git a/server/tests/unit/test_authority.py b/server/tests/unit/test_authority.py index 474c336..6a20f76 100644 --- a/server/tests/unit/test_authority.py +++ b/server/tests/unit/test_authority.py @@ -17,41 +17,7 @@ from arbiter_server.main import main -@pytest.fixture(autouse=True) -def private_windows_test_parent(tmp_path: Path) -> None: - if os.name != "nt": - return - from arbiter_server.file_protection import ensure_runtime_config_permissions - from arbiter_server.file_protection.windows import _windows_file_security - - security = _windows_file_security(tmp_path) - owner_sid = security.owner_sid - assert owner_sid is not None - allowed = {owner_sid, "S-1-5-18", "S-1-5-32-544"} - # Use real, inheritable ACLs. Python/pytest's special 0o700 Owner Rights ACE - # is not an operator-prepared private parent under Arbiter's allowlist. - subprocess.run( - [ - "icacls", - str(tmp_path), - "/inheritance:r", - "/grant:r", - f"*{owner_sid}:(OI)(CI)F", - "*S-1-5-18:(OI)(CI)F", - "*S-1-5-32-544:(OI)(CI)F", - ], - check=True, - capture_output=True, - text=True, - ) - for sid in sorted({ace.sid for ace in security.access_aces} - allowed): - subprocess.run( - ["icacls", str(tmp_path), "/remove:g", f"*{sid}"], - check=True, - capture_output=True, - text=True, - ) - ensure_runtime_config_permissions(config_dir=tmp_path, env_file=None) +pytestmark = pytest.mark.usefixtures("private_windows_test_parent") def test_initialize_reopen_and_independent_roots(tmp_path: Path) -> None: @@ -66,7 +32,7 @@ def test_initialize_reopen_and_independent_roots(tmp_path: Path) -> None: ) with closing(sqlite3.connect(root / authority.DATABASE)) as db: assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) - assert db.execute("PRAGMA user_version").fetchone() == (1,) + assert db.execute("PRAGMA user_version").fetchone() == (2,) if os.name != "nt": assert root.stat().st_mode & 0o777 == 0o700 assert (root / authority.DATABASE).stat().st_mode & 0o777 == 0o600 @@ -120,7 +86,7 @@ def no_composition(*args, **kwargs): assert main([*args, "init", *state]) == 0 output = json.loads(capsys.readouterr().out) assert output["initialized"] is True - assert output["schema_version"] == 1 + assert output["schema_version"] == 2 assert main([*args, "status", *state]) == 0 assert json.loads(capsys.readouterr().out) == output assert main([*args, "init", *state]) == 1 diff --git a/server/tests/unit/test_credentials.py b/server/tests/unit/test_credentials.py new file mode 100644 index 0000000..102b696 --- /dev/null +++ b/server/tests/unit/test_credentials.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import base64 +from contextlib import closing +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path +import sqlite3 + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, ObjectIdentifier + +from arbiter_server import authority, credentials as c + +pytestmark = pytest.mark.usefixtures("private_windows_test_parent") + +NOW = datetime(2026, 10, 21, tzinfo=timezone.utc) +OWNER = "00000000-0000-4000-8000-000000000001" +SERVER = "00000000-0000-4000-8000-000000000002" +AGENT = "00000000-0000-4000-8000-000000000003" +ENROLLMENT = "00000000-0000-4000-8000-000000000004" +P256_CURVE_OID = bytes.fromhex("06082a8648ce3d030107") +UNSUPPORTED_CURVE_OID = bytes.fromhex("06082a8648ce3d03017f") + + +@pytest.fixture +def issuers(): + return ( + c.create_issuer(c.Identity(c.Role.OWNER, OWNER), now=NOW), + c.create_issuer(c.Identity(c.Role.SERVER_AUTHORITY, SERVER), now=NOW), + ) + + +def leaf(issuers, role=c.Role.ACCESS): + issuer = issuers[0] if role is c.Role.PORTABLE else issuers[1] + identity = c.Identity( + role, + SERVER if role is c.Role.SERVER_TLS else AGENT, + SERVER if role is c.Role.ACCESS else None, + ENROLLMENT if role is c.Role.ACCESS else None, + ) + key = ec.generate_private_key(ec.SECP256R1()) + data = c.issue_certificate(identity, key.public_key(), issuer, now=NOW) + return identity, data, issuer + + +def verify(data, identity, issuer, now=NOW): + return c.verify_certificate( + data, + identity, + issuer_der=issuer.certificate_der, + issuer_id=issuer.identity.subject_id, + now=now, + ) + + +def unsupported_curve(data): + assert data.count(P256_CURVE_OID) == 1 + return data.replace(P256_CURVE_OID, UNSUPPORTED_CURVE_OID, 1) + + +@pytest.mark.parametrize("role", [c.Role.PORTABLE, c.Role.ACCESS, c.Role.SERVER_TLS]) +def test_positive_role_and_exact_boundaries(issuers, role): + identity, data, issuer = leaf(issuers, role) + cert = verify(data, identity, issuer) + assert cert.not_valid_after_utc == NOW + timedelta(days=365) + for when in (cert.not_valid_before_utc, cert.not_valid_after_utc): + assert ( + verify(data, identity, issuer, when).public_bytes( + serialization.Encoding.DER + ) + == data + ) + for when in ( + cert.not_valid_before_utc - timedelta(microseconds=1), + cert.not_valid_after_utc + timedelta(microseconds=1), + ): + with pytest.raises(c.CredentialError, match="currently valid"): + verify(data, identity, issuer, when) + due = cert.not_valid_after_utc - timedelta(days=30) + assert not c.renewal_due(cert, now=due - timedelta(microseconds=1)) + assert c.renewal_due(cert, now=due) + + +def test_issuer_profile_and_renewal(issuers): + for issuer in issuers: + cert = c.verify_certificate(issuer.certificate_der, issuer.identity, now=NOW) + assert cert.not_valid_after_utc == NOW + timedelta(days=3650) + assert ( + cert.extensions.get_extension_for_class( + x509.BasicConstraints + ).value.path_length + == 0 + ) + assert not cert.extensions.get_extension_for_class( + x509.KeyUsage + ).value.digital_signature + due = cert.not_valid_after_utc - timedelta(days=366) + assert not c.renewal_due(cert, now=due - timedelta(microseconds=1)) + assert c.renewal_due(cert, now=due) + identity, _, issuer = leaf(issuers) + with pytest.raises(c.CredentialError, match="renew the issuer"): + c.issue_certificate( + identity, + ec.generate_private_key(ec.SECP256R1()).public_key(), + issuer, + now=NOW + timedelta(days=3286), + ) + + +@pytest.mark.parametrize("role", [c.Role.PORTABLE, c.Role.SERVER_TLS]) +def test_cross_role_is_not_identity_or_native_authority(issuers, role): + identity, data, issuer = leaf(issuers, role) + access = c.Identity(c.Role.ACCESS, AGENT, SERVER, ENROLLMENT) + with pytest.raises(c.CredentialError): + verify(data, access, issuer) + with pytest.raises(c.CredentialError, match="registered issuer"): + c.verify_certificate(data, identity, now=NOW) + + +def test_registered_issuer_and_server_binding_are_required(issuers): + identity, data, issuer = leaf(issuers) + impostor = c.create_issuer(issuer.identity, now=NOW) + with pytest.raises(c.CredentialError, match="not signed"): + verify(data, identity, impostor) + with pytest.raises(c.CredentialError): + verify(data, identity, issuers[0]) + with pytest.raises(c.CredentialError, match="server authority binding"): + c.verify_certificate( + data, identity, issuer_der=issuer.certificate_der, issuer_id=OWNER, now=NOW + ) + with pytest.raises(c.CredentialError): + c.issue_certificate( + c.Identity(c.Role.OWNER, OWNER), + issuer.private_key.public_key(), + issuer, + now=NOW, + ) + with pytest.raises(c.CredentialError, match="separate"): + c.issue_certificate(identity, issuer.private_key.public_key(), issuer, now=NOW) + + +def test_unsupported_certificate_key_and_version_are_credential_errors(issuers): + identity, data, issuer = leaf(issuers, c.Role.SERVER_TLS) + with pytest.raises(c.CredentialError): + verify(unsupported_curve(data), identity, issuer) + invalid_version = data.replace( + bytes.fromhex("a003020102"), bytes.fromhex("a003020103"), 1 + ) + with pytest.raises(c.CredentialError): + c.parse_certificate(invalid_version) + + +@pytest.mark.parametrize( + "identity", + [ + ("owner", OWNER, None, None), + (c.Role.OWNER, "bad", None, None), + (c.Role.OWNER, OWNER, SERVER, None), + (c.Role.ACCESS, AGENT, SERVER, None), + (c.Role.ACCESS, AGENT, SERVER, AGENT), + ], +) +def test_invalid_or_duplicate_identity_is_rejected(identity): + with pytest.raises(c.CredentialError): + c.Identity(*identity) + + +def changed_leaf(issuers, change): + identity, data, issuer = leaf(issuers) + original = c.parse_certificate(data) + key = original.public_key() + if change == "curve": + key = ec.generate_private_key(ec.SECP384R1()).public_key() + start = original.not_valid_before_utc + end = original.not_valid_after_utc + if change == "lifetime": + end += timedelta(days=1) + if change == "issuer-window": + start -= timedelta(minutes=1) + end -= timedelta(days=1) + builder = ( + x509.CertificateBuilder() + .subject_name(original.subject) + .issuer_name(original.issuer) + .public_key(key) + .serial_number(123) + .not_valid_before(start) + .not_valid_after(end) + ) + for ext in original.extensions: + value = ext.value + if change == "missing-usage" and isinstance(value, x509.KeyUsage): + continue + if change == "eku" and isinstance(value, x509.ExtendedKeyUsage): + value = x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]) + if change == "ca" and isinstance(value, x509.BasicConstraints): + value = x509.BasicConstraints(True, 1) + if change in {"duplicate-san", "dns-san"} and isinstance( + value, x509.SubjectAlternativeName + ): + names = list(value) + names.append( + names[0] if change == "duplicate-san" else x509.DNSName("example.test") + ) + value = x509.SubjectAlternativeName(names) + builder = builder.add_extension(value, ext.critical) + if change == "critical": + builder = builder.add_extension( + x509.UnrecognizedExtension(ObjectIdentifier("1.2.3.4"), b"\x05\x00"), True + ) + digest = hashes.SHA384() if change == "signature" else hashes.SHA256() + return ( + identity, + builder.sign(issuer.private_key, digest).public_bytes( + serialization.Encoding.DER + ), + issuer, + ) + + +@pytest.mark.parametrize( + "change", + [ + "curve", + "lifetime", + "issuer-window", + "missing-usage", + "eku", + "ca", + "duplicate-san", + "dns-san", + "critical", + "signature", + ], +) +def test_validly_signed_malformed_profiles_are_rejected(issuers, change): + identity, data, issuer = changed_leaf(issuers, change) + with pytest.raises(c.CredentialError): + verify(data, identity, issuer) + + +@pytest.mark.parametrize( + "change", ["empty", "oversize", "trailing", "signature", "garbage"] +) +def test_der_boundaries_and_signature(issuers, change): + identity, data, issuer = leaf(issuers) + malformed = { + "empty": b"", + "oversize": b"x" * (c.MAX_CERTIFICATE_BYTES + 1), + "trailing": data + b"x", + "signature": data[:-1] + bytes([data[-1] ^ 1]), + "garbage": b"not DER", + }[change] + with pytest.raises(c.CredentialError): + verify(malformed, identity, issuer) + + +def test_setup_is_real_atomic_private_and_restartable(tmp_path, capsys): + root = tmp_path / "state" + assert authority.authority_cli("init", root, as_json=True) == 0 + output = capsys.readouterr().out + record = authority.authority_status(root) + assert ( + record is not None and record.owner_id in output and record.server_id in output + ) + with closing(sqlite3.connect(root / authority.DATABASE)) as db: + rows = db.execute( + "SELECT role, certificate_der, private_key_der FROM credentials" + ).fetchall() + assert len(rows) == 3 + c.verify_setup_credentials( + rows, + record.owner_id, + record.server_id, + datetime.fromisoformat(record.created_at), + ) + for _, _, private in rows: + assert base64.b64encode(private).decode() not in output + before = (root / authority.DATABASE).read_bytes() + with pytest.raises(authority.AuthorityError, match="already exists"): + authority.initialize_authority(root) + assert authority.authority_status(root) == record + assert (root / authority.DATABASE).read_bytes() == before + assert {p.name for p in root.iterdir()} == {authority.DATABASE, authority._LOCK} + + +@pytest.mark.parametrize( + "damage", + ["missing", "key", "oversize", "cert", "reuse", "old-schema", "unsupported-key"], +) +def test_damaged_setup_never_repairs_or_replaces_keys(tmp_path, damage, capsys): + root = tmp_path / "state" + authority.initialize_authority(root) + with closing(sqlite3.connect(root / authority.DATABASE)) as db: + if damage == "old-schema": + db.execute("PRAGMA user_version=1") + elif damage == "missing": + db.execute("DELETE FROM credentials WHERE role='owner'") + else: + column = "certificate_der" if damage == "cert" else "private_key_der" + value = b"x" * 513 if damage == "oversize" else b"invalid" + if damage == "reuse": + value = db.execute( + "SELECT private_key_der FROM credentials WHERE role='server-authority'" + ).fetchone()[0] + if damage == "unsupported-key": + value = unsupported_curve( + db.execute( + "SELECT private_key_der FROM credentials WHERE role='owner'" + ).fetchone()[0] + ) + db.execute( + f"UPDATE credentials SET {column}=? WHERE role='owner'", (value,) + ) + db.commit() + before = (root / authority.DATABASE).read_bytes() + with pytest.raises(authority.AuthorityError): + authority.authority_status(root) + assert (root / authority.DATABASE).read_bytes() == before + if damage == "unsupported-key": + assert authority.authority_cli("status", root, as_json=True) == 1 + captured = capsys.readouterr() + assert not captured.out + assert "invalid or incomplete authority database" in captured.err + + +def test_crypto_failure_cannot_publish_partial_setup(tmp_path, monkeypatch): + root = tmp_path / "state" + real = c.store_setup_credentials + + def fail(db, owner, server, now): + real(db, owner, server, now) + raise OSError("injected credential transaction failure") + + monkeypatch.setattr(c, "store_setup_credentials", fail) + with pytest.raises(OSError, match="injected"): + authority.initialize_authority(root) + assert {p.name for p in root.iterdir()} == {authority._LOCK} + monkeypatch.setattr(c, "store_setup_credentials", real) + assert authority.initialize_authority(root) == authority.authority_status(root) + + +def test_shared_profile_fixtures(): + path = ( + Path(__file__).resolve().parents[3] + / "tests/fixtures/authentication/credential-profiles-v1.json" + ) + data = json.loads(path.read_text()) + assert ( + data["schema"] == "arbiter-credential-profiles-v1" and data["test_only"] is True + ) + now = datetime.fromisoformat(data["now"]) + for case in data["cases"]: + identity = c.Identity(c.Role(case["role"]), *case["ids"]) + cert = base64.b64decode( + data["certificates"][case["certificate"]], validate=True + ) + issuer = ( + base64.b64decode(data["certificates"][case["issuer"]], validate=True) + if case.get("issuer") + else None + ) + if case["valid"]: + c.verify_certificate( + cert, + identity, + issuer_der=issuer, + issuer_id=case.get("issuer_id"), + now=now, + ) + else: + with pytest.raises(c.CredentialError): + c.verify_certificate( + cert, + identity, + issuer_der=issuer, + issuer_id=case.get("issuer_id"), + now=now, + ) diff --git a/tests/fixtures/authentication/credential-profiles-v1.json b/tests/fixtures/authentication/credential-profiles-v1.json new file mode 100644 index 0000000..24e41a4 --- /dev/null +++ b/tests/fixtures/authentication/credential-profiles-v1.json @@ -0,0 +1,275 @@ +{ + "schema": "arbiter-credential-profiles-v1", + "test_only": true, + "note": "Synthetic public verification vectors, not product trust. No private keys. Go consumes these in slice2.1.", + "now": "2026-10-21T00:00:00+00:00", + "certificates": { + "signature-algorithm-mismatch": "MIIBvDCCAWOgAwIBAgIUTc6PvaiSkepnNQUvmFFbwdmy8UQwCgYIKoZIzj0EAwMwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTI3MTAyMTAwMDAwMFowGTEXMBUGA1UEAwwOQXJiaXRlciBzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATfjcZi7hVSZ2r9/cdtd8O5wkc0I9W8npabEV6CihhDE4NLLoHLxv5d0q0bfk4EX8srqrOEBZFZvq2NkVJUGNYGo38wfTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDBFBgNVHREBAf8EOzA5hjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMBMAoGCCqGSM49BAMCA0cAMEQCIBYwJGRbm0S3D40yBkkhlKRA7rQLGETADMC/iNaxwEjzAiB5O2pZuAmZ3os5JaMzU1b2ws7/K6B/PaXdFmq+jhKijw==", + "signature-algorithm-issuer": "MIIBvjCCAWWgAwIBAgIUeWfFNI4EU611/67/gAkInIJPgnMwCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTM2MTAxODAwMDAwMFowIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu9/NAM+CHoFUnjBPR1TxkOrzbrzrCwZIK+2dhEpemARCtpbnOtuWmIb1qkTjRvTBnvoOlXmpSrpPvTtJY7AdWKN3MHUwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAgQwTwYDVR0RAQH/BEUwQ4ZBdXJuOmFyYml0ZXI6c2VydmVyLWF1dGhvcml0eTowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIwCgYIKoZIzj0EAwIDRwAwRAIgLfU8ydcgduCYoznGqTejoVen++nB13MLshhpnC2xmKACIE36C13A2DZAlLbsACECQJON6DiiRfJLbV/gWlZ9qprJ", + "owner": "MIIBnjCCAUSgAwIBAgIUFtDLeP2Kki+3pyS5M0Bt3+KxYX0wCgYIKoZIzj0EAwIwGDEWMBQGA1UEAwwNQXJiaXRlciBvd25lcjAeFw0yNjEwMjAyMzU1MDBaFw0zNjEwMTgwMDAwMDBaMBgxFjAUBgNVBAMMDUFyYml0ZXIgb3duZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQM8P/htFjcjl9KAU/Gwn3cz9y4fWtA109mHKu5NcRnhdt478q3fEvlKgF6g4GnrP0ZqxvyTfh2Mybp41tDWWb8o2wwajASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwICBDBEBgNVHREBAf8EOjA4hjZ1cm46YXJiaXRlcjpvd25lcjowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEwCgYIKoZIzj0EAwIDSAAwRQIhAJLJICTf7QNXkxbvc65x/qQfXu1Sd/30DX8X8QuNiXtVAiBa4/91IOZv3K+rVgrKh+4ZslZCNfa2cdy98iRcG/U7gg==", + "server-authority": "MIIBvzCCAWWgAwIBAgIUb8WmkwQ58lr7x4W/gEXoMQ2Oun0wCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTM2MTAxODAwMDAwMFowIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAJoYsvQMxZVt+Z4RA3NWiKjrXaoTISnF73vvSpsx4pk6wSgbKla8GtIVvHPaTI98iHIDMMO0WRVv+QB0m/ntn6N3MHUwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAgQwTwYDVR0RAQH/BEUwQ4ZBdXJuOmFyYml0ZXI6c2VydmVyLWF1dGhvcml0eTowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIwCgYIKoZIzj0EAwIDSAAwRQIhAPSYpwx9w6ne46NvOMRxUG47uhDWbbYtOpRt0Xtw4jILAiBNTgOOBYJDA9v320j8k7VAjJShngfZbSylBCKcW6lIdw==", + "portable": "MIIBuDCCAV2gAwIBAgIUeQSjeIUN8y3vLVXgEBv6UjZPxp4wCgYIKoZIzj0EAwIwGDEWMBQGA1UEAwwNQXJiaXRlciBvd25lcjAeFw0yNjEwMjAyMzU1MDBaFw0yNzEwMjEwMDAwMDBaMBsxGTAXBgNVBAMMEEFyYml0ZXIgcG9ydGFibGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATJ3+lfvJjLcJdkUiaj7NjLJe9vWN69QXxFI9BFVkaDOKa0mQ/P4vcLPLNNXYxUfp2hGpIpoB3WVuztWShyxtx4o4GBMH8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwRwYDVR0RAQH/BD0wO4Y5dXJuOmFyYml0ZXI6cG9ydGFibGU6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMAoGCCqGSM49BAMCA0kAMEYCIQDBiikOGolxn4QJFxyhQhuENuPgeGkA+BdkTg8KTcWF1gIhALxeGOkhIiE2bwzpDFjzlWSdqNfh4OejXv3Z0QQ+LWQC", + "access": "MIICODCCAd6gAwIBAgIULKg+4C53kC87SDJewTu3URohiL0wCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTI3MTAyMTAwMDAwMFowGTEXMBUGA1UEAwwOQXJiaXRlciBhY2Nlc3MwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQP2gjU9SoENRsEGb3btwtwiwNHv9d2dJB02WDM3eD1ae+HlJE17JApsKRctcvGC0m6P70HZYvbWN3OoHtKd1K4o4H5MIH2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMIG9BgNVHREBAf8EgbIwga+GN3VybjphcmJpdGVyOmFjY2VzczowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDOGN3VybjphcmJpdGVyOnNlcnZlcjowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDKGO3VybjphcmJpdGVyOmVucm9sbG1lbnQ6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDA0MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMAoGCCqGSM49BAMCA0gAMEUCIGWuOmVfZgZfPjCpzffdSU1pNzhhEc+4EKl+VqhsnzuGAiEApDdAImcOhzm5ZfWFd60u8+Mzh8v+7YZlcLxjVoXgaw0=", + "server": "MIIBvDCCAWOgAwIBAgIUWmJd0Yl4l4vAjg053AQGeLVt5LMwCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTI3MTAyMTAwMDAwMFowGTEXMBUGA1UEAwwOQXJiaXRlciBzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASxKJiGnbPmoy/gy6GDBc6d9KY9oMsXpF01dJ7Zf2pb7Nnnt543oXGnkLBRKXbvgWn2sM2n+zMOmaJfL+cFBkmno38wfTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDBFBgNVHREBAf8EOzA5hjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMBMAoGCCqGSM49BAMCA0cAMEQCIFgR5ddrEtZe/HauMjcMtqc6PdINh8ueWlXuCofZ/YLmAiB6tUB7BYKPNN8F+GOP+IspEgpXxNC7wJ9KIUYPIhIXMw==", + "invalid-curve": "MIICQTCCAeigAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczB2MBAGByqGSM49AgEGBSuBBAAiA2IABL+MCfHN1E9/2m4W+gZU4ra29pE/SqK0Vr2OC5fJsh1eJB+vhZ+olXY5Atkpt9S9NCx/47Hnpyq7LVjK/6E268IJrATl5G0EzbMtx5Wvj6uli84P1N6xvuDY5lPKp/p9cKOB+TCB9jAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDCBvQYDVR0RAQH/BIGyMIGvhjd1cm46YXJiaXRlcjphY2Nlc3M6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzhjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyhjt1cm46YXJiaXRlcjplbnJvbGxtZW50OjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwNDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAKBggqhkjOPQQDAgNHADBEAiATQAbjw6sUF4o0Wc77QyXyLNKdJoAPdUkPVo83YwVrNQIgdI9OW1sO8vvL9UHRTSyuECJ+pHauQiDD11Rz+4PWOC4=", + "invalid-lifetime": "MIICJjCCAcugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIyMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOylIJxzIs/9oBPg96eorum50tEaqV9x//kSOY+E/dgY6Onx+3BWrSmO1rxjlEqKmb3sIWX0M3Ab4Gvs7tY1SlqjgfkwgfYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4Awgb0GA1UdEQEB/wSBsjCBr4Y3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwM4Y3dXJuOmFyYml0ZXI6c2VydmVyOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMoY7dXJuOmFyYml0ZXI6ZW5yb2xsbWVudDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDQwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwCgYIKoZIzj0EAwIDSQAwRgIhAINt6gRnFU9Ii0eUdRnwazSjx2GBTHAPmVi1UPbnzRhfAiEAxx0p7JUBIxtkzIBwzkrmpQrFAkHWNXjUvk7T7zTSx/I=", + "invalid-issuer-window": "MIICJjCCAcugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NDAwWhcNMjcxMDIwMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOJrxc+FaEl4iHlQRrFqnlhbugVjtmTVjNhjgatuEDvhr8UmjT1/nHT8OAvHyKV4O4CZcLUlpVni+HOgMW5oHoajgfkwgfYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4Awgb0GA1UdEQEB/wSBsjCBr4Y3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwM4Y3dXJuOmFyYml0ZXI6c2VydmVyOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMoY7dXJuOmFyYml0ZXI6ZW5yb2xsbWVudDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDQwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwCgYIKoZIzj0EAwIDSQAwRgIhAO/bGaJ3tHXw4WhK/ZSLUbLVq8NR7g7gCx1TEHH9lXWyAiEA11HcDCjyLoph8VZMAZVSh/uSXRaPyt3iOM3gsTK8VXU=", + "invalid-missing-usage": "MIICFDCCAbugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLU1TA0GNDyy6cUrLrp01cUm9XUVMamOH1fdEymsB/GBjwDhwJAZABQi/8cT6aIwVic+YWmBbNFflUcNuym7VZejgekwgeYwDAYDVR0TAQH/BAIwADCBvQYDVR0RAQH/BIGyMIGvhjd1cm46YXJiaXRlcjphY2Nlc3M6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzhjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyhjt1cm46YXJiaXRlcjplbnJvbGxtZW50OjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwNDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAKBggqhkjOPQQDAgNHADBEAiALBFsu9VASUv5bW/vb5mCMfj/djOUrWsXwOtRaGh2k6gIgf5yNj/BGg1YiLIOX88iczhfbJnM+SqYg+crEtDy1hRI=", + "invalid-eku": "MIICJTCCAcugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABERNC79HuEWWatYmHc9istvGrZQw2mOWoZf/19V3GInY7yP6u/gFVlTIwDRgW9tcSCTHqRnhxJuHdYdbsU8M3DWjgfkwgfYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4Awgb0GA1UdEQEB/wSBsjCBr4Y3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwM4Y3dXJuOmFyYml0ZXI6c2VydmVyOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMoY7dXJuOmFyYml0ZXI6ZW5yb2xsbWVudDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDQwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwEwCgYIKoZIzj0EAwIDSAAwRQIgBAaIRjX2+tK4I1EZZblPPb+iRVMIUd0L7kdnoU+co+wCIQDVlNFwULJKgVisq94+depQAnkBjJcZ0vblj092s3TXlg==", + "invalid-ca": "MIICLDCCAdGgAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI7pcPgrDGLOLxtl94QsekMPf922zO/5EHAZUc60JcPxfnOyYRnTkelTffi532nMD9M9/Y0zMij1qFHEmM0drDmjgf8wgfwwEgYDVR0TAQH/BAgwBgEB/wIBATAOBgNVHQ8BAf8EBAMCB4Awgb0GA1UdEQEB/wSBsjCBr4Y3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwM4Y3dXJuOmFyYml0ZXI6c2VydmVyOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMoY7dXJuOmFyYml0ZXI6ZW5yb2xsbWVudDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDQwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwCgYIKoZIzj0EAwIDSQAwRgIhAKlE8sHiV1Ix2F9nMHpeUrCMgBiR1MJJ5ekLbvEMdtTRAiEA6JyYxg8HIYiwEnAWRG2Li6rQtM1nEJHXDeck1fboVfg=", + "invalid-duplicate-san": "MIICYDCCAgagAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMxHEqba+ha6Cs796BiYk/l8rSrwXeVcg7VOmQLhQn8ERxwt2cTh7wRkmKLdgPWLq/JcA2dZ4DB0jOL64A3C6AijggEzMIIBLzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDCB9gYDVR0RAQH/BIHrMIHohjd1cm46YXJiaXRlcjphY2Nlc3M6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzhjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyhjt1cm46YXJiaXRlcjplbnJvbGxtZW50OjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwNIY3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMzAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAKBggqhkjOPQQDAgNIADBFAiBwMlzOiDZHfKAubwmULxsYEcmSfzX9g60MAPTAFimQgQIhAL+1oAH8I4tG3f24pfKCkBy/gJcIpdq9FSVVVp8M4ptS", + "invalid-dns-san": "MIICNDCCAdugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH3tlKxUP5nXDAVdPiDcNuwp0RN0b+uuaFXBkmM+uYBCoMWporBb3Ao8ftufpOnuwzakRXt4Qcaebs5C9oBjNESjggEIMIIBBDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDCBywYDVR0RAQH/BIHAMIG9hjd1cm46YXJiaXRlcjphY2Nlc3M6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzhjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyhjt1cm46YXJiaXRlcjplbnJvbGxtZW50OjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwNIIMZXhhbXBsZS50ZXN0MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMAoGCCqGSM49BAMCA0cAMEQCIF2kvgsLeQmtmIF4yaXWqTfZXRELYa7Ia9wsNAGoFj8BAiBapzTbMsirQ5kRKJCQbr2DSdUIpDTmmN501Pw3RMDTaA==", + "invalid-critical": "MIICNDCCAdugAwIBAgIBezAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDOJghWI4P2pLu//pyuWuS5RMxY7oglwtHukch2dtifwqlImQAbTUZMOQwuX2ixqea3M5sus2wKYUjNJ5gwg6qOjggEIMIIBBDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDCBvQYDVR0RAQH/BIGyMIGvhjd1cm46YXJiaXRlcjphY2Nlc3M6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAzhjd1cm46YXJiaXRlcjpzZXJ2ZXI6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyhjt1cm46YXJiaXRlcjplbnJvbGxtZW50OjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwNDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjAMBgMqAwQBAf8EAgUAMAoGCCqGSM49BAMCA0cAMEQCIBBH3eR/XGp7YWBJvFHgAA9xt1aizHzrmtd0NcYymvbjAiAh+y5WDHrva1xLh4cSro3MKorRUDmGDgq+/RLCh9VPcQ==", + "invalid-signature": "MIICJTCCAcugAwIBAgIBezAKBggqhkjOPQQDAzAjMSEwHwYDVQQDDBhBcmJpdGVyIHNlcnZlci1hdXRob3JpdHkwHhcNMjYxMDIwMjM1NTAwWhcNMjcxMDIxMDAwMDAwWjAZMRcwFQYDVQQDDA5BcmJpdGVyIGFjY2VzczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABGtwvrvfi2ZqehEWQpliST4TFq/flr5Yhde7VqcIQdgkxYOJ+a4MW/88z1n+860DPg7xG9tZx1m9y0K6GArGHLmjgfkwgfYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4Awgb0GA1UdEQEB/wSBsjCBr4Y3dXJuOmFyYml0ZXI6YWNjZXNzOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwM4Y3dXJuOmFyYml0ZXI6c2VydmVyOjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDAwMoY7dXJuOmFyYml0ZXI6ZW5yb2xsbWVudDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDQwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwCgYIKoZIzj0EAwMDSAAwRQIgFB5Aanx9djh2Od4MDHuytlYbX3hWH8uCNX3dv1Q0K7YCIQDVTjDUg5xvxQa+WisDH8r4/pHpk6oaR9RsSXnorW5F0A==", + "trailing": "MIICODCCAd6gAwIBAgIULKg+4C53kC87SDJewTu3URohiL0wCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTI3MTAyMTAwMDAwMFowGTEXMBUGA1UEAwwOQXJiaXRlciBhY2Nlc3MwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQP2gjU9SoENRsEGb3btwtwiwNHv9d2dJB02WDM3eD1ae+HlJE17JApsKRctcvGC0m6P70HZYvbWN3OoHtKd1K4o4H5MIH2MAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMIG9BgNVHREBAf8EgbIwga+GN3VybjphcmJpdGVyOmFjY2VzczowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDOGN3VybjphcmJpdGVyOnNlcnZlcjowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDKGO3VybjphcmJpdGVyOmVucm9sbG1lbnQ6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDA0MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMCMAoGCCqGSM49BAMCA0gAMEUCIGWuOmVfZgZfPjCpzffdSU1pNzhhEc+4EKl+VqhsnzuGAiEApDdAImcOhzm5ZfWFd60u8+Mzh8v+7YZlcLxjVoXgaw14", + "other-server-authority": "MIIBvzCCAWWgAwIBAgIUZ+prYCOUBYGnxL7w2LyJjxW4PL0wCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MB4XDTI2MTAyMDIzNTUwMFoXDTM2MTAxODAwMDAwMFowIzEhMB8GA1UEAwwYQXJiaXRlciBzZXJ2ZXItYXV0aG9yaXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETS751ezASwIcCl850BWqLL9EF2BXx2lQWQkQ3H7BlCUdRIcKIcimTJ7MqJvvlxyh6UHOriuMFTN6U+q7znk36qN3MHUwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAgQwTwYDVR0RAQH/BEUwQ4ZBdXJuOmFyYml0ZXI6c2VydmVyLWF1dGhvcml0eTowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIwCgYIKoZIzj0EAwIDSAAwRQIgXBTM+s7LD8Yi/raEDRIQ/mYIsjXQx9BQ7sV0Ktf46ooCIQDMnvvl+g83QSBvd0K5Y29J9mWiJ/6+Dip00s3ABSFjBQ==" + }, + "cases": [ + { + "name": "owner", + "certificate": "owner", + "role": "owner", + "ids": [ + "00000000-0000-4000-8000-000000000001" + ], + "valid": true + }, + { + "name": "server-authority", + "certificate": "server-authority", + "role": "server-authority", + "ids": [ + "00000000-0000-4000-8000-000000000002" + ], + "valid": true + }, + { + "name": "portable", + "certificate": "portable", + "role": "portable", + "ids": [ + "00000000-0000-4000-8000-000000000003" + ], + "issuer": "owner", + "issuer_id": "00000000-0000-4000-8000-000000000001", + "valid": true + }, + { + "name": "access", + "certificate": "access", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": true + }, + { + "name": "server", + "certificate": "server", + "role": "server", + "ids": [ + "00000000-0000-4000-8000-000000000002" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": true + }, + { + "name": "mismatched-signature-algorithm", + "certificate": "signature-algorithm-mismatch", + "role": "server", + "ids": [ + "00000000-0000-4000-8000-000000000002" + ], + "issuer": "signature-algorithm-issuer", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-curve", + "certificate": "invalid-curve", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-lifetime", + "certificate": "invalid-lifetime", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-issuer-window", + "certificate": "invalid-issuer-window", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-missing-usage", + "certificate": "invalid-missing-usage", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-eku", + "certificate": "invalid-eku", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-ca", + "certificate": "invalid-ca", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-duplicate-san", + "certificate": "invalid-duplicate-san", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-dns-san", + "certificate": "invalid-dns-san", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-critical", + "certificate": "invalid-critical", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "invalid-signature", + "certificate": "invalid-signature", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "portable-is-not-access", + "certificate": "portable", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "wrong-registered-issuer", + "certificate": "access", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "other-server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "wrong-purpose-issuer", + "certificate": "access", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "owner", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + }, + { + "name": "trailing-DER", + "certificate": "trailing", + "role": "access", + "ids": [ + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000004" + ], + "issuer": "server-authority", + "issuer_id": "00000000-0000-4000-8000-000000000002", + "valid": false + } + ] +} diff --git a/website/docs/operate/credentials.md b/website/docs/operate/credentials.md new file mode 100644 index 0000000..6718f74 --- /dev/null +++ b/website/docs/operate/credentials.md @@ -0,0 +1,73 @@ +--- +title: Development Credential Profiles +--- + +Offline `arbiter-server authority init` creates separate owner-issuer, +server-authority and inner server-TLS credentials. They are committed with the +owner/server IDs in the private SQLite database. Status returns public identity +metadata, never private keys. Setup is still development work: pairing and runtime +caller authentication are separate milestones. Keep trusted-local-only operation. + +## Certificate profiles, version 1 + +All certificates use X.509 v3, P-256 keys and ECDSA/SHA-256 signatures. IDs are +canonical lowercase version-four UUIDs. The Common Name does not grant authority. +The verifier requires an exact role and identity, rather than discovering a role +from whatever a peer supplied. The DER limit is 4,096 bytes before parsing; +noncanonical/trailing DER, duplicate identity bindings and unknown critical +extensions are rejected. + +| Role | Required URI SANs | Issuer and purpose | +| --- | --- | --- | +| Owner issuer | `urn:arbiter:owner:OWNER` | Self-signed CA; certificate signing only | +| Server authority | `urn:arbiter:server-authority:SERVER` | Self-signed CA; certificate signing only | +| Portable agent | `urn:arbiter:portable:AGENT` | Registered owner issuer; `clientAuth`; enrollment identity only | +| Per-server access | `urn:arbiter:access:AGENT`, `urn:arbiter:server:SERVER`, `urn:arbiter:enrollment:ENROLLMENT` | That server's authority; `clientAuth` | +| Inner server TLS | `urn:arbiter:server:SERVER` | That server's authority; `serverAuth` | + +Basic Constraints, Key Usage and SAN are required critical extensions. Leaves +also have exactly their required critical EKU. CAs have path length zero and no +leaf EKU. Issuer and leaf keys must differ. Leaf validity must fit within the +issuer's validity. Signature, issuer name, issuer role and expected IDs are all +verified. A generic valid client certificate is insufficient. + +Self-signed parsing does not register trust. Leaf verification requires issuer +bytes selected by the caller from its registered trust store, with the expected +issuer ID. Portable and ordinary access identities use different issuer purposes +and SAN roles. Future enrollment additionally checks live approval and grants; +these certificate functions do not grant access by themselves. + +## Validity and renewal schedule + +New issuers expire 3,650 days after issuance; renewal becomes due 366 days before +expiry. Portable, access and server-TLS leaves expire 365 days after issuance; +renewal becomes due 30 days before expiry. Issuance backdates `notBefore` by five +minutes. Verification adds no expiry grace and uses the inclusive X.509 validity +bounds documented by [cryptography](https://cryptography.io/en/42.0.8/x509/reference/#cryptography.x509.Certificate.not_valid_after_utc). +An issuer with insufficient validity for a full-lifetime leaf must be renewed +before issuing it. The separate 30-day access-key rotation, 24-hour grace and +180-day inactivity rules remain later authorization checks, not these lifetimes. + +This slice defines due-time calculations; it does not implement renewal or +rotation. Expired peer-supplied trust anchors cannot authorize renewal. Offline +status validates the initial setup at its recorded creation time so an expired +certificate does not prevent inspection. Network verifiers must use current time. + +## Storage and compatibility + +The database contains the three server-held private P-256 keys as bounded, +unencrypted PKCS8 DER inside the existing private filesystem boundary. Access +and portable client private keys are not created or retained by server setup. +Certificate status output does not export private material. The setup transaction +must succeed completely before publication; failure does not replace existing +IDs or keys. Never copy the authority database into an agent workspace. + +Authority schema 2 requires the complete credential set. The earlier experimental +IDs-only schema 1 is rejected without modification; there is no implicit upgrade +or reinitialization. Preserve old state rather than deleting identity records to +work around a version error. + +Shared public-only verification vectors are in +`tests/fixtures/authentication/credential-profiles-v1.json`. They are synthetic +test trust, consumed by Python now and intended for Go's slice 2.1 verifier. +They must never become product trust or default credentials. diff --git a/website/docs/operate/security.md b/website/docs/operate/security.md index 96ec8f3..88c715e 100644 --- a/website/docs/operate/security.md +++ b/website/docs/operate/security.md @@ -194,6 +194,8 @@ every state file must be owned by the caller, SYSTEM or Administrators, in addition to passing the existing private ACL checks. Use a private path under the operator's profile; other unsupported ownership fails closed. -These commands create identity metadata only. They do not issue credentials or -enable caller authentication. The current trusted-local-client restriction -above still applies. +Initialization also creates separate owner-issuer, server-authority and server +TLS signing credentials in the same private database transaction. Status never +prints private keys. See [credential profiles](credentials.md) for their purposes, +validity and verification rules. This does not enable caller authentication; +the current trusted-local-client restriction above still applies.