From 4171405ab94283ee3ac55f0ada45ee3d649e29f2 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Wed, 21 Oct 2026 01:19:46 +0800 Subject: [PATCH] [authentication/1.2] Add role-separated credential profiles Add fixed P-256 X.509 profiles for owner issuers, server authorities, portable agents, per-server access and inner server TLS. Validate exact purpose, URI identity, issuer, validity and size bounds through standard cryptography primitives, and report malformed credential inputs through controlled errors. Offline authority setup creates three separate signing credentials atomically in its private SQLite transaction. Status verifies setup coherence without exposing keys; unreleased schema-1 prototypes are refused without overwriting identities. Publish public Python/Go profile vectors and document the profiles and renewal schedules. Plan slice: arbiter-authentication/1.2. Runtime caller authentication and actual renewal are delivered by later slices. Validation: complete Python suite (1,225 passed, 9 skipped), minimum cryptography 42.0.8 credential suite (38 passed), full Black/Pyrefly, Go tests and independent certificate-chain/malformed-vector probe, website build. Native platform CI remains a required PR-cycle gate. --- .../+authority-credential-profiles.feature.md | 6 + server/src/arbiter_server/authority.py | 18 +- server/src/arbiter_server/credentials.py | 402 ++++++++++++++++++ server/tests/unit/conftest.py | 44 ++ server/tests/unit/test_authority.py | 40 +- server/tests/unit/test_credentials.py | 383 +++++++++++++++++ .../credential-profiles-v1.json | 275 ++++++++++++ website/docs/operate/credentials.md | 73 ++++ website/docs/operate/security.md | 8 +- 9 files changed, 1208 insertions(+), 41 deletions(-) create mode 100644 server/news/+authority-credential-profiles.feature.md create mode 100644 server/src/arbiter_server/credentials.py create mode 100644 server/tests/unit/conftest.py create mode 100644 server/tests/unit/test_credentials.py create mode 100644 tests/fixtures/authentication/credential-profiles-v1.json create mode 100644 website/docs/operate/credentials.md 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.