Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions scripts/prepare_lifecycle_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@
binds the requested change to that commit with a domain-separated idempotency
digest, writes one candidate file, and leaves the result for normal pull
request review.

The lifecycle App private key is not in this repository. After the founder
stores it in Keychain item ``openadapt-lifecycle-app-key``, set the GitHub
environment secret on every environment whose workflow reads
``secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY``. Create each environment first.
Do not generate a new App key. Do not delete the Keychain item. Confirm every
``gh secret set`` exit status before treating the loop as done::

for E in \\
production-lifecycle-activation \\
production-release-admission \\
qualification-authority-state \\
qualification-revocation-state \\
production-lifecycle-feed
do
security find-generic-password -a "$USER" -s openadapt-lifecycle-app-key -w \\
| gh secret set OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY \\
-R OpenAdaptAI/.github --env "$E"
done
"""

from __future__ import annotations
Expand Down
164 changes: 160 additions & 4 deletions scripts/qualification_software_ed25519.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
The issuer workflows stay inactive until the founder provisions the key and a
later change arms them. An agent must not mint this trust root: run `provision`
on the founder's Mac.

``local-candidate`` builds an unpublished, Keychain-signed registry candidate
with ``activation_state=not-installed`` and ``clock=unset``. It does not write
``generated_at`` or ``expires_at``, so it cannot start the seven-day live
registry clock. Do not copy its output onto ``main``.
"""

from __future__ import annotations
Expand All @@ -25,6 +30,7 @@
from pathlib import Path
from typing import Any, Mapping

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Expand All @@ -45,6 +51,13 @@
KEYCHAIN_SERVICE = "openadapt-qualification-ed25519"
SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100")
DECISION_USAGE = "qualification-evidence-decision-receipt"
LOCAL_CANDIDATE_SCHEMA = (
"openadapt.qualification-signer-registry-local-candidate/v1"
)
LOCAL_CANDIDATE_DOMAIN = (
b"OpenAdapt qualification software Ed25519 local registry candidate v1\0"
)
INTERFACE_DOMAIN = b"OpenAdapt qualification software Ed25519 interface v1\0"


class SoftwareEd25519Error(ValueError):
Expand Down Expand Up @@ -170,14 +183,134 @@ def signer_registry_candidate(
"activation_state": "not-installed",
"custody": list(interface["custody"]),
"interface_sha256": "sha256:"
+ hashlib.sha256(
b"OpenAdapt qualification software Ed25519 interface v1\0"
+ canonical(interface)
).hexdigest(),
+ hashlib.sha256(INTERFACE_DOMAIN + canonical(interface)).hexdigest(),
"proposed_registry": proposed_registry,
}


def _decode_raw_public_key(public_key: str) -> bytes:
if not isinstance(public_key, str) or "=" in public_key:
raise SoftwareEd25519Error("public key must be canonical unpadded base64url")
try:
raw = base64.urlsafe_b64decode(public_key + "=" * (-len(public_key) % 4))
except (ValueError, binascii.Error) as exc:
raise SoftwareEd25519Error("public key is not valid base64url") from exc
if len(raw) != 32:
raise SoftwareEd25519Error("public key must encode 32 Ed25519 bytes")
if base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") != public_key:
raise SoftwareEd25519Error("public key must be canonical unpadded base64url")
return raw


def unsigned_local_registry_candidate(
*,
public_material_value: Mapping[str, str],
revision: int,
) -> dict[str, Any]:
"""Build an unpublished candidate with no registry clock.

The result is not a live signer registry. It omits ``generated_at`` and
``expires_at`` so copying it onto ``main`` cannot start a seven-day
window. A later publish step must stamp those fields at publish time.
"""

if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
raise SoftwareEd25519Error("registry revision must be a positive integer")
import public_trust_kms as public_trust

