829 lines
34 KiB
Go
829 lines
34 KiB
Go
package authstorage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
|
)
|
|
|
|
func TestProtocolListUsesACallerSuppliedBound(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
for index := 0; index < 300; index++ {
|
|
filename := fmt.Sprintf("%064x.json", index)
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
}
|
|
|
|
listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 300})
|
|
if listed.Entries == nil || len(*listed.Entries) != 300 {
|
|
t.Fatalf("bounded list size = %d, want 300", len(*listed.Entries))
|
|
}
|
|
filename := fmt.Sprintf("%064x.json", 300)
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 300})
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: maximumEntries + 1})
|
|
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename, MaximumEntries: 1})
|
|
}
|
|
|
|
func TestProtocolListPaginatesOrdinarySessionRecords(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
for index := 0; index < 513; index++ {
|
|
filename := fmt.Sprintf("%064x.json", index)
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
}
|
|
|
|
first := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 512, Continuation: true})
|
|
if first.Entries == nil || len(*first.Entries) != 512 || first.More == nil || !*first.More {
|
|
t.Fatalf("first continuation page = %#v", first)
|
|
}
|
|
second := runRequest(t, request{
|
|
Version: 1,
|
|
Operation: "list",
|
|
Root: root,
|
|
Directory: "sessions",
|
|
MaximumEntries: 512,
|
|
Continuation: true,
|
|
AfterName: (*first.Entries)[511].Name,
|
|
})
|
|
if second.Entries == nil || len(*second.Entries) != 1 || second.More == nil || *second.More || (*second.Entries)[0].Name != fmt.Sprintf("%064x.json", 512) {
|
|
t.Fatalf("second continuation page = %#v", second)
|
|
}
|
|
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc", MaximumEntries: 1, Continuation: true, AfterName: fmt.Sprintf("%064x.json", 0)})
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true, AfterName: "../unsafe.json"})
|
|
}
|
|
|
|
func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"
|
|
|
|
created := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("first"))})
|
|
if !created.Created {
|
|
t.Fatal("create did not report a new record")
|
|
}
|
|
if duplicate := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("other"))}); duplicate.Created {
|
|
t.Fatal("duplicate exclusive create reported success")
|
|
}
|
|
read := runRequest(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename})
|
|
if !read.Found || decodeContent(t, read) != "first" {
|
|
t.Fatalf("read = %#v, want private first record", read)
|
|
}
|
|
updated := runRequest(t, request{Version: 1, Operation: "replace", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("second"))})
|
|
if !updated.Replaced {
|
|
t.Fatal("replace did not report success")
|
|
}
|
|
listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
|
if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != filename {
|
|
t.Fatalf("list = %#v, want exactly %q", listed.Entries, filename)
|
|
}
|
|
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: filename})
|
|
if !removed.Removed {
|
|
t.Fatal("remove did not report success")
|
|
}
|
|
}
|
|
|
|
func TestProtocolPreflightsRootWithoutCreatingOrFollowingLinks(t *testing.T) {
|
|
parent := privateTestRoot(t)
|
|
missing := filepath.Join(parent, "auth")
|
|
validated := runRequest(t, request{Version: 1, Operation: "validate-root", Root: missing})
|
|
if !validated.Validated {
|
|
t.Fatal("missing final root was not validated")
|
|
}
|
|
if _, err := os.Lstat(missing); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("validate-root mutated missing root: %v", err)
|
|
}
|
|
|
|
realRoot := filepath.Join(parent, "real-auth")
|
|
if err := safeio.EnsurePrivateDirectory(realRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linkedRoot := filepath.Join(parent, "linked-auth")
|
|
testsupport.SymlinkOrSkip(t, realRoot, linkedRoot)
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: linkedRoot})
|
|
if entries, err := os.ReadDir(realRoot); err != nil || len(entries) != 0 {
|
|
t.Fatalf("linked target was mutated: entries=%v error=%v", entries, err)
|
|
}
|
|
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "missing", "auth")})
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: missing, Directory: "sessions"})
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "auth\n")})
|
|
}
|
|
|
|
func TestProtocolValidatesTheCompleteSessionLayoutWithoutCreatingIt(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
if err := safeio.EnsurePrivateDirectory(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
validated := runRequest(t, request{Version: 1, Operation: "validate-root", Root: root})
|
|
if !validated.Validated {
|
|
t.Fatal("layout with safely creatable children was not validated")
|
|
}
|
|
for _, child := range []string{"sessions", "oidc"} {
|
|
if _, err := os.Lstat(filepath.Join(root, child)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("validate-root created %s: %v", child, err)
|
|
}
|
|
}
|
|
|
|
outside := privateTestRoot(t)
|
|
testsupport.SymlinkOrSkip(t, outside, filepath.Join(root, "sessions"))
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: root})
|
|
if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 {
|
|
t.Fatalf("linked child target was mutated: entries=%v error=%v", entries, err)
|
|
}
|
|
if err := os.Remove(filepath.Join(root, "sessions")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "sessions"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
return // Native DACL coverage lives in storage_windows_test.go.
|
|
}
|
|
if err := os.Chmod(filepath.Join(root, "sessions"), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: root})
|
|
}
|
|
|
|
func TestProtocolEnsuresTheCompletePrivateSessionLayout(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
runRequest(t, request{Version: 1, Operation: "ensure-layout", Root: root})
|
|
for _, path := range []string{root, filepath.Join(root, "sessions"), filepath.Join(root, "oidc")} {
|
|
if err := safeio.ValidatePrivateDirectory(path); err != nil {
|
|
t.Fatalf("private layout path %q: %v", filepath.Base(path), err)
|
|
}
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "ensure-layout", Root: root, Directory: "sessions"})
|
|
}
|
|
|
|
func TestProtocolPinsTheOriginalPrivateRootBeforeEveryRecordMutation(t *testing.T) {
|
|
parent := privateTestRoot(t)
|
|
root := filepath.Join(parent, "auth")
|
|
replacement := filepath.Join(parent, "replacement")
|
|
moved := filepath.Join(parent, "auth-original")
|
|
filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json"
|
|
for _, directory := range []string{root, replacement} {
|
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, child := range []string{"sessions", "oidc"} {
|
|
if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, child)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
swapped := false
|
|
blocked := false
|
|
restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
|
|
if stage != "after-auth-root-open" || swapped || blocked {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if err := os.Rename(root, moved); err == nil {
|
|
t.Fatal("retained Windows root handle permitted rename")
|
|
}
|
|
blocked = true
|
|
return
|
|
}
|
|
if err := os.Rename(root, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(replacement, root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swapped = true
|
|
})
|
|
t.Cleanup(restore)
|
|
|
|
runRequest(t, request{
|
|
Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename,
|
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pinned-root-record")),
|
|
})
|
|
if !swapped && !blocked {
|
|
t.Fatal("record operation did not expose the retained-root test hook")
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if _, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(root, "sessions", filename), maximumSessionBytes); err != nil {
|
|
t.Fatalf("record missing from retained Windows root: %v", err)
|
|
}
|
|
return
|
|
}
|
|
contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(moved, "sessions", filename), maximumSessionBytes)
|
|
if err != nil || string(contents) != "pinned-root-record" {
|
|
t.Fatalf("pinned root contents = %q error = %v", contents, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(root, "sessions", filename)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("replacement root was mutated: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProtocolPinsTheOriginalPrivateRootForEveryRecordOperation(t *testing.T) {
|
|
filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json"
|
|
otherFilename := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff.json"
|
|
for _, operation := range []string{
|
|
"create", "read", "replace", "remove", "list", "claim-consume", "read-claim", "remove-claim",
|
|
} {
|
|
t.Run(operation, func(t *testing.T) {
|
|
parent := privateTestRoot(t)
|
|
root := filepath.Join(parent, "auth")
|
|
replacement := filepath.Join(parent, "replacement")
|
|
moved := filepath.Join(parent, "auth-original")
|
|
for _, directory := range []string{root, replacement} {
|
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, child := range []string{"sessions", "oidc"} {
|
|
if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, child)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
write := func(base, directory, name, contents string) {
|
|
t.Helper()
|
|
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(base, directory, name), []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
pair := func(base, name, contents string) {
|
|
t.Helper()
|
|
write(base, "oidc", name, contents)
|
|
if err := os.Link(filepath.Join(base, "oidc", name), filepath.Join(base, "oidc", asClaimFilename(name))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
input := request{Version: 1, Operation: operation, Root: root}
|
|
switch operation {
|
|
case "create":
|
|
input.Directory, input.Filename = "sessions", filename
|
|
input.ContentBase64 = base64.StdEncoding.EncodeToString([]byte("original-created"))
|
|
case "read":
|
|
write(root, "sessions", filename, "original-read")
|
|
write(replacement, "sessions", filename, "replacement-read")
|
|
input.Directory, input.Filename = "sessions", filename
|
|
case "replace":
|
|
write(root, "sessions", filename, "original-before")
|
|
write(replacement, "sessions", filename, "replacement-before")
|
|
input.Directory, input.Filename = "sessions", filename
|
|
input.ContentBase64 = base64.StdEncoding.EncodeToString([]byte("original-replaced"))
|
|
case "remove":
|
|
write(root, "sessions", filename, "original-remove")
|
|
write(replacement, "sessions", filename, "replacement-remove")
|
|
input.Directory, input.Filename = "sessions", filename
|
|
case "list":
|
|
write(root, "sessions", filename, "original-list")
|
|
write(replacement, "sessions", otherFilename, "replacement-list")
|
|
input.Directory = "sessions"
|
|
case "claim-consume":
|
|
write(root, "oidc", filename, "original-claim")
|
|
write(replacement, "oidc", filename, "replacement-claim")
|
|
input.Directory, input.Filename = "oidc", filename
|
|
case "read-claim", "remove-claim":
|
|
pair(root, filename, "original-pair")
|
|
pair(replacement, filename, "replacement-pair")
|
|
input.Directory, input.Filename = "oidc", filename
|
|
}
|
|
|
|
swapped := false
|
|
blocked := false
|
|
restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
|
|
if stage != "after-auth-root-open" || swapped || blocked {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if err := os.Rename(root, moved); err == nil {
|
|
t.Fatal("retained Windows root handle permitted rename")
|
|
}
|
|
blocked = true
|
|
return
|
|
}
|
|
if err := os.Rename(root, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(replacement, root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swapped = true
|
|
})
|
|
t.Cleanup(restore)
|
|
|
|
output := runRequest(t, input)
|
|
if !swapped && !blocked {
|
|
t.Fatal("record operation did not expose the retained-root test hook")
|
|
}
|
|
original := root
|
|
replacementRoot := replacement
|
|
if swapped {
|
|
original = moved
|
|
replacementRoot = root
|
|
}
|
|
switch operation {
|
|
case "create":
|
|
contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(original, "sessions", filename), maximumSessionBytes)
|
|
if err != nil || string(contents) != "original-created" {
|
|
t.Fatalf("pinned create contents = %q error = %v", contents, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(replacementRoot, "sessions", filename)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("replacement root was mutated: %v", err)
|
|
}
|
|
case "read":
|
|
if !output.Found || decodeContent(t, output) != "original-read" {
|
|
t.Fatalf("pinned read = %#v", output)
|
|
}
|
|
case "replace":
|
|
contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(original, "sessions", filename), maximumSessionBytes)
|
|
if err != nil || string(contents) != "original-replaced" {
|
|
t.Fatalf("pinned replace contents = %q error = %v", contents, err)
|
|
}
|
|
replacementContents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "sessions", filename), maximumSessionBytes)
|
|
if err != nil || string(replacementContents) != "replacement-before" {
|
|
t.Fatalf("replacement contents = %q error = %v", replacementContents, err)
|
|
}
|
|
case "remove":
|
|
if _, err := os.Lstat(filepath.Join(original, "sessions", filename)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pinned remove left original record: %v", err)
|
|
}
|
|
if _, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "sessions", filename), maximumSessionBytes); err != nil {
|
|
t.Fatalf("replacement record was mutated: %v", err)
|
|
}
|
|
case "list":
|
|
if output.Entries == nil || len(*output.Entries) != 1 || (*output.Entries)[0].Name != filename {
|
|
t.Fatalf("pinned list = %#v", output)
|
|
}
|
|
case "claim-consume":
|
|
if !output.Found || decodeContent(t, output) != "original-claim" {
|
|
t.Fatalf("pinned claim consume = %#v", output)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(original, "oidc", filename)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pinned claim source remains: %v", err)
|
|
}
|
|
if contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "oidc", filename), maximumOIDCStateBytes); err != nil || string(contents) != "replacement-claim" {
|
|
t.Fatalf("replacement claim contents = %q error = %v", contents, err)
|
|
}
|
|
case "read-claim":
|
|
if !output.Found || decodeContent(t, output) != "original-pair" {
|
|
t.Fatalf("pinned claim read = %#v", output)
|
|
}
|
|
case "remove-claim":
|
|
for _, name := range []string{filename, asClaimFilename(filename)} {
|
|
if _, err := os.Lstat(filepath.Join(original, "oidc", name)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pinned claim remove left original %s: %v", name, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(replacementRoot, "oidc", name)); err != nil {
|
|
t.Fatalf("replacement claim pair was mutated: %v", err)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProtocolPinsTheOriginalPrivateRootForLayoutCreation(t *testing.T) {
|
|
parent := privateTestRoot(t)
|
|
root := filepath.Join(parent, "auth")
|
|
replacement := filepath.Join(parent, "replacement")
|
|
moved := filepath.Join(parent, "auth-original")
|
|
for _, directory := range []string{root, replacement} {
|
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
swapped := false
|
|
blocked := false
|
|
restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
|
|
if stage != "after-auth-root-open" || swapped || blocked {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if err := os.Rename(root, moved); err == nil {
|
|
t.Fatal("retained Windows root handle permitted rename")
|
|
}
|
|
blocked = true
|
|
return
|
|
}
|
|
if err := os.Rename(root, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(replacement, root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swapped = true
|
|
})
|
|
t.Cleanup(restore)
|
|
|
|
runRequest(t, request{Version: 1, Operation: "ensure-layout", Root: root})
|
|
if !swapped && !blocked {
|
|
t.Fatal("layout creation did not expose the retained-root test hook")
|
|
}
|
|
original := root
|
|
replacementRoot := replacement
|
|
if swapped {
|
|
original = moved
|
|
replacementRoot = root
|
|
}
|
|
for _, child := range []string{"sessions", "oidc"} {
|
|
if err := safeio.ValidatePrivateDirectory(filepath.Join(original, child)); err != nil {
|
|
t.Fatalf("pinned %s child: %v", child, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(replacementRoot, child)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("replacement root received layout mutation: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProtocolValidateRootRechecksChildrenObservedMissingUnderTheRetainedRoot(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
if err := safeio.EnsurePrivateDirectory(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installedUnsafeChild := false
|
|
restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
|
|
if stage != "after-auth-layout-first-pass" || installedUnsafeChild {
|
|
return
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "sessions"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installedUnsafeChild = true
|
|
})
|
|
t.Cleanup(restore)
|
|
|
|
runRejected(t, request{Version: 1, Operation: "validate-root", Root: root})
|
|
if !installedUnsafeChild {
|
|
t.Fatal("layout validation did not expose the missing-child recheck hook")
|
|
}
|
|
}
|
|
|
|
func TestProtocolValidateLayoutPinsTheOriginalPOSIXRootAcrossMissingAndUnsafeChildren(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("native Windows retained-handle coverage uses DACLs in storage_windows_test.go")
|
|
}
|
|
for _, scenario := range []struct {
|
|
name string
|
|
originalUnsafe bool
|
|
replacementUnsafe bool
|
|
wantAccepted bool
|
|
}{
|
|
{name: "rejects an unsafe retained child after a safe lexical replacement", originalUnsafe: true, wantAccepted: false},
|
|
{name: "accepts missing retained children despite an unsafe lexical replacement", replacementUnsafe: true, wantAccepted: true},
|
|
} {
|
|
t.Run(scenario.name, func(t *testing.T) {
|
|
parent := privateTestRoot(t)
|
|
root := filepath.Join(parent, "auth")
|
|
replacement := filepath.Join(parent, "replacement")
|
|
moved := filepath.Join(parent, "auth-original")
|
|
for _, directory := range []string{root, replacement} {
|
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if scenario.originalUnsafe {
|
|
if err := os.Mkdir(filepath.Join(root, "sessions"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if scenario.replacementUnsafe {
|
|
if err := os.Mkdir(filepath.Join(replacement, "sessions"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
swapped := false
|
|
restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) {
|
|
if stage != "after-auth-root-open" || swapped {
|
|
return
|
|
}
|
|
if err := os.Rename(root, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(replacement, root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
swapped = true
|
|
})
|
|
t.Cleanup(restore)
|
|
input := request{Version: 1, Operation: "validate-root", Root: root}
|
|
if scenario.wantAccepted {
|
|
runRequest(t, input)
|
|
} else {
|
|
runRejected(t, input)
|
|
}
|
|
if !swapped {
|
|
t.Fatal("layout validation did not retain and expose the original root")
|
|
}
|
|
for _, child := range []string{"sessions", "oidc"} {
|
|
if _, err := os.Lstat(filepath.Join(moved, child)); !errors.Is(err, os.ErrNotExist) && !(scenario.originalUnsafe && child == "sessions") {
|
|
t.Fatalf("validate-root mutated retained %s child: %v", child, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProtocolReadsOnlyBoundedPrivateAuthConfig(t *testing.T) {
|
|
root := privateTestRoot(t)
|
|
filename := "auth.yaml"
|
|
path := filepath.Join(root, filename)
|
|
contents := []byte("version: 1\nmode: local\n")
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
read := runRequest(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
|
if !read.Found || decodeContent(t, read) != string(contents) {
|
|
t.Fatalf("read-auth-config = %#v", read)
|
|
}
|
|
|
|
hardLink := filepath.Join(root, "auth-copy.yaml")
|
|
if err := os.Link(path, hardLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
|
if err := os.Remove(hardLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path, bytes.Repeat([]byte("x"), maximumAuthConfigBytes+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename})
|
|
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: "../auth.yaml"})
|
|
runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Directory: "sessions", Filename: filename})
|
|
}
|
|
|
|
func TestProtocolReadsLocalUsersOnlyAsABoundedPrivateRegularFile(t *testing.T) {
|
|
root := privateTestRoot(t)
|
|
filename := "users.yaml"
|
|
path := filepath.Join(root, filename)
|
|
contents := []byte("version: 1\nusers: []\n")
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
read := runRequest(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename})
|
|
if !read.Found || decodeContent(t, read) != string(contents) {
|
|
t.Fatalf("read-local-users = %#v", read)
|
|
}
|
|
|
|
hardLink := filepath.Join(root, "users-copy.yaml")
|
|
if err := os.Link(path, hardLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename})
|
|
if err := os.Remove(hardLink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := safeio.WriteCanonicalNewPrivateFile(path, bytes.Repeat([]byte("x"), maximumAuthConfigBytes+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename})
|
|
}
|
|
|
|
func TestProtocolPermitsBoundedReservationSlotsOnlyForOIDCRecords(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
slot := "slot-00.json"
|
|
contents := base64.StdEncoding.EncodeToString([]byte("reservation"))
|
|
|
|
if created := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: slot, ContentBase64: contents}); !created.Created {
|
|
t.Fatal("OIDC slot create did not report success")
|
|
}
|
|
if read := runRequest(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: slot}); !read.Found || decodeContent(t, read) != "reservation" {
|
|
t.Fatalf("OIDC slot read = %#v", read)
|
|
}
|
|
listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc"})
|
|
if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != slot {
|
|
t.Fatalf("OIDC slot list = %#v", listed.Entries)
|
|
}
|
|
if removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: slot}); !removed.Removed {
|
|
t.Fatal("OIDC slot remove did not report success")
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: slot, ContentBase64: contents})
|
|
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: "slot-64.json", ContentBase64: contents})
|
|
}
|
|
|
|
func TestProtocolListSerializesLowerCamelBridgeDTO(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
filename := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.json"
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 {
|
|
t.Fatalf("Run() code = %d stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"name":"`+filename+`"`) || strings.Contains(stdout.String(), `"Name":`) {
|
|
t.Fatalf("list bridge JSON = %q, want lower-camel entry fields", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestProtocolListAlwaysSerializesAnEmptyEntriesArray(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 {
|
|
t.Fatalf("Run() code = %d stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"entries":[]`) {
|
|
t.Fatalf("empty list bridge JSON = %q, want entries array", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestProtocolPermitsClaimNamesOnlyForOIDCRemove(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
digest := "abababababababababababababababababababababababababababababababab.json"
|
|
claim := strings.TrimSuffix(digest, ".json") + ".claim"
|
|
for _, directory := range []string{"sessions", "oidc"} {
|
|
runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: directory})
|
|
}
|
|
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "sessions", claim), []byte("orphan"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "oidc", claim), []byte("orphan"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: claim})
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", Filename: claim})
|
|
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: claim})
|
|
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
runRejected(t, request{Version: 1, Operation: "replace", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: "../" + claim})
|
|
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: strings.TrimSuffix(claim, ".claim") + ".claim.bak"})
|
|
|
|
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: claim})
|
|
if !removed.Removed {
|
|
t.Fatal("OIDC orphan claim removal did not report success")
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsProtocolOverflowAndTrailingJSONValues(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
valid, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(valid) >= maximumProtocolBytes {
|
|
t.Fatal("test request unexpectedly consumes protocol bound")
|
|
}
|
|
|
|
overflowWhitespace := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maximumProtocolBytes+1-len(valid))...)
|
|
runRawRejected(t, overflowWhitespace)
|
|
|
|
withinBound := append(append([]byte(nil), valid...), []byte("{}")...)
|
|
runRawRejected(t, withinBound)
|
|
|
|
crossingBound := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maximumProtocolBytes-len(valid)-1)...)
|
|
crossingBound = append(crossingBound, []byte("{}")...)
|
|
runRawRejected(t, crossingBound)
|
|
}
|
|
|
|
func TestProtocolClaimConsumeIsAtomicAcrossConcurrentRequests(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
filename := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.json"
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("oidc-record"))})
|
|
|
|
request := request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename}
|
|
results := make(chan response, 2)
|
|
errors := make(chan error, 2)
|
|
var group sync.WaitGroup
|
|
for range 2 {
|
|
group.Add(1)
|
|
go func() {
|
|
defer group.Done()
|
|
result, err := execute(request)
|
|
if err != nil {
|
|
errors <- err
|
|
return
|
|
}
|
|
results <- result
|
|
}()
|
|
}
|
|
group.Wait()
|
|
close(results)
|
|
close(errors)
|
|
for err := range errors {
|
|
t.Fatalf("concurrent claim error = %v", err)
|
|
}
|
|
found := 0
|
|
for result := range results {
|
|
if result.Found {
|
|
found++
|
|
}
|
|
}
|
|
if found != 1 {
|
|
t.Fatalf("winning claim count = %d, want 1", found)
|
|
}
|
|
}
|
|
|
|
func TestProtocolRejectsBoundsReparseAndUnexpectedStorageNames(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json"
|
|
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString(make([]byte, maximumSessionBytes+1))})
|
|
|
|
valid := request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}
|
|
runRequest(t, valid)
|
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(root, "sessions", "unexpected.txt"), []byte("junk"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
|
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true})
|
|
|
|
outer := privateTestRoot(t)
|
|
linkedRoot := filepath.Join(outer, "linked-auth")
|
|
testsupport.SymlinkOrSkip(t, filepath.Join(outer, "missing-real-auth"), linkedRoot)
|
|
runRejected(t, request{Version: 1, Operation: "create", Root: linkedRoot, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
|
}
|
|
|
|
func TestProtocolClaimConsumeHasOneWinner(t *testing.T) {
|
|
root := filepath.Join(privateTestRoot(t), "auth")
|
|
filename := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json"
|
|
runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("oidc-record"))})
|
|
|
|
first := runRequest(t, request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename})
|
|
second := runRequest(t, request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename})
|
|
if !first.Found || decodeContent(t, first) != "oidc-record" || second.Found {
|
|
t.Fatalf("claim results = %#v, %#v", first, second)
|
|
}
|
|
}
|
|
|
|
func runRequest(t *testing.T, value request) response {
|
|
t.Helper()
|
|
input, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 {
|
|
t.Fatalf("Run() code = %d stderr = %q", code, stderr.String())
|
|
}
|
|
var output response
|
|
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil || !output.OK || output.Version != 1 {
|
|
t.Fatalf("stdout = %q response = %#v error = %v", stdout.String(), output, err)
|
|
}
|
|
return output
|
|
}
|
|
|
|
func runRejected(t *testing.T, value request) {
|
|
t.Helper()
|
|
input, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code == 0 || stdout.Len() != 0 || stderr.String() != "tht: auth storage request failed\n" {
|
|
t.Fatalf("Run() rejection code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func runRawRejected(t *testing.T, input []byte) {
|
|
t.Helper()
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code == 0 || stdout.Len() != 0 || stderr.String() != "tht: auth storage request failed\n" {
|
|
t.Fatalf("Run() rejection code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func decodeContent(t *testing.T, value response) string {
|
|
t.Helper()
|
|
contents, err := base64.StdEncoding.DecodeString(value.ContentBase64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(contents)
|
|
}
|
|
|
|
func privateTestRoot(t *testing.T) string {
|
|
t.Helper()
|
|
temporary, err := filepath.EvalSymlinks(os.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := os.MkdirTemp(temporary, "tht-authstorage-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := safeio.ProtectPrivateDirectory(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
return root
|
|
}
|