499 lines
15 KiB
Go
499 lines
15 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"
|
|
"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" {
|
|
return readRootPrivateRegular(input.Root, input.Filename)
|
|
}
|
|
if input.Operation == "read-local-users" {
|
|
return readRootPrivateRegular(input.Root, input.Filename)
|
|
}
|
|
if !validDirectory(input.Directory) {
|
|
return response{}, errInvalid
|
|
}
|
|
layout, err := openStorageLayout(input.Root, true)
|
|
if err != nil || layout == nil {
|
|
return response{}, errInvalid
|
|
}
|
|
defer layout.Close()
|
|
directory := layout.directory(input.Directory)
|
|
if directory == 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 := directory.ReplaceRegular(input.Filename, contents); 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
|
|
}
|
|
afterName := ""
|
|
if input.Continuation {
|
|
afterName = input.AfterName
|
|
}
|
|
page, err := directory.ListPage(limit, afterName, recordListName(input.Directory), recordListLinks(input.Directory))
|
|
if err != nil {
|
|
return response{}, errInvalid
|
|
}
|
|
if input.Continuation {
|
|
more := page.More
|
|
return response{Version: protocolVersion, OK: true, Entries: &page.Entries, More: &more}, nil
|
|
}
|
|
if page.More {
|
|
return response{}, errInvalid
|
|
}
|
|
return response{Version: protocolVersion, OK: true, Entries: &page.Entries}, nil
|
|
case "claim-consume":
|
|
return claimConsume(directory, input.Filename)
|
|
case "read-claim":
|
|
contents, found, err := directory.ReadClaim(input.Filename, asClaimFilename(input.Filename), maximumOIDCStateBytes)
|
|
if err != nil {
|
|
return response{}, errInvalid
|
|
}
|
|
return contentResponse(found, contents), nil
|
|
case "remove-claim":
|
|
removed, err := directory.RemoveClaim(input.Filename, 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", "read-local-users":
|
|
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
|
|
}
|
|
}
|
|
|
|
type storageLayout struct {
|
|
root safeio.PrivateDirectoryHandle
|
|
sessions safeio.PrivateDirectoryHandle
|
|
oidc safeio.PrivateDirectoryHandle
|
|
}
|
|
|
|
func (layout *storageLayout) Close() {
|
|
if layout == nil {
|
|
return
|
|
}
|
|
if layout.oidc != nil {
|
|
_ = layout.oidc.Close()
|
|
layout.oidc = nil
|
|
}
|
|
if layout.sessions != nil {
|
|
_ = layout.sessions.Close()
|
|
layout.sessions = nil
|
|
}
|
|
if layout.root != nil {
|
|
_ = layout.root.Close()
|
|
layout.root = nil
|
|
}
|
|
}
|
|
|
|
func (layout *storageLayout) closeChildren() {
|
|
if layout == nil {
|
|
return
|
|
}
|
|
if layout.oidc != nil {
|
|
_ = layout.oidc.Close()
|
|
layout.oidc = nil
|
|
}
|
|
if layout.sessions != nil {
|
|
_ = layout.sessions.Close()
|
|
layout.sessions = nil
|
|
}
|
|
}
|
|
|
|
func (layout *storageLayout) directory(name string) safeio.PrivateDirectoryHandle {
|
|
if layout == nil {
|
|
return nil
|
|
}
|
|
if name == "sessions" {
|
|
return layout.sessions
|
|
}
|
|
if name == "oidc" {
|
|
return layout.oidc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validStorageRoot(root string) bool {
|
|
return strings.IndexFunc(root, unicode.IsControl) < 0
|
|
}
|
|
|
|
// openStorageLayout keeps the root descriptor/handle open from its initial canonical validation
|
|
// through every child observation. The side-effect-free path performs a second child pass after
|
|
// the testable race boundary and retains those final handles; it never validates an earlier,
|
|
// discarded exists result.
|
|
func openStorageLayout(root string, ensure bool) (*storageLayout, error) {
|
|
if !validStorageRoot(root) {
|
|
return nil, errInvalid
|
|
}
|
|
rootHandle, found, err := safeio.OpenPrivateDirectory(root, ensure)
|
|
if err != nil {
|
|
return nil, errInvalid
|
|
}
|
|
if !found {
|
|
return nil, nil
|
|
}
|
|
layout := &storageLayout{root: rootHandle}
|
|
failed := true
|
|
defer func() {
|
|
if failed {
|
|
layout.Close()
|
|
}
|
|
}()
|
|
safeio.NotifyPrivateDirectoryTestHookForTest("after-auth-root-open")
|
|
if err := layout.openChildren(ensure); err != nil {
|
|
return nil, errInvalid
|
|
}
|
|
safeio.NotifyPrivateDirectoryTestHookForTest("after-auth-layout-first-pass")
|
|
if !ensure {
|
|
layout.closeChildren()
|
|
if err := layout.openChildren(false); err != nil {
|
|
return nil, errInvalid
|
|
}
|
|
}
|
|
if layout.root.Validate() != nil || (layout.sessions != nil && layout.sessions.Validate() != nil) ||
|
|
(layout.oidc != nil && layout.oidc.Validate() != nil) {
|
|
return nil, errInvalid
|
|
}
|
|
failed = false
|
|
return layout, nil
|
|
}
|
|
|
|
func (layout *storageLayout) openChildren(ensure bool) error {
|
|
if layout == nil || layout.root == nil || layout.root.Validate() != nil {
|
|
return errInvalid
|
|
}
|
|
for _, name := range []string{"sessions", "oidc"} {
|
|
child, found, err := layout.root.OpenChild(name, ensure)
|
|
if err != nil || (ensure && !found) {
|
|
if child != nil {
|
|
_ = child.Close()
|
|
}
|
|
return errInvalid
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
if child.Validate() != nil {
|
|
_ = child.Close()
|
|
return errInvalid
|
|
}
|
|
if name == "sessions" {
|
|
layout.sessions = child
|
|
} else {
|
|
layout.oidc = child
|
|
}
|
|
}
|
|
if layout.root.Validate() != nil {
|
|
return errInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateStorageLayout(root string) error {
|
|
layout, err := openStorageLayout(root, false)
|
|
if err != nil {
|
|
return errInvalid
|
|
}
|
|
if layout != nil {
|
|
layout.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureStorageLayout(root string) error {
|
|
layout, err := openStorageLayout(root, true)
|
|
if err != nil || layout == nil {
|
|
return errInvalid
|
|
}
|
|
layout.Close()
|
|
return nil
|
|
}
|
|
|
|
func readRootPrivateRegular(root, filename string) (response, error) {
|
|
if !validStorageRoot(root) {
|
|
return response{}, errInvalid
|
|
}
|
|
directory, found, err := safeio.OpenPrivateDirectory(root, false)
|
|
if err != nil || !found || directory == nil {
|
|
return response{}, errInvalid
|
|
}
|
|
defer directory.Close()
|
|
safeio.NotifyPrivateDirectoryTestHookForTest("after-auth-root-open")
|
|
contents, found, err := directory.ReadRegular(filename, maximumAuthConfigBytes)
|
|
if err != nil || !found || directory.Validate() != nil {
|
|
return response{}, errInvalid
|
|
}
|
|
return contentResponse(true, contents), 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 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 safeio.PrivateDirectoryHandle, filename string, contents []byte) (bool, error) {
|
|
created, err := directory.CreateRegular(filename, contents)
|
|
if err != nil {
|
|
return false, errInvalid
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func readPrivate(directory safeio.PrivateDirectoryHandle, filename string, maximum int64) ([]byte, bool, error) {
|
|
contents, found, err := directory.ReadRegular(filename, maximum)
|
|
if err != nil {
|
|
return nil, false, errInvalid
|
|
}
|
|
return contents, found, nil
|
|
}
|
|
|
|
func removePrivate(directory safeio.PrivateDirectoryHandle, filename string) (bool, error) {
|
|
removed, err := directory.RemoveRegular(filename)
|
|
if err != nil {
|
|
return false, errInvalid
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
func recordListName(directory string) func(string) bool {
|
|
if directory == "sessions" {
|
|
return digestFilename.MatchString
|
|
}
|
|
return func(name string) bool {
|
|
return digestFilename.MatchString(name) || claimFilename.MatchString(name) || oidcSlotFilename.MatchString(name)
|
|
}
|
|
}
|
|
|
|
func recordListLinks(directory string) func(string, uint64) bool {
|
|
if directory == "sessions" {
|
|
return func(name string, links uint64) bool {
|
|
return digestFilename.MatchString(name) && links == 1
|
|
}
|
|
}
|
|
return func(name string, links uint64) bool {
|
|
if digestFilename.MatchString(name) || claimFilename.MatchString(name) {
|
|
return links == 1 || links == 2
|
|
}
|
|
return oidcSlotFilename.MatchString(name) && links == 1
|
|
}
|
|
}
|
|
|
|
func claimConsume(directory safeio.PrivateDirectoryHandle, filename string) (response, error) {
|
|
claim := asClaimFilename(filename)
|
|
claimed, err := directory.ClaimRegular(filename, claim)
|
|
if err != nil {
|
|
return response{}, errInvalid
|
|
}
|
|
if !claimed {
|
|
return response{Version: protocolVersion, OK: true}, nil
|
|
}
|
|
contents, found, err := directory.ReadClaim(filename, claim, maximumOIDCStateBytes)
|
|
if err != nil || !found {
|
|
return response{}, errInvalid
|
|
}
|
|
removed, err := directory.RemoveClaim(filename, claim)
|
|
if err != nil || !removed {
|
|
return response{}, errInvalid
|
|
}
|
|
return contentResponse(true, contents), nil
|
|
}
|
|
|
|
func asClaimFilename(filename string) string {
|
|
return strings.TrimSuffix(filename, ".json") + ".claim"
|
|
}
|