package link import ( "crypto/ed25519" "encoding/hex" "encoding/json" "fmt" "strings" "sync" "time" ) // A hand-over asked over the bus (novox/hq issue 356, issue 339). // // A directory used as found is handed to the mesh by its operator, at the controller's terminal: // `nox node hand-over ` on the control-node (novox/hq ADR 0272). The controller asks this // node's engine on AskHandOverSubject, a request on core NATS, and the engine answers once, on the reply // the request carried. // // **The subject proves nothing; the signature does** (review of issue 356). Only the controller is granted // a publish on the subject, but the bus lets any principal allowed to answer reply to a message it received, // on whatever reply subject that message named — a tool server asked on its own subject with this reply // would deliver a body of its choosing here. So the controller signs the ask with the mesh's key, over // HandOverContext and the ask's bytes, and the engine verifies it with the key it verifies declarations with // before it reads anything out of it; then it holds the ask to this node, to its expiry, and to a nonce it // takes once. Then it judges every value and records the hand-over, or refuses and records nothing; the next // apply gives the directory its declared owner and mode. The controller holds the same shapes in its own link // code, with a test on each side holding the field names. // AskHandOverSubject is where the controller asks this node's engine to take a directory used as found as // the mesh's. func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" } // HandOverAsk is one hand-over asked: the node it is for, the directory's absolute path as the engine states // it, who asked in the controller's words, when it stops being good, and a nonce taken once. type HandOverAsk struct { Node string `json:"node"` Path string `json:"path"` By string `json:"by"` Expires time.Time `json:"expires"` Nonce string `json:"nonce"` } // SignedHandOver is the ask as it travels: its bytes exactly as signed, and the mesh's signature. type SignedHandOver struct { Ask []byte `json:"ask"` Signature []byte `json:"signature"` } // HandOverContext is prefixed to an ask's bytes before signing: a hand-over's signature is never a // declaration's, which the same key makes over a declaration's bytes alone. const HandOverContext = "novox-mesh hand-over v1\n" // HandOverGood is how long the controller makes an ask good for; HandOverSkew is how far the machines' clocks // may differ. An ask whose expiry is further ahead than both together is refused: it would be good too long. const ( HandOverGood = time.Minute HandOverSkew = time.Minute ) // HandOverAnswer is the engine's answer: what it recorded, or why it refused — never both, never neither. type HandOverAnswer struct { Said string `json:"said,omitempty"` Refused string `json:"refused,omitempty"` } // HandOverFunc judges and records one hand-over: the words to answer with, or the refusal. type HandOverFunc func(path, by string) (string, error) // MaxHandOverAsk bounds a request: a path, a name, a time, a nonce and a signature are hundreds of bytes. const MaxHandOverAsk = 8 << 10 // MaxHandOverBy bounds who asked. const MaxHandOverBy = 200 // HandOverCheck is what an ask is held to: the mesh's key, this node's name, the clock, and the nonces taken. type HandOverCheck struct { Signer ed25519.PublicKey Node string Now func() time.Time Nonces *Nonces } // Nonces are the nonces of asks taken, each kept until its ask expires: an ask is taken once. Kept in memory — // an ask outlives no restart by more than its minute, and taking one again records the same hand-over again. type Nonces struct { mu sync.Mutex seen map[string]time.Time } // take records a nonce until its expiry, and says false when it was already taken. func (n *Nonces) take(nonce string, expires, now time.Time) bool { n.mu.Lock() defer n.mu.Unlock() if n.seen == nil { n.seen = map[string]time.Time{} } for k, until := range n.seen { if now.After(until) { delete(n.seen, k) } } if _, taken := n.seen[nonce]; taken { return false } n.seen[nonce] = expires return true } // HandOverNonces are this process's: every link it opens takes from the same set, so a reconnect is no replay. var HandOverNonces = &Nonces{} func refusedAsk(why string) HandOverAnswer { return HandOverAnswer{Refused: why + "; nothing was handed over"} } // AnswerHandOver is the engine's judgement of one ask's bytes: the signature first, then this node, the expiry // and the nonce, then every value — and only then the handler, given only what passed. A refusal records nothing. func AnswerHandOver(check HandOverCheck, handle HandOverFunc, body []byte) HandOverAnswer { if handle == nil { return refusedAsk("this engine takes no hand-over") } ask, refused := believed(check, HandOverContext, true, body) if refused != "" { return refusedAsk(refused) } said, err := handle(ask.Path, ask.By) if err != nil { return HandOverAnswer{Refused: err.Error()} } return HandOverAnswer{Said: said} } // believed is an ask's bytes held to what every signed ask is: signed by the mesh over context and its bytes, // for this node, unexpired and good no longer than an ask may be, with a nonce of its own taken once here, and // one short line naming who asked, and a path when withPath and none otherwise. The nonce is taken last, after // every other check passed. It answers the ask with who asked trimmed, or why it is refused. func believed(check HandOverCheck, context string, withPath bool, body []byte) (HandOverAsk, string) { if len(body) > MaxHandOverAsk { return HandOverAsk{}, fmt.Sprintf("the ask is %d bytes, more than an ask", len(body)) } var signed SignedHandOver if err := json.Unmarshal(body, &signed); err != nil { return HandOverAsk{}, "the ask is not a signed ask: " + err.Error() } if len(check.Signer) != ed25519.PublicKeySize || !ed25519.Verify(check.Signer, append([]byte(context), signed.Ask...), signed.Signature) { return HandOverAsk{}, "the ask is not signed by the mesh" } var ask HandOverAsk if err := json.Unmarshal(signed.Ask, &ask); err != nil { return HandOverAsk{}, "the signed ask is not an ask: " + err.Error() } now := time.Now() if check.Now != nil { now = check.Now() } nonces := check.Nonces if nonces == nil { nonces = HandOverNonces } ask.By = strings.TrimSpace(ask.By) nonce, nonceErr := hex.DecodeString(ask.Nonce) switch { case check.Node == "" || ask.Node != check.Node: return HandOverAsk{}, fmt.Sprintf("the ask is for %q, and this is %q", ask.Node, check.Node) case ask.Expires.IsZero() || !now.Before(ask.Expires): return HandOverAsk{}, "the ask has expired" case ask.Expires.After(now.Add(HandOverGood + HandOverSkew)): return HandOverAsk{}, "the ask is good for longer than an ask may be" case nonceErr != nil || len(nonce) < 16: return HandOverAsk{}, "the ask carries no nonce of its own" case ask.By == "": return HandOverAsk{}, "the ask names nobody as asking" case len(ask.By) > MaxHandOverBy || strings.ContainsAny(ask.By, "\r\n\x00"): return HandOverAsk{}, "who asked is not one short line" case withPath && ask.Path == "": return HandOverAsk{}, "the ask names no directory" case !withPath && ask.Path != "": return HandOverAsk{}, "the ask names a path, and this ask takes none" case !nonces.take(ask.Nonce, ask.Expires, now): return HandOverAsk{}, "the ask was taken already" } return ask, "" } // HandOverReply is what a hand-over request is answered with, and whether to answer: a request with no reply // subject is nobody's question, so nothing is judged, recorded or said. func HandOverReply(check HandOverCheck, handle HandOverFunc, reply string, body []byte) ([]byte, bool) { if reply == "" { return nil, false } answer, err := json.Marshal(AnswerHandOver(check, handle, body)) if err != nil { return nil, false } return answer, true } // HandOvers is a link that hears the controller asking this node to hand a directory over. Optional, as // Asked is. type HandOvers interface { HearHandOvers(HandOverFunc) } // A search for setuid programs asked over the bus (novox/hq issue 361). // // The operator who changed by hand what the last search found — removed a setuid-root copy no package owns — // asks for a fresh one at the controller's terminal: `nox node setuid-search ` on the control-node. The // controller asks this node's engine on AskSetuidSearchSubject, signed as a hand-over is and held to the same // checks (believed), under a signing context of its own so neither ask's signature is the other's. The engine // throws away the last search and starts a full one; it never names a path in its answer. // // **At the terminal alone, never a verb** (decided in issue 361): a search can never make a machine free // wrongly — it judges only once complete — but one asked again and again would keep the machine unjudged and // its disks busy, and whoever may call a verb includes agents. // AskSetuidSearchSubject is where the controller asks this node's engine for a fresh search. func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" } // SetuidSearchContext is prefixed to a search ask's bytes before signing: never a hand-over's, nor a // declaration's. const SetuidSearchContext = "novox-mesh setuid-search v1\n" // SetuidSearchFunc throws away the last search and starts a full one, asked by by: the words to answer with. type SetuidSearchFunc func(by string) (string, error) func refusedSearch(why string) HandOverAnswer { return HandOverAnswer{Refused: why + "; no search was started"} } // AnswerSetuidSearch is the engine's judgement of one search ask's bytes: believed as a hand-over is, a path // refused, and only then the handler. func AnswerSetuidSearch(check HandOverCheck, handle SetuidSearchFunc, body []byte) HandOverAnswer { if handle == nil { return refusedSearch("this engine searches for setuid programs on no ask") } ask, refused := believed(check, SetuidSearchContext, false, body) if refused != "" { return refusedSearch(refused) } said, err := handle(ask.By) if err != nil { return refusedSearch(err.Error()) } return HandOverAnswer{Said: said} } // SetuidSearchReply is what a search request is answered with, and whether to answer, as HandOverReply. func SetuidSearchReply(check HandOverCheck, handle SetuidSearchFunc, reply string, body []byte) ([]byte, bool) { if reply == "" { return nil, false } answer, err := json.Marshal(AnswerSetuidSearch(check, handle, body)) if err != nil { return nil, false } return answer, true } // SetuidSearches is a link that hears the controller asking this node for a fresh search. Optional. type SetuidSearches interface { HearSetuidSearches(SetuidSearchFunc) }