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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ jobs:
if: ${{ matrix.go_tests == true }}
env:
GOCACHE: ${{ runner.temp }}/go-build
run: nox -s unit -- server/tests/integration/test_credential_peer.py server/tests/integration/test_provisional_peer.py server/tests/integration/test_pairing_peer.py server/tests/integration/test_enrollment_cli_peer.py server/tests/integration/test_control_tls_peer.py server/tests/integration/test_enrollment_receipt_peer.py
run: nox -s unit -- server/tests/integration/test_credential_peer.py server/tests/integration/test_provisional_peer.py server/tests/integration/test_pairing_peer.py server/tests/integration/test_enrollment_cli_peer.py server/tests/integration/test_control_tls_peer.py server/tests/integration/test_enrollment_receipt_peer.py server/tests/integration/test_activation_restart_peer.py

- name: Verify native local-control rejection across OS users
if: ${{ startsWith(matrix.platform_id, 'linux-') || startsWith(matrix.platform_id, 'macos-') }}
Expand Down
74 changes: 65 additions & 9 deletions client/go-cli/internal/cli/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
Expand All @@ -17,20 +18,28 @@ 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")
fmt.Fprintln(stdout, "Requires an interactive terminal. Saves prepared enrollment; activation is not available yet.")
fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME | arbiter enrol resume=SELECTOR")
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") || value == "" || values[key] != "" {
if !ok || (key != "url" && key != "name" && key != "resume") || value == "" || values[key] != "" {
printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage")
return 2
}
values[key] = value
}
if len(values) != 2 {
resume := len(values) == 1 && values["resume"] != ""
if resume {
decoded, err := base64.RawURLEncoding.DecodeString(values["resume"])
if err != nil || len(decoded) != 16 || base64.RawURLEncoding.EncodeToString(decoded) != values["resume"] {
printClientError(stderr, safeClientError("expected a valid saved enrollment selector"), "usage")
return 2
}
}
if !resume && (len(values) != 2 || values["url"] == "" || values["name"] == "") {
printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage")
return 2
}
Expand All @@ -44,30 +53,77 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
printClientError(stderr, err, "enrollment")
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
preparedDirectory := filepath.Join(filepath.Dir(configPath), "prepared")
receiptTimeout := config.ConnectTimeout
if receiptTimeout > 10*time.Second {
receiptTimeout = 10 * time.Second
}
finish := func(selector string) (bool, error) {
receipt, err := identity.ReconcileEnrollment(ctx, preparedDirectory, selector, receiptTimeout)
fromEnrolled := errors.Is(err, os.ErrNotExist)
saved := !fromEnrolled
if fromEnrolled {
receipt, err = identity.ReconcileEnrollment(ctx, filepath.Join(filepath.Dir(configPath), "enrolled"), selector, receiptTimeout)
saved = !errors.Is(err, os.ErrNotExist)
}
if err == nil {
err = receipt.Promote(ctx, filepath.Join(filepath.Dir(configPath), "enrolled"))
}
if err != nil {
return saved, safeClientError("Enrollment is not confirmed. Saved private state is retained; retry arbiter enrol resume=" + selector + ". If the server did not commit, ask the operator and start a new enrollment.")
}
if !fromEnrolled {
return true, identity.RemoveProvisional(preparedDirectory, selector)
}
return true, nil
}
if resume {
err := identity.WithEnrollmentOwner(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), func() error {
_, err := finish(values["resume"])
return err
})
if err != nil {
printClientError(stderr, err, "enrollment")
return 1
}
fmt.Fprintln(stdout, "Enrollment activated and saved. Ordinary authenticated API access is not available in this milestone.")
return 0
}
in, out, err := openEnrollmentTerminal()
if err != nil {
printClientError(stderr, safeClientError("enrollment requires an interactive terminal for deliberate secret handoff"), "enrollment")
return 1
}
defer in.Close()
defer out.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
printClientError(stderr, err, "enrollment")
return 1
}
preparedDirectory := filepath.Join(filepath.Dir(configPath), "prepared")
err = identity.RunProvisional(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), values["url"], values["name"],
func(ctx context.Context, p *identity.Provisional, secret identity.PairingSecret) error {
// RunProvisional 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
}
return identity.PairEnrollment(ctx, p, secret, config.ConnectTimeout, preparedDirectory, func(ctx context.Context, selector, psk string) error {
pairErr := identity.PairEnrollment(ctx, p, secret, config.ConnectTimeout, preparedDirectory, func(ctx context.Context, selector, psk string) error {
return confirmEnrollment(ctx, in, out, selector, psk)
})
public, _ := p.Public()
saved, finishErr := finish(public.Selector)
if finishErr != nil {
if !saved {
if pairErr == nil {
return safeClientError("Prepared enrollment is unavailable; enrollment was not locally promoted.")
}
return pairErr
}
return finishErr
}
return nil
})
if err != nil {
if errors.Is(err, identity.ErrEnrollmentClosed) {
Expand All @@ -78,6 +134,6 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer,
printClientError(stderr, err, "enrollment")
return 1
}
fmt.Fprintln(stdout, "Prepared enrollment saved and ready proof verified. Enrollment is not active; activation is not available yet.")
fmt.Fprintln(stdout, "Enrollment activated and saved. Ordinary authenticated API access is not available in this milestone.")
return 0
}
72 changes: 63 additions & 9 deletions client/go-cli/internal/cli/enrollment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net"
Expand Down Expand Up @@ -35,6 +36,28 @@ func TestPythonEnrollmentCLI(t *testing.T) {
if err := decoder.Decode(&start); err != nil {
t.Fatal(err)
}
if start.Mode == "resume" {
entries, err := os.ReadDir(filepath.Join(start.Directory, "prepared"))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
selector := strings.TrimSuffix(entry.Name(), ".json")
var out, diagnostics bytes.Buffer
if Main([]string{"--config-dir", start.Directory, "arbiter.connect_timeout=15s", "enrol", "resume=" + selector}, &out, &diagnostics,
func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil }) != 0 {
t.Fatal(diagnostics.String())
}
if _, err := os.Stat(filepath.Join(start.Directory, "enrolled", entry.Name())); err != nil {
t.Fatal(err)
}
return
}
t.Fatal("missing recoverable prepared state")
}
in, feed, _ := os.Pipe()
output, out, _ := os.Pipe()
defer in.Close()
Expand Down Expand Up @@ -70,19 +93,27 @@ func TestPythonEnrollmentCLI(t *testing.T) {
finished <- secret
}()
var stdout, stderr bytes.Buffer
code := Main([]string{"--config-dir", start.Directory, "enrol", "url=" + start.URL, "name=Fresh CLI"}, &stdout, &stderr,
args := []string{"--config-dir", start.Directory}
if start.Mode == "ok" {
args = append(args, "arbiter.connect_timeout=15s")
}
if start.Mode == "lost-reply" || start.Mode == "delayed-reply" {
args = append(args, "arbiter.connect_timeout=10s")
}
args = append(args, "enrol", "url="+start.URL, "name=Fresh CLI")
code := Main(args, &stdout, &stderr,
func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil })
// Closing the command's terminal output also releases the synthetic reader
// when closed enrollment fails before a handoff.
secret := <-finished
if secret != "" && (strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret)) {
t.Fatal("secret reached output")
}
if start.Mode == "ok" {
if code != 0 || !strings.Contains(stdout.String(), "Enrollment is not active") {
if start.Mode == "ok" || start.Mode == "lost-reply" || start.Mode == "delayed-reply" {
if code != 0 || !strings.Contains(stdout.String(), "Enrollment activated and saved") {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
entries, err := os.ReadDir(filepath.Join(start.Directory, "prepared"))
entries, err := os.ReadDir(filepath.Join(start.Directory, "enrolled"))
if err != nil {
t.Fatal(err)
}
Expand All @@ -92,11 +123,21 @@ func TestPythonEnrollmentCLI(t *testing.T) {
continue
}
selector := strings.TrimSuffix(entry.Name(), ".json")
material, prepared, err := identity.LoadPrepared(filepath.Join(start.Directory, "prepared"), selector, time.Now().UTC())
if err != nil || material == nil || prepared == nil || !strings.Contains(stdout.String(), "Prepared enrollment saved") {
t.Fatal("prepared private result did not survive reload", err)
if start.Mode == "lost-reply" || start.Mode == "delayed-reply" {
receipt, reconcileErr := identity.ReconcileEnrollment(context.Background(), filepath.Join(start.Directory, "enrolled"), selector, 10*time.Second)
if reconcileErr == nil {
reconcileErr = receipt.Promote(context.Background(), filepath.Join(start.Directory, "enrolled"))
}
if reconcileErr != nil {
t.Fatal("expired enrolled receipt did not survive reload", reconcileErr)
}
} else {
material, prepared, err := identity.LoadPrepared(filepath.Join(start.Directory, "enrolled"), selector, time.Now().UTC())
if err != nil || material == nil || prepared == nil {
t.Fatal("enrolled private result did not survive reload", err)
}
}
path := filepath.Join(start.Directory, "prepared", entry.Name())
path := filepath.Join(start.Directory, "enrolled", entry.Name())
original, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
Expand All @@ -110,14 +151,27 @@ func TestPythonEnrollmentCLI(t *testing.T) {
if err := os.WriteFile(path, changed, 0600); err != nil {
t.Fatal(err)
}
if _, _, err := identity.LoadPrepared(filepath.Join(start.Directory, "prepared"), selector, time.Now().UTC()); err == nil {
var loadErr error
if start.Mode == "lost-reply" || start.Mode == "delayed-reply" {
_, loadErr = identity.ReconcileEnrollment(context.Background(), filepath.Join(start.Directory, "enrolled"), selector, 10*time.Second)
} else {
_, _, loadErr = identity.LoadPrepared(filepath.Join(start.Directory, "enrolled"), selector, time.Now().UTC())
}
if loadErr == nil {
t.Fatal("corrupted prepared record accepted")
}
}
if err := os.WriteFile(path, original, 0600); err != nil {
t.Fatal(err)
}
count++
// Explicit recovery after cleanup authenticates against enrolled
// custody and never creates another activation or handoff.
var resumed, resumeErr bytes.Buffer
if Main([]string{"--config-dir", start.Directory, "arbiter.connect_timeout=15s", "enrol", "resume=" + selector}, &resumed, &resumeErr,
func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil }) != 0 {
t.Fatal("resume after promotion", resumeErr.String())
}
}
if count != 1 {
t.Fatal("expected one prepared result", count)
Expand Down
2 changes: 1 addition & 1 deletion client/go-cli/internal/identity/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ func pairingSocket(ctx context.Context, target string, initiator *PairingInitiat
return err
}
plain, err := initiator.Decrypt(ack)
if err != nil || string(plain) != preparedComplete {
if err != nil || !bytes.Equal(plain, prepared) {
return ErrEnrollmentUnavailable
}
return ctx.Err()
Expand Down
Loading
Loading