diff --git a/tools/dwh-auth/cmd/dwh-auth/main.go b/tools/dwh-auth/cmd/dwh-auth/main.go new file mode 100644 index 00000000..d42aee6e --- /dev/null +++ b/tools/dwh-auth/cmd/dwh-auth/main.go @@ -0,0 +1,18 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/aritmolab/thothii/tools/dwh-auth/internal/command" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + os.Exit(command.Run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/tools/dwh-auth/internal/command/command.go b/tools/dwh-auth/internal/command/command.go new file mode 100644 index 00000000..8ead69a1 --- /dev/null +++ b/tools/dwh-auth/internal/command/command.go @@ -0,0 +1,558 @@ +//go:build linux + +// Package command implements the deliberately small dwh-auth operator CLI. +// It is intentionally independent of flag.CommandLine: command-line values +// are never included in diagnostics, and the grammar is closed by default. +package command + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aritmolab/thothii/tools/dwh-auth/internal/credential" + "github.com/aritmolab/thothii/tools/dwh-auth/internal/record" + "github.com/aritmolab/thothii/tools/dwh-auth/internal/registry" + "github.com/aritmolab/thothii/tools/dwh-auth/internal/securefile" +) + +const ( + exitOK = 0 + exitUsage = 2 + exitNotFound = 3 + exitIntegrity = 4 + maxReasonBytes = 160 + commandServeMsg = "serve is not available in this release" +) + +// Run executes one dwh-auth invocation and returns its stable process exit +// status. It writes only redacted, deterministic diagnostics to stderr. +func Run(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) int { + if ctx == nil { + ctx = context.Background() + } + if stdout == nil || stderr == nil { + return exitIntegrity + } + root, rest, err := parseRoot(args) + if err != nil { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + if len(rest) == 0 { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + + switch rest[0] { + case "key": + return runKey(ctx, root, rest[1:], stdout, stderr) + case "check": + return runCheck(ctx, root, rest[1:], stdout, stderr) + case "serve": + return runServe(root, rest[1:], stderr) + default: + writeLine(stderr, "unsafe invocation") + return exitUsage + } +} + +func parseRoot(args []string) (string, []string, error) { + if len(args) < 2 || args[0] != "--registry-root" || args[1] == "" || !filepath.IsAbs(args[1]) { + return "", nil, errors.New("invalid registry root") + } + return filepath.Clean(args[1]), args[2:], nil +} + +func runKey(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + switch args[0] { + case "create": + return runCreate(ctx, root, args[1:], stdout, stderr) + case "import": + return runImport(ctx, root, args[1:], stdout, stderr) + case "list": + return runList(ctx, root, args[1:], stdout, stderr) + case "status": + return runStatus(ctx, root, args[1:], stdout, stderr) + case "revoke": + return runRevoke(ctx, root, args[1:], stdout, stderr) + default: + writeLine(stderr, "unsafe invocation") + return exitUsage + } +} + +type parsed struct { + installationID string + description string + expiresAt string + output string + fromFile string + keyID string + reason string + json bool + legacyRaw bool +} + +func parseFlags(args []string, allowed map[string]bool) (parsed, error) { + var value parsed + seen := make(map[string]bool) + for i := 0; i < len(args); i++ { + name := args[i] + if !strings.HasPrefix(name, "--") || name == "--" || strings.Contains(name, "=") { + return parsed{}, errors.New("invalid option") + } + if !allowed[name] || seen[name] { + return parsed{}, errors.New("invalid option") + } + seen[name] = true + if name == "--json" || name == "--legacy-raw" { + switch name { + case "--json": + value.json = true + case "--legacy-raw": + value.legacyRaw = true + } + continue + } + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return parsed{}, errors.New("missing option value") + } + i++ + switch name { + case "--installation-id": + value.installationID = args[i] + case "--description": + value.description = args[i] + case "--expires-at": + value.expiresAt = args[i] + case "--output": + value.output = args[i] + case "--from-file": + value.fromFile = args[i] + case "--key-id": + value.keyID = args[i] + case "--reason": + value.reason = args[i] + } + } + return value, nil +} + +func runCreate(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{ + "--installation-id": true, + "--description": true, + "--expires-at": true, + "--output": true, + }) + if err != nil || options.installationID == "" || options.output == "" || !filepath.IsAbs(options.output) || !validInstallationID(options.installationID) || !validMetadata(options.description) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + expiresAt, err := parseExpiry(options.expiresAt) + if err != nil { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + if err := ctx.Err(); err != nil { + writeLine(stderr, "operation cancelled") + return exitIntegrity + } + + material, err := credential.Generate(rand.Reader) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + outputFile, err := securefile.CreateSecret(options.output) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + if err := writeAndSync(outputFile, material.Value); err != nil { + _ = outputFile.Close() + if cleanupOutput(options.output) != nil { + writeCleanupFailure(stderr, options.output) + return exitIntegrity + } + writeLine(stderr, "integrity failure") + return exitIntegrity + } + if err := outputFile.Close(); err != nil { + if cleanupOutput(options.output) != nil { + writeCleanupFailure(stderr, options.output) + return exitIntegrity + } + writeLine(stderr, "integrity failure") + return exitIntegrity + } + + store, err := registry.Open(root) + if err != nil { + if cleanupOutput(options.output) != nil { + writeCleanupFailure(stderr, options.output) + return exitIntegrity + } + writeLine(stderr, "integrity failure") + return exitIntegrity + } + now := time.Now().UTC() + value := record.Record{ + SchemaVersion: record.SchemaVersion, + Kind: record.KindV1, + KeyID: material.KeyID, + InstallationID: options.installationID, + Description: options.description, + SecretSHA256: base64.RawURLEncoding.EncodeToString(material.Digest[:]), + CreatedAt: now, + ExpiresAt: expiresAt, + } + addErr := store.Add(value) + closeErr := store.Close() + if addErr != nil || closeErr != nil { + if cleanupOutput(options.output) != nil { + writeCleanupFailure(stderr, options.output) + return exitIntegrity + } + if errors.Is(addErr, registry.ErrConflict) || errors.Is(addErr, registry.ErrRevoked) { + writeLine(stderr, "credential already exists") + return exitIntegrity + } + writeLine(stderr, "integrity failure") + return exitIntegrity + } + _, _ = fmt.Fprintf(stdout, "created key_id=%s installation_id=%s output=%s\n", material.KeyID, options.installationID, filepath.Clean(options.output)) + return exitOK +} + +func runImport(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{ + "--legacy-raw": true, + "--installation-id": true, + "--from-file": true, + }) + if err != nil || !options.legacyRaw || options.installationID != record.LegacyKeyID || options.fromFile == "" || !filepath.IsAbs(options.fromFile) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + if err := ctx.Err(); err != nil { + writeLine(stderr, "operation cancelled") + return exitIntegrity + } + value, err := securefile.ReadSecret(options.fromFile, credential.MaxHeaderBytes) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + sum := sha256.Sum256(value) + digest := record.Digest(sum) + if !credential.VerifyLegacy(value, digest) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + store, err := registry.Open(root) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + now := time.Now().UTC() + addErr := store.Add(record.Record{ + SchemaVersion: record.SchemaVersion, + Kind: record.KindLegacyRaw, + KeyID: record.LegacyKeyID, + InstallationID: record.LegacyKeyID, + SecretSHA256: base64.RawURLEncoding.EncodeToString(digest[:]), + CreatedAt: now, + }) + closeErr := store.Close() + if closeErr != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + if addErr != nil { + switch { + case errors.Is(addErr, registry.ErrConflict), errors.Is(addErr, registry.ErrRevoked): + writeLine(stderr, "credential already exists") + return exitIntegrity + default: + writeLine(stderr, "integrity failure") + return exitIntegrity + } + } + _, _ = fmt.Fprintf(stdout, "imported key_id=%s installation_id=%s\n", record.LegacyKeyID, record.LegacyKeyID) + return exitOK +} + +func runList(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{"--json": true}) + if err != nil { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + store, err := openCheckedStore(ctx, root) + if err != nil { + writeStoreError(stderr, err) + return storeExit(err) + } + values, listErr := store.List() + closeErr := store.Close() + if listErr != nil || closeErr != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + if options.json { + writeJSON(stdout, values) + return exitOK + } + for _, value := range values { + _, _ = fmt.Fprintf(stdout, "key_id=%s installation_id=%s state=%s\n", value.KeyID, value.InstallationID, value.State) + } + return exitOK +} + +func runStatus(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{"--key-id": true, "--json": true}) + if err != nil || options.keyID == "" || !validKeyID(options.keyID) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + store, err := openCheckedStore(ctx, root) + if err != nil { + writeStoreError(stderr, err) + return storeExit(err) + } + values, listErr := store.List() + closeErr := store.Close() + if listErr != nil || closeErr != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + for _, value := range values { + if value.KeyID != options.keyID { + continue + } + if options.json { + writeJSON(stdout, value) + } else { + _, _ = fmt.Fprintf(stdout, "key_id=%s installation_id=%s state=%s\n", value.KeyID, value.InstallationID, value.State) + } + return exitOK + } + writeLine(stderr, "not found") + return exitNotFound +} + +func runRevoke(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{"--key-id": true, "--reason": true}) + if err != nil || options.keyID == "" || !validKeyID(options.keyID) || options.reason == "" || !validMetadata(options.reason) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + if err := ctx.Err(); err != nil { + writeLine(stderr, "operation cancelled") + return exitIntegrity + } + store, err := registry.Open(root) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + err = store.Revoke(options.keyID, options.reason, time.Now().UTC()) + closeErr := store.Close() + if errors.Is(err, registry.ErrNotFound) { + writeLine(stderr, "not found") + return exitNotFound + } + if err != nil || closeErr != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + _, _ = fmt.Fprintf(stdout, "revoked key_id=%s\n", options.keyID) + return exitOK +} + +func runCheck(ctx context.Context, root string, args []string, stdout, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{"--json": true}) + if err != nil { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + if err := ctx.Err(); err != nil { + writeLine(stderr, "operation cancelled") + return exitIntegrity + } + store, err := registry.Open(root) + if err != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + checkErr := store.Check() + closeErr := store.Close() + if checkErr != nil || closeErr != nil { + writeLine(stderr, "integrity failure") + return exitIntegrity + } + if options.json { + _, _ = io.WriteString(stdout, "{\"status\":\"ok\"}\n") + } else { + writeLine(stdout, "ok") + } + return exitOK +} + +func runServe(root string, args []string, stderr io.Writer) int { + options, err := parseFlags(args, map[string]bool{"--socket": true}) + if err != nil || options.output != "" { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + // The Unix-socket service is intentionally introduced by Task 4. Keeping + // this grammar reserved prevents accidental plaintext or network fallback. + for i := range args { + if args[i] == "--socket" { + if i+1 >= len(args) || !filepath.IsAbs(args[i+1]) { + writeLine(stderr, "unsafe invocation") + return exitUsage + } + } + } + _ = root + writeLine(stderr, commandServeMsg) + return exitIntegrity +} + +func openCheckedStore(ctx context.Context, root string) (*registry.Store, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + store, err := registry.Open(root) + if err != nil { + return nil, err + } + if err := store.Check(); err != nil { + _ = store.Close() + return nil, err + } + return store, nil +} + +func writeStoreError(stderr io.Writer, err error) { + if errors.Is(err, registry.ErrNotFound) { + writeLine(stderr, "not found") + return + } + writeLine(stderr, "integrity failure") +} + +func storeExit(err error) int { + if errors.Is(err, registry.ErrNotFound) { + return exitNotFound + } + return exitIntegrity +} + +func writeJSON(writer io.Writer, value any) { + data, err := json.Marshal(value) + if err != nil { + return + } + _, _ = writer.Write(append(data, '\n')) +} + +func writeLine(writer io.Writer, value string) { + _, _ = io.WriteString(writer, value+"\n") +} + +func writeAndSync(file *os.File, value []byte) error { + for len(value) > 0 { + n, err := file.Write(value) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + value = value[n:] + } + return file.Sync() +} + +func cleanupOutput(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil || !info.Mode().IsRegular() { + return errors.New("cleanup target is not a regular file") + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func writeCleanupFailure(stderr io.Writer, path string) { + _, _ = fmt.Fprintf(stderr, "cleanup failed path=%s\n", filepath.Clean(path)) +} + +func parseExpiry(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil || parsed.Location() != time.UTC { + return nil, errors.New("expiry must be UTC RFC3339") + } + return &parsed, nil +} + +func validInstallationID(value string) bool { + if len(value) < 1 || len(value) > 63 { + return false + } + for i := range value { + c := value[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || (i > 0 && c == '-') { + continue + } + return false + } + return true +} + +func validMetadata(value string) bool { + if len([]rune(value)) > maxReasonBytes { + return false + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func validKeyID(value string) bool { + if value == record.LegacyKeyID { + return true + } + if len(value) != credential.KeyIDEncodedLength { + return false + } + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 12 && base64.RawURLEncoding.EncodeToString(decoded) == value +} diff --git a/tools/dwh-auth/internal/command/command_test.go b/tools/dwh-auth/internal/command/command_test.go new file mode 100644 index 00000000..d2d0ce24 --- /dev/null +++ b/tools/dwh-auth/internal/command/command_test.go @@ -0,0 +1,235 @@ +//go:build linux + +package command + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aritmolab/thothii/tools/dwh-auth/internal/credential" + "github.com/aritmolab/thothii/tools/dwh-auth/internal/record" + "github.com/aritmolab/thothii/tools/dwh-auth/internal/registry" +) + +const sentinelSecret = "SENTINEL-DWH-SECRET-must-never-be-emitted" + +func TestCreateWritesNew0600KeyAndOnlyPublicSummary(t *testing.T) { + root := t.TempDir() + output := filepath.Join(t.TempDir(), "new-key") + stdout, stderr, code := run(t, "--registry-root", root, "key", "create", "--installation-id", "mac-client", "--description", "synthetic", "--output", output) + if code != 0 { + t.Fatalf("Run() exit = %d, stdout=%q stderr=%q", code, stdout, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + if !strings.HasPrefix(stdout, "created key_id=") || !strings.HasSuffix(stdout, " output="+output+"\n") { + t.Fatalf("stdout = %q, want only creation summary", stdout) + } + if strings.Contains(stdout+stderr, sentinelSecret) { + t.Fatal("sentinel secret appeared in command output") + } + value, err := os.ReadFile(output) + if err != nil { + t.Fatalf("ReadFile(output) error = %v", err) + } + if len(value) == 0 || !strings.HasPrefix(string(value), credential.Prefix+".") { + t.Fatalf("created key does not have canonical prefix: %q", value) + } + info, err := os.Stat(output) + if err != nil { + t.Fatalf("Stat(output) error = %v", err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { + t.Fatalf("output mode = %04o, want %04o", got, want) + } + if code := runOnly(t, "--registry-root", root, "key", "create", "--installation-id", "other", "--output", output); code != 4 { + t.Fatalf("second create exit = %d, want filesystem/integrity exit 4", code) + } + if got, err := os.ReadFile(output); err != nil || !bytes.Equal(got, value) { + t.Fatalf("existing output changed after rejected create: err=%v value=%q", err, got) + } +} + +func TestCreateSupportsExpiryAndImportReads0600WithoutChangingSource(t *testing.T) { + root := t.TempDir() + keyFile := filepath.Join(t.TempDir(), "legacy-key") + if err := os.WriteFile(keyFile, []byte(sentinelSecret), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + original, err := os.ReadFile(keyFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + stdout, stderr, code := run(t, "--registry-root", root, "key", "import", "--legacy-raw", "--installation-id", "legacy-shared", "--from-file", keyFile) + if code != 0 || stderr != "" || stdout != "imported key_id=legacy-shared installation_id=legacy-shared\n" { + t.Fatalf("import result = (%d, %q, %q)", code, stdout, stderr) + } + got, err := os.ReadFile(keyFile) + if err != nil { + t.Fatalf("ReadFile(source) error = %v", err) + } + if !bytes.Equal(got, original) { + t.Fatal("legacy source file was modified") + } + if strings.Contains(stdout+stderr, sentinelSecret) { + t.Fatal("sentinel secret appeared in import output") + } + if info, err := os.Stat(keyFile); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("legacy source mode = %v, err=%v; want 0600", info.Mode().Perm(), err) + } + if code := runOnly(t, "--registry-root", root, "key", "import", "--legacy-raw", "--installation-id", "legacy-shared", "--from-file", keyFile); code != 4 { + t.Fatalf("duplicate import exit = %d, want 4", code) + } +} + +func TestListAndStatusAreRedactedAndJSONIsPristine(t *testing.T) { + root := t.TempDir() + output := filepath.Join(t.TempDir(), "key") + if code := runOnly(t, "--registry-root", root, "key", "create", "--installation-id", "json-client", "--output", output); code != 0 { + t.Fatalf("create exit = %d", code) + } + stdout, stderr, code := run(t, "--registry-root", root, "key", "list", "--json") + if code != 0 || stderr != "" { + t.Fatalf("list result = (%d, %q, %q)", code, stdout, stderr) + } + var listed []registry.PublicRecord + if err := json.Unmarshal([]byte(stdout), &listed); err != nil { + t.Fatalf("list stdout is not pristine JSON: %v (%q)", err, stdout) + } + if len(listed) != 1 || listed[0].InstallationID != "json-client" { + t.Fatalf("list = %#v", listed) + } + if strings.Contains(stdout, "secret_sha256") || strings.Contains(stdout, sentinelSecret) { + t.Fatalf("list exposed secret material: %q", stdout) + } + keyID := listed[0].KeyID + stdout, stderr, code = run(t, "--registry-root", root, "key", "status", "--key-id", keyID, "--json") + if code != 0 || stderr != "" { + t.Fatalf("status result = (%d, %q, %q)", code, stdout, stderr) + } + var status registry.PublicRecord + if err := json.Unmarshal([]byte(stdout), &status); err != nil { + t.Fatalf("status stdout is not pristine JSON: %v", err) + } + if status.KeyID != keyID || strings.Contains(stdout, "secret_sha256") || strings.Contains(stdout, sentinelSecret) { + t.Fatalf("status exposed secret material: %q", stdout) + } +} + +func TestRevokeRequiresReasonAndStatusShowsRevokedWithoutDigest(t *testing.T) { + root := t.TempDir() + output := filepath.Join(t.TempDir(), "key") + if code := runOnly(t, "--registry-root", root, "key", "create", "--installation-id", "revoke-client", "--output", output); code != 0 { + t.Fatalf("create exit = %d", code) + } + listOut, _, _ := run(t, "--registry-root", root, "key", "list", "--json") + var listed []registry.PublicRecord + if err := json.Unmarshal([]byte(listOut), &listed); err != nil || len(listed) != 1 { + t.Fatalf("list = %q, err=%v", listOut, err) + } + keyID := listed[0].KeyID + if code := runOnly(t, "--registry-root", root, "key", "revoke", "--key-id", keyID); code != 2 { + t.Fatalf("missing reason exit = %d, want usage 2", code) + } + stdout, stderr, code := run(t, "--registry-root", root, "key", "revoke", "--key-id", keyID, "--reason", "synthetic rotation") + if code != 0 || stdout != "revoked key_id="+keyID+"\n" || stderr != "" { + t.Fatalf("revoke result = (%d, %q, %q)", code, stdout, stderr) + } + statusOut, _, statusCode := run(t, "--registry-root", root, "key", "status", "--key-id", keyID, "--json") + if statusCode != 0 || strings.Contains(statusOut, "secret_sha256") || strings.Contains(statusOut, sentinelSecret) { + t.Fatalf("revoked status = (%d, %q)", statusCode, statusOut) + } + if !strings.Contains(statusOut, "\"state\":\"revoked\"") { + t.Fatalf("status did not show revoked: %q", statusOut) + } +} + +func TestCheckJSONIsPristineAndNotFoundIsExitThree(t *testing.T) { + root := t.TempDir() + stdout, stderr, code := run(t, "--registry-root", root, "check", "--json") + if code != 0 || stderr != "" || stdout != "{\"status\":\"ok\"}\n" { + t.Fatalf("check result = (%d, %q, %q)", code, stdout, stderr) + } + _, stderr, code = run(t, "--registry-root", root, "key", "status", "--key-id", "AAAAAAAAAAAAAAAA") + if code != 3 || !strings.Contains(stderr, "not found") { + t.Fatalf("not-found result = (%d, %q)", code, stderr) + } +} + +func TestUnsafeInvocationRejectsRelativeAndSecretValuedFlags(t *testing.T) { + cases := [][]string{ + {"--registry-root", "relative", "check"}, + {"--registry-root", "/tmp/registry", "key", "create", "--installation-id", sentinelSecret, "--output", "/tmp/key"}, + {"--registry-root", "/tmp/registry", "key", "create", "--installation-id", "safe", "--output", "relative"}, + {"--registry-root", "/tmp/registry", "key", "import", "--legacy-raw", "--installation-id", "legacy-shared", "--from-file", "relative"}, + {"--registry-root", "/tmp/registry", "key", "status", "--key-id", sentinelSecret}, + } + for _, args := range cases { + stdout, stderr, code := run(t, args...) + if code != 2 { + t.Errorf("args %q exit = %d, want 2 (stdout=%q stderr=%q)", args, code, stdout, stderr) + } + if strings.Contains(stdout+stderr, sentinelSecret) { + t.Errorf("args %q leaked sentinel secret in output", args) + } + } +} + +func TestIntegrityFailureUsesExitFourWithoutDigest(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "active"), 0o750); err != nil { + t.Fatalf("Mkdir(active) error = %v", err) + } + if err := os.Mkdir(filepath.Join(root, "revoked"), 0o750); err != nil { + t.Fatalf("Mkdir(revoked) error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "active", "unexpected"), []byte(sentinelSecret), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + stdout, stderr, code := run(t, "--registry-root", root, "check") + if code != 4 || stdout != "" || strings.Contains(stderr, sentinelSecret) || strings.Contains(stderr, "sha") { + t.Fatalf("integrity result = (%d, %q, %q)", code, stdout, stderr) + } +} + +func TestCreateRejectsInvalidExpiryWithoutWritingOutput(t *testing.T) { + root := t.TempDir() + output := filepath.Join(t.TempDir(), "key") + if code := runOnly(t, "--registry-root", root, "key", "create", "--installation-id", "client", "--expires-at", "not-time", "--output", output); code != 2 { + t.Fatalf("invalid expiry exit = %d, want 2", code) + } + if _, err := os.Stat(output); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid expiry output stat error = %v, want absent", err) + } +} + +func run(t *testing.T, args ...string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + code := Run(context.Background(), args, strings.NewReader(""), &stdout, &stderr) + return stdout.String(), stderr.String(), code +} + +func runOnly(t *testing.T, args ...string) int { + t.Helper() + _, _, code := run(t, args...) + return code +} + +// Keep imports and fixtures honest if this test file is extended with direct records. +var _ = base64.RawURLEncoding +var _ = sha256.Sum256 +var _ = io.EOF +var _ = time.Time{} +var _ record.Record