inner = signer_from_public_material(public_material_value)
try:
public_key = Ed25519PublicKey.from_public_bytes(
_decode_raw_public_key(public_material_value["public_key"])
)
except (KeyError, ValueError) as exc:
raise SoftwareEd25519Error("public material is not a valid Ed25519 key") from exc
outer = public_trust.software_public_signer(public_key)
interface = interface_contract()
unsigned = {
"schema_version": LOCAL_CANDIDATE_SCHEMA,
"activation_state": "not-installed",
"clock": "unset",
"custody": list(interface["custody"]),
"interface_sha256": "sha256:"
+ hashlib.sha256(INTERFACE_DOMAIN + canonical(interface)).hexdigest(),
"revision": revision,
"qualification_signer": inner,
"public_trust_signer": outer,
}
for forbidden in ("generated_at", "expires_at", "proposed_registry", "signature"):
if forbidden in unsigned:
raise SoftwareEd25519Error("local candidate must not start the registry clock")
return unsigned


def sign_local_registry_candidate(
unsigned: Mapping[str, Any],
*,
private_key: Ed25519PrivateKey,
) -> dict[str, Any]:
"""Keychain-sign one unsigned local candidate. Do not publish the result."""

if unsigned.get("schema_version") != LOCAL_CANDIDATE_SCHEMA:
raise SoftwareEd25519Error("local candidate schema is not supported")
if unsigned.get("activation_state") != "not-installed":
raise SoftwareEd25519Error("local candidate activation_state must be not-installed")
if unsigned.get("clock") != "unset":
raise SoftwareEd25519Error("local candidate must not start the registry clock")
for forbidden in ("generated_at", "expires_at", "signature", "proposed_registry"):
if forbidden in unsigned:
raise SoftwareEd25519Error(f"local candidate must omit {forbidden}")
material = public_material(private_key)
inner = unsigned.get("qualification_signer")
if not isinstance(inner, dict) or inner.get("key_id") != material["key_id"]:
raise SoftwareEd25519Error(
"Keychain public half does not match the unsigned candidate"
)
payload = LOCAL_CANDIDATE_DOMAIN + canonical(unsigned)
signed = dict(unsigned)
signed["signature"] = base64.b64encode(private_key.sign(payload)).decode("ascii")
signed["signature_key_id"] = material["key_id"]
return signed


def verify_local_registry_candidate(value: Mapping[str, Any]) -> dict[str, Any]:
"""Verify a Keychain-signed local candidate without installing it."""

if value.get("schema_version") != LOCAL_CANDIDATE_SCHEMA:
raise SoftwareEd25519Error("local candidate schema is not supported")
if value.get("activation_state") != "not-installed":
raise SoftwareEd25519Error("local candidate activation_state must be not-installed")
if value.get("clock") != "unset":
raise SoftwareEd25519Error("local candidate must not start the registry clock")
for forbidden in ("generated_at", "expires_at", "proposed_registry"):
if forbidden in value:
raise SoftwareEd25519Error(f"local candidate must omit {forbidden}")
signature_b64 = value.get("signature")
key_id = value.get("signature_key_id")
inner = value.get("qualification_signer")
if not isinstance(signature_b64, str) or not isinstance(key_id, str):
raise SoftwareEd25519Error("local candidate signature is absent")
if not isinstance(inner, dict) or inner.get("key_id") != key_id:
raise SoftwareEd25519Error("local candidate signature key id does not match")
try:
signature = base64.b64decode(signature_b64, validate=True)
public_key = Ed25519PublicKey.from_public_bytes(
_decode_raw_public_key(inner["public_key"])
)
except (KeyError, ValueError, binascii.Error) as exc:
raise SoftwareEd25519Error("local candidate signature material is invalid") from exc
unsigned = {
field: value[field]
for field in value
if field not in {"signature", "signature_key_id"}
}
try:
public_key.verify(signature, LOCAL_CANDIDATE_DOMAIN + canonical(unsigned))
except InvalidSignature as exc:
raise SoftwareEd25519Error("local candidate signature verification failed") from exc
return dict(value)


