fix: sync temp auth key expiry boundaries
This commit is contained in:
parent
305e8a0008
commit
20a310f6ca
50 changed files with 3626 additions and 335 deletions
447
internal/mtprotoedge/auth_key_expiry_test.go
Normal file
447
internal/mtprotoedge/auth_key_expiry_test.go
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/gotd/td/bin"
|
||||
"github.com/gotd/td/clock"
|
||||
"github.com/gotd/td/crypto"
|
||||
"github.com/gotd/td/exchange"
|
||||
"github.com/gotd/td/mt"
|
||||
"github.com/gotd/td/proto"
|
||||
"github.com/gotd/td/proto/codec"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/gotd/td/transport"
|
||||
)
|
||||
|
||||
func TestAuthKeyProtocolUnavailable(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt int
|
||||
want bool
|
||||
}{
|
||||
{name: "legacy unknown", expiresAt: -1, want: true},
|
||||
{name: "permanent", expiresAt: 0, want: false},
|
||||
{name: "expired temporary", expiresAt: int(now.Unix()), want: true},
|
||||
{name: "live temporary", expiresAt: int(now.Add(time.Second).Unix()), want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := authKeyProtocolUnavailable(tt.expiresAt, now); got != tt.want {
|
||||
t.Fatalf("authKeyProtocolUnavailable(%d) = %v, want %v", tt.expiresAt, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// expiryTestClock keeps server protocol time deterministic while retaining real
|
||||
// timers for transport/RPC deadlines. Expiry admission reads Now before any
|
||||
// envelope validation, so advancing it exercises the cached active-connection
|
||||
// boundary without making the test sleep until a wall-clock second rolls over.
|
||||
type expiryTestClock struct {
|
||||
mu sync.RWMutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newExpiryTestClock(now time.Time) *expiryTestClock {
|
||||
return &expiryTestClock{now: now}
|
||||
}
|
||||
|
||||
func (c *expiryTestClock) Now() time.Time {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *expiryTestClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.now = c.now.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (*expiryTestClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
|
||||
func (*expiryTestClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
|
||||
|
||||
type signalingGuardedLeaseWriter struct {
|
||||
lease *physicalTransportLease
|
||||
entered chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *signalingGuardedLeaseWriter) Send(ctx context.Context, b *bin.Buffer) error {
|
||||
return w.lease.Send(ctx, b)
|
||||
}
|
||||
|
||||
func (w *signalingGuardedLeaseWriter) SendDeadlineWithScratchGuarded(deadline time.Time, b *bin.Buffer, scratch *[]byte, guard func() error) error {
|
||||
w.once.Do(func() { close(w.entered) })
|
||||
return w.lease.SendDeadlineWithScratchGuarded(deadline, b, scratch, guard)
|
||||
}
|
||||
|
||||
func dialTemporaryHandshakeForExpiryTest(
|
||||
t *testing.T,
|
||||
addr string,
|
||||
dc, expiresIn int,
|
||||
pub exchange.PublicKey,
|
||||
) (transport.Conn, exchange.ClientExchangeResult, crypto.Cipher) {
|
||||
t.Helper()
|
||||
conn := dialTransportOnly(t, addr)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
auth, err := exchange.NewExchanger(conn, dc).
|
||||
WithTempMode(expiresIn).
|
||||
WithRand(rand.Reader).
|
||||
WithLogger(logzap.New(zaptest.NewLogger(t).Named("temp-client"))).
|
||||
Client([]exchange.PublicKey{pub}).
|
||||
Run(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("temporary client exchange: %v", err)
|
||||
}
|
||||
return conn, auth, crypto.NewClientCipher(rand.Reader)
|
||||
}
|
||||
|
||||
func TestActiveTemporaryAuthKeyExpiresBeforeNextRPCDispatch(t *testing.T) {
|
||||
const (
|
||||
dc = 2
|
||||
expiresIn = 60 * 60
|
||||
)
|
||||
now := time.Now()
|
||||
testClock := newExpiryTestClock(now)
|
||||
handler := &admissionCountingRPC{}
|
||||
addr, pub, srv := startTestServer(t, Options{
|
||||
DC: dc,
|
||||
Clock: testClock,
|
||||
RPC: handler,
|
||||
})
|
||||
conn, auth, cipher := dialTemporaryHandshakeForExpiryTest(t, addr, dc, expiresIn, pub)
|
||||
|
||||
stored, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("temporary auth key after exchange: found=%v err=%v", found, err)
|
||||
}
|
||||
wantExpiresAt := int(now.Unix()) + expiresIn
|
||||
if stored.ExpiresAt != wantExpiresAt {
|
||||
t.Fatalf("temporary auth key expires_at = %d, want %d", stored.ExpiresAt, wantExpiresAt)
|
||||
}
|
||||
|
||||
ids := proto.NewMessageIDGen(time.Now)
|
||||
firstID := ids.New(proto.MessageFromClient)
|
||||
sendEncrypted(t, conn, cipher, auth, firstID, &tg.HelpGetConfigRequest{})
|
||||
collectReplyFrames(t, conn, cipher, auth.AuthKey, map[uint32]int{
|
||||
proto.ResultTypeID: 1,
|
||||
mt.MsgsAckTypeID: 1,
|
||||
})
|
||||
waitForAtomicCalls(t, &handler.calls, 1)
|
||||
|
||||
key := sessionKey{authKeyID: auth.AuthKey.ID, sessionID: auth.SessionID}
|
||||
srv.conns.mu.RLock()
|
||||
active := srv.conns.bySession[key]
|
||||
srv.conns.mu.RUnlock()
|
||||
if active == nil || !active.isActive() {
|
||||
t.Fatalf("temporary session was not active before expiry: %p", active)
|
||||
}
|
||||
|
||||
// Cross the exact protocol boundary: expires_at <= now is invalid. The next
|
||||
// frame must be rejected before decrypt/preflight/Dispatch, even though this
|
||||
// connection already cached the key and completed session activation.
|
||||
testClock.Advance(time.Duration(expiresIn+1) * time.Second)
|
||||
sendEncrypted(t, conn, cipher, auth, ids.New(proto.MessageFromClient), &tg.HelpGetConfigRequest{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
var response bin.Buffer
|
||||
err = conn.Recv(ctx, &response)
|
||||
var protocolErr *codec.ProtocolErr
|
||||
if !errors.As(err, &protocolErr) || protocolErr.Code != codec.CodeAuthKeyNotFound {
|
||||
t.Fatalf("expired active temp key recv = %T %v, want protocol -404", err, err)
|
||||
}
|
||||
|
||||
waitForManagedSessionAbsent(t, srv.conns, key)
|
||||
if got := handler.calls.Load(); got != 1 {
|
||||
t.Fatalf("expired active temp key executed %d RPCs, want only the pre-expiry request", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredTemporaryAuthKeyRejectsServerPushWithoutWireWrite(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
clock := newExpiryTestClock(now)
|
||||
tr := &failAfterTransport{}
|
||||
c := newOutboundTestConn(t, tr, nil)
|
||||
c.now = clock.Now
|
||||
c.authKeyExpiresAt = int(now.Unix())
|
||||
|
||||
err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer,
|
||||
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}}, 0)
|
||||
if !errors.Is(err, ErrConnClosed) {
|
||||
t.Fatalf("push on expired temp key = %v, want ErrConnClosed", err)
|
||||
}
|
||||
if got := tr.sends.Load(); got != 0 {
|
||||
t.Fatalf("wire sends after expiry = %d, want zero", got)
|
||||
}
|
||||
if !c.isRetired() || tr.closes.Load() != 1 {
|
||||
t.Fatalf("expired connection retired=%v transport_closes=%d, want true/1", c.isRetired(), tr.closes.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedPushCannotCrossTemporaryAuthKeyExpiry(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
clock := newExpiryTestClock(now)
|
||||
tr := newGatedRecordingTransport()
|
||||
c := newOutboundTestConn(t, tr, nil)
|
||||
c.now = clock.Now
|
||||
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
|
||||
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}}
|
||||
|
||||
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
|
||||
t.Fatalf("enqueue first push: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-tr.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first push did not enter blocked writer")
|
||||
}
|
||||
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
|
||||
t.Fatalf("enqueue second push: %v", err)
|
||||
}
|
||||
clock.Advance(time.Minute)
|
||||
tr.once.Do(func() { close(tr.release) })
|
||||
|
||||
select {
|
||||
case <-c.outboundDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expired outbound actor did not stop")
|
||||
}
|
||||
if got := len(tr.snapshot()); got != 1 {
|
||||
t.Fatalf("wire frames across expiry = %d, want only already-writing frame", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryAuthKeyExpiryWhileWaitingForPhysicalWriterSkipsRawSend(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
testClock := newExpiryTestClock(now)
|
||||
raw := newGatedRecordingTransport()
|
||||
_, lease := newPhysicalTransportOwner(raw)
|
||||
c := newOutboundTestConn(t, lease, nil)
|
||||
c.transportLease = lease
|
||||
c.now = testClock.Now
|
||||
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
|
||||
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
|
||||
c.writer = signaling
|
||||
|
||||
// Simulate a quick ACK/protocol write that already owns the physical writer.
|
||||
quickDone := make(chan error, 1)
|
||||
go func() {
|
||||
quickDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{1, 2, 3, 4}})
|
||||
}()
|
||||
select {
|
||||
case <-raw.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("direct protocol write did not acquire physical writer")
|
||||
}
|
||||
|
||||
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}}
|
||||
actorDone := make(chan error, 1)
|
||||
go func() {
|
||||
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
|
||||
}()
|
||||
select {
|
||||
case <-signaling.entered:
|
||||
// writeFrame passed its outer expiry check and entered the guarded lease;
|
||||
// the direct write still owns writeMu, so raw.Send cannot have started.
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("outbound actor did not wait for physical writer ownership")
|
||||
}
|
||||
|
||||
testClock.Advance(time.Minute)
|
||||
raw.once.Do(func() { close(raw.release) })
|
||||
if err := <-quickDone; err != nil {
|
||||
t.Fatalf("direct protocol write: %v", err)
|
||||
}
|
||||
if err := <-actorDone; !errors.Is(err, ErrConnClosed) {
|
||||
t.Fatalf("actor write after expiry = %v, want ErrConnClosed", err)
|
||||
}
|
||||
if frames := raw.snapshot(); len(frames) != 1 {
|
||||
t.Fatalf("raw wire frames = %d, want only the pre-expiry direct frame", len(frames))
|
||||
}
|
||||
if !c.isRetired() {
|
||||
t.Fatal("connection was not fenced after guarded expiry rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredActorWaitingForPhysicalWriterDoesNotDefeatLeaseTransfer(t *testing.T) {
|
||||
raw := newGatedRecordingTransport()
|
||||
_, lease := newPhysicalTransportOwner(raw)
|
||||
c := newOutboundTestConn(t, lease, nil)
|
||||
c.transportLease = lease
|
||||
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
|
||||
c.writer = signaling
|
||||
|
||||
directDone := make(chan error, 1)
|
||||
go func() {
|
||||
directDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{5, 6, 7, 8}})
|
||||
}()
|
||||
select {
|
||||
case <-raw.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("direct protocol write did not acquire physical writer")
|
||||
}
|
||||
|
||||
actorDone := make(chan error, 1)
|
||||
go func() {
|
||||
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer,
|
||||
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}})
|
||||
}()
|
||||
select {
|
||||
case <-signaling.entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("outbound actor did not reach guarded physical writer")
|
||||
}
|
||||
|
||||
c.beginTerminalShutdown()
|
||||
raw.once.Do(func() { close(raw.release) })
|
||||
if err := <-directDone; err != nil {
|
||||
t.Fatalf("direct protocol write: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-c.outboundDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("retired outbound actor did not drain")
|
||||
}
|
||||
select {
|
||||
case err := <-actorDone:
|
||||
if !errors.Is(err, ErrConnClosed) {
|
||||
t.Fatalf("retired actor write = %v, want ErrConnClosed", err)
|
||||
}
|
||||
default:
|
||||
}
|
||||
if frames := raw.snapshot(); len(frames) != 1 {
|
||||
t.Fatalf("retired actor reached raw writer: frames=%d, want one direct frame", len(frames))
|
||||
}
|
||||
if !lease.IsCurrentOpen() {
|
||||
t.Fatal("retired actor closed physical lease")
|
||||
}
|
||||
if next, ok := lease.Transfer(); !ok || next == nil {
|
||||
t.Fatal("retired actor defeated physical lease transfer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAuthKeyNotFoundSurvivesActorWaitingForPhysicalWriter(t *testing.T) {
|
||||
raw := newGatedRecordingTransport()
|
||||
_, lease := newPhysicalTransportOwner(raw)
|
||||
c := newOutboundTestConn(t, lease, nil)
|
||||
c.transportLease = lease
|
||||
signaling := &signalingGuardedLeaseWriter{lease: lease, entered: make(chan struct{})}
|
||||
c.writer = signaling
|
||||
|
||||
directDone := make(chan error, 1)
|
||||
go func() {
|
||||
directDone <- lease.Send(context.Background(), &bin.Buffer{Buf: []byte{9, 10, 11, 12}})
|
||||
}()
|
||||
select {
|
||||
case <-raw.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("direct protocol write did not acquire physical writer")
|
||||
}
|
||||
go func() {
|
||||
_ = c.SendEncoded(context.Background(), proto.MessageFromServer,
|
||||
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}})
|
||||
}()
|
||||
select {
|
||||
case <-signaling.entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("outbound actor did not reach guarded physical writer")
|
||||
}
|
||||
|
||||
srv := New(Options{WriteTimeout: time.Second})
|
||||
terminalDone := make(chan error, 1)
|
||||
go func() {
|
||||
terminalDone <- srv.sendTerminalProtoError(context.Background(), c, codec.CodeAuthKeyNotFound)
|
||||
}()
|
||||
select {
|
||||
case err := <-terminalDone:
|
||||
t.Fatalf("terminal error bypassed waiting actor: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
raw.once.Do(func() { close(raw.release) })
|
||||
if err := <-directDone; err != nil {
|
||||
t.Fatalf("direct protocol write: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-terminalDone:
|
||||
if err != nil {
|
||||
t.Fatalf("send terminal -404: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("terminal -404 did not follow waiting actor drain")
|
||||
}
|
||||
|
||||
frames := raw.snapshot()
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("wire frames = %d, want direct frame then -404", len(frames))
|
||||
}
|
||||
last := frames[len(frames)-1]
|
||||
if len(last) != 4 || int32(binary.LittleEndian.Uint32(last)) != -codec.CodeAuthKeyNotFound {
|
||||
t.Fatalf("last wire frame = %x, want bare -404", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalAuthKeyNotFoundWaitsForOutboundAndIsLastFrame(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
clock := newExpiryTestClock(now)
|
||||
tr := newGatedRecordingTransport()
|
||||
_, lease := newPhysicalTransportOwner(tr)
|
||||
c := newOutboundTestConn(t, lease, nil)
|
||||
c.transportLease = lease
|
||||
c.now = clock.Now
|
||||
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
|
||||
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}}
|
||||
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
|
||||
t.Fatalf("enqueue blocked push: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-tr.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("push did not enter blocked writer")
|
||||
}
|
||||
|
||||
clock.Advance(time.Minute)
|
||||
srv := New(Options{WriteTimeout: time.Second})
|
||||
terminalDone := make(chan error, 1)
|
||||
go func() {
|
||||
terminalDone <- srv.sendTerminalProtoError(context.Background(), c, codec.CodeAuthKeyNotFound)
|
||||
}()
|
||||
select {
|
||||
case err := <-terminalDone:
|
||||
t.Fatalf("terminal error bypassed active outbound writer: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); !errors.Is(err, ErrConnClosed) {
|
||||
t.Fatalf("push admitted behind terminal fence: %v", err)
|
||||
}
|
||||
tr.once.Do(func() { close(tr.release) })
|
||||
select {
|
||||
case err := <-terminalDone:
|
||||
if err != nil {
|
||||
t.Fatalf("send terminal -404: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("terminal -404 did not follow drained writer")
|
||||
}
|
||||
|
||||
frames := tr.snapshot()
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("wire frames = %d, want encrypted frame then -404", len(frames))
|
||||
}
|
||||
last := frames[len(frames)-1]
|
||||
if len(last) != 4 || int32(binary.LittleEndian.Uint32(last)) != -codec.CodeAuthKeyNotFound {
|
||||
t.Fatalf("last wire frame = %x, want bare -404", last)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ func newBotCallbackEnv(t *testing.T, ctx context.Context) *botCallbackEnv {
|
|||
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
|
||||
memory.NewTempAuthKeyBindingStore(), "12345", auth.WithBotLogin(botStore)),
|
||||
memory.NewTempAuthKeyBindingStore(authKeyStore), "12345", auth.WithBotLogin(botStore)),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func TestBotManagementRPCFlow(t *testing.T) {
|
|||
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
|
||||
memory.NewTempAuthKeyBindingStore(), code, auth.WithBotLogin(botStore)),
|
||||
memory.NewTempAuthKeyBindingStore(authKeyStore), code, auth.WithBotLogin(botStore)),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
|
|
@ -308,7 +308,7 @@ func TestBotFatherCreateAndBotLoginFlow(t *testing.T) {
|
|||
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore,
|
||||
memory.NewTempAuthKeyBindingStore(), code, auth.WithBotLogin(botStore)),
|
||||
memory.NewTempAuthKeyBindingStore(authKeyStore), code, auth.WithBotLogin(botStore)),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
|
|
|
|||
|
|
@ -50,14 +50,21 @@ type Conn struct {
|
|||
msgID *proto.MessageIDGen
|
||||
writeTimeout time.Duration
|
||||
metrics Metrics
|
||||
// now shares the Server protocol clock with inbound expiry admission. Tests may
|
||||
// advance it without sleeping; construction-only Conns fall back to time.Now.
|
||||
now func() time.Time
|
||||
|
||||
authKeyID [8]byte
|
||||
// authKeyHex 是 authKeyID 的 hex 缓存:每条 RPC 的结构化日志都会带它,
|
||||
// 建连时算一次,避免热路径反复 hex 编码分配。
|
||||
authKeyHex string
|
||||
sessionID int64
|
||||
salt int64
|
||||
key crypto.AuthKey
|
||||
// authKeyExpiresAt=0 表示 permanent key;正值是 temporary/media-temporary
|
||||
// key 在握手时确定的绝对协议失效时间;-1 是仅供迁移的 legacy-unknown
|
||||
// sentinel(edge 会在创建 Conn 前以 -404 拒绝)。Conn 创建后不可变。
|
||||
authKeyExpiresAt int
|
||||
sessionID int64
|
||||
salt int64
|
||||
key crypto.AuthKey
|
||||
|
||||
outbound chan outboundOp
|
||||
outboundControl chan outboundOp
|
||||
|
|
@ -243,6 +250,20 @@ func (c *Conn) SetClientLayer(layer int) { c.clientLayer.Store(int32(layer)) }
|
|||
// AuthKeyID 返回连接的 auth_key_id。
|
||||
func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID }
|
||||
|
||||
// AuthKeyExpiresAt 返回 raw 协议 key 的失效时间;0 表示 permanent key。
|
||||
func (c *Conn) AuthKeyExpiresAt() int { return c.authKeyExpiresAt }
|
||||
|
||||
func (c *Conn) authKeyProtocolUnavailableNow() bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
if c.now != nil {
|
||||
now = c.now()
|
||||
}
|
||||
return authKeyProtocolUnavailable(c.authKeyExpiresAt, now)
|
||||
}
|
||||
|
||||
// BusinessAuthKeyID 返回业务视角的 auth_key_id。
|
||||
//
|
||||
// temp auth_key 绑定后解析为 perm auth_key;第二个返回值表示本连接是否已完成解析,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ func TestTelegramClientEndToEnd(t *testing.T) {
|
|||
authzStore := memory.NewAuthorizationStore()
|
||||
authKeyStore := memory.NewAuthKeyStore()
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), "12345"),
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), "12345"),
|
||||
Users: users.NewService(userStore),
|
||||
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,13 +117,16 @@ var errActivationAuthKeyRejected = errors.New("activation auth key no longer exi
|
|||
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
|
||||
var key crypto.AuthKey
|
||||
var serverSalt int64
|
||||
var authKeyExpiresAt int
|
||||
if fetchedKey != nil {
|
||||
key = crypto.AuthKey{Value: crypto.Key(fetchedKey.Value), ID: fetchedKey.ID}
|
||||
serverSalt = fetchedKey.ServerSalt
|
||||
authKeyExpiresAt = fetchedKey.ExpiresAt
|
||||
} else {
|
||||
// 快路径:复用已建立连接缓存的密钥与盐(同一 auth key 的后续帧,含同连接换 session)。
|
||||
key = current.key
|
||||
serverSalt = current.salt
|
||||
authKeyExpiresAt = current.authKeyExpiresAt
|
||||
}
|
||||
|
||||
frame, err := decryptClientFrame(key, b, plain)
|
||||
|
|
@ -152,6 +155,7 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
|
|||
} else {
|
||||
current = s.newConn(tc, key, frame.sessionID, serverSalt)
|
||||
}
|
||||
current.authKeyExpiresAt = authKeyExpiresAt
|
||||
// 注册即播种协商 layer:新 Conn 的 clientLayer 为 0(=canonical 227),若等到
|
||||
// 首条 RPC 的 Dispatch 返回后才刷新,重连老客户端在首条 RPC handler 执行期间
|
||||
// 收到的 pending flush / 并发 push 会漏降级。进程内重连时 rpc 层留有
|
||||
|
|
@ -221,10 +225,10 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
|
|||
if getErr != nil {
|
||||
return current, fmt.Errorf("revalidate activation auth key: %w", getErr)
|
||||
}
|
||||
if !found || fresh.ID != current.authKeyID || fresh.Value != [256]byte(current.key.Value) {
|
||||
if !found || fresh.ID != current.authKeyID || fresh.Value != [256]byte(current.key.Value) || authKeyProtocolUnavailable(fresh.ExpiresAt, s.clock.Now()) {
|
||||
// Send the terminal protocol error while the claim still owns a live writer;
|
||||
// the deferred abort then fences and removes it before serveConn returns.
|
||||
if sendErr := s.sendProtoError(ctx, current.transport, codec.CodeAuthKeyNotFound); sendErr != nil {
|
||||
if sendErr := s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound); sendErr != nil {
|
||||
return current, sendErr
|
||||
}
|
||||
return current, errActivationAuthKeyRejected
|
||||
|
|
|
|||
|
|
@ -98,12 +98,13 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first
|
|||
}
|
||||
|
||||
// authKeyData 把握手结果转换为 store 记录。
|
||||
func authKeyData(key crypto.AuthKey, salt, createdAt int64) store.AuthKeyData {
|
||||
func authKeyData(key crypto.AuthKey, salt, createdAt int64, expiresAt int) store.AuthKeyData {
|
||||
return store.AuthKeyData{
|
||||
ID: key.ID,
|
||||
Value: [256]byte(key.Value),
|
||||
ServerSalt: salt,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +121,24 @@ func (s *Server) sendProtoError(ctx context.Context, conn transport.Conn, code i
|
|||
return nil
|
||||
}
|
||||
|
||||
// sendTerminalProtoError serializes a bare transport error after the authenticated
|
||||
// outbound actor has stopped. A direct write while the actor is still draining can
|
||||
// otherwise interleave an encrypted update/result after -404 on the same socket.
|
||||
func (s *Server) sendTerminalProtoError(ctx context.Context, c *Conn, code int32) error {
|
||||
if c == nil {
|
||||
return errors.New("send terminal protocol error without logical connection")
|
||||
}
|
||||
c.beginTerminalShutdown()
|
||||
if !c.waitOutboundShutdownUntil(forceCloseBatchTimeout) {
|
||||
c.closeTransport()
|
||||
return errors.New("outbound writer did not stop before terminal protocol error")
|
||||
}
|
||||
if c.transport == nil {
|
||||
return ErrConnClosed
|
||||
}
|
||||
return s.sendProtoError(ctx, c.transport, code)
|
||||
}
|
||||
|
||||
// maxHandshakeReqPQ 是一次密钥交换内允许的 req_pq(_multi) 帧数上界。正常握手只发 1 个
|
||||
// req_pq(含个别客户端的「fake+真」也就 2 个);客户端因 nonce 失步陷入「收到 ResPQ→立刻
|
||||
// 重启握手换 nonce 重发 req_pq」死循环时,会在同一连接上无限发 req_pq,而委托给 gotd 的
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ func (s *Server) runServerExchange(ctx context.Context, conn transport.Conn) (ex
|
|||
// client is allowed to immediately use the new key, possibly on another TCP
|
||||
// connection. Persisting after the response creates a split-brain window when
|
||||
// storage fails or the process exits between those two operations.
|
||||
func (s *Server) commitExchangeAuthKey(ctx context.Context, result exchange.ServerExchangeResult) error {
|
||||
func (s *Server) commitExchangeAuthKey(ctx context.Context, result exchange.ServerExchangeResult, expiresAt int) error {
|
||||
createdAt := s.clock.Now().Unix()
|
||||
if err := s.authKeys.Save(ctx, authKeyData(result.Key, result.ServerSalt, createdAt)); err != nil {
|
||||
if err := s.authKeys.Save(ctx, authKeyData(result.Key, result.ServerSalt, createdAt, expiresAt)); err != nil {
|
||||
return fmt.Errorf("persist auth key before DhGenOk: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -67,7 +67,7 @@ type serverExchangeCompat struct {
|
|||
dc int
|
||||
log *zap.Logger
|
||||
rng compatServerRNG
|
||||
commitKey func(context.Context, exchange.ServerExchangeResult) error
|
||||
commitKey func(context.Context, exchange.ServerExchangeResult, int) error
|
||||
}
|
||||
|
||||
const pqInnerDataTempTypeID uint32 = 0x3c6a84d4
|
||||
|
|
@ -129,7 +129,10 @@ SendResPQ:
|
|||
s.log.Debug("Received client ReqDHParamsRequest")
|
||||
}
|
||||
|
||||
var innerData mt.PQInnerData
|
||||
var (
|
||||
innerData mt.PQInnerData
|
||||
authKeyExpiresAt int
|
||||
)
|
||||
{
|
||||
if dhParams.DH.Nonce != req.Nonce {
|
||||
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params nonce does not match req_pq")
|
||||
|
|
@ -161,6 +164,15 @@ SendResPQ:
|
|||
}
|
||||
|
||||
innerData = d.Data
|
||||
if d.Temp {
|
||||
expiresAt := s.clock.Now().Unix() + int64(d.ExpiresIn)
|
||||
// TL timestamps are signed int32 on the wire. Reject an impossible
|
||||
// lifetime instead of wrapping a temporary key into a permanent one.
|
||||
if expiresAt <= 0 || expiresAt > int64(^uint32(0)>>1) {
|
||||
return exchange.ServerExchangeResult{}, gofaster.New("temporary auth key expiry is out of int32 range")
|
||||
}
|
||||
authKeyExpiresAt = int(expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
dhPrime, err := s.rng.DhPrime()
|
||||
|
|
@ -251,7 +263,7 @@ SendResPQ:
|
|||
if s.commitKey == nil {
|
||||
return exchange.ServerExchangeResult{}, gofaster.New("auth key commit hook is required before DhGenOk")
|
||||
}
|
||||
if err := s.commitKey(ctx, serverResult); err != nil {
|
||||
if err := s.commitKey(ctx, serverResult, authKeyExpiresAt); err != nil {
|
||||
return exchange.ServerExchangeResult{}, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,15 +318,19 @@ func TestKeyExchangeAuthKeySaveUsesHandshakeDeadline(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestKeyExchangeAcceptsAndroidMediaTempNegativeDC(t *testing.T) {
|
||||
const dc = 2
|
||||
const (
|
||||
dc = 2
|
||||
expiresIn = 24 * 60 * 60
|
||||
)
|
||||
addr, pub, srv := startTestServer(t, Options{DC: dc})
|
||||
conn := dialTransportOnly(t, addr)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
startedAt := time.Now()
|
||||
res, err := exchange.NewExchanger(conn, -dc).
|
||||
WithTempMode(24 * 60 * 60).
|
||||
WithTempMode(expiresIn).
|
||||
WithRand(rand.Reader).
|
||||
WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))).
|
||||
Client([]exchange.PublicKey{pub}).
|
||||
|
|
@ -334,6 +338,7 @@ func TestKeyExchangeAcceptsAndroidMediaTempNegativeDC(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("client exchange: %v", err)
|
||||
}
|
||||
completedAt := time.Now()
|
||||
|
||||
var saved store.AuthKeyData
|
||||
found := false
|
||||
|
|
@ -354,6 +359,11 @@ func TestKeyExchangeAcceptsAndroidMediaTempNegativeDC(t *testing.T) {
|
|||
if saved.ServerSalt != res.ServerSalt {
|
||||
t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt)
|
||||
}
|
||||
minExpiresAt := int(startedAt.Unix()) + expiresIn
|
||||
maxExpiresAt := int(completedAt.Unix()) + expiresIn
|
||||
if saved.ExpiresAt < minExpiresAt || saved.ExpiresAt > maxExpiresAt {
|
||||
t.Fatalf("server temp auth key expires_at = %d, want absolute unix time in [%d, %d]", saved.ExpiresAt, minExpiresAt, maxExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyExchangeRejectsWrongNegativeTempDC(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ func TestLoginRegisterFlow(t *testing.T) {
|
|||
t.Fatalf("seed langpack: %v", err)
|
||||
}
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code),
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
|
|
@ -302,7 +302,7 @@ func TestPrivateMessageRoundTripFlow(t *testing.T) {
|
|||
messageStore := memory.NewMessageStore(dialogStore)
|
||||
activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions"))
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code),
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func TestLoginEmailEndToEnd(t *testing.T) {
|
|||
accountService := account.NewService(passwordStore,
|
||||
account.WithUsers(userStore),
|
||||
account.WithLoginEmailVerification(codeStore, emailSender, 5*time.Minute, 5, 6))
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, memory.NewTempAuthKeyBindingStore(), code,
|
||||
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code,
|
||||
auth.WithLoginMessages(messageStore, dialogStore),
|
||||
auth.WithLoginCodeDelivery(memory.NewLoginCodeDeliveryStore(messageStore, updateEventStore)),
|
||||
auth.WithPasswords(passwordStore),
|
||||
|
|
|
|||
|
|
@ -567,6 +567,18 @@ func (c *Conn) failTransport() {
|
|||
c.closeTransport()
|
||||
}
|
||||
|
||||
// fenceUnavailableAuthKey turns protocol expiry into a connection-level terminal
|
||||
// boundary. Outbound producers may discover expiry before the read loop sees the
|
||||
// client's next frame; in that case the socket is closed so the client reconnects
|
||||
// and receives the ordinary -404 admission response for the stale raw key.
|
||||
func (c *Conn) fenceUnavailableAuthKey() {
|
||||
if c == nil || !c.authKeyProtocolUnavailableNow() {
|
||||
return
|
||||
}
|
||||
c.beginTerminalShutdown()
|
||||
c.closeTransport()
|
||||
}
|
||||
|
||||
// fenceUndeliveredRPCResult is the no-reentry terminal path used from a task's
|
||||
// release callback. That callback may itself run while rpcClose.Do is draining
|
||||
// queued tasks, so calling beginCloseInboundRPCScheduler again would deadlock on
|
||||
|
|
@ -1022,6 +1034,13 @@ func (c *Conn) outboundQueue(op outboundOp) chan outboundOp {
|
|||
}
|
||||
|
||||
func (c *Conn) beginOutboundEnqueue() bool {
|
||||
// A temporary key is unusable for both inbound RPCs and server-originated
|
||||
// updates at the same absolute boundary. Reject before encoding admission;
|
||||
// the actor and write path repeat this check to close the two race windows.
|
||||
if c.authKeyProtocolUnavailableNow() {
|
||||
c.fenceUnavailableAuthKey()
|
||||
return false
|
||||
}
|
||||
c.outboundEnqueueMu.Lock()
|
||||
defer c.outboundEnqueueMu.Unlock()
|
||||
if c.outboundClosing || c.isRetired() {
|
||||
|
|
@ -1146,6 +1165,14 @@ func (c *Conn) drainOutbound() {
|
|||
}
|
||||
|
||||
func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) {
|
||||
// An operation can sit in a bounded queue across the protocol expiry instant.
|
||||
// It must be failed and released without touching the wire.
|
||||
if c.authKeyProtocolUnavailableNow() {
|
||||
op.releaseReservation(state.budget)
|
||||
op.finish(outboundResult{err: ErrConnClosed})
|
||||
c.fenceUnavailableAuthKey()
|
||||
return
|
||||
}
|
||||
if op.kind != outboundSend {
|
||||
defer op.releaseReservation(state.budget)
|
||||
}
|
||||
|
|
@ -1570,7 +1597,20 @@ type deadlineOutboundScratchWriter interface {
|
|||
SendDeadlineWithScratch(deadline time.Time, b *bin.Buffer, scratch *[]byte) error
|
||||
}
|
||||
|
||||
type deadlineOutboundGuardedScratchWriter interface {
|
||||
SendDeadlineWithScratchGuarded(deadline time.Time, b *bin.Buffer, scratch *[]byte, guard func() error) error
|
||||
}
|
||||
|
||||
var (
|
||||
errAuthKeyUnavailableAtPhysicalWrite = errors.New("auth key unavailable at physical write admission")
|
||||
errConnRetiredAtPhysicalWrite = errors.New("connection retired at physical write admission")
|
||||
)
|
||||
|
||||
func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error {
|
||||
if c.authKeyProtocolUnavailableNow() {
|
||||
c.fenceUnavailableAuthKey()
|
||||
return ErrConnClosed
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
|
@ -1595,12 +1635,28 @@ func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error {
|
|||
if err := prewriteDeadlineError(ctx, deadline); err != nil {
|
||||
return fmt.Errorf("outbound deadline before write: %w", err)
|
||||
}
|
||||
// Scratch admission and encryption may straddle expires_at. Recheck at the
|
||||
// final pre-write barrier so neither fresh sends nor resends use a stale key.
|
||||
if c.authKeyProtocolUnavailableNow() {
|
||||
c.fenceUnavailableAuthKey()
|
||||
return ErrConnClosed
|
||||
}
|
||||
|
||||
writer := c.writer
|
||||
if writer == nil {
|
||||
writer = c.transport
|
||||
}
|
||||
if sw, ok := writer.(deadlineOutboundScratchWriter); ok {
|
||||
if guarded, ok := writer.(deadlineOutboundGuardedScratchWriter); ok {
|
||||
err = guarded.SendDeadlineWithScratchGuarded(deadline, out, &scratch.codec, func() error {
|
||||
if c.isRetired() {
|
||||
return errConnRetiredAtPhysicalWrite
|
||||
}
|
||||
if c.authKeyProtocolUnavailableNow() {
|
||||
return errAuthKeyUnavailableAtPhysicalWrite
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else if sw, ok := writer.(deadlineOutboundScratchWriter); ok {
|
||||
err = sw.SendDeadlineWithScratch(deadline, out, &scratch.codec)
|
||||
} else if dw, ok := writer.(deadlineOutboundWriter); ok {
|
||||
err = dw.SendDeadline(deadline, out)
|
||||
|
|
@ -1614,6 +1670,18 @@ func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error {
|
|||
err = writer.Send(sendCtx, out)
|
||||
cancel()
|
||||
}
|
||||
if errors.Is(err, errAuthKeyUnavailableAtPhysicalWrite) {
|
||||
// The guarded lease has already released physical write ownership and no
|
||||
// raw bytes were emitted. Fence outside writeMu so Close cannot deadlock.
|
||||
c.fenceUnavailableAuthKey()
|
||||
return ErrConnClosed
|
||||
}
|
||||
if errors.Is(err, errConnRetiredAtPhysicalWrite) {
|
||||
// Terminal shutdown already owns lifecycle/transport close. Do not call
|
||||
// failTransport here: sendTerminalProtoError is waiting for this actor to
|
||||
// drain and must retain the lease long enough to write the final bare -404.
|
||||
return ErrConnClosed
|
||||
}
|
||||
if err != nil {
|
||||
// 任一 partial write / timeout 都可能破坏 MTProto 帧边界;该 socket
|
||||
// 不可继续复用。这里只发 terminal 信号,不在 actor 内等待自身退出。
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
|
|||
passkeyService := passkeyapp.NewService(memory.NewPasskeyStore(), memory.NewPasskeyChallengeStore(), rpID, dc)
|
||||
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, memory.NewAuthorizationStore(), memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code,
|
||||
Auth: auth.NewService(userStore, memory.NewAuthorizationStore(), memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code,
|
||||
auth.WithLoginMessages(messageStore, dialogStore),
|
||||
auth.WithLoginCodeDelivery(memory.NewLoginCodeDeliveryStore(messageStore, updateEventStore))),
|
||||
Account: account.NewService(memory.NewPasswordStore(), account.WithUsers(userStore)),
|
||||
|
|
|
|||
|
|
@ -369,6 +369,7 @@ func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key
|
|||
msgID: proto.NewMessageIDGen(s.clock.Now),
|
||||
writeTimeout: s.writeTimeout,
|
||||
metrics: s.metrics,
|
||||
now: s.clock.Now,
|
||||
authKeyID: key.ID,
|
||||
authKeyHex: hex.EncodeToString(key.ID[:]),
|
||||
sessionID: sessionID,
|
||||
|
|
@ -770,6 +771,19 @@ func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, loca
|
|||
// 撤销由 SessionManager 主动 Close 连接保证失效,不依赖被动的“下一帧 -404”。
|
||||
// 尚未进入 SessionManager 的 bad-salt provisional 会在 handleEncrypted 建立 activation claim
|
||||
// 后精确复查一次,既把撤销与激活线性化,也不把 salt storm 放大成 PG 写风暴。
|
||||
// temporary key 的绝对 expiry 缓存在 Conn 上,逐帧只做内存比较;到期必须在
|
||||
// RPC 前返回 -404,让官方客户端仅轮换 temp key。绝不能落到 Router 后退化为
|
||||
// raw business identity,再以会触发整账号退出的 401 结束。
|
||||
if current != nil && current.authKeyID == authKeyID && authKeyProtocolUnavailable(current.authKeyExpiresAt, s.clock.Now()) {
|
||||
s.log.Info("Rejecting unavailable temporary or legacy auth key",
|
||||
zap.String("auth_key_id", current.authKeyHex),
|
||||
zap.Int("expires_at", current.authKeyExpiresAt),
|
||||
)
|
||||
if err := s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var fetchedKey *store.AuthKeyData
|
||||
if current == nil || current.authKeyID != authKeyID {
|
||||
d, found, err := s.authKeys.Get(ctx, authKeyID)
|
||||
|
|
@ -777,17 +791,35 @@ func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, loca
|
|||
return fmt.Errorf("lookup auth key: %w", err)
|
||||
}
|
||||
if !found {
|
||||
writer := transport.Conn(conn)
|
||||
var sendErr error
|
||||
if current != nil {
|
||||
writer = current.transport
|
||||
sendErr = s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound)
|
||||
} else {
|
||||
sendErr = s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound)
|
||||
}
|
||||
if err := s.sendProtoError(ctx, writer, codec.CodeAuthKeyNotFound); err != nil {
|
||||
return err
|
||||
if sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
// -404 对 TDesktop 是 terminal key failure;继续保留 socket 只会允许
|
||||
// 同一客户端反复触发 AuthKeyStore 查询。回包一次后立即断开。
|
||||
return nil
|
||||
}
|
||||
if authKeyProtocolUnavailable(d.ExpiresAt, s.clock.Now()) {
|
||||
s.log.Info("Rejecting unavailable temporary or legacy auth key",
|
||||
zap.String("auth_key_id", hex.EncodeToString(d.ID[:])),
|
||||
zap.Int("expires_at", d.ExpiresAt),
|
||||
)
|
||||
var sendErr error
|
||||
if current != nil {
|
||||
sendErr = s.sendTerminalProtoError(ctx, current, codec.CodeAuthKeyNotFound)
|
||||
} else {
|
||||
sendErr = s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound)
|
||||
}
|
||||
if sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fetchedKey = &d
|
||||
}
|
||||
|
||||
|
|
@ -806,6 +838,12 @@ func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, loca
|
|||
}
|
||||
}
|
||||
|
||||
func authKeyProtocolUnavailable(expiresAt int, now time.Time) bool {
|
||||
// -1 is migration 0086's explicit legacy-unknown sentinel. Reject it once
|
||||
// instead of guessing permanent and allowing account authorization on a temp key.
|
||||
return expiresAt < 0 || (expiresAt > 0 && int64(expiresAt) <= now.Unix())
|
||||
}
|
||||
|
||||
// maxRetainedConnBuffer keeps normal upload/download frames allocation-free while preventing one
|
||||
// exceptional near-16MiB transport frame from pinning that capacity for the lifetime of a long
|
||||
// connection. RPC bodies that outlive dispatch already own a budgeted Copy.
|
||||
|
|
|
|||
|
|
@ -443,6 +443,27 @@ func (m *SessionManager) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID i
|
|||
m.bindAuthKeyLocked(c, key, authKeyID)
|
||||
}
|
||||
|
||||
// BindAuthKeyForRawAuthKey 把同一 raw temporary key 的全部活跃 session 绑定到
|
||||
// canonical permanent key。Android/TDesktop 会在一个 temp key 上并发创建多个
|
||||
// session;bind 只发生在其中一个 session,其他 session 不能继续把 raw temp 当业务 key。
|
||||
func (m *SessionManager) BindAuthKeyForRawAuthKey(rawAuthKeyID [8]byte, authKeyID [8]byte) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
bound := 0
|
||||
for sessionID, c := range m.byAuthKey[rawAuthKeyID] {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
if m.bySession[key] != c {
|
||||
continue
|
||||
}
|
||||
m.bindAuthKeyLocked(c, key, authKeyID)
|
||||
bound++
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8]byte) {
|
||||
oldAuthKeyID, resolved := c.BusinessAuthKeyID()
|
||||
changed := !resolved || oldAuthKeyID != authKeyID
|
||||
|
|
@ -477,6 +498,17 @@ func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int
|
|||
return c.BusinessAuthKeyID()
|
||||
}
|
||||
|
||||
// AuthKeyExpiresAtForSession 返回 raw key 的握手协议失效时间;0 表示 permanent。
|
||||
func (m *SessionManager) AuthKeyExpiresAtForSession(rawAuthKeyID [8]byte, sessionID int64) (int, bool) {
|
||||
m.mu.RLock()
|
||||
c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return c.AuthKeyExpiresAt(), true
|
||||
}
|
||||
|
||||
// CloseSessionsForBusinessAuthKey 强制断开指定业务 auth_key 的全部活跃连接,
|
||||
// 供授权撤销(被踢设备)使用:出站推送用连接持有的密钥加密、不回查密钥库,
|
||||
// 不断开的话被撤销的设备会继续收到推送直至自然断线;perm-key 连接的授权
|
||||
|
|
|
|||
|
|
@ -109,6 +109,40 @@ func TestSessionManagerRegistry(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBindAuthKeyForRawAuthKeyUpdatesEveryTemporarySession(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
raw := [8]byte{0x71}
|
||||
perm := [8]byte{0x31}
|
||||
expiresAt := int(time.Now().Add(time.Hour).Unix())
|
||||
c1 := &Conn{sessionID: 101, authKeyID: raw, authKeyExpiresAt: expiresAt}
|
||||
c2 := &Conn{sessionID: 102, authKeyID: raw, authKeyExpiresAt: expiresAt}
|
||||
if err := sm.Register(c1); err != nil {
|
||||
t.Fatalf("register c1: %v", err)
|
||||
}
|
||||
if err := sm.Register(c2); err != nil {
|
||||
t.Fatalf("register c2: %v", err)
|
||||
}
|
||||
sm.BindAuthKeyForSession(raw, c1.sessionID, raw)
|
||||
sm.BindAuthKeyForSession(raw, c2.sessionID, raw)
|
||||
sm.BindUserForAuthKey(raw, c1.sessionID, 1001)
|
||||
sm.BindUserForAuthKey(raw, c2.sessionID, 1001)
|
||||
|
||||
if got := sm.BindAuthKeyForRawAuthKey(raw, perm); got != 2 {
|
||||
t.Fatalf("bound sessions = %d, want 2", got)
|
||||
}
|
||||
for _, sessionID := range []int64{c1.sessionID, c2.sessionID} {
|
||||
if got, ok := sm.AuthKeyIDForSession(raw, sessionID); !ok || got != perm {
|
||||
t.Fatalf("session %d business key = %x/%v, want perm %x", sessionID, got, ok, perm)
|
||||
}
|
||||
if userID, resolved := sm.UserIDResolvedForAuthKey(raw, sessionID); resolved || userID != 0 {
|
||||
t.Fatalf("session %d user after identity switch = %d/%v, want unresolved", sessionID, userID, resolved)
|
||||
}
|
||||
if got, found := sm.AuthKeyExpiresAtForSession(raw, sessionID); !found || got != expiresAt {
|
||||
t.Fatalf("session %d raw expiry = %d/%v, want %d", sessionID, got, found, expiresAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerReplacementClosesOldPhysicalTransport(t *testing.T) {
|
||||
sm := NewSessionManager(zaptest.NewLogger(t))
|
||||
raw := [8]byte{1, 2, 3}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,35 @@ func (l *physicalTransportLease) SendDeadlineWithScratch(deadline time.Time, b *
|
|||
})
|
||||
}
|
||||
|
||||
// SendDeadlineWithScratchGuarded evaluates guard while holding the physical
|
||||
// write-ownership lock, immediately before entering the raw writer. Conn-level
|
||||
// checks performed before this call are insufficient: a quick ACK or protocol
|
||||
// write may hold writeMu across a temporary-key expiry boundary. The guard must
|
||||
// not close/fence the connection itself because that would re-enter transport
|
||||
// shutdown while writeMu is held; callers handle its error after the lock drops.
|
||||
func (l *physicalTransportLease) SendDeadlineWithScratchGuarded(deadline time.Time, b *bin.Buffer, scratch *[]byte, guard func() error) error {
|
||||
return l.withCurrentWriterGuarded(guard, func(raw transport.Conn) error {
|
||||
if writer, ok := raw.(deadlineOutboundScratchWriter); ok {
|
||||
return writer.SendDeadlineWithScratch(deadline, b, scratch)
|
||||
}
|
||||
if writer, ok := raw.(deadlineOutboundWriter); ok {
|
||||
return writer.SendDeadline(deadline, b)
|
||||
}
|
||||
ctx := context.Background()
|
||||
cancel := func() {}
|
||||
if !deadline.IsZero() {
|
||||
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||
}
|
||||
defer cancel()
|
||||
return raw.Send(ctx, b)
|
||||
})
|
||||
}
|
||||
|
||||
func (l *physicalTransportLease) withCurrentWriter(send func(transport.Conn) error) error {
|
||||
return l.withCurrentWriterGuarded(nil, send)
|
||||
}
|
||||
|
||||
func (l *physicalTransportLease) withCurrentWriterGuarded(guard func() error, send func(transport.Conn) error) error {
|
||||
if l == nil || l.owner == nil || l.owner.raw == nil {
|
||||
return ErrConnClosed
|
||||
}
|
||||
|
|
@ -174,6 +202,11 @@ func (l *physicalTransportLease) withCurrentWriter(send func(transport.Conn) err
|
|||
if owner.state.Load() != l.generation {
|
||||
return ErrConnClosed
|
||||
}
|
||||
if guard != nil {
|
||||
if err := guard(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return send(owner.raw)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue