602 lines
17 KiB
Go
602 lines
17 KiB
Go
//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"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
nowUTC = func() time.Time { return time.Now().UTC() }
|
|
generateCredential = func() (credential.Material, error) { return credential.Generate(rand.Reader) }
|
|
addRecord = func(store *registry.Store, value record.Record) error { return store.Add(value) }
|
|
closeStore = func(store *registry.Store) error { return store.Close() }
|
|
)
|
|
|
|
// 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
|
|
}
|
|
if len(args) > 0 && args[0] == "serve" {
|
|
return runServe(args[1:], stderr)
|
|
}
|
|
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)
|
|
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
|
|
socket string
|
|
}
|
|
|
|
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]
|
|
case "--socket":
|
|
value.socket = 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) || containsCanonicalCredential(options.description) {
|
|
writeLine(stderr, "unsafe invocation")
|
|
return exitUsage
|
|
}
|
|
createdAt := nowUTC()
|
|
expiresAt, err := parseExpiry(options.expiresAt, createdAt)
|
|
if err != nil {
|
|
writeLine(stderr, "unsafe invocation")
|
|
return exitUsage
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
writeLine(stderr, "operation cancelled")
|
|
return exitIntegrity
|
|
}
|
|
|
|
material, err := generateCredential()
|
|
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
|
|
}
|
|
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: createdAt,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
addErr := addRecord(store, value)
|
|
if addErr != nil {
|
|
lookupErr := lookupCreatedRecord(store, material.KeyID)
|
|
closeErr := closeStore(store)
|
|
if errors.Is(lookupErr, registry.ErrNotFound) && closeErr == nil {
|
|
if cleanupOutput(options.output) != nil {
|
|
writeCleanupFailure(stderr, options.output)
|
|
return exitIntegrity
|
|
}
|
|
} else {
|
|
writeRecoveryFailure(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
|
|
}
|
|
if err := closeStore(store); err != nil {
|
|
writeRecoveryFailure(stderr, options.output)
|
|
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) || containsCanonicalCredential(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, nowUTC())
|
|
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(args []string, stderr io.Writer) int {
|
|
if len(args) != 4 || args[0] != "--registry-root" || args[2] != "--socket" || !filepath.IsAbs(args[1]) || !filepath.IsAbs(args[3]) {
|
|
writeLine(stderr, "unsafe invocation")
|
|
return exitUsage
|
|
}
|
|
// The Unix-socket service is intentionally introduced by Task 4. Keeping
|
|
// this exact grammar reserved prevents accidental plaintext or network fallback.
|
|
writeLine(stderr, commandServeMsg)
|
|
return exitIntegrity
|
|
}
|
|
|
|
func lookupCreatedRecord(store *registry.Store, keyID string) error {
|
|
_, err := store.Find(keyID)
|
|
return err
|
|
}
|
|
|
|
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 writeRecoveryFailure(stderr io.Writer, path string) {
|
|
_, _ = fmt.Fprintf(stderr, "publication uncertain path=%s\n", filepath.Clean(path))
|
|
}
|
|
|
|
func parseExpiry(value string, createdAt time.Time) (*time.Time, error) {
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil || parsed.Location() != time.UTC || !parsed.After(createdAt) {
|
|
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 !utf8.ValidString(value) || len([]rune(value)) > maxReasonBytes {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsCanonicalCredential(value string) bool {
|
|
const credentialLength = len(credential.Prefix) + 1 + credential.KeyIDEncodedLength + 1 + credential.SecretEncodedLength
|
|
needle := credential.Prefix + "."
|
|
for offset := 0; offset < len(value); {
|
|
index := strings.Index(value[offset:], needle)
|
|
if index < 0 {
|
|
return false
|
|
}
|
|
index += offset
|
|
if index+credentialLength <= len(value) {
|
|
candidate := []byte(value[index : index+credentialLength])
|
|
sum := sha256.Sum256(candidate)
|
|
if _, ok := credential.VerifyV1(candidate, record.Digest(sum)); ok {
|
|
return true
|
|
}
|
|
}
|
|
offset = index + len(needle)
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|