def sign_receipt(
receipt: Mapping[str, Any],
*,
Expand Down Expand Up @@ -403,6 +536,11 @@ def main(argv: list[str] | None = None) -> int:
registry_parser.add_argument("--generated-at", required=True)
registry_parser.add_argument("--expires-at", required=True)

local_parser = subparsers.add_parser("local-candidate")
local_parser.add_argument("--revision", required=True, type=int)
local_parser.add_argument("--pem-file")
local_parser.add_argument("--from-keychain", action="store_true")

sign_parser = subparsers.add_parser("sign")
sign_parser.add_argument("--receipt", required=True)
sign_parser.add_argument("--signer-registry", required=True)
Expand Down Expand Up @@ -431,6 +569,24 @@ def main(argv: list[str] | None = None) -> int:
generated_at=_parse_timestamp(args.generated_at),
expires_at=_parse_timestamp(args.expires_at),
)
elif args.command == "local-candidate":
sources = [args.pem_file, args.from_keychain]
if sum(bool(item) for item in sources) != 1:
raise SoftwareEd25519Error(
"choose one private key source: --pem-file or --from-keychain"
)
private_key = load_private_key_from_sources(
pem_file=Path(args.pem_file) if args.pem_file else None,
use_keychain=args.from_keychain,
use_env=False,
)
unsigned = unsigned_local_registry_candidate(
public_material_value=public_material(private_key),
revision=args.revision,
)
result = verify_local_registry_candidate(
sign_local_registry_candidate(unsigned, private_key=private_key)
)
elif args.command == "sign":
sources = [args.pem_file, args.from_env, args.from_keychain]
if sum(bool(item) for item in sources) != 1:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_qualification_software_ed25519.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import sys
import tempfile
import unittest
Expand Down Expand Up @@ -147,6 +148,70 @@ def test_main_interface_json(self) -> None:
code = software.main(["interface"])
self.assertEqual(code, 0)

def test_local_candidate_is_unpublished_and_has_no_registry_clock(self) -> None:
import public_trust_kms as public_trust

private_key = Ed25519PrivateKey.generate()
unsigned = software.unsigned_local_registry_candidate(
public_material_value=software.public_material(private_key),
revision=1,
)
self.assertEqual(unsigned["activation_state"], "not-installed")
self.assertEqual(unsigned["clock"], "unset")
self.assertNotIn("generated_at", unsigned)
self.assertNotIn("expires_at", unsigned)
self.assertNotIn("proposed_registry", unsigned)
signed = software.sign_local_registry_candidate(
unsigned, private_key=private_key
)
verified = software.verify_local_registry_candidate(signed)
self.assertEqual(verified["signature_key_id"], unsigned["qualification_signer"]["key_id"])
self.assertTrue(verified["qualification_signer"]["key_id"].startswith("qa-ed25519-"))
self.assertTrue(
verified["public_trust_signer"]["key_id"].startswith("oa-public-trust-ed25519-")
)
self.assertEqual(
verified["qualification_signer"]["public_key"],
verified["public_trust_signer"]["public_key"],
)
public_trust.validate_public_signer(verified["public_trust_signer"])
with self.assertRaisesRegex(software.SoftwareEd25519Error, "registry clock"):
software.sign_local_registry_candidate(
{**unsigned, "clock": "2026-09-02T12:00:00Z"},
private_key=private_key,
)

def test_local_candidate_cli_from_pem_file(self) -> None:
import io

private_key = Ed25519PrivateKey.generate()
pem = software.private_key_pem(private_key)
with tempfile.TemporaryDirectory() as directory:
pem_path = Path(directory) / "key.pem"
pem_path.write_bytes(pem)
stdout = io.StringIO()
with mock.patch("sys.stdout", stdout):
code = software.main(
["local-candidate", "--revision", "1", "--pem-file", str(pem_path)]
)
self.assertEqual(code, 0)
payload = json.loads(stdout.getvalue())
self.assertEqual(payload["activation_state"], "not-installed")
self.assertEqual(payload["clock"], "unset")
self.assertNotIn("generated_at", payload)
self.assertNotIn("expires_at", payload)
software.verify_local_registry_candidate(payload)

def test_local_candidate_refuses_mismatched_key(self) -> None:
first = Ed25519PrivateKey.generate()
second = Ed25519PrivateKey.generate()
unsigned = software.unsigned_local_registry_candidate(
public_material_value=software.public_material(first),
revision=1,
)
with self.assertRaisesRegex(software.SoftwareEd25519Error, "does not match"):
software.sign_local_registry_candidate(unsigned, private_key=second)


if __name__ == "__main__":
unittest.main()