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
15 changes: 9 additions & 6 deletions client/go-cli/internal/cli/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
lookupEnv EnvLookup, homeDir HomeDirFunc,
) int {
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME | arbiter enrol resume=SELECTOR")
fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME [identity=ENROLLED_SELECTOR] | arbiter enrol resume=SELECTOR")
fmt.Fprintln(stdout, "identity reuses a saved portable identity on a different server, which must independently trust its issuer and approve enrollment. Do not combine identity with resume.")
fmt.Fprintln(stdout, "New enrollment requires an interactive terminal. Resume uses saved keys and remembered server trust, without a pairing secret.")
return 0
}
values := map[string]string{}
for _, arg := range args {
key, value, ok := strings.Cut(arg, "=")
if !ok || (key != "url" && key != "name" && key != "resume") || value == "" || values[key] != "" {
if !ok || (key != "url" && key != "name" && key != "resume" && key != "identity") || value == "" || values[key] != "" {
printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage")
return 2
}
Expand All @@ -39,8 +40,8 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
return 2
}
}
if !resume && (len(values) != 2 || values["url"] == "" || values["name"] == "") {
printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage")
if !resume && (values["resume"] != "" || values["url"] == "" || values["name"] == "" || len(values) != 2 && (len(values) != 3 || values["identity"] == "")) {
printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME [identity=ENROLLED_SELECTOR]; identity cannot combine with resume"), "usage")
return 2
}
config, err := ResolveClientConfig(options.Overrides, lookupEnv, homeDir, options)
Expand Down Expand Up @@ -102,9 +103,9 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
printClientError(stderr, err, "enrollment")
return 1
}
err = identity.RunProvisional(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), values["url"], values["name"],
err = identity.RunEnrollment(ctx, filepath.Dir(configPath), values["url"], values["name"], values["identity"],
func(ctx context.Context, p *identity.Provisional, secret identity.PairingSecret) error {
// RunProvisional holds the one live enrollment owner. A second
// RunEnrollment holds the one live enrollment owner. A second
// invocation cannot remove a result while the first sends ready.
if err := identity.CleanupPrepared(ctx, preparedDirectory); err != nil {
return err
Expand All @@ -130,6 +131,8 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
err = safeClientError(identity.ErrEnrollmentClosed.Error())
} else if errors.Is(err, identity.ErrEnrollmentUnavailable) {
err = safeClientError(identity.ErrEnrollmentUnavailable.Error())
} else if errors.Is(err, identity.ErrEnrollmentSource) {
err = safeClientError(identity.ErrEnrollmentSource.Error())
}
printClientError(stderr, err, "enrollment")
return 1
Expand Down
21 changes: 21 additions & 0 deletions client/go-cli/internal/cli/enrollment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ func TestEnrollmentRequiresTerminalAndRejectsSecretArguments(t *testing.T) {
}
}

func TestEnrollmentIdentityArgumentsAreExclusive(t *testing.T) {
previous := openEnrollmentTerminal
defer func() { openEnrollmentTerminal = previous }()
openEnrollmentTerminal = func() (*os.File, *os.File, error) {
t.Fatal("invalid arguments reached terminal")
return nil, nil, nil
}
for _, args := range [][]string{
{"identity=private-input", "resume=private-input"},
{"url=https://localhost", "name=target", "identity=private-input", "resume=private-input"},
{"url=https://localhost", "name=target", "identity="},
{"url=https://localhost", "name=target", "identity=a", "identity=b"},
} {
var out, diagnostics bytes.Buffer
code := Main(append([]string{"enrol"}, args...), &out, &diagnostics, func(string) (string, bool) { return "", false }, func() (string, error) { t.Fatal("invalid arguments read config"); return "", nil })
if code != 2 || strings.Contains(out.String()+diagnostics.String(), "private-input") {
t.Fatal("unsafe argument handling", code)
}
}
}

func TestEnrollmentInvalidCustodyDoesNotAdvertise(t *testing.T) {
var requests atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
Expand Down
117 changes: 117 additions & 0 deletions client/go-cli/internal/identity/enrollment_test.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,139 @@
package identity

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/gorilla/websocket"
)

func TestEnrollmentSourceValidationSharesOwnerAndPrecedesCleanup(t *testing.T) {
for _, mode := range []string{"fresh", "reuse", "reuse-other-host", "reuse-other-port", "reuse-other-prefix", "reuse-other-ipv6", "reuse-other-ipv6-zone", "missing", "prepared-only", "expired", "same-server", "same-server-trailing-slash", "same-server-prefix", "same-server-host-case", "same-server-default-port", "same-server-source-default-port", "same-server-padded-default-port", "same-server-padded-source-port", "same-server-padded-nondefault-port", "same-server-ipv6", "same-server-mapped-ipv4"} {
t.Run(mode, func(t *testing.T) {
state := custodyTestDir(t)
age := time.Duration(0)
if mode == "expired" {
age = 366 * 24 * time.Hour
}
original, raw, _ := portableFixture(t, age, 2*365*24*time.Hour)
sourceURL, target := "https://source.invalid", "https://target.invalid"
sameServer := false
switch mode {
case "same-server":
target, sameServer = sourceURL, true
case "same-server-trailing-slash":
target, sameServer = sourceURL+"/", true
case "same-server-prefix":
sourceURL, target, sameServer = sourceURL+"/tenant/", sourceURL+"/tenant", true
case "same-server-host-case":
target, sameServer = "https://SOURCE.invalid", true
case "same-server-default-port":
target, sameServer = sourceURL+":443", true
case "same-server-source-default-port":
sourceURL, target, sameServer = sourceURL+":443", sourceURL, true
case "same-server-padded-default-port":
target, sameServer = sourceURL+":0443", true
case "same-server-padded-source-port":
sourceURL, target, sameServer = sourceURL+":00443", sourceURL, true
case "same-server-padded-nondefault-port":
sourceURL, target, sameServer = sourceURL+":444", sourceURL+":00444", true
case "same-server-ipv6":
sourceURL, target, sameServer = "https://[2001:db8::1]", "https://[2001:0db8:0:0:0:0:0:1]", true
case "same-server-mapped-ipv4":
sourceURL, target, sameServer = "https://127.0.0.1", "https://[::ffff:127.0.0.1]", true
case "reuse-other-host":
target = "https://other.invalid"
case "reuse-other-port":
target = sourceURL + ":444"
case "reuse-other-prefix":
target = sourceURL + "/tenant"
case "reuse-other-ipv6":
sourceURL, target = "https://[2001:db8::1]", "https://[2001:db8::2]"
case "reuse-other-ipv6-zone":
sourceURL, target = "https://[fe80::1%25zoneA]", "https://[fe80::1%25zonea]"
}
original.record.ServerURL = sourceURL
domain := "enrolled"
if mode == "prepared-only" {
domain = "prepared"
}
before := savePortableFixture(t, original, raw, state, domain)
directory := filepath.Join(state, "provisional")
orphan, _, err := NewProvisional("https://abandoned.invalid", "abandoned", time.Now())
if err != nil {
t.Fatal(err)
}
if err = SaveProvisional(context.Background(), directory, orphan); err != nil {
t.Fatal(err)
}
orphanPath := filepath.Join(directory, orphan.record.Selector+".json")
orphanBefore, err := os.ReadFile(orphanPath)
if err != nil {
t.Fatal(err)
}
selector := original.record.Selector
if mode == "fresh" {
selector = ""
}
if mode == "missing" {
selector = orphan.record.Selector
}
called := false
err = RunEnrollment(context.Background(), state, target, "target", selector,
func(ctx context.Context, p *Provisional, _ PairingSecret) error {
called = true
if p.record.Version != 2 {
t.Fatal("new public enrollment is not v2")
}
if _, e := os.Stat(orphanPath); !errors.Is(e, os.ErrNotExist) {
t.Fatal("abandoned request was not cleaned", e)
}
if e := CleanupProvisional(ctx, directory); !errors.Is(e, ErrProvisionalBusy) {
t.Fatal("source/exchange lost shared owner", e)
}
if strings.HasPrefix(mode, "reuse") && (!samePublicKey(p.portable.Public(), original.portable.Public()) || samePublicKey(p.access.Public(), original.access.Public()) || bytes.Equal(p.recipient.Bytes(), original.recipient.Bytes())) {
t.Fatal("incorrect source or target keys")
}
if _, e := LoadProvisional(directory, p.record.Selector); e != nil {
t.Fatal("new material not saved before exchange", e)
}
return nil
})
valid := mode == "fresh" || strings.HasPrefix(mode, "reuse")
if sameServer {
valid = false
}
if called != valid || valid && err != nil || !valid && !errors.Is(err, ErrEnrollmentSource) {
t.Fatal("source validation outcome", err)
}
if !valid {
after, e := os.ReadFile(orphanPath)
if e != nil || !bytes.Equal(after, orphanBefore) {
t.Fatal("invalid source cleaned existing state", e)
}
}
after, e := os.ReadFile(filepath.Join(state, domain, original.record.Selector+".json"))
if e != nil || !bytes.Equal(after, before) {
t.Fatal("source state changed", e)
}
})
}
}

func TestEnrollmentRegistrationAndCarrierBounds(t *testing.T) {
for _, mode := range []string{"closed", "redirect", "digest", "oversize", "header", "text", "fragment", "controls", "deadline", "cancel"} {
t.Run(mode, func(t *testing.T) {
Expand Down
57 changes: 55 additions & 2 deletions client/go-cli/internal/identity/provisional_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
Expand All @@ -13,6 +14,7 @@ import (
var ErrProvisionalBusy = errors.New("another enrollment owns this provisional directory")
var ErrProvisionalCleanupIncomplete = errors.New("provisional cleanup batch completed; retry cleanup before enrollment")
var ErrProvisionalUnexpectedContents = errors.New("provisional cleanup made no progress; move unrelated files out of the provisional directory before retrying")
var ErrEnrollmentSource = errors.New("identity must select a valid saved enrolled identity from a different server; keep saved state and choose another source or target")

const provisionalLockName = ".enrollment.lock"
const provisionalCleanupBatch = 64
Expand Down Expand Up @@ -153,7 +155,9 @@ func CleanupProvisional(ctx context.Context, directory string) (result error) {
return cleanupProvisional(ctx, root)
}

// RunProvisional saves fresh keys before exposing anything to exchange. It
// RunProvisional retains the v1 custody/receipt fixture entry point. The product
// enrollment command uses RunEnrollment for fresh and reused v2 registrations.
// It saves fresh keys before exposing anything to exchange. It
// cleans abandoned files first and removes this request when exchange returns.
// Exchange must observe cancellation; ownership remains until it actually ends.
// Any prepared/activated state must be persisted separately before returning.
Expand All @@ -165,6 +169,21 @@ func RunProvisional(ctx context.Context, directory, serverURL, name string,

func runProvisional(ctx context.Context, directory, serverURL, name string, lifetime time.Duration,
exchange func(context.Context, *Provisional, PairingSecret) error,
) (result error) {
return runProvisionalSource(ctx, directory, serverURL, name, lifetime, nil, exchange)
}

// RunEnrollment uses the same owner for source validation, cleanup and the
// entire exchange. An empty selector generates fresh v2 portable material.
func RunEnrollment(ctx context.Context, stateRoot, serverURL, name, sourceSelector string,
exchange func(context.Context, *Provisional, PairingSecret) error,
) error {
return runProvisionalSource(ctx, filepath.Join(stateRoot, "provisional"), serverURL, name,
10*time.Minute, &sourceSelector, exchange)
}

func runProvisionalSource(ctx context.Context, directory, serverURL, name string, lifetime time.Duration,
sourceSelector *string, exchange func(context.Context, *Provisional, PairingSecret) error,
) (result error) {
if exchange == nil {
return errCustody
Expand All @@ -174,11 +193,45 @@ func runProvisional(ctx context.Context, directory, serverURL, name string, life
return err
}
defer func() { result = errors.Join(result, lock.Close()) }()
var source *PortableIdentity
if sourceSelector != nil && *sourceSelector != "" {
source, err = LoadPortableIdentity(filepath.Dir(root), *sourceSelector, time.Now().UTC())
if err != nil {
return ErrEnrollmentSource
}
sourceEndpoint, sourceEndpointErr := enrollmentURL(source.serverURL, "/api/v1/enrollment/requests")
targetEndpoint, targetEndpointErr := enrollmentURL(serverURL, "/api/v1/enrollment/requests")
if sourceEndpointErr == nil && targetEndpointErr == nil {
endpointHost := func(host string) string {
if address, err := netip.ParseAddr(host); err == nil {
return address.Unmap().String()
}
return strings.ToLower(host)
}
endpointPort := func(port string) string {
if port == "" {
port = "443"
}
// URL parsing already requires decimal port syntax.
return strings.TrimLeft(port, "0")
}
if endpointHost(sourceEndpoint.Hostname()) == endpointHost(targetEndpoint.Hostname()) &&
endpointPort(sourceEndpoint.Port()) == endpointPort(targetEndpoint.Port()) && sourceEndpoint.Path == targetEndpoint.Path {
return ErrEnrollmentSource
}
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
}
if err := cleanupProvisional(ctx, root); err != nil {
return err
}
now := time.Now().UTC()
p, secret, err := NewProvisional(serverURL, name, now)
var p *Provisional
var secret PairingSecret
if sourceSelector == nil {
p, secret, err = NewProvisional(serverURL, name, now)
} else {
p, secret, err = NewProvisionalFromIdentity(serverURL, name, now, source)
}
if err != nil {
return err
}
Expand Down
9 changes: 6 additions & 3 deletions client/go-cli/internal/identity/registration_peer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ func TestPythonRegistrationPeer(t *testing.T) {
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
defer client.CloseIdleConnections()
root := filepath.Join(custodyTestDir(t), "state")
err = RunProvisional(context.Background(), root, "https://localhost", "Agent Smith",
if err := os.Mkdir(root, 0700); err != nil {
t.Fatal(err)
}
err = RunEnrollment(context.Background(), root, "https://localhost", "Agent Smith", "",
func(ctx context.Context, p *Provisional, _ PairingSecret) error {
advertisement, err := p.Public()
if err != nil {
Expand Down Expand Up @@ -71,7 +74,7 @@ func TestPythonRegistrationPeer(t *testing.T) {
if err != nil {
t.Fatal("Go registration peer failed", err)
}
assertOnlyProvisionalLock(t, root)
assertOnlyProvisionalLock(t, filepath.Join(root, "provisional"))
}

// Real A/B servers supply the production HTTP/Noise/TLS exchange. Only local
Expand Down Expand Up @@ -99,7 +102,7 @@ func TestPythonPortableEnrollmentPeer(t *testing.T) {
ctx := context.Background()
root := filepath.Join(custodyTestDir(t), "state")
err = WithEnrollmentOwner(ctx, root, func() error {
original, secret, e := NewProvisional(config.A, "source", time.Now())
original, secret, e := NewProvisionalFromIdentity(config.A, "source", time.Now(), nil)
if e != nil {
return e
}
Expand Down
37 changes: 33 additions & 4 deletions docs/enrollment.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Fresh client enrollment uses the existing `arbiter enrol` command and local
operator approval. Registering an owner issuer establishes public trust only;
it does not enroll a client or grant account access. Selecting an existing
portable identity for another server remains unavailable in this revision.
portable identity for another server requires the target's separate issuer trust
and enrollment approval.

Use `arbiter-server authority owner-issuer` through the same authenticated local
OS account as server administration. Commands work while the instance is stopped
Expand Down Expand Up @@ -46,11 +47,39 @@ The portable identity codec groundwork validates a source only from complete
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 CLI creates fresh portable material unless an enrolled source is selected.

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.
owner-approved repair.

To enroll a saved portable identity on another server, use the same local config
directory and select its enrolled record:

```sh
arbiter --config-dir STATE enrol url=https://SERVER-B name=NAME identity=SELECTOR
```

The source must be a complete record under `STATE/enrolled/`. Its portable
certificate must still be valid and its private key must match. Source validation
runs under the enrollment lock before cleanup or registration. Missing or invalid
sources leave saved state intact. The source server can be offline. B receives
fresh access and recipient keys and a separate enrollment ID; the portable key,
certificate and agent ID stay the same. The server-local enrollment IDs may differ.

Before approving on B, register the exact public owner issuer from A using the
commands above. Then open B's enrollment window and use the existing local
enrollment list/approve commands and deliberate terminal secret handoff. Issuer
registration alone grants no access. A denial on B cannot be bypassed by selecting
the identity again, and A's access certificate does not authenticate to B.

Do not combine `identity=...` with `resume=...`. After a lost reply or failed local
promotion, keep saved state and run `arbiter enrol resume=TARGET_SELECTOR` using
the same config directory. This retrieves the exact committed target result; it
does not start another enrollment. Existing committed v1 records remain recoverable.

New public enrollment requires v2 on both client and server. Update older clients
instead of deleting identities or retrying the old wire format. Ordinary
authenticated API access remains future milestone 3 work.
1 change: 1 addition & 0 deletions server/news/+portable-enrollment.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enroll an existing saved portable identity on another server with `arbiter enrol url=... name=... identity=SELECTOR`. The target server must register the exact owner issuer and independently approve the request. New public enrollment uses v2; update older clients and keep saved identity state. Existing committed enrollment remains recoverable with `arbiter enrol resume=SELECTOR`.
Loading
Loading