package service import ( "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const gameClientBridgeCapability = "game-client.bridge" func (svc *CoreService) ClaimGameClientBridgeCommands(request domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) { if err := validator.ValidateGameClientBridgeClaimRequest(request); err != nil { return nil, err } component, err := svc.authorizeGameClientBridgeSession(request.SessionToken) if err != nil { return nil, err } return svc.claimGameClientBridgeCommands(component, request.Limit) } func (svc *CoreService) AckGameClientBridgeCommand(request domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) { if err := validator.ValidateGameClientBridgeAckRequest(request); err != nil { return domain.GameClientBridgeCommand{}, err } component, err := svc.authorizeGameClientBridgeSession(request.SessionToken) if err != nil { return domain.GameClientBridgeCommand{}, err } return svc.ackGameClientBridgeCommand(component, request) } func (svc *CoreService) CompleteGameClientBridgeCommand(request domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) { if err := validator.ValidateGameClientBridgeResultRequest(request); err != nil { return domain.GameClientBridgeCommand{}, err } component, err := svc.authorizeGameClientBridgeSession(request.SessionToken) if err != nil { return domain.GameClientBridgeCommand{}, err } return svc.completeGameClientBridgeCommand(component, request) } func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) { request.Payload = domain.CopyGameClientBridgePayload(request.Payload) if err := validator.ValidateGameClientBridgeSnapshotIngestRequest(request); err != nil { return domain.GameClientBridgeSnapshot{}, err } component, err := svc.authorizeGameClientBridgeSession(request.SessionToken) if err != nil { return domain.GameClientBridgeSnapshot{}, err } stamp := svc.now() if request.ObservedAt.After(stamp.Add(5 * time.Minute)) { return domain.GameClientBridgeSnapshot{}, validationError("snapshot observedAt is too far in the future") } plugin, err := svc.store.GamePlugins().Get(component.Installation.PluginID) if err != nil { return domain.GameClientBridgeSnapshot{}, err } declaration, declared := gameClientBridgeSnapshotDeclaration(plugin, request.Type, request.SchemaVersion) if !declared { return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot type and schema version are not declared") } if request.Retention != declaration.Retention { return domain.GameClientBridgeSnapshot{}, validationError("bridge snapshot retention does not match declaration") } svc.bridgeMu.Lock() defer svc.bridgeMu.Unlock() streamID := gameClientBridgeStreamID(component.Session.ServerInstanceID, component.Installation.PluginID, component.Session.ProfileKey, request.Type, request.StreamKey) latestSequence := uint64(0) stream, streamErr := svc.store.GameClientBridgeSnapshotStreams().Get(streamID) if streamErr == nil { latestSequence = stream.LatestSequence } else if streamErr != repo.ErrNotFound { return domain.GameClientBridgeSnapshot{}, streamErr } existing, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, StreamKey: request.StreamKey}) if err != nil { return domain.GameClientBridgeSnapshot{}, err } for _, snapshot := range existing { if snapshot.Sequence > latestSequence { latestSequence = snapshot.Sequence } } if request.Sequence <= latestSequence { return domain.GameClientBridgeSnapshot{}, validationError("snapshot sequence is stale") } svc.bridgeSeq++ snapshot := domain.GameClientBridgeSnapshot{ ID: fmt.Sprintf("bridge-snapshot-%d-%d", stamp.UnixNano(), svc.bridgeSeq), ServerInstanceID: component.Session.ServerInstanceID, PluginID: component.Installation.PluginID, ProfileKey: component.Session.ProfileKey, Type: request.Type, SchemaVersion: request.SchemaVersion, StreamKey: request.StreamKey, Sequence: request.Sequence, SourceSessionID: component.Session.ID, ObservedAt: request.ObservedAt, Payload: domain.CopyGameClientBridgePayload(request.Payload), Retention: request.Retention, CreatedAt: stamp, ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second), } auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot") if err != nil { return domain.GameClientBridgeSnapshot{}, err } snapshot.AuditReferences = []string{auditID} if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil { return domain.GameClientBridgeSnapshot{}, err } stream = domain.GameClientBridgeSnapshotStream{ID: streamID, ServerInstanceID: snapshot.ServerInstanceID, PluginID: snapshot.PluginID, ProfileKey: snapshot.ProfileKey, Type: snapshot.Type, StreamKey: snapshot.StreamKey, LatestSequence: snapshot.Sequence, UpdatedAt: stamp} if streamErr == repo.ErrNotFound { if err := svc.store.GameClientBridgeSnapshotStreams().Create(stream); err != nil { return domain.GameClientBridgeSnapshot{}, err } } else { if err := svc.store.GameClientBridgeSnapshotStreams().Update(stream); err != nil { return domain.GameClientBridgeSnapshot{}, err } } return domain.CopyGameClientBridgeSnapshot(snapshot), nil } func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) { for _, declaration := range plugin.GameClientBridge.Snapshots { if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion { return declaration, true } } return domain.GameClientBridgeSnapshotDeclaration{}, false } func (svc *CoreService) authorizeGameClientBridgeSession(sessionToken string) (gameClientBridgeComponentSession, error) { presentedHash := tokenHash(sessionToken) sessions, err := svc.store.ClientManagerSessions().List(domain.ClientManagerSessionFilter{Status: domain.ClientManagerSessionActive}) if err != nil { return gameClientBridgeComponentSession{}, err } var session domain.ClientManagerSession for _, candidate := range sessions { if subtle.ConstantTimeCompare([]byte(candidate.TokenHash), []byte(presentedHash)) == 1 { session = candidate break } } stamp := svc.now() if session.ID == "" || !stamp.Before(session.ExpiresAt) { return gameClientBridgeComponentSession{}, ErrUnauthorized } if !containsString(session.Capabilities, gameClientBridgeCapability) { return gameClientBridgeComponentSession{}, ErrForbidden } installation, err := svc.store.ClientManagerInstallations().Get(session.InstallationID) if err != nil { return gameClientBridgeComponentSession{}, ErrUnauthorized } if installation.Status != domain.ClientManagerLifecycleOnline && installation.Status != domain.ClientManagerLifecycleDegraded { return gameClientBridgeComponentSession{}, ErrUnauthorized } if installation.ServerInstanceID != session.ServerInstanceID || installation.ProfileKey != session.ProfileKey || installation.RunEndpointID != session.RunEndpointID || installation.ActiveArtifactID != session.ArtifactID || installation.KeyGeneration != session.KeyGeneration || installation.DeploymentGeneration != session.DeploymentGeneration || installation.RequiresRedeploy { return gameClientBridgeComponentSession{}, ErrUnauthorized } key, err := svc.activeComponentKey(session.ServerInstanceID, domain.DistributionComponentClientManager, session.ProfileKey) if err != nil || key.Generation != session.KeyGeneration { return gameClientBridgeComponentSession{}, ErrUnauthorized } return gameClientBridgeComponentSession{Session: domain.CopyClientManagerSession(session), Installation: domain.CopyClientManagerInstallation(installation)}, nil } func gameClientBridgeStreamID(serverInstanceID, pluginID, profileKey, snapshotType, streamKey string) string { digest := sha256.Sum256([]byte(serverInstanceID + "\x00" + pluginID + "\x00" + profileKey + "\x00" + snapshotType + "\x00" + streamKey)) return "bridge-stream-" + hex.EncodeToString(digest[:16]) }