Files
ThothII/tools/tht/internal/authstorage/storage.go
T

434 lines
14 KiB
Go

// Package authstorage implements tht's hidden, stdin/stdout-only bridge for protected browser
// session files. It deliberately has no operator-facing commands or installation configuration.
package authstorage
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
const (
protocolVersion = 1
maximumProtocolBytes = 64 * 1024
maximumSessionBytes = 16 * 1024
maximumOIDCStateBytes = 8 * 1024
maximumAuthConfigBytes = 1024 * 1024
defaultMaximumEntries = 256
maximumEntries = 512
)
var (
digestFilename = regexp.MustCompile(`^[a-f0-9]{64}\.json$`)
claimFilename = regexp.MustCompile(`^[a-f0-9]{64}\.claim$`)
oidcSlotFilename = regexp.MustCompile(`^slot-(?:[0-5][0-9]|6[0-3])\.json$`)
authConfigFilename = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$`)
errInvalid = errors.New("auth storage request invalid")
)
type request struct {
Version int `json:"version"`
Operation string `json:"operation"`
Root string `json:"root"`
Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"`
ContentBase64 string `json:"contentBase64,omitempty"`
MaximumEntries int `json:"maximumEntries,omitempty"`
AfterName string `json:"afterName,omitempty"`
Continuation bool `json:"continuation,omitempty"`
}
type response struct {
Version int `json:"version"`
OK bool `json:"ok"`
Created bool `json:"created,omitempty"`
Replaced bool `json:"replaced,omitempty"`
Removed bool `json:"removed,omitempty"`
Found bool `json:"found,omitempty"`
Claimed bool `json:"claimed,omitempty"`
ContentBase64 string `json:"contentBase64,omitempty"`
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
More *bool `json:"more,omitempty"`
Validated bool `json:"validated,omitempty"`
Prepared bool `json:"prepared,omitempty"`
}
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
// stdout. All diagnostic text is fixed and goes only to stderr.
func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) != 0 || ctx == nil || stdin == nil || stdout == nil || stderr == nil {
return fail(stderr)
}
if err := ctx.Err(); err != nil {
return fail(stderr)
}
payload, err := io.ReadAll(io.LimitReader(stdin, maximumProtocolBytes+1))
if err != nil || len(payload) > maximumProtocolBytes {
return fail(stderr)
}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
var input request
if err := decoder.Decode(&input); err != nil {
return fail(stderr)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return fail(stderr)
}
if err := ctx.Err(); err != nil {
return fail(stderr)
}
result, err := execute(input)
if err != nil || ctx.Err() != nil {
return fail(stderr)
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return fail(stderr)
}
return 0
}
func fail(stderr io.Writer) int {
_, _ = io.WriteString(stderr, "tht: auth storage request failed\n")
return 1
}
func execute(input request) (response, error) {
if input.Version != protocolVersion || !validOperationShape(input) {
return response{}, errInvalid
}
if input.Operation == "validate-root" {
if err := validateStorageLayout(input.Root); err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Validated: true}, nil
}
if input.Operation == "ensure-layout" {
if err := ensureStorageLayout(input.Root); err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Prepared: true}, nil
}
if input.Operation == "read-auth-config" {
root, err := existingPrivateRoot(input.Root)
if err != nil {
return response{}, errInvalid
}
contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(root, input.Filename), maximumAuthConfigBytes)
if err != nil {
return response{}, errInvalid
}
return contentResponse(true, contents), nil
}
if !validDirectory(input.Directory) {
return response{}, errInvalid
}
directory, err := storageDirectory(input.Root, input.Directory)
if err != nil {
return response{}, errInvalid
}
switch input.Operation {
case "create":
contents, err := decodeContents(input)
if err != nil {
return response{}, errInvalid
}
created, err := createPrivate(directory, input.Filename, contents)
if err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Created: created}, nil
case "read":
contents, found, err := readPrivate(directory, input.Filename, recordMaximum(input.Directory))
if err != nil {
return response{}, errInvalid
}
return contentResponse(found, contents), nil
case "replace":
contents, err := decodeContents(input)
if err != nil {
return response{}, errInvalid
}
if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, input.Filename), contents, 0o600); err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Replaced: true}, nil
case "remove":
removed, err := removePrivate(directory, input.Filename)
if err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Removed: removed}, nil
case "list":
limit := input.MaximumEntries
if limit == 0 {
limit = defaultMaximumEntries
}
if input.Continuation {
page, err := safeio.ListCanonicalPrivateDirectoryPage(
directory,
limit,
input.AfterName,
digestFilename.MatchString,
)
if err != nil {
return response{}, errInvalid
}
more := page.More
return response{Version: protocolVersion, OK: true, Entries: &page.Entries, More: &more}, nil
}
entries, err := safeio.ListCanonicalPrivateDirectory(directory, limit)
if err != nil {
return response{}, errInvalid
}
for _, entry := range entries {
if !digestFilename.MatchString(entry.Name) && !(input.Directory == "oidc" && (claimFilename.MatchString(entry.Name) || oidcSlotFilename.MatchString(entry.Name))) {
return response{}, errInvalid
}
}
return response{Version: protocolVersion, OK: true, Entries: &entries}, nil
case "claim-consume":
return claimConsume(directory, input.Filename)
case "read-claim":
contents, found, err := safeio.ReadCanonicalPrivateClaim(
filepath.Join(directory, input.Filename),
filepath.Join(directory, asClaimFilename(input.Filename)),
maximumOIDCStateBytes,
)
if err != nil {
return response{}, errInvalid
}
return contentResponse(found, contents), nil
case "remove-claim":
removed, err := safeio.RemoveCanonicalPrivateClaim(
filepath.Join(directory, input.Filename),
filepath.Join(directory, asClaimFilename(input.Filename)),
)
if err != nil {
return response{}, errInvalid
}
return response{Version: protocolVersion, OK: true, Removed: removed}, nil
default:
return response{}, errInvalid
}
}
func validOperationShape(input request) bool {
noContents := input.ContentBase64 == ""
noMaximumEntries := input.MaximumEntries == 0
noAfterName := input.AfterName == ""
noContinuation := !input.Continuation
switch input.Operation {
case "validate-root", "ensure-layout":
return input.Directory == "" && input.Filename == "" && noContents && noMaximumEntries && noAfterName && noContinuation
case "read-auth-config":
return input.Directory == "" && authConfigFilename.MatchString(input.Filename) && noContents && noMaximumEntries && noAfterName && noContinuation
case "create", "replace":
return noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Operation == "create" && input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
case "read":
return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && oidcSlotFilename.MatchString(input.Filename)))
case "remove":
return noContents && noMaximumEntries && noAfterName && noContinuation && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && (claimFilename.MatchString(input.Filename) || oidcSlotFilename.MatchString(input.Filename))))
case "list":
return input.Filename == "" && noContents && input.MaximumEntries >= 0 && input.MaximumEntries <= maximumEntries &&
((noContinuation && noAfterName) || (input.Continuation && input.Directory == "sessions" && input.MaximumEntries >= 1 && (noAfterName || digestFilename.MatchString(input.AfterName))))
case "claim-consume", "read-claim", "remove-claim":
return input.Directory == "oidc" && noContents && noMaximumEntries && noAfterName && noContinuation && digestFilename.MatchString(input.Filename)
default:
return false
}
}
func preflightRoot(root string) (bool, error) {
if !filepath.IsAbs(root) || filepath.Clean(root) != root || strings.IndexFunc(root, unicode.IsControl) >= 0 {
return false, errInvalid
}
return safeio.PreflightPrivateDirectory(root)
}
func existingPrivateRoot(root string) (string, error) {
exists, err := preflightRoot(root)
if err != nil || !exists || safeio.ValidatePrivateDirectory(root) != nil {
return "", errInvalid
}
return root, nil
}
func validateStorageLayout(root string) error {
exists, err := preflightRoot(root)
if err != nil {
return errInvalid
}
if !exists {
return nil
}
existingChildren := make([]string, 0, 2)
for _, directory := range []string{"sessions", "oidc"} {
path := filepath.Join(root, directory)
if filepath.Dir(path) != root {
return errInvalid
}
childExists, err := safeio.PreflightPrivateDirectory(path)
if err != nil {
return errInvalid
}
if childExists {
existingChildren = append(existingChildren, path)
}
}
// Close permission/identity races between the individual side-effect-free preflights.
if safeio.ValidatePrivateDirectory(root) != nil {
return errInvalid
}
for _, directory := range []string{"sessions", "oidc"} {
if _, err := safeio.PreflightPrivateDirectory(filepath.Join(root, directory)); err != nil {
return errInvalid
}
}
for _, path := range existingChildren {
if safeio.ValidatePrivateDirectory(path) != nil {
return errInvalid
}
}
return nil
}
func ensureStorageLayout(root string) error {
if _, err := preflightRoot(root); err != nil || safeio.EnsurePrivateDirectory(root) != nil {
return errInvalid
}
for _, directory := range []string{"sessions", "oidc"} {
path := filepath.Join(root, directory)
if filepath.Dir(path) != root || safeio.EnsurePrivateDirectory(path) != nil {
return errInvalid
}
}
if safeio.ValidatePrivateDirectory(root) != nil ||
safeio.ValidatePrivateDirectory(filepath.Join(root, "sessions")) != nil ||
safeio.ValidatePrivateDirectory(filepath.Join(root, "oidc")) != nil {
return errInvalid
}
return nil
}
func contentResponse(found bool, contents []byte) response {
if !found {
return response{Version: protocolVersion, OK: true}
}
return response{Version: protocolVersion, OK: true, Found: true, ContentBase64: base64.StdEncoding.EncodeToString(contents)}
}
func storageDirectory(root, directory string) (string, error) {
if _, err := preflightRoot(root); err != nil || safeio.EnsurePrivateDirectory(root) != nil {
return "", errInvalid
}
path := filepath.Join(root, directory)
if filepath.Dir(path) != root || safeio.EnsurePrivateDirectory(path) != nil {
return "", errInvalid
}
return path, nil
}
func validDirectory(value string) bool {
return value == "sessions" || value == "oidc"
}
func recordMaximum(directory string) int64 {
if directory == "oidc" {
return maximumOIDCStateBytes
}
return maximumSessionBytes
}
func decodeContents(input request) ([]byte, error) {
contents, err := base64.StdEncoding.DecodeString(input.ContentBase64)
if err != nil || len(contents) == 0 || int64(len(contents)) > recordMaximum(input.Directory) {
return nil, errInvalid
}
return contents, nil
}
func createPrivate(directory, filename string, contents []byte) (bool, error) {
path := filepath.Join(directory, filename)
if _, err := os.Lstat(path); err == nil {
if safeio.ValidatePrivateRegular(path) != nil {
return false, errInvalid
}
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, errInvalid
}
if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err == nil {
return true, nil
}
if safeio.ValidatePrivateRegular(path) == nil {
return false, nil
}
return false, errInvalid
}
func readPrivate(directory, filename string, maximum int64) ([]byte, bool, error) {
path := filepath.Join(directory, filename)
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
return nil, false, nil
} else if err != nil {
return nil, false, errInvalid
}
contents, err := safeio.ReadCanonicalPrivateRegular(path, maximum)
if err != nil {
return nil, false, errInvalid
}
return contents, true, nil
}
func removePrivate(directory, filename string) (bool, error) {
path := filepath.Join(directory, filename)
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
return false, nil
} else if err != nil {
return false, errInvalid
}
if err := safeio.RemoveCanonicalPrivateRegular(path); err != nil {
return false, errInvalid
}
return true, nil
}
func claimConsume(directory, filename string) (response, error) {
source := filepath.Join(directory, filename)
claim := filepath.Join(directory, asClaimFilename(filename))
claimed, err := safeio.ClaimCanonicalPrivateRegular(source, claim)
if err != nil {
return response{}, errInvalid
}
if !claimed {
return response{Version: protocolVersion, OK: true}, nil
}
contents, found, err := safeio.ReadCanonicalPrivateClaim(source, claim, maximumOIDCStateBytes)
if err != nil || !found {
return response{}, errInvalid
}
removed, err := safeio.RemoveCanonicalPrivateClaim(source, claim)
if err != nil || !removed {
return response{}, errInvalid
}
return contentResponse(true, contents), nil
}
func asClaimFilename(filename string) string {
return strings.TrimSuffix(filename, ".json") + ".claim"
}