S1 list-secret-place: mesh-files lists a place a secret is kept, entries only, marked secret-place
Issue 1409. A list of a place the mesh keeps a secret, or of a machine's credential places, at any depth, answers each entry's name, type, owner, group, mode, size and a link's target held to the content rule, never content, and says secret-place; a process's environ, mem, fd, map_files, root and cwd stay refused. Lines touched cite ADR 0394 §7. Builds: design 39 parts D3 (§15), bullets 2 and 5
This commit is contained in:
@@ -58,7 +58,7 @@ an override the override and the owned paths it went through. The caller is the
|
||||
## What it refuses
|
||||
|
||||
- A place a secret is kept (`MESH_SECRET_PLACES`, and the known credential files in `refuse.go`) is refused by
|
||||
name for `read`, `list` and `search`. Copying or moving such a place is allowed: nothing is printed.
|
||||
name for `read` and `search`; `list` answers its entries, never content, marked `secret-place`. Copying or moving such a place is allowed: nothing is printed.
|
||||
- A copy or move to or from `<node>:/path` is refused: copying between nodes comes in step 2 (issue 903).
|
||||
- A write without a reason; a path that is not absolute; a mode that is not a mode.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type fakeRoot struct {
|
||||
calls []string
|
||||
cat map[string]string
|
||||
out map[string]string // the answer to a whole call, "bin arg arg"
|
||||
fail map[string]error // the error of a whole call
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -31,6 +32,9 @@ func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, bin+" "+strings.Join(args, " "))
|
||||
if e, ok := r.fail[bin+" "+strings.Join(args, " ")]; ok {
|
||||
return nil, e
|
||||
}
|
||||
if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok {
|
||||
return []byte(o), nil
|
||||
}
|
||||
@@ -699,11 +703,15 @@ func TestTheReadsFallBackToRoot(t *testing.T) {
|
||||
if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") {
|
||||
t.Error("root read a credential file")
|
||||
}
|
||||
// a secret place is refused by name, and root is never asked
|
||||
g.root.calls = nil
|
||||
if _, err := g.List(g.path("home/.ssh")); err == nil {
|
||||
t.Error("a secret place was listed")
|
||||
// a secret place is listed, as root lists it, its entries marked and never their content (ADR 0394 §7); a path
|
||||
// that is not there under a directory the account may not enter is said missing (design 39 parts §15)
|
||||
secretDir := locked + "/.ssh"
|
||||
g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] =
|
||||
"f\t1000\t1000\t600\t9\t\tid_rsa\x00"
|
||||
if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" {
|
||||
t.Errorf("a secret place was not listed through root: %+v %v", ls, err)
|
||||
}
|
||||
g.root.calls = nil
|
||||
if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 {
|
||||
t.Errorf("a credential file was read: %v %v", err, g.root.calls)
|
||||
}
|
||||
|
||||
@@ -287,7 +287,8 @@ func rawReadTools(r Reader) []stdio.Tool {
|
||||
func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }),
|
||||
verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+
|
||||
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+
|
||||
"refused by name.",
|
||||
"listed too, at any depth, and marked secret-place: its entries, never their content; what reaches a "+
|
||||
"process's memory or files (/proc/<pid>/environ, mem, fd, map_files, root, cwd) is refused by name.",
|
||||
object(map[string]any{"path": str("an absolute path to a directory")}, "path"),
|
||||
func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }),
|
||||
verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+
|
||||
|
||||
@@ -100,6 +100,12 @@ var errRefusedByName = errors.New("refused by name")
|
||||
// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is
|
||||
// refused.
|
||||
func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) {
|
||||
return r.openJudgedIn(path, nofollow, dir, false)
|
||||
}
|
||||
|
||||
// openJudgedIn is openJudged where a list may open a place a secret is kept (listing it reads no content;
|
||||
// novox/hq ADR 0394 §7): what reaches a process's memory or files stays refused.
|
||||
func (r Reader) openJudgedIn(path string, nofollow, dir, listing bool) (*os.File, fs.FileInfo, error) {
|
||||
stat := os.Stat
|
||||
if nofollow {
|
||||
stat = os.Lstat
|
||||
@@ -136,9 +142,9 @@ func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileIn
|
||||
return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+
|
||||
"judged by name (novox/hq ADR 0394 §5)", path)
|
||||
}
|
||||
if why := r.Refused(real); why != "" {
|
||||
if why := r.Refused(real); why != "" && !(listing && !reachesProcess(real)) {
|
||||
f.Close()
|
||||
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName)
|
||||
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §7): %w", path, real, why, errRefusedByName)
|
||||
}
|
||||
return f, info, nil
|
||||
}
|
||||
@@ -478,6 +484,9 @@ type ListAnswer struct {
|
||||
Count int `json:"count"`
|
||||
Cut string `json:"cut,omitempty"`
|
||||
Withheld []Withheld `json:"withheld,omitempty"`
|
||||
// Secret says why a read under the place is refused, when the directory is a place a secret is kept: its
|
||||
// entries are answered, never their content (novox/hq ADR 0394 §7).
|
||||
Secret string `json:"secret-place,omitempty"`
|
||||
}
|
||||
|
||||
// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a
|
||||
@@ -494,17 +503,45 @@ func (r Reader) refusedRead(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// listedPlace judges a directory asked to be listed, and where it leads: a place a secret is kept is listed
|
||||
// (its entries, never content) and said so; what reaches a process's memory or files is refused by name
|
||||
// (novox/hq ADR 0394 §7).
|
||||
func (r Reader) listedPlace(path string) (string, error) {
|
||||
var secret string
|
||||
judge := func(p, how string) error {
|
||||
why := r.Refused(p)
|
||||
switch {
|
||||
case why == "":
|
||||
case reachesProcess(p):
|
||||
return fmt.Errorf("%s%s is refused by name: %s (novox/hq ADR 0394 §7)", path, how, why)
|
||||
case secret == "":
|
||||
secret = why
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := judge(path, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path {
|
||||
if err := judge(leads, " leads to "+leads+", which"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
path, err := absolute(raw)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
if err := r.refusedRead(path); err != nil {
|
||||
secret, err := r.listedPlace(path)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
names := r.names()
|
||||
var all []Entry
|
||||
dir, _, err := r.openJudged(path, false, true)
|
||||
dir, _, err := r.openJudgedIn(path, false, true, true)
|
||||
switch {
|
||||
case err != nil && r.Root != nil && errors.Is(err, fs.ErrPermission):
|
||||
if all, err = r.listAsRoot(path, names); err != nil {
|
||||
@@ -540,15 +577,15 @@ func (r Reader) List(raw string) (ListAnswer, error) {
|
||||
}
|
||||
all = append(all, en)
|
||||
}
|
||||
return r.finishList(path, all, len(entries)), nil
|
||||
return r.finishList(path, all, len(entries), secret), nil
|
||||
}
|
||||
return r.finishList(path, all, len(all)), nil
|
||||
return r.finishList(path, all, len(all), secret), nil
|
||||
}
|
||||
|
||||
// finishList bounds a list and holds each link's target to the content rule. total is how many entries the
|
||||
// directory holds.
|
||||
func (r Reader) finishList(path string, all []Entry, total int) ListAnswer {
|
||||
a := ListAnswer{Path: path, Count: total, Entries: []Entry{}}
|
||||
func (r Reader) finishList(path string, all []Entry, total int, secret string) ListAnswer {
|
||||
a := ListAnswer{Path: path, Count: total, Entries: []Entry{}, Secret: secret}
|
||||
if total > MaxEntries {
|
||||
a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, total)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,18 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
|
||||
t.Errorf("%s was read: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("the vault's directory was listed: %v", err)
|
||||
// a list of a place a secret is kept answers its entries, never content, marked (ADR 0394 §7)
|
||||
if a, err := r.List(vault); err != nil || a.Secret == "" || a.Count != 1 || a.Entries[0].Name != "ledger" {
|
||||
t.Errorf("the vault's directory was not listed as a secret place: %+v %v", a, err)
|
||||
}
|
||||
if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || a.Count != 1 {
|
||||
t.Errorf("beneath the vault's directory: %+v %v", a, err)
|
||||
}
|
||||
if a, err := r.List(filepath.Join(dir, "home/ops")); err != nil || a.Secret != "" {
|
||||
t.Errorf("a plain directory is marked: %+v %v", a, err)
|
||||
}
|
||||
if _, err := r.List("/proc/self/fd"); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||
t.Errorf("a process's open files were listed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "tmp/innocent")
|
||||
write(t, filepath.Join(dir, "tmp/x"), "")
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// What is refused by name, before any read (novox/hq ADR 0394 §5), and what an answer withholds (§4).
|
||||
// What is refused by name, before any read (novox/hq ADR 0394 §7), and what an answer withholds (§4).
|
||||
//
|
||||
// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret
|
||||
// directory, every credential file and grant directory, the vault's directories — come from the controller,
|
||||
@@ -103,6 +103,12 @@ func (r Refuser) Refused(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// reachesProcess says whether a path is an entry of a process that reaches its memory, environment or files.
|
||||
func reachesProcess(path string) bool {
|
||||
parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/")
|
||||
return len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]]
|
||||
}
|
||||
|
||||
func within(path, place string) bool {
|
||||
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user