Add lease-bound Remote key IPC and SDK validation
This commit is contained in:
1 parent
9ac33560dc
commit
792cac5d0b
12 files changed
+807
-9
No files matched your search
@@ -41,6 +41,8 @@ type remoteAccess struct {
|
||||
speechBatch *remoteSpeechBatch
|
||||
speechItems int
|
||||
speechBytes int
|
||||
keyItems int
|
||||
keyBytes int
|
||||
}
|
||||
|
||||
// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers
|
||||
@@ -178,6 +180,7 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
|
||||
access.cancel()
|
||||
access.speechBatch = nil
|
||||
access.speechItems, access.speechBytes = 0, 0
|
||||
access.keyItems, access.keyBytes = 0, 0
|
||||
if session.remoteSpeech != nil {
|
||||
drain:
|
||||
for {
|
||||
@@ -188,6 +191,16 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.remoteKeys != nil {
|
||||
drainKeys:
|
||||
for {
|
||||
select {
|
||||
case <-session.remoteKeys:
|
||||
default:
|
||||
break drainKeys
|
||||
}
|
||||
}
|
||||
}
|
||||
if access.stopPolicy != nil {
|
||||
access.stopPolicy()
|
||||
}
|
||||
@@ -265,6 +278,8 @@ func (session *Session) remote_call_locked(method string, params json.RawMessage
|
||||
switch method {
|
||||
case "remote.present":
|
||||
return session.remote_present_locked(params)
|
||||
case "remote.key":
|
||||
return session.remote_key_locked(params)
|
||||
case "remote.open":
|
||||
var request tentaclewire.RemoteOpen
|
||||
if decode_strict(params, &request) != nil || len(request.Action) != 32 || request.Epoch == 0 || !tentaclewire.ValidRoleGrants(request.Role, request.Grants) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
// RemoteKeyDelivery is accepted work, not evidence of native injection. A
|
||||
// reader input owner must recheck Context, RemoteContext(keyboard-inject),
|
||||
// focus/security policy and its own key ledger immediately before delivery.
|
||||
// Revocation must independently release native-owned keys, without relying on
|
||||
// this queue or a final key-up from the network child.
|
||||
type RemoteKeyDelivery struct {
|
||||
Context context.Context
|
||||
Lease tentaclewire.RemoteLease
|
||||
Sequence uint64
|
||||
Key tentaclewire.RemoteKey
|
||||
// Release returns retained capacity after delivery/discard. Dequeuing does
|
||||
// not free capacity. It is idempotent and scoped to the original lease.
|
||||
Release func()
|
||||
}
|
||||
|
||||
// EnableRemoteKeys attaches one reader-owned mailbox. It performs no native
|
||||
// setup and grants no authority. No production owner is attached yet, so
|
||||
// remote.key remains unavailable in ordinary reader sessions.
|
||||
func (session *Session) EnableRemoteKeys() <-chan RemoteKeyDelivery {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.remoteKeys == nil {
|
||||
session.remoteKeys = make(chan RemoteKeyDelivery, tentaclewire.RemoteKeyQueueSize)
|
||||
}
|
||||
return session.remoteKeys
|
||||
}
|
||||
|
||||
func (session *Session) remote_key_locked(params json.RawMessage) (any, *RPCError) {
|
||||
var request tentaclewire.RemoteKeyRequest
|
||||
if decode_strict(params, &request) != nil {
|
||||
return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"}
|
||||
}
|
||||
lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch}
|
||||
access := session.remote_access_locked(lease, "keyboard-inject")
|
||||
if access == nil || access.role != "controlled" || request.Sequence <= access.sequence {
|
||||
return nil, &RPCError{Code: -32001, Message: "Session permission denied"}
|
||||
}
|
||||
if session.remoteKeys == nil {
|
||||
return nil, &RPCError{Code: -32601, Message: "Remote keyboard owner unavailable"}
|
||||
}
|
||||
// Account canonical full request bytes, including the lease/sequence. Both
|
||||
// admission counts include queued and in-flight work until Release.
|
||||
data, _ := json.Marshal(request)
|
||||
bytes := len(data)
|
||||
if access.keyItems == tentaclewire.RemoteKeyQueueSize || access.keyBytes+bytes > tentaclewire.RemoteKeyQueueBytes {
|
||||
session.revoke_remote_locked("local", true)
|
||||
return nil, &RPCError{Code: -32002, Message: "Remote keyboard queue full"}
|
||||
}
|
||||
access.keyItems++
|
||||
access.keyBytes += bytes
|
||||
access.sequence = request.Sequence
|
||||
var released sync.Once
|
||||
delivery := RemoteKeyDelivery{Context: access.ctx, Lease: lease, Sequence: request.Sequence, Key: request.Key, Release: func() {
|
||||
released.Do(func() {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.remote == access {
|
||||
access.keyItems--
|
||||
access.keyBytes -= bytes
|
||||
}
|
||||
})
|
||||
}}
|
||||
select {
|
||||
case session.remoteKeys <- delivery:
|
||||
return struct{}{}, nil
|
||||
default:
|
||||
// Losing a release must never leave the input lease active.
|
||||
session.revoke_remote_locked("local", true)
|
||||
return nil, &RPCError{Code: -32002, Message: "Remote keyboard queue full"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func keyRequest(lease tentaclewire.RemoteLease, sequence uint64, pressed bool) tentaclewire.RemoteKeyRequest {
|
||||
return tentaclewire.RemoteKeyRequest{RemoteOperation: tentaclewire.RemoteOperation{
|
||||
Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence},
|
||||
Key: tentaclewire.RemoteKey{Name: "Control_R", Pressed: pressed}}
|
||||
}
|
||||
|
||||
func TestRemoteKeySDKMailboxAndSequence(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
session := startSessionSDK(t, manager, language, "keys", "keyboard-inject")
|
||||
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
request := keyRequest(lease, 1, true)
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != -32601 {
|
||||
t.Fatal("accepted keys without reader input owner", code)
|
||||
}
|
||||
deliveries := session.EnableRemoteKeys()
|
||||
if session.EnableRemoteKeys() != deliveries {
|
||||
t.Fatal("attachment created another owner mailbox")
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
|
||||
t.Fatal("valid key was not admitted", code)
|
||||
}
|
||||
first := <-deliveries
|
||||
if first.Key != request.Key || first.Sequence != 1 || first.Lease != lease || first.Context.Err() != nil {
|
||||
t.Fatal("delivery changed key identity, sequence or lifetime")
|
||||
}
|
||||
if _, err := session.RemoteContext(first.Lease, "keyboard-inject"); err != nil {
|
||||
t.Fatal("delivery lost original consent", err)
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != -32001 {
|
||||
t.Fatal("replayed key was accepted", code)
|
||||
}
|
||||
request.Sequence++
|
||||
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
|
||||
t.Fatal("heartbeat did not share the operation sequence", code)
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != -32001 {
|
||||
t.Fatal("key reused heartbeat sequence", code)
|
||||
}
|
||||
request.Sequence++
|
||||
request.Key.Pressed = false
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
|
||||
t.Fatal("key release was not admitted", code)
|
||||
}
|
||||
second := <-deliveries
|
||||
if second.Key != request.Key || second.Sequence != request.Sequence {
|
||||
t.Fatal("lost release ordering")
|
||||
}
|
||||
firstBytes, _ := json.Marshal(keyRequest(lease, 1, true))
|
||||
secondBytes, _ := json.Marshal(request)
|
||||
session.mutex.Lock()
|
||||
items, bytes := session.remote.keyItems, session.remote.keyBytes
|
||||
session.mutex.Unlock()
|
||||
if items != 2 || bytes != len(firstBytes)+len(secondBytes) {
|
||||
t.Fatal("in-flight accounting omitted request metadata", items, bytes)
|
||||
}
|
||||
var releases sync.WaitGroup
|
||||
for range 8 {
|
||||
releases.Add(1)
|
||||
go func() { defer releases.Done(); first.Release(); second.Release() }()
|
||||
}
|
||||
releases.Wait()
|
||||
session.mutex.Lock()
|
||||
items, bytes = session.remote.keyItems, session.remote.keyBytes
|
||||
session.mutex.Unlock()
|
||||
if items != 0 || bytes != 0 {
|
||||
t.Fatal("idempotent concurrent release retained capacity", items, bytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteKeySDKPermissionAndParameters(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
session := startSessionSDK(t, manager, language, "keys", "keyboard-inject")
|
||||
deliveries := session.EnableRemoteKeys()
|
||||
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
|
||||
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, 1, true)); code != -32001 {
|
||||
t.Fatal("controller role injected input", code)
|
||||
}
|
||||
lease = speech_fixture_lease(t, session, "controlled", []string{"output-observe"})
|
||||
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, 1, true)); code != -32001 {
|
||||
t.Fatal("output grant allowed input", code)
|
||||
}
|
||||
lease = speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
request := keyRequest(lease, 1, true)
|
||||
for _, foreign := range []tentaclewire.RemoteKeyRequest{
|
||||
keyRequest(tentaclewire.RemoteLease{Lease: strings.Repeat("f", 32), Epoch: lease.Epoch}, 1, true),
|
||||
keyRequest(tentaclewire.RemoteLease{Lease: lease.Lease, Epoch: lease.Epoch + 1}, 1, true),
|
||||
} {
|
||||
if _, code := remoteCall(t, session, "remote.key", foreign); code != -32001 {
|
||||
t.Fatal("foreign lease/epoch admitted input", code)
|
||||
}
|
||||
}
|
||||
other := startSessionSDK(t, manager, language, "other-keys", "keyboard-inject")
|
||||
other.EnableRemoteKeys()
|
||||
if _, code := remoteCall(t, other, "remote.key", request); code != -32001 {
|
||||
t.Fatal("foreign process used another session's lease", code)
|
||||
}
|
||||
data, _ := json.Marshal(request)
|
||||
for _, raw := range []string{
|
||||
strings.Replace(string(data), `"pressed":true`, `"pressed":null`, 1),
|
||||
strings.Replace(string(data), `"name":"Control_R"`, `"name":"unknown private value"`, 1),
|
||||
strings.Replace(string(data), `"sequence":1`, `"sequence":1,"origin":"local"`, 1),
|
||||
strings.Replace(string(data), `"sequence":1`, `"sequence":0`, 1),
|
||||
} {
|
||||
if _, code := remoteCall(t, session, "remote.key", json.RawMessage(raw)); code != -32602 {
|
||||
t.Fatal("invalid key parameters were admitted", code)
|
||||
}
|
||||
}
|
||||
if len(deliveries) != 0 {
|
||||
t.Fatal("denied or invalid input reached the owner")
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
|
||||
t.Fatal("denied requests consumed the live operation sequence", code)
|
||||
}
|
||||
(<-deliveries).Release()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteKeySDKOverflowRetiresInFlightWork(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
t.Run(language, func(t *testing.T) {
|
||||
session := startSessionSDK(t, test_manager(t, DefaultLimits(), nil), language, "keys", "keyboard-inject")
|
||||
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
deliveries := session.EnableRemoteKeys()
|
||||
request := keyRequest(lease, 1, true)
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
inFlight := <-deliveries // Dequeue must not free input capacity.
|
||||
session.mutex.Lock()
|
||||
old := session.remote
|
||||
session.mutex.Unlock()
|
||||
for index := 2; index <= tentaclewire.RemoteKeyQueueSize; index++ {
|
||||
request.Sequence++
|
||||
if index%32 == 0 {
|
||||
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
request.Sequence++
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != 0 {
|
||||
t.Fatal("input capacity ended early", index, code)
|
||||
}
|
||||
}
|
||||
request.Sequence++
|
||||
request.Key.Pressed = false
|
||||
if _, code := remoteCall(t, session, "remote.key", request); code != -32002 {
|
||||
t.Fatal("full queue silently lost a release", code)
|
||||
}
|
||||
if inFlight.Context.Err() == nil {
|
||||
t.Fatal("overflow retained in-flight authority")
|
||||
}
|
||||
if _, err := session.RemoteContext(lease, "keyboard-inject"); !errors.Is(err, ErrPermission) {
|
||||
t.Fatal("overflow retained input consent", err)
|
||||
}
|
||||
inFlight.Release()
|
||||
inFlight.Release()
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if session.remote != nil || old.keyItems != 0 || old.keyBytes != 0 || len(deliveries) != 0 {
|
||||
t.Fatal("overflow retained queued input or accounting")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteKeySDKRetirementAndReplacement(t *testing.T) {
|
||||
for _, language := range []string{"go", "python"} {
|
||||
for _, reason := range []string{"local", "close", "replace", "expire", "disable", "failure", "policy"} {
|
||||
t.Run(language+"/"+reason, func(t *testing.T) {
|
||||
manager := test_manager(t, DefaultLimits(), nil)
|
||||
policy := NewRemotePolicy()
|
||||
if err := manager.UseRemotePolicy(policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := startSessionSDK(t, manager, language, "keys", "keyboard-inject")
|
||||
lease := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
deliveries := session.EnableRemoteKeys()
|
||||
for sequence := uint64(1); sequence <= 2; sequence++ {
|
||||
if _, code := remoteCall(t, session, "remote.key", keyRequest(lease, sequence, true)); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
}
|
||||
inFlight := <-deliveries
|
||||
session.mutex.Lock()
|
||||
old := session.remote
|
||||
session.mutex.Unlock()
|
||||
switch reason {
|
||||
case "local":
|
||||
session.RevokeRemote()
|
||||
case "close":
|
||||
if _, code := remoteCall(t, session, "remote.close", keyRequest(lease, 3, false).RemoteOperation); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
case "replace":
|
||||
fresh := speech_fixture_lease(t, session, "controlled", []string{"keyboard-inject"})
|
||||
if _, code := remoteCall(t, session, "remote.key", keyRequest(fresh, 1, true)); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
replacement := <-deliveries
|
||||
inFlight.Release() // Must not release the replacement budget.
|
||||
session.mutex.Lock()
|
||||
count := session.remote.keyItems
|
||||
session.mutex.Unlock()
|
||||
if count != 1 || replacement.Context.Err() != nil {
|
||||
t.Fatal("stale release affected replacement work")
|
||||
}
|
||||
replacement.Release()
|
||||
case "expire", "policy":
|
||||
session.mutex.Lock()
|
||||
if reason == "expire" {
|
||||
session.refresh_remote_locked(time.Millisecond)
|
||||
} else {
|
||||
policy.SetAvailable(false)
|
||||
if inFlight.Context.Err() == nil {
|
||||
session.mutex.Unlock()
|
||||
t.Fatal("policy cancellation waited for a session worker")
|
||||
}
|
||||
policy.SetAvailable(true)
|
||||
}
|
||||
session.mutex.Unlock()
|
||||
var event tentaclewire.SessionEvent
|
||||
if json.Unmarshal(invokeSessionSDK(t, session, "event", nil), &event) != nil || event.Kind != "remote.revoked" || event.Epoch != lease.Epoch {
|
||||
t.Fatal("missing original-epoch retirement notification")
|
||||
}
|
||||
case "disable":
|
||||
session.Disable()
|
||||
case "failure":
|
||||
session.fail(ErrProtocol)
|
||||
}
|
||||
if inFlight.Context.Err() == nil {
|
||||
t.Fatal("retired input survived")
|
||||
}
|
||||
if _, err := session.RemoteContext(lease, "keyboard-inject"); !errors.Is(err, ErrPermission) {
|
||||
t.Fatal("retired input passed delivery-time consent check")
|
||||
}
|
||||
inFlight.Release()
|
||||
inFlight.Release()
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
if old.keyItems != 0 || old.keyBytes != 0 || len(deliveries) != 0 {
|
||||
t.Fatal("retirement retained pending input/accounting")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,9 +79,10 @@ func sessionSDKProgram(t *testing.T, language string) []string {
|
||||
return []string{python, "-B", peer}
|
||||
}
|
||||
|
||||
func startSessionSDK(t *testing.T, manager *Manager, language, id string) *Session {
|
||||
func startSessionSDK(t *testing.T, manager *Manager, language, id string, extraGrants ...string) *Session {
|
||||
t.Helper()
|
||||
session, err := manager.Start(t.Context(), Spec{ID: id, Program: sessionSDKProgram(t, language), SessionEvents: true, RemoteGrants: []string{"output-observe", "remote-present", "clipboard-exchange"}})
|
||||
grants := append([]string{"output-observe", "remote-present", "clipboard-exchange"}, extraGrants...)
|
||||
session, err := manager.Start(t.Context(), Spec{ID: id, Program: sessionSDKProgram(t, language), SessionEvents: true, RemoteGrants: grants})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -214,8 +215,11 @@ func TestSessionSDKLifecycleConformance(t *testing.T) {
|
||||
if _, err := session.RemoteContext(lease, "clipboard-exchange"); !errors.Is(err, ErrPermission) {
|
||||
t.Fatal("install grant became session grant")
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32601 {
|
||||
t.Fatal("unimplemented desktop operation accepted")
|
||||
if _, code := remoteCall(t, session, "remote.key", map[string]any{}); code != -32602 {
|
||||
t.Fatal("malformed typed key operation accepted", code)
|
||||
}
|
||||
if _, code := remoteCall(t, session, "remote.braille", map[string]any{}); code != -32601 {
|
||||
t.Fatal("unimplemented desktop operation accepted", code)
|
||||
}
|
||||
// One blocked handler cannot block the endpoint receive loop or lifecycle calls.
|
||||
waiting, err := session.Invoke(t.Context(), "wait", nil)
|
||||
|
||||
@@ -77,6 +77,7 @@ type Session struct {
|
||||
remote *remoteAccess
|
||||
remoteApproval *RemoteApproval
|
||||
remoteSpeech chan RemoteSpeechDelivery
|
||||
remoteKeys chan RemoteKeyDelivery
|
||||
}
|
||||
|
||||
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
||||
|
||||
Reference in new issue
Block a user