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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/go-cli/internal/identity/prepared.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ func verifyPreparedPurpose(raw []byte, material *Provisional, server *PairingSer
if len(data) != 0 || !bytes.Equal(fields[0], server.AuthorityDER) || !bytes.Equal(fields[1], server.CertificateDER) || !bytes.Equal(fields[5], public.RecipientKey) {
return nil, errPairing
}
if material.record.Version == 2 && !bytes.Equal(material.record.Source, []byte("null")) {
selected, err := decodePortableSource(material.record.Source, material.portable, nil)
if err != nil || selected == nil || ids[1] != selected.OwnerID || ids[2] != selected.AgentID || !bytes.Equal(fields[2], selected.OwnerDER) || !bytes.Equal(fields[3], selected.Certificate) {
return nil, errPairing
}
}
var serverErr error
if receipt || source {
serverErr = verifyHistoricalServerCredential(fields[1], Identity{Role: ServerTLS, SubjectID: ids[0]}, &Issuer{DER: fields[0], ID: ids[0]}, now)
Expand Down
126 changes: 126 additions & 0 deletions client/go-cli/internal/identity/registration_peer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -72,3 +73,128 @@ func TestPythonRegistrationPeer(t *testing.T) {
}
assertOnlyProvisionalLock(t, root)
}

// Real A/B servers supply the production HTTP/Noise/TLS exchange. Only local
// approval and controlled shutdown use this bounded synthetic handoff socket.
func TestPythonPortableEnrollmentPeer(t *testing.T) {
endpoint := os.Getenv("ARBITER_TEST_PORTABLE_ENROLLMENT_CONTROL")
if endpoint == "" {
t.Skip("Python portable enrollment peer is not running")
}
host, _, err := net.SplitHostPort(endpoint)
if err != nil || host != "127.0.0.1" {
t.Fatal("loopback required")
}
conn, err := net.DialTimeout("tcp", endpoint, 5*time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(40 * time.Second))
input, output := json.NewDecoder(io.LimitReader(conn, 32768)), json.NewEncoder(conn)
var config struct{ A, B, Mode string }
if input.Decode(&config) != nil {
t.Fatal("fixture configuration")
}
ctx := context.Background()
root := filepath.Join(custodyTestDir(t), "state")
err = WithEnrollmentOwner(ctx, root, func() error {
original, secret, e := NewProvisional(config.A, "source", time.Now())
if e != nil {
return e
}
var first *EnrollmentReceipt
var sourceBytes []byte
for phase := 0; phase < 2; phase++ {
material := original
if phase == 1 {
source, e := LoadPortableIdentity(root, original.record.Selector, time.Now())
if e != nil {
return e
}
material, secret, e = NewProvisionalFromIdentity(config.B, "target", time.Now(), source)
if e != nil {
return e
}
if samePublicKey(material.access.Public(), original.access.Public()) || bytes.Equal(material.recipient.Bytes(), original.recipient.Bytes()) {
return fmt.Errorf("target keys reused")
}
}
e = PairEnrollment(ctx, material, secret, 5*time.Second, filepath.Join(root, "prepared"), func(_ context.Context, selector, secret string) error {
if output.Encode(map[string]any{"phase": phase, "selector": selector, "secret": secret}) != nil {
return fmt.Errorf("handoff")
}
var accepted bool
if input.Decode(&accepted) != nil || !accepted {
return fmt.Errorf("approval")
}
return nil
})
if phase == 0 && e != nil || phase == 1 && (e == nil) != (config.Mode == "ok") {
return fmt.Errorf("pairing outcome: %v", e)
}
if phase == 1 {
if config.Mode == "changed-portable" {
if _, err := os.Stat(filepath.Join(root, "prepared", material.record.Selector+".json")); !os.IsNotExist(err) {
return fmt.Errorf("changed portable identity reached ready")
}
if output.Encode("rejected") != nil {
return fmt.Errorf("rejection handoff")
}
var rejected bool
if input.Decode(&rejected) != nil || !rejected {
return fmt.Errorf("rejection acknowledgement")
}
return nil
}
if output.Encode("prepared") != nil {
return fmt.Errorf("restart handoff")
}
var restarted bool
if input.Decode(&restarted) != nil || !restarted {
return fmt.Errorf("restart")
}
}
receipt, e := ReconcileEnrollment(ctx, filepath.Join(root, "prepared"), material.record.Selector, 5*time.Second)
if e != nil {
return e
}
if phase == 0 {
first = receipt
} else {
if receipt.prepared.AgentID != first.prepared.AgentID || receipt.prepared.OwnerID != first.prepared.OwnerID || receipt.prepared.ServerID == first.prepared.ServerID || receipt.prepared.EnrollmentID == first.prepared.EnrollmentID {
return fmt.Errorf("portable/target IDs")
}
again, e := ReconcileEnrollment(ctx, filepath.Join(root, "prepared"), material.record.Selector, 5*time.Second)
if e != nil || !bytes.Equal(again.prepared.raw, receipt.prepared.raw) {
return fmt.Errorf("exact recovery")
}
}
if e = receipt.Promote(ctx, filepath.Join(root, "enrolled")); e != nil {
return e
}
if phase == 0 {
sourceBytes, e = os.ReadFile(filepath.Join(root, "enrolled", original.record.Selector+".json"))
if e != nil {
return e
}
if output.Encode("source-ready") != nil {
return fmt.Errorf("source handoff")
}
var offline bool
if input.Decode(&offline) != nil || !offline {
return fmt.Errorf("source shutdown")
}
}
}
after, e := os.ReadFile(filepath.Join(root, "enrolled", original.record.Selector+".json"))
if e != nil || !bytes.Equal(after, sourceBytes) {
return fmt.Errorf("source custody changed")
}
return nil
})
if err != nil {
t.Fatal(err)
}
fmt.Println("ARBITER_PORTABLE_ENROLLMENT=pass")
}
7 changes: 7 additions & 0 deletions docs/enrollment.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ time without contacting the source server or requiring its access certificate
to remain valid. Saved receipt recovery checks portable evidence at issuance;
this does not permit an expired portable identity to start another enrollment.
The public CLI continues to create fresh identities at this intermediate cut.

The internal portable enrollment path now keeps a locally selected agent identity
through target preparation and atomic activation. Target approval binds the exact
registered issuer revision; removal and re-registration require a new approval.
Existing target bindings, including denied or inactive enrollments, require
owner-approved repair. Public registration and CLI source selection remain on the
fresh enrollment path until the final portable-enrollment cutover.
1 change: 1 addition & 0 deletions server/news/+portable-enrollment-activation.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add internal portable-identity preparation and atomic activation with exact issuer-revision approval, unique target bindings and unchanged receipt recovery. Public enrollment remains on the fresh flow pending the final client/server cutover.
50 changes: 44 additions & 6 deletions server/src/arbiter_server/enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import hashlib
import hmac
import json
from pathlib import Path
import re
import threading
import time
Expand Down Expand Up @@ -214,6 +215,7 @@ class ApprovedRequest:
request: PendingRequest
grants: tuple[tuple[str, str], ...]
secret: bytes = field(repr=False)
portable_issuer: tuple[bytes, int] | None = None


class EnrollmentRequests:
Expand Down Expand Up @@ -333,7 +335,7 @@ def check() -> None:
def _summary(self, request: PendingRequest, now: float) -> dict[str, object]:
advertisement = request.advertisement
approved = self._approved.get(advertisement.selector)
return {
result: dict[str, object] = {
"selector": advertisement.selector,
"name": advertisement.name,
"revision": request.revision,
Expand All @@ -350,12 +352,24 @@ def _summary(self, request: PendingRequest, now: float) -> dict[str, object]:
"remaining_seconds": max(0, int(request.deadline - now)),
"grant_count": len(approved.grants) if approved else 0,
}
if advertisement.portable_identity is not None:
claim = advertisement.portable_identity
result["portable_identity"] = {
"owner_id": claim.owner_id,
"agent_id": claim.agent_id,
"issuer_sha256": claim.issuer_sha256,
"certificate_validated": approved is not None,
"possession_proved": False,
}
return result

def control(
self,
command: str,
arguments: dict[str, object],
accounts: set[tuple[str, str]],
*,
root: Path | None = None,
) -> dict[str, object]:
"""Called only through the OS-authenticated local control dispatcher."""
if command == "open":
Expand Down Expand Up @@ -449,12 +463,28 @@ def control(
selector = arguments["selector"]
if not isinstance(selector, str):
raise ValueError("Invalid enrollment selector.")
issuer = None
selected = None
if command == "approve":
with self._lock:
selected = self._requests.get(selector)
if selected is not None and selected.advertisement.portable_identity:
from .owner_issuers import approve_portable

if root is None:
raise ValueError(
"Portable enrollment requires target issuer validation"
)
# No request lock spans bounded database or certificate work.
issuer = approve_portable(root, selected.advertisement)
with self._lock:
now = self._clock()
self._prune(now)
request = self._requests.get(selector)
if request is None:
raise ValueError("Enrollment request is missing or expired.")
if command == "approve" and request is not selected:
raise ValueError("Enrollment selection changed; list requests again.")
if (
request.revision != arguments["revision"]
or request.advertisement.digest != arguments["request_digest"]
Expand All @@ -471,12 +501,16 @@ def control(
return {"initialized": True, "rejected": selector}
existing = self._approved.get(selector)
if existing is not None:
if existing.grants != grants or not hmac.compare_digest(
existing.secret, secret
if (
existing.portable_issuer != issuer
or existing.grants != grants
or not hmac.compare_digest(existing.secret, secret)
):
raise ValueError("Enrollment approval is immutable; reject first.")
else:
self._approved[selector] = ApprovedRequest(request, grants, secret)
self._approved[selector] = ApprovedRequest(
request, grants, secret, issuer
)
return {"initialized": True, "request": self._summary(request, now)}

def open_window(self) -> None:
Expand All @@ -502,9 +536,13 @@ def pending(self) -> tuple[PendingRequest, ...]:
self._prune(self._clock())
return tuple(self._requests.values())

def register(self, raw: bytes | bytearray) -> PendingRequest:
def register(self, raw: bytes | bytearray, *, version: int = 1) -> PendingRequest:
epoch, window_deadline = self.require_open()
advertisement = decode_advertisement(raw)
advertisement = (
decode_advertisement(raw)
if version == 1
else decode_advertisement(raw, version=version)
)
with self._lock:
if advertisement.selector in self._committing:
raise _invalid()
Expand Down
34 changes: 31 additions & 3 deletions server/src/arbiter_server/enrollment_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from collections.abc import Callable
from contextlib import closing
from datetime import datetime
from datetime import datetime, timezone
import json
import hashlib
from pathlib import Path
Expand All @@ -16,6 +16,7 @@
from .enrollment_receipt import result_fields
from .generations import ACTIVE, _control, _database
from .prepared import PREPARED_TAG
from .owner_issuers import portable_issuer


def activate(
Expand Down Expand Up @@ -48,8 +49,18 @@ def activate(
raise ValueError("Enrollment activation unavailable")
path = _database(root, active["active"])
authority = _read_record(path)
if ids[:2] != [authority.server_id, authority.owner_id]:
claim = ad.portable_identity
if ids[0] != authority.server_id or (
claim is None and ids[1] != authority.owner_id
):
raise ValueError("Enrollment activation unavailable")
if claim is not None and (
ids[1:3] != [claim.owner_id, claim.agent_id]
or fields[3] != claim.certificate_der
or approval.portable_issuer is None
or fields[2] != approval.portable_issuer[0]
):
raise ValueError("Portable identity differs from approval")
for index, identity, issuer, issuer_id, public in (
(3, Identity(Role.PORTABLE, ids[2]), fields[2], ids[1], ad.portable_spki),
(
Expand Down Expand Up @@ -78,7 +89,8 @@ def activate(
stored = dict(db.execute("SELECT role, certificate_der FROM credentials"))
if (
fields[0] != stored[Role.SERVER_AUTHORITY.value]
or fields[2] != stored[Role.OWNER.value]
or claim is None
and fields[2] != stored[Role.OWNER.value]
):
raise ValueError("Enrollment activation unavailable")
db.execute(
Expand Down Expand Up @@ -111,6 +123,15 @@ def activate(
(hashlib.sha256(fields[2]).hexdigest(), ids[1], fields[2]),
).fetchone() != (1,):
raise ValueError("Enrollment issuer unavailable")
if (
claim is not None
and db.execute(
"SELECT 1 FROM agent_enrollments WHERE agent_id=?", (ids[2],)
).fetchone()
):
raise ValueError(
"Agent is already enrolled; use owner-approved repair"
)
db.execute(
"INSERT INTO agent_enrollments VALUES (?, ?)", (ids[2], ids[3])
)
Expand All @@ -126,6 +147,13 @@ def activate(
)
if not set(approval.grants) <= accounts():
raise ValueError("An approved account is no longer configured")
if (
claim is not None
and existing is None
and portable_issuer(db, ad, datetime.now(timezone.utc))
!= approval.portable_issuer
):
raise ValueError("Portable issuer approval is no longer current")
# Account evaluation can invalidate or outlive the original request.
# Consume its authorization only after that work, under the live
# caller's attempt/approval locks. No such lock spans SQLite I/O.
Expand Down
Loading
Loading