From ec800fc4b6d6f6c85d97cb0c485f0a256399f46c Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 21 Sep 2026 00:27:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=9C=AC=E6=9C=BA=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E9=80=9A=E9=81=93=E5=88=9D=E7=A8=BF=E5=B9=B6=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E6=8E=A5=E5=85=A5=E5=89=8D=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/localreset/client.go | 118 +++++++ backend/internal/localreset/protocol.go | 315 ++++++++++++++++++ backend/internal/localreset/protocol_test.go | 281 ++++++++++++++++ .../internal/localreset/transport_linux.go | 183 ++++++++++ .../internal/localreset/transport_other.go | 13 + .../internal/localreset/transport_windows.go | 132 ++++++++ 8 files changed, 1045 insertions(+) create mode 100644 backend/internal/localreset/client.go create mode 100644 backend/internal/localreset/protocol.go create mode 100644 backend/internal/localreset/protocol_test.go create mode 100644 backend/internal/localreset/transport_linux.go create mode 100644 backend/internal/localreset/transport_other.go create mode 100644 backend/internal/localreset/transport_windows.go diff --git a/backend/go.mod b/backend/go.mod index ad0d580..04078a7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect filippo.io/edwards25519 v1.1.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect diff --git a/backend/go.sum b/backend/go.sum index c0758a7..b26e0fb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -8,6 +8,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= diff --git a/backend/internal/localreset/client.go b/backend/internal/localreset/client.go new file mode 100644 index 0000000..04eb0eb --- /dev/null +++ b/backend/internal/localreset/client.go @@ -0,0 +1,118 @@ +package localreset + +import ( + "bufio" + "context" + "net" + "sync" + "time" +) + +type Client struct { + conn net.Conn + reader *bufio.Reader + nonce string + methods []Method + stage int + stop func() bool + mu sync.Mutex + once sync.Once +} + +// Begin 只接受已通过 Dial 校验身份的连接;调用过程不会通过参数或文件传递密码。 +func Begin(ctx context.Context, conn net.Conn) (*Client, error) { + if conn == nil { + return nil, ErrUnavailable + } + deadline := time.Now().Add(SessionTimeout) + if requested, ok := ctx.Deadline(); ok && requested.Before(deadline) { + deadline = requested + } + if err := conn.SetDeadline(deadline); err != nil { + _ = conn.Close() + return nil, err + } + client := &Client{conn: conn, reader: bufio.NewReaderSize(conn, MaxFrameSize+1)} + client.stop = context.AfterFunc(ctx, func() { _ = conn.Close() }) + var response Response + if err := readFrame(client.reader, &response); err != nil || !validChallenge(response) || + len(response.Methods) == 0 || len(response.Methods) > 8 { + _ = client.Close() + return nil, ErrProtocol + } + client.nonce = response.Nonce + client.methods = response.Methods + return client, nil +} + +func (c *Client) Methods() []Method { return append([]Method(nil), c.methods...) } +func (c *Client) Select(method string) error { + return c.exchange(Request{Action: "select", Method: method}, 0) +} +func (c *Client) Verify(code string) error { + return c.exchange(Request{Action: "verify", Code: code}, 1) +} +func (c *Client) Reset(password string) error { + return c.exchange(Request{Action: "reset", Password: password}, 2) +} + +func (c *Client) exchange(request Request, stage int) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.stage != stage { + _ = c.Close() + return ErrProtocol + } + request.Version, request.Nonce = Version, c.nonce + if !validRequest(request, c.nonce, request.Action) || writeFrame(c.conn, request) != nil { + c.stage = -1 + _ = c.Close() + return ErrProtocol + } + var response Response + if err := readFrame(c.reader, &response); err != nil { + c.stage = -1 + _ = c.Close() + return ErrProtocol + } + if stage == 2 { + c.stage = -1 + _ = c.Close() + if response.Version != Version || !response.OK || response.Code != "COMPLETE" || + response.Nonce != "" || len(response.Methods) != 0 { + return ErrProtocol + } + return nil + } + if !validChallenge(response) || response.Nonce == c.nonce || len(response.Methods) != 0 { + c.stage = -1 + _ = c.Close() + return ErrProtocol + } + c.nonce = response.Nonce + c.stage++ + return nil +} + +func validChallenge(response Response) bool { + if response.Version != Version || !response.OK || response.Code != "CHALLENGE" || len(response.Nonce) != 64 { + return false + } + for _, value := range response.Nonce { + if (value < '0' || value > '9') && (value < 'a' || value > 'f') { + return false + } + } + return true +} + +func (c *Client) Close() error { + var err error + c.once.Do(func() { + if c.stop != nil { + c.stop() + } + err = c.conn.Close() + }) + return err +} diff --git a/backend/internal/localreset/protocol.go b/backend/internal/localreset/protocol.go new file mode 100644 index 0000000..c77d23a --- /dev/null +++ b/backend/internal/localreset/protocol.go @@ -0,0 +1,315 @@ +package localreset + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "sync" + "time" +) + +const ( + Version = 1 + MaxFrameSize = 16 << 10 + SessionTimeout = 2 * time.Minute + MaxConnections = 8 +) + +var ( + ErrUnauthorized = errors.New("本机调用者身份不受信任") + ErrProtocol = errors.New("本机重置请求无效或已失效") + ErrUnavailable = errors.New("本机重置通道不可用") +) + +type Peer struct { + Identity string +} + +type Method struct { + ID string `json:"id"` + Label string `json:"label"` +} + +type Request struct { + Version int `json:"version"` + Nonce string `json:"nonce"` + Action string `json:"action"` + Method string `json:"method,omitempty"` + Code string `json:"code,omitempty"` + Password string `json:"password,omitempty"` +} + +type Response struct { + Version int `json:"version"` + OK bool `json:"ok"` + Code string `json:"code"` + Nonce string `json:"nonce,omitempty"` + Methods []Method `json:"methods,omitempty"` +} + +// Session 由主服务创建,身份验证及密码持久化仍由主服务负责。 +type Session interface { + Methods() []Method + Select(context.Context, string) error + Verify(context.Context, string) error + Reset(context.Context, string) error + Close() +} + +type Factory func(context.Context, Peer) (Session, error) + +type failureWindow struct { + since time.Time + count int +} + +type Server struct { + listener net.Listener + authorize func(net.Conn) (Peer, error) + cleanup func() error + mu sync.Mutex + active map[net.Conn]struct{} + failures map[string]failureWindow + closed bool + serving bool + timeout time.Duration + now func() time.Time + once sync.Once + closeErr error + workers sync.WaitGroup +} + +func newServer(listener net.Listener, authorize func(net.Conn) (Peer, error), cleanup func() error) *Server { + return &Server{listener: listener, authorize: authorize, cleanup: cleanup, + active: make(map[net.Conn]struct{}), failures: make(map[string]failureWindow), + timeout: SessionTimeout, now: time.Now} +} + +// Serve 限制并发连接,取消时关闭全部连接并等待业务处理退出。 +func (s *Server) Serve(ctx context.Context, factory Factory) error { + if s == nil || factory == nil { + return ErrUnavailable + } + s.mu.Lock() + if s.closed || s.serving { + s.mu.Unlock() + return ErrUnavailable + } + s.serving = true + s.mu.Unlock() + stop := context.AfterFunc(ctx, func() { _ = s.Close() }) + defer stop() + defer s.workers.Wait() + defer s.Close() + for { + conn, err := s.listener.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return nil + } + return err + } + s.mu.Lock() + if s.closed || len(s.active) >= MaxConnections { + s.mu.Unlock() + _ = conn.Close() + continue + } + s.active[conn] = struct{}{} + s.workers.Add(1) + s.mu.Unlock() + go func() { + defer s.workers.Done() + defer conn.Close() + defer func() { s.mu.Lock(); delete(s.active, conn); s.mu.Unlock() }() + s.handle(ctx, conn, factory) + }() + } +} + +func (s *Server) Close() error { + if s == nil { + return nil + } + s.once.Do(func() { + s.mu.Lock() + s.closed = true + s.closeErr = s.listener.Close() + for conn := range s.active { + _ = conn.Close() + } + s.mu.Unlock() + if s.cleanup != nil { + s.closeErr = errors.Join(s.closeErr, s.cleanup()) + } + }) + return s.closeErr +} + +func (s *Server) permit(identity string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for key, entry := range s.failures { + if !now.Before(entry.since.Add(time.Minute)) { + delete(s.failures, key) + } + } + entry, found := s.failures[identity] + return (!found && len(s.failures) < 128) || (found && entry.count < 5) +} + +func (s *Server) failed(identity string) { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.failures[identity] + if entry.since.IsZero() { + entry.since = s.now() + } + entry.count++ + s.failures[identity] = entry +} + +func (s *Server) handle(parent context.Context, conn net.Conn, factory Factory) { + ctx, cancel := context.WithTimeout(parent, s.timeout) + defer cancel() + deadline, _ := ctx.Deadline() + if conn.SetDeadline(deadline) != nil { + return + } + peer, err := s.authorize(conn) + if err != nil { + _ = writeFrame(conn, Response{Version: Version, Code: "UNAUTHORIZED"}) + return + } + if !s.permit(peer.Identity) { + _ = writeFrame(conn, Response{Version: Version, Code: "RATE_LIMITED"}) + return + } + session, err := factory(ctx, peer) + if err != nil || session == nil { + if session != nil { + session.Close() + } + _ = writeFrame(conn, Response{Version: Version, Code: "UNAVAILABLE"}) + return + } + defer session.Close() + methods := session.Methods() + if len(methods) == 0 || len(methods) > 8 { + _ = writeFrame(conn, Response{Version: Version, Code: "UNAVAILABLE"}) + return + } + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + for index, action := range []string{"select", "verify", "reset"} { + nonce, err := newNonce() + if err != nil { + return + } + response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce} + if index == 0 { + response.Methods = methods + } + if writeFrame(conn, response) != nil { + return + } + var request Request + err = readFrame(reader, &request) + if err != nil || ctx.Err() != nil || !validRequest(request, nonce, action) { + s.failed(peer.Identity) + _ = writeFrame(conn, Response{Version: Version, Code: "INVALID_REQUEST"}) + return + } + switch action { + case "select": + allowed := false + for _, method := range methods { + if method.ID == request.Method { + allowed = true + } + } + if !allowed { + err = ErrProtocol + } else { + err = session.Select(ctx, request.Method) + } + case "verify": + err = session.Verify(ctx, request.Code) + case "reset": + err = session.Reset(ctx, request.Password) + } + if err != nil { + s.failed(peer.Identity) + _ = writeFrame(conn, Response{Version: Version, Code: "REJECTED"}) + return + } + } + _ = writeFrame(conn, Response{Version: Version, OK: true, Code: "COMPLETE"}) +} + +func validRequest(request Request, nonce, action string) bool { + if request.Version != Version || request.Action != action || + subtle.ConstantTimeCompare([]byte(request.Nonce), []byte(nonce)) != 1 { + return false + } + switch action { + case "select": + return request.Method != "" && len(request.Method) <= 128 && request.Code == "" && request.Password == "" + case "verify": + return request.Code != "" && len(request.Code) <= 128 && request.Method == "" && request.Password == "" + case "reset": + return request.Password != "" && len(request.Password) <= 256 && request.Method == "" && request.Code == "" + } + return false +} + +func newNonce() (string, error) { + var value [32]byte + if _, err := rand.Read(value[:]); err != nil { + return "", err + } + return hex.EncodeToString(value[:]), nil +} + +func readFrame(reader *bufio.Reader, value any) error { + data, err := reader.ReadSlice('\n') + if err != nil || len(data) > MaxFrameSize { + return ErrProtocol + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(value) != nil { + return ErrProtocol + } + var extra any + if !errors.Is(decoder.Decode(&extra), io.EOF) { + return ErrProtocol + } + return nil +} + +func writeFrame(writer io.Writer, value any) error { + data, err := json.Marshal(value) + if err != nil || len(data)+1 > MaxFrameSize { + return ErrProtocol + } + data = append(data, '\n') + for len(data) > 0 { + n, err := writer.Write(data) + if err != nil { + return err + } + if n <= 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} diff --git a/backend/internal/localreset/protocol_test.go b/backend/internal/localreset/protocol_test.go new file mode 100644 index 0000000..05f4125 --- /dev/null +++ b/backend/internal/localreset/protocol_test.go @@ -0,0 +1,281 @@ +package localreset + +import ( + "bufio" + "context" + "errors" + "io" + "net" + "strings" + "sync/atomic" + "testing" + "time" +) + +type testSession struct { + selected atomic.Int32 + verified atomic.Int32 + resets atomic.Int32 + closed atomic.Int32 +} + +func (s *testSession) Methods() []Method { return []Method{{ID: "recovery", Label: "恢复码"}} } +func (s *testSession) Select(ctx context.Context, method string) error { + if ctx.Err() != nil || method != "recovery" { + return ErrProtocol + } + s.selected.Add(1) + return nil +} +func (s *testSession) Verify(ctx context.Context, code string) error { + if ctx.Err() != nil || s.selected.Load() != 1 || code != "test-code" { + return ErrProtocol + } + s.verified.Add(1) + return nil +} +func (s *testSession) Reset(ctx context.Context, password string) error { + if ctx.Err() != nil || s.verified.Load() != 1 || password != "Example123!" { + return ErrProtocol + } + s.resets.Add(1) + return nil +} +func (s *testSession) Close() { s.closed.Add(1) } + +func testConnection(t *testing.T, timeout time.Duration) (*Server, net.Conn, *testSession, <-chan struct{}) { + t.Helper() + serverConn, clientConn := net.Pipe() + server := newServer(nil, func(net.Conn) (Peer, error) { return Peer{Identity: "test"}, nil }, nil) + server.timeout = timeout + session := &testSession{} + done := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer close(done) + defer serverConn.Close() + server.handle(ctx, serverConn, func(context.Context, Peer) (Session, error) { return session, nil }) + }() + t.Cleanup(func() { + cancel() + _ = clientConn.Close() + _ = serverConn.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("connection did not stop") + } + }) + return server, clientConn, session, done +} + +func TestProtocolRoundTrip(t *testing.T) { + _, conn, session, done := testConnection(t, time.Second) + client, err := Begin(context.Background(), conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + methods := client.Methods() + methods[0].ID = "mutated" + if client.Methods()[0].ID != "recovery" { + t.Fatal("methods leaked mutable state") + } + if err := client.Select("recovery"); err != nil { + t.Fatal(err) + } + if err := client.Verify("test-code"); err != nil { + t.Fatal(err) + } + if err := client.Reset("Example123!"); err != nil { + t.Fatal(err) + } + if err := client.Reset("Example123!"); err == nil { + t.Fatal("replay accepted") + } + <-done + if session.resets.Load() != 1 || session.closed.Load() != 1 { + t.Fatal("session not consumed and closed once") + } +} + +func TestProtocolRejectsInvalidFrames(t *testing.T) { + cases := map[string]func(string) string{ + "invalid-json": func(string) string { return "{broken}\n" }, + "oversized": func(string) string { return strings.Repeat("x", MaxFrameSize+1) + "\n" }, + "wrong-version": func(n string) string { + return `{"version":9,"nonce":"` + n + `","action":"select","method":"recovery"}` + "\n" + }, + "wrong-nonce": func(string) string { + return `{"version":1,"nonce":"wrong","action":"select","method":"recovery"}` + "\n" + }, + "skip-verification": func(n string) string { + return `{"version":1,"nonce":"` + n + `","action":"reset","password":"Example123!"}` + "\n" + }, + "unknown-field": func(n string) string { + return `{"version":1,"nonce":"` + n + `","action":"select","method":"recovery","extra":true}` + "\n" + }, + "trailing-object": func(n string) string { + return `{"version":1,"nonce":"` + n + `","action":"select","method":"recovery"}{}` + "\n" + }, + "wrong-method": func(n string) string { + return `{"version":1,"nonce":"` + n + `","action":"select","method":"missing"}` + "\n" + }, + "mixed-payload": func(n string) string { + return `{"version":1,"nonce":"` + n + `","action":"select","method":"recovery","password":"Example123!"}` + "\n" + }, + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + _, conn, session, done := testConnection(t, time.Second) + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + var challenge Response + if err := readFrame(reader, &challenge); err != nil { + t.Fatal(err) + } + go func() { _, _ = io.WriteString(conn, payload(challenge.Nonce)) }() + var response Response + if err := readFrame(reader, &response); err != nil || response.OK { + t.Fatalf("accepted malformed request: %+v %v", response, err) + } + <-done + if session.resets.Load() != 0 || session.closed.Load() != 1 { + t.Fatal("invalid request reached reset or leaked session") + } + }) + } +} + +func TestProtocolRotatesNonce(t *testing.T) { + _, conn, session, done := testConnection(t, time.Second) + reader := bufio.NewReaderSize(conn, MaxFrameSize+1) + var first, second, failure Response + if err := readFrame(reader, &first); err != nil { + t.Fatal(err) + } + if err := writeFrame(conn, Request{Version: Version, Nonce: first.Nonce, Action: "select", Method: "recovery"}); err != nil { + t.Fatal(err) + } + if err := readFrame(reader, &second); err != nil || second.Nonce == first.Nonce { + t.Fatal("nonce not rotated") + } + if err := writeFrame(conn, Request{Version: Version, Nonce: first.Nonce, Action: "verify", Code: "test-code"}); err != nil { + t.Fatal(err) + } + if err := readFrame(reader, &failure); err != nil || failure.OK { + t.Fatal("replayed nonce accepted") + } + <-done + if session.verified.Load() != 0 { + t.Fatal("replay reached verification") + } +} + +func TestProtocolDeadlineClosesIdleConnection(t *testing.T) { + _, conn, session, done := testConnection(t, 30*time.Millisecond) + var response Response + if err := readFrame(bufio.NewReaderSize(conn, MaxFrameSize+1), &response); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("idle connection survived deadline") + } + if session.closed.Load() != 1 { + t.Fatal("expired session leaked") + } +} + +func TestProtocolVerificationFailureIsTerminal(t *testing.T) { + _, conn, session, done := testConnection(t, time.Second) + client, err := Begin(context.Background(), conn) + if err != nil { + t.Fatal(err) + } + if err := client.Select("recovery"); err != nil { + t.Fatal(err) + } + if err := client.Verify("wrong-code"); err == nil { + t.Fatal("wrong code accepted") + } + if err := client.Reset("Example123!"); err == nil { + t.Fatal("failed session reused") + } + <-done + if session.resets.Load() != 0 { + t.Fatal("verification bypassed") + } +} + +func TestFailureWindowExpires(t *testing.T) { + server := newServer(nil, nil, nil) + now := time.Now() + server.now = func() time.Time { return now } + for i := 0; i < 5; i++ { + if !server.permit("peer") { + t.Fatal("premature limit") + } + server.failed("peer") + } + if server.permit("peer") { + t.Fatal("failure limit bypassed") + } + now = now.Add(time.Minute) + if !server.permit("peer") { + t.Fatal("failure limit did not expire") + } +} + +func TestClientCancellationClosesConnection(t *testing.T) { + _, conn, session, done := testConnection(t, time.Minute) + ctx, cancel := context.WithCancel(context.Background()) + client, err := Begin(ctx, conn) + if err != nil { + t.Fatal(err) + } + defer client.Close() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("cancellation did not close connection") + } + if session.closed.Load() != 1 { + t.Fatal("cancelled session leaked") + } +} + +func TestFactoryErrorDoesNotDiscloseDetails(t *testing.T) { + left, right := net.Pipe() + defer right.Close() + server := newServer(nil, func(net.Conn) (Peer, error) { return Peer{Identity: "test"}, nil }, nil) + go func() { + defer left.Close() + server.handle(context.Background(), left, func(context.Context, Peer) (Session, error) { + return nil, errors.New("secret detail") + }) + }() + data, err := io.ReadAll(right) + if err != nil || strings.Contains(string(data), "secret") || !strings.Contains(string(data), "UNAVAILABLE") { + t.Fatalf("unsafe response: %s %v", data, err) + } +} + +func TestUnauthorizedPeerCannotCreateSession(t *testing.T) { + left, right := net.Pipe() + defer right.Close() + server := newServer(nil, func(net.Conn) (Peer, error) { return Peer{}, ErrUnauthorized }, nil) + var calls atomic.Int32 + go func() { + defer left.Close() + server.handle(context.Background(), left, func(context.Context, Peer) (Session, error) { + calls.Add(1) + return &testSession{}, nil + }) + }() + data, err := io.ReadAll(right) + if err != nil || !strings.Contains(string(data), "UNAUTHORIZED") || calls.Load() != 0 { + t.Fatal("unauthorized peer reached factory") + } +} diff --git a/backend/internal/localreset/transport_linux.go b/backend/internal/localreset/transport_linux.go new file mode 100644 index 0000000..3ca592a --- /dev/null +++ b/backend/internal/localreset/transport_linux.go @@ -0,0 +1,183 @@ +//go:build linux + +package localreset + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const DefaultPath = "/run/scert/admin-control.sock" + +func socketPath(path string) (string, error) { + if path == "" { + path = DefaultPath + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path || len(path) > 107 { + return "", ErrUnavailable + } + return path, nil +} + +// validateDirectory 拒绝符号链接及不可信的可写上级目录。 +func validateDirectory(parent string, owner uint32) error { + for path := parent; ; path = filepath.Dir(path) { + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return ErrUnauthorized + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || (stat.Uid != owner && stat.Uid != 0) { + return ErrUnauthorized + } + if path == parent { + if stat.Uid != owner || info.Mode().Perm()&0022 != 0 { + return ErrUnauthorized + } + } else if info.Mode().Perm()&0022 != 0 && info.Mode()&os.ModeSticky == 0 { + return ErrUnauthorized + } + if path == filepath.Dir(path) { + return nil + } + } +} + +func validateSocket(path string, owner uint32) (os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != owner || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0077 != 0 { + return nil, ErrUnauthorized + } + return info, nil +} + +func Listen(path string) (*Server, error) { + path, err := socketPath(path) + if err != nil { + return nil, err + } + parent := filepath.Dir(path) + if err := os.Mkdir(parent, 0700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, err + } + owner := uint32(os.Geteuid()) + if err := validateDirectory(parent, owner); err != nil { + return nil, err + } + info, err := validateSocket(path, owner) + if err == nil { + conn, dialErr := net.DialTimeout("unix", path, 100*time.Millisecond) + if dialErr == nil { + _ = conn.Close() + return nil, ErrUnavailable + } + if !errors.Is(dialErr, syscall.ECONNREFUSED) { + return nil, ErrUnavailable + } + current, checkErr := validateSocket(path, owner) + if checkErr != nil || !os.SameFile(info, current) { + return nil, ErrUnauthorized + } + if err := os.Remove(path); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + return nil, err + } + listener.SetUnlinkOnClose(false) + info, err = os.Lstat(path) + if err != nil { + _ = listener.Close() + return nil, err + } + cleanup := func() error { + current, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !os.SameFile(info, current) { + return ErrUnauthorized + } + return os.Remove(path) + } + if err := os.Chmod(path, 0600); err != nil { + _ = listener.Close() + _ = cleanup() + return nil, err + } + authorize := func(conn net.Conn) (Peer, error) { + uid, err := peerUID(conn) + if err != nil || (uid != 0 && uid != owner) { + return Peer{}, ErrUnauthorized + } + return Peer{Identity: "uid:" + strconv.FormatUint(uint64(uid), 10)}, nil + } + return newServer(listener, authorize, cleanup), nil +} + +func peerUID(conn net.Conn) (uint32, error) { + socket, ok := conn.(*net.UnixConn) + if !ok { + return 0, ErrUnauthorized + } + raw, err := socket.SyscallConn() + if err != nil { + return 0, err + } + var credential *unix.Ucred + var credentialErr error + err = raw.Control(func(fd uintptr) { + credential, credentialErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }) + if err != nil || credentialErr != nil || credential == nil || credential.Pid <= 0 { + return 0, ErrUnauthorized + } + return credential.Uid, nil +} + +// Dial 同时验证目录、套接字和服务进程身份,不能只信任路径名称。 +func Dial(ctx context.Context, path string, expectedUID uint32) (net.Conn, error) { + path, err := socketPath(path) + if err != nil { + return nil, err + } + if err := validateDirectory(filepath.Dir(path), expectedUID); err != nil { + return nil, err + } + before, err := validateSocket(path, expectedUID) + if err != nil { + return nil, err + } + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "unix", path) + if err != nil { + return nil, err + } + uid, err := peerUID(conn) + after, checkErr := validateSocket(path, expectedUID) + if err != nil || uid != expectedUID || checkErr != nil || !os.SameFile(before, after) { + _ = conn.Close() + return nil, fmt.Errorf("%w", ErrUnauthorized) + } + return conn, nil +} diff --git a/backend/internal/localreset/transport_other.go b/backend/internal/localreset/transport_other.go new file mode 100644 index 0000000..dd535e1 --- /dev/null +++ b/backend/internal/localreset/transport_other.go @@ -0,0 +1,13 @@ +//go:build !linux && !windows + +package localreset + +import ( + "context" + "net" +) + +const DefaultPath = "" + +func Listen(string) (*Server, error) { return nil, ErrUnavailable } +func Dial(context.Context, string, uint32) (net.Conn, error) { return nil, ErrUnavailable } diff --git a/backend/internal/localreset/transport_windows.go b/backend/internal/localreset/transport_windows.go new file mode 100644 index 0000000..3b6d76d --- /dev/null +++ b/backend/internal/localreset/transport_windows.go @@ -0,0 +1,132 @@ +//go:build windows + +package localreset + +import ( + "context" + "net" + "strings" + + "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +const DefaultPath = `\\.\pipe\scert-admin-control` +const pipeSDDL = "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;BA)" + +func pipePath(path string) (string, error) { + if path == "" { + path = DefaultPath + } + prefix := `\\.\pipe\` + if !strings.HasPrefix(path, prefix) || len(path) <= len(prefix) || + strings.ContainsAny(strings.TrimPrefix(path, prefix), `\/:`) { + return "", ErrUnavailable + } + return path, nil +} + +func Listen(path string) (*Server, error) { + return listenPipe(path, pipeSDDL) +} + +func listenPipe(path, sddl string) (*Server, error) { + path, err := pipePath(path) + if err != nil { + return nil, err + } + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: sddl, + InputBufferSize: MaxFrameSize, + OutputBufferSize: MaxFrameSize, + }) + if err != nil { + return nil, err + } + return newServer(listener, func(conn net.Conn) (Peer, error) { + return pipePeer(conn, false) + }, nil), nil +} + +// pipePeer 除管道 ACL 外再次验证进程令牌,拒绝非提升管理员及网络登录令牌。 +func pipePeer(conn net.Conn, server bool) (Peer, error) { + provider, ok := conn.(interface{ Fd() uintptr }) + if !ok || provider.Fd() == 0 { + return Peer{}, ErrUnauthorized + } + var pid uint32 + var err error + if server { + err = windows.GetNamedPipeServerProcessId(windows.Handle(provider.Fd()), &pid) + } else { + err = windows.GetNamedPipeClientProcessId(windows.Handle(provider.Fd()), &pid) + } + if err != nil || pid == 0 { + return Peer{}, ErrUnauthorized + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return Peer{}, ErrUnauthorized + } + defer windows.CloseHandle(process) + var token windows.Token + if windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token) != nil { + return Peer{}, ErrUnauthorized + } + defer token.Close() + return tokenPeer(token) +} + +func tokenPeer(token windows.Token) (Peer, error) { + user, err := token.GetTokenUser() + if err != nil || user == nil || user.User.Sid == nil { + return Peer{}, ErrUnauthorized + } + groups, err := token.GetTokenGroups() + if err != nil { + return Peer{}, ErrUnauthorized + } + network, err := windows.CreateWellKnownSid(windows.WinNetworkSid) + if err != nil { + return Peer{}, ErrUnauthorized + } + admin, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return Peer{}, ErrUnauthorized + } + enabledAdmin := false + for _, group := range groups.AllGroups() { + if group.Sid.Equals(network) { + return Peer{}, ErrUnauthorized + } + if group.Sid.Equals(admin) && group.Attributes&windows.SE_GROUP_ENABLED != 0 && + group.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY == 0 { + enabledAdmin = true + } + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return Peer{}, ErrUnauthorized + } + if !user.User.Sid.Equals(system) && (!enabledAdmin || !token.IsElevated()) { + return Peer{}, ErrUnauthorized + } + return Peer{Identity: "sid:" + user.User.Sid.String()}, nil +} + +// expectedUID 仅用于 Unix;Windows 通过 SYSTEM 或提升管理员令牌识别服务。 +func Dial(ctx context.Context, path string, expectedUID uint32) (net.Conn, error) { + path, err := pipePath(path) + if err != nil { + return nil, err + } + conn, err := winio.DialPipeContext(ctx, path) + if err != nil { + return nil, err + } + if _, err := pipePeer(conn, true); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +}