From 51c5a8e99a8184c555ebeb89354c732e382a3aba Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Sun, 21 Jun 2026 01:31:17 +0200 Subject: [PATCH 1/2] config: parse optional [agent.auth.peers] per-peer tokens Add an opt-in per-peer bearer token map keyed by node name. Each entry carries its own secret in the same shape [nodes.X] uses (literal or { env = "VAR" }). Tokens must be distinct and must differ from the shared auth.token so a credential never maps to two identities; collisions are rejected at load time. Absent leaves PeerTokens nil and preserves the single-shared-token behaviour. --- config/agent.go | 56 +++++++++++++++++++++- config/config_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/config/agent.go b/config/agent.go index 7bdf4c8..16d02ef 100644 --- a/config/agent.go +++ b/config/agent.go @@ -32,6 +32,14 @@ type Agent struct { // without a token is an unauthenticated open port and we refuse to // start one. Token string + // PeerTokens maps a per-peer bearer token to the node name that + // presents it, so the agent can recover an authenticated caller + // identity instead of treating every token-holder as anonymous. When + // non-empty the agent binds each in-flight sync session to the node + // whose token opened it and rejects phase calls from a different + // identity (#110a). Empty (the default) keeps the single shared Token + // as the only credential, authenticating every caller identically. + PeerTokens map[string]string // ScanInterval is the period between drift-detection passes the // agent runs over its hosted volumes (#17). Zero (the default, // when the TOML key is absent) disables the scheduler — the @@ -63,9 +71,16 @@ type rawTLS struct { // rawAuth keeps Token as `any` because the resolved value is either a // plain string or an inline `{ env = "VAR" }` table — same shape we use -// for destination secrets. resolveSecret normalises both. +// for destination secrets. resolveSecret normalises both. Peers is the +// optional per-peer token map keyed by node name (`[agent.auth.peers.X]`), +// each carrying its own `bearer` secret in the same shape `[nodes.X]` uses. type rawAuth struct { - Token any `toml:"token"` + Token any `toml:"token"` + Peers map[string]*rawPeerAuth `toml:"peers"` +} + +type rawPeerAuth struct { + Bearer any `toml:"bearer"` } func resolveAgent(r *rawAgent) (*Agent, error) { @@ -161,5 +176,42 @@ func resolveAgentAuth(r *rawAuth, a *Agent) error { return errors.New("auth.token must not be empty") } a.Token = tok + return resolveAgentPeerTokens(r.Peers, a) +} + +// resolveAgentPeerTokens resolves the optional `[agent.auth.peers.X]` +// per-peer token map into Agent.PeerTokens (token → node name). A token +// that maps to two identities can't authenticate either, so every peer +// token must be distinct and must differ from the shared auth.token; +// both collisions are rejected at load time rather than silently +// shadowing one peer. Absent or empty leaves PeerTokens nil — the agent +// then authenticates every caller with the shared token alone. +func resolveAgentPeerTokens(peers map[string]*rawPeerAuth, a *Agent) error { + if len(peers) == 0 { + return nil + } + resolved := make(map[string]string, len(peers)) + owners := map[string]string{a.Token: "auth.token"} + for name, peer := range peers { + if !nameRE.MatchString(name) { + return fmt.Errorf("auth.peers: invalid node name %q (must match %s)", name, nameRE) + } + if peer == nil || peer.Bearer == nil { + return fmt.Errorf("auth.peers.%s.bearer is required", name) + } + tok, err := resolveSecret(map[string]any{"bearer": peer.Bearer}, "bearer") + if err != nil { + return fmt.Errorf("auth.peers.%s.%w", name, err) + } + if tok == "" { + return fmt.Errorf("auth.peers.%s.bearer must not be empty", name) + } + if owner, dup := owners[tok]; dup { + return fmt.Errorf("auth.peers.%s.bearer reuses the token already bound to %s; each credential must map to one identity", name, owner) + } + owners[tok] = "auth.peers." + name + resolved[tok] = name + } + a.PeerTokens = resolved return nil } diff --git a/config/config_test.go b/config/config_test.go index cc1ef3c..0dd784b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -809,6 +809,113 @@ auth = { token = "literal-token" } } } +func TestLoadAgentPeerTokens(t *testing.T) { + t.Setenv("NAS_TOKEN", "nas-secret") + p := writeConfig(t, ` +[agent] +listen = "127.0.0.1:9000" + +[agent.auth] +token = "shared-fallback" + +[agent.auth.peers.laptop] +bearer = "laptop-secret" + +[agent.auth.peers.nas] +bearer = { env = "NAS_TOKEN" } +`) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := map[string]string{"laptop-secret": "laptop", "nas-secret": "nas"} + if got := cfg.Agent.PeerTokens; len(got) != len(want) { + t.Fatalf("PeerTokens = %v, want %v", got, want) + } + for token, node := range want { + if cfg.Agent.PeerTokens[token] != node { + t.Fatalf("PeerTokens[%q] = %q, want %q", token, cfg.Agent.PeerTokens[token], node) + } + } +} + +func TestLoadAgentPeerTokensAbsentLeavesNil(t *testing.T) { + p := writeConfig(t, ` +[agent] +listen = "127.0.0.1:9000" +auth = { token = "only-shared" } +`) + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Agent.PeerTokens != nil { + t.Fatalf("PeerTokens = %v, want nil when no peers configured", cfg.Agent.PeerTokens) + } +} + +func TestLoadAgentPeerTokensRejectsCollisions(t *testing.T) { + cases := map[string]struct { + toml string + want string + }{ + "duplicate across peers": { + toml: ` +[agent] +listen = "127.0.0.1:9000" +[agent.auth] +token = "shared" +[agent.auth.peers.a] +bearer = "same" +[agent.auth.peers.b] +bearer = "same" +`, + want: "reuses the token", + }, + "collides with shared token": { + toml: ` +[agent] +listen = "127.0.0.1:9000" +[agent.auth] +token = "shared" +[agent.auth.peers.a] +bearer = "shared" +`, + want: "auth.token", + }, + "empty bearer": { + toml: ` +[agent] +listen = "127.0.0.1:9000" +[agent.auth] +token = "shared" +[agent.auth.peers.a] +bearer = "" +`, + want: "must not be empty", + }, + "invalid node name": { + toml: ` +[agent] +listen = "127.0.0.1:9000" +[agent.auth] +token = "shared" +[agent.auth.peers."bad/name"] +bearer = "x" +`, + want: "invalid node name", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := Load(writeConfig(t, tc.toml)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Load err = %v, want one containing %q", err, tc.want) + } + }) + } +} + func TestLoadAgentMissingToken(t *testing.T) { // auth = { } without a token must fail — an open agent port is a // footgun even in lab setups, so we refuse to start one. From bb7d412e48b4eb71dcda305782c1c05b2724c9e0 Mon Sep 17 00:00:00 2001 From: Martin Bertschler Date: Sun, 21 Jun 2026 01:31:26 +0200 Subject: [PATCH 2/2] agent: activate session-caller binding via per-peer tokens (#110a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireBearer now resolves a presented token to an authenticated node identity when per-peer tokens are configured: a per-peer match stamps the caller's node name on the request context, which callerNodeName reads back and the sync handlers bind each in-flight session to (#110a). The shared token still authenticates but carries no identity, so its binding stays a no-op — preserving today's single-token behaviour. /begin additionally refuses a declared initiator_node_name that contradicts the authenticated identity, binding the declared node name to the credential rather than letting it be self-asserted (a safe, incremental slice of #110d; the full per-peer-token redesign and volume scoping remain deferred). The per-peer token set is keyed by SHA-256 digest so the map probe never compares attacker bytes against a stored secret directly; the shared-token check stays constant-time. --- agent/agent.go | 66 +++++++++++++++++++++++++++++------ agent/agent_test.go | 43 +++++++++++++++++++++++ agent/sync.go | 58 +++++++++++++++++++------------ agent/sync_test.go | 80 ++++++++++++++++++++++++++++++++++++++++--- cmd/squirrel/agent.go | 1 + 5 files changed, 213 insertions(+), 35 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 68874ad..5084e35 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -63,6 +63,15 @@ type Config struct { // Token is the resolved bearer token compared (in constant time) // against the Authorization header on every authenticated request. Token string + // PeerTokens optionally maps a per-peer bearer token to the node + // name that presents it. When non-empty, requireBearer recovers the + // caller's authenticated node identity and the sync handlers bind + // each in-flight session to it (#110a); a token absent from the map + // still authenticates if it equals Token but carries no identity. + // Empty (the default) preserves the single-shared-token behaviour: + // every caller authenticates identically and no session binding is + // enforced. + PeerTokens map[string]string // TLSCert and TLSKey are filesystem paths to a PEM-encoded certificate // and matching private key. When both are empty the agent serves // plain HTTP; when both are set it terminates TLS natively. @@ -208,29 +217,66 @@ func (s *Server) buildHandler() http.Handler { // requireBearer is the auth middleware. The Authorization header must // parse as ` ` with scheme matching "Bearer" case- -// insensitively (per RFC 7235 §2.1) and token matching the configured -// value. We hash both sides to a fixed-length SHA-256 digest before -// subtle.ConstantTimeCompare so the comparison time is independent of -// the attacker-controlled token length (subtle.ConstantTimeCompare -// short-circuits on len mismatch, which would otherwise leak length). -// The configured token is non-empty (enforced by validateConfig). +// insensitively (per RFC 7235 §2.1). The token authenticates when it +// matches the shared token or any configured per-peer token; a per-peer +// match attaches that node's identity to the request context so the sync +// handlers can bind a session to its caller (#110a). func (s *Server) requireBearer(next http.Handler) http.Handler { - expectedHash := sha256.Sum256([]byte(s.cfg.Token)) + auth := newAuthenticator(s.cfg.Token, s.cfg.PeerTokens) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := extractBearerToken(r.Header.Get("Authorization")) if !ok { writeError(w, http.StatusUnauthorized, "missing bearer token") return } - gotHash := sha256.Sum256([]byte(token)) - if subtle.ConstantTimeCompare(gotHash[:], expectedHash[:]) != 1 { + caller, ok := auth.authenticate(token) + if !ok { writeError(w, http.StatusUnauthorized, "invalid bearer token") return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, withCallerNode(r, caller)) }) } +// authenticator resolves a presented bearer token to an authenticated +// caller. The shared token and every per-peer token are pre-hashed to a +// fixed-length SHA-256 digest so the comparison time is independent of +// the attacker-controlled token length (subtle.ConstantTimeCompare +// short-circuits on a length mismatch, which would otherwise leak it). +// The per-peer set is keyed by digest rather than by the raw secret, so +// the map probe never compares attacker bytes against a stored secret +// directly. +type authenticator struct { + sharedHash [32]byte + peerNodes map[[32]byte]string +} + +func newAuthenticator(sharedToken string, peerTokens map[string]string) authenticator { + a := authenticator{sharedHash: sha256.Sum256([]byte(sharedToken))} + if len(peerTokens) > 0 { + a.peerNodes = make(map[[32]byte]string, len(peerTokens)) + for token, node := range peerTokens { + a.peerNodes[sha256.Sum256([]byte(token))] = node + } + } + return a +} + +// authenticate returns the caller's authenticated node name and whether +// the token is valid. A per-peer token yields its node name; the shared +// token authenticates with an empty name (no recoverable identity, the +// single-token case #110d leaves unbound). +func (a authenticator) authenticate(token string) (string, bool) { + gotHash := sha256.Sum256([]byte(token)) + if node, ok := a.peerNodes[gotHash]; ok { + return node, true + } + if subtle.ConstantTimeCompare(gotHash[:], a.sharedHash[:]) == 1 { + return "", true + } + return "", false +} + // extractBearerToken parses ` ` from an Authorization // header value. The scheme match is case-insensitive; trailing // whitespace after the scheme is consumed so `Bearer tok` (double diff --git a/agent/agent_test.go b/agent/agent_test.go index f7fa16a..9606f21 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -199,6 +199,49 @@ func TestSyncBeginRejectsBearerWithLengthDifference(t *testing.T) { } } +// TestAuthenticatorResolvesCaller (#110a/#110d): a per-peer token +// resolves to its node name; the shared token authenticates with an +// empty identity; an unknown token is rejected. The empty-identity case +// is what keeps the session binding a no-op for shared-token callers. +func TestAuthenticatorResolvesCaller(t *testing.T) { + auth := newAuthenticator("shared", map[string]string{ + "laptop-token": "laptop", + "nas-token": "nas", + }) + cases := []struct { + token string + wantNode string + wantOK bool + }{ + {"laptop-token", "laptop", true}, + {"nas-token", "nas", true}, + {"shared", "", true}, + {"unknown", "", false}, + } + for _, c := range cases { + t.Run(c.token, func(t *testing.T) { + node, ok := auth.authenticate(c.token) + if node != c.wantNode || ok != c.wantOK { + t.Fatalf("authenticate(%q) = (%q, %v), want (%q, %v)", c.token, node, ok, c.wantNode, c.wantOK) + } + }) + } +} + +// TestAuthenticatorNoPeerTokensSharedOnly: with no per-peer tokens the +// authenticator behaves exactly as the single-shared-token agent did — +// the shared token authenticates (empty identity), everything else is +// rejected. +func TestAuthenticatorNoPeerTokensSharedOnly(t *testing.T) { + auth := newAuthenticator("only-shared", nil) + if node, ok := auth.authenticate("only-shared"); !ok || node != "" { + t.Fatalf("shared token = (%q, %v), want (\"\", true)", node, ok) + } + if _, ok := auth.authenticate("nope"); ok { + t.Fatalf("unknown token authenticated, want rejection") + } +} + func TestServePlainHTTP(t *testing.T) { srv := newTestServer(t, Config{}) ln, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/agent/sync.go b/agent/sync.go index d8765d2..29d6a7f 100644 --- a/agent/sync.go +++ b/agent/sync.go @@ -89,12 +89,11 @@ type peerSession struct { volumeID int64 peerNodeID int64 // initiatorNodeName is the caller identity declared at /begin, - // recorded so a phase call can be bound back to the node that - // opened the session. Under the single shared agent token there is - // no per-request authenticated identity to compare it against yet - // (see #110d); lookupSession is the chokepoint where that - // comparison lands once per-peer tokens make a caller identity - // recoverable. + // recorded so a later phase call is bound back to the node that + // opened the session. lookupSession compares it against the caller's + // authenticated identity when per-peer tokens make one recoverable + // (#110a); a shared-token caller carries no identity (#110d) and the + // comparison is skipped. initiatorNodeName string correlatedRunID int64 // dedupStrategy is the initiator-supplied preference applied by @@ -228,13 +227,12 @@ var errSessionCallerMismatch = errors.New("caller node does not own this session // lookupSession resolves the session for receiverRunID and binds it to // the caller. A non-empty callerNode must equal the initiator name -// recorded at /begin, so a second node holding the shared token cannot -// drive another node's in-flight session. callerNode is empty today -// because the single shared agent token carries no per-request identity -// (#110d): the comparison is a no-op until per-peer tokens make a caller -// identity recoverable, at which point this is the single place it is -// enforced. ok is false when no session exists; err is non-nil only on a -// caller mismatch. +// recorded at /begin, so a node authenticated as one identity cannot +// drive another node's in-flight session (#110a). callerNode is "" for a +// shared-token caller (no recoverable identity, #110d); the comparison +// then yields verbatim, leaving the session unbound for that caller. This +// is the single place the binding is enforced. ok is false when no +// session exists; err is non-nil only on a caller mismatch. func (r *peerSyncRouter) lookupSession(receiverRunID int64, callerNode string) (sess *peerSession, ok bool, err error) { r.mu.Lock() defer r.mu.Unlock() @@ -248,12 +246,27 @@ func (r *peerSyncRouter) lookupSession(receiverRunID int64, callerNode string) ( return sess, true, nil } -// callerNodeName returns the authenticated initiator identity for a -// phase request, or "" when none is recoverable. The single shared -// agent token authenticates every peer identically, so no per-request -// identity exists yet (#110d); this returns "" until per-peer tokens -// land, keeping the lookupSession binding point in one spot. -func callerNodeName(*http.Request) string { return "" } +// callerNodeContextKey types the request-context slot requireBearer +// stamps with the authenticated caller's node name. +type callerNodeContextKey struct{} + +// withCallerNode returns req carrying node as its authenticated caller +// identity. An empty node (the shared-token case, which carries no +// recoverable identity) is stored verbatim so callerNodeName reads it +// back as "" and the session binding stays a no-op for that caller. +func withCallerNode(req *http.Request, node string) *http.Request { + return req.WithContext(context.WithValue(req.Context(), callerNodeContextKey{}, node)) +} + +// callerNodeName returns the authenticated initiator identity requireBearer +// stamped on the request, or "" when none is recoverable. A per-peer token +// resolves to its node name; the single shared token authenticates without +// an identity (#110d), so this returns "" and lookupSession leaves the +// session unbound for that caller. +func callerNodeName(req *http.Request) string { + node, _ := req.Context().Value(callerNodeContextKey{}).(string) + return node +} // handleBegin implements POST /v1/sync/begin. The handler is the // thin HTTP shell over beginSession, which carries the actual flow. @@ -263,7 +276,7 @@ func (r *peerSyncRouter) handleBegin(w http.ResponseWriter, req *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - resp, status, err := r.beginSession(req.Context(), body) + resp, status, err := r.beginSession(req.Context(), body, callerNodeName(req)) if err != nil { writeError(w, status, err.Error()) return @@ -277,10 +290,13 @@ func (r *peerSyncRouter) handleBegin(w http.ResponseWriter, req *http.Request) { // The lock is acquired before any DB row insertion that would need // rollback on a later failure; releasing it lives in the per-phase // guard. -func (r *peerSyncRouter) beginSession(ctx context.Context, body syncproto.BeginRequest) (syncproto.BeginResponse, int, error) { +func (r *peerSyncRouter) beginSession(ctx context.Context, body syncproto.BeginRequest, callerNode string) (syncproto.BeginResponse, int, error) { if body.Volume == "" || body.InitiatorNodeName == "" || body.InitiatorRunID == 0 { return syncproto.BeginResponse{}, http.StatusBadRequest, errors.New("volume, initiator_node_name, and initiator_run_id are required") } + if callerNode != "" && callerNode != body.InitiatorNodeName { + return syncproto.BeginResponse{}, http.StatusForbidden, fmt.Errorf("authenticated node %q may not open a session as %q", callerNode, body.InitiatorNodeName) + } strategy, err := normalizeDedupStrategy(body.DedupStrategy) if err != nil { return syncproto.BeginResponse{}, http.StatusBadRequest, err diff --git a/agent/sync_test.go b/agent/sync_test.go index 89e3684..92c84d0 100644 --- a/agent/sync_test.go +++ b/agent/sync_test.go @@ -612,10 +612,10 @@ func TestValidateRelPathRejectsAllReservedDirs(t *testing.T) { // TestSessionBoundToCaller (#110a): a phase call presenting a caller // identity that differs from the node that opened the session is refused. -// The single shared agent token carries no per-request identity yet -// (#110d), so the production phase handlers pass "" (no binding) — this -// exercises the binding directly to prove the chokepoint is correct for -// when per-peer tokens make a caller identity recoverable. +// This exercises the lookup binding directly; the empty-caller case is +// the shared-token path (#110d), which carries no identity and so reaches +// the session unbound. The end-to-end binding over per-peer tokens is +// covered by TestPeerTokenSessionBinding. func TestSessionBoundToCaller(t *testing.T) { f := newPreStageFixture(t) r := f.router @@ -705,6 +705,78 @@ func TestPeerEndpointIgnoresWireEndpoint(t *testing.T) { } } +// TestPeerTokenSessionBinding (#110a + safe #110d increment): with +// per-peer tokens configured, a session opened by one peer's token is +// bound to that peer's identity. A phase call carrying the same +// receiver_run_id but a different peer's token is refused with 403, so a +// second token-holder can't hijack (or /close-abort) the session. A +// shared-token caller still authenticates but, carrying no identity, +// reaches the session unbound (the pre-#110d behaviour). +func TestPeerTokenSessionBinding(t *testing.T) { + vol := &config.Volume{Name: "pics", Path: t.TempDir()} + srv := newTestServer(t, Config{ + Token: "shared", + Volumes: map[string]*config.Volume{vol.Name: vol}, + PeerTokens: map[string]string{"owner-token": "owner", "intruder-token": "intruder"}, + }) + + var begin syncproto.BeginResponse + if code := postJSON(t, srv, "/v1/sync/begin", "owner-token", syncproto.BeginRequest{ + Volume: vol.Name, InitiatorNodeName: "owner", InitiatorRunID: 1, + }, &begin); code != http.StatusOK { + t.Fatalf("begin as owner: status = %d, want 200", code) + } + + verify := syncproto.VerifyRequest{ReceiverRunID: begin.ReceiverRunID} + if code := postJSON(t, srv, "/v1/sync/verify", "intruder-token", verify, nil); code != http.StatusForbidden { + t.Fatalf("verify as intruder: status = %d, want 403", code) + } + if code := postJSON(t, srv, "/v1/sync/verify", "owner-token", verify, nil); code != http.StatusOK { + t.Fatalf("verify as owner: status = %d, want 200", code) + } +} + +// TestBeginRejectsImpersonatedNodeName (safe #110d increment): a caller +// authenticated as one node may not open a session declaring a different +// initiator_node_name, so the declared identity is bound to the +// credential rather than self-asserted. +func TestBeginRejectsImpersonatedNodeName(t *testing.T) { + vol := &config.Volume{Name: "pics", Path: t.TempDir()} + srv := newTestServer(t, Config{ + Token: "shared", + Volumes: map[string]*config.Volume{vol.Name: vol}, + PeerTokens: map[string]string{"owner-token": "owner"}, + }) + code := postJSON(t, srv, "/v1/sync/begin", "owner-token", syncproto.BeginRequest{ + Volume: vol.Name, InitiatorNodeName: "someone-else", InitiatorRunID: 1, + }, nil) + if code != http.StatusForbidden { + t.Fatalf("begin impersonating someone-else: status = %d, want 403", code) + } +} + +// postJSON marshals body, POSTs it to urlPath with the given bearer +// token, decodes a 200 response into out (when non-nil), and returns the +// status so a test can assert auth/binding rejections. +func postJSON(t *testing.T, srv *Server, urlPath, token string, body, out any) int { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + req := httptest.NewRequest(http.MethodPost, urlPath, bytes.NewReader(encoded)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code == http.StatusOK && out != nil { + if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { + t.Fatalf("decode response: %v", err) + } + } + return rec.Code +} + // postRaw POSTs body verbatim to urlPath with the test bearer token and // returns the HTTP status, so a malformed or oversized body can be driven // without the typed marshal helpers rejecting it first. diff --git a/cmd/squirrel/agent.go b/cmd/squirrel/agent.go index 943be49..d554c77 100644 --- a/cmd/squirrel/agent.go +++ b/cmd/squirrel/agent.go @@ -58,6 +58,7 @@ func runAgent(cmd *cobra.Command) error { srv, err := agent.New(agent.Config{ Listen: cfg.Agent.Listen, Token: cfg.Agent.Token, + PeerTokens: cfg.Agent.PeerTokens, TLSCert: cfg.Agent.TLSCert, TLSKey: cfg.Agent.TLSKey, Version: agentVersion,