// 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" }