保存本机重置通道初稿并建立接入前检查点
This commit is contained in:
parent
856539d105
commit
ec800fc4b6
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
118
backend/internal/localreset/client.go
Normal file
118
backend/internal/localreset/client.go
Normal file
@ -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
|
||||
}
|
||||
315
backend/internal/localreset/protocol.go
Normal file
315
backend/internal/localreset/protocol.go
Normal file
@ -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
|
||||
}
|
||||
281
backend/internal/localreset/protocol_test.go
Normal file
281
backend/internal/localreset/protocol_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
183
backend/internal/localreset/transport_linux.go
Normal file
183
backend/internal/localreset/transport_linux.go
Normal file
@ -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
|
||||
}
|
||||
13
backend/internal/localreset/transport_other.go
Normal file
13
backend/internal/localreset/transport_other.go
Normal file
@ -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 }
|
||||
132
backend/internal/localreset/transport_windows.go
Normal file
132
backend/internal/localreset/transport_windows.go
Normal file
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user