diff --git a/.gitignore b/.gitignore index 137b40d..03c6b43 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ backup_*/ # Artefatos de terceiros (gadget Frida, consumidor OpenKore) - nao versionados relay/resources/ + +# Marcas temporais de sessão para diagnóstico de uso de item (dados locais) +itemuse_input_marks.json diff --git a/README.md b/README.md index f8435b0..bb3d05a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ sem cliente, com futura integração no projeto OpenKore. - [Estado atual das pesquisas](docs/RESEARCH_STATUS.md) - [Arquitetura](docs/ARCHITECTURE.md) - [Operação, instalação e diagnóstico](docs/OPERATIONS.md) +- [Recuperação dinâmica de atores e evidências](docs/DYNAMIC_ACTOR_RING.md) +- [Resumo da auditoria e alterações locais de 2026-10-20](docs/RESEARCH_STATUS.md#revisão-do-checkout--2026-10-20) - [Regras dos agentes](AGENTS.md) ## Setup rápido @@ -21,9 +23,9 @@ O OpenKore é instalado separadamente. Configure seu xKore 1 conforme [operação](docs/OPERATIONS.md#openkore-e-enquadramento-xkore). Com cliente e consumidor prontos, `./run.sh` usa `relay_capture.conf`; -`./run.sh --no-inject` desativa escrita nessa execução. Os valores intencionais +`./run.sh --raw --no-inject` desativa escrita nessa execução (o wrapper normal não reconhece `--no-inject`). Os valores intencionais `inject = true` e `inject-call = true` do arquivo permanecem preservados. -O movimento via merge continua reativo; a cifra independente permanece não resolvida. +Na configuração atual, `035F` segue o dispatcher nativo e `0437`/`0438`/`0AF4` usam carriers com merge; a cifra independente permanece não resolvida. ## Testes diff --git a/agent/relay_hook_linux.js b/agent/relay_hook_linux.js index a7838a0..6aeaf4a 100644 --- a/agent/relay_hook_linux.js +++ b/agent/relay_hook_linux.js @@ -111,6 +111,27 @@ function ringBytes(peer, chunkLen) { // whole chunk + context, or return Math.max(ringSpan[peer] || 0, 0x40 + chunkLen + 0x800); } +// Fail closed: never let the outgoing scramble merge modify a registered RX ring. +// Compare the entire write interval, not just its first byte (including _fix0). +function overlapsRecvRing(at, length) { + if (!(at instanceof NativePointer) || !Number.isSafeInteger(length) || length < 0) return true; + if (length === 0) return false; + try { + var end = at.add(length); + if (end.compare(at) <= 0) return true; + for (var peer in ringBase) { + if (!Object.prototype.hasOwnProperty.call(ringBase, peer)) continue; + var base = ringBase[peer]; + if (!(base instanceof NativePointer) || base.isNull()) return true; + var span = ringBytes(peer, lastLen[peer] || 0); + var ringEnd = base.add(span); + if (ringEnd.compare(base) <= 0) return true; + if (at.compare(ringEnd) < 0 && end.compare(base) > 0) return true; + } + return false; + } catch (e) { return true; } +} + hookAll(['__recvmsg64', 'recvmsg'], { onEnter: function (args) { this.fd = args[0].toInt32(); this.msg = args[1]; @@ -237,6 +258,23 @@ var scrCall = null; // sendcall: {pc} do ponto de c // Session-scoped allocator: never overwrite a slot owned by another hook. // Hardware API reports whether a candidate slot is actually available. var hwOwners = {}; +// Bounded breakpoint lifecycle telemetry; no payload, stack, or user data. +var bpStats = {}; +function bpEvent(owner, event, extra) { + var o = {tag:'BP-TELEMETRY', owner:owner, hit:event, ts:Date.now(), generation:sessionGeneration}; + if (extra) Object.keys(extra).forEach(function(k) { o[k] = extra[k]; }); + send(o); +} +function bpHit(owner, durationMs, result) { + var now = Date.now(), s = bpStats[owner] || (bpStats[owner] = {count:0, last:0, maxMs:0}); + s.count++; + s.maxMs = Math.max(s.maxMs, durationMs); + // A rare slow handler is reported immediately; normal hits at most every 5 seconds. + if (durationMs >= 20 || now - s.last >= 5000) { + bpEvent(owner, 'hit-summary', {count:s.count, max_ms:s.maxMs, duration_ms:durationMs, result:result}); + s.count = 0; s.maxMs = 0; s.last = now; + } +} function hwSet(owner, th, pc) { var old = hwOwners[owner]; if (old) { hwClear(owner); } @@ -245,21 +283,39 @@ function hwSet(owner, th, pc) { var errors = []; for (var i = 0; i < 4; i++) { if (used[i]) continue; - try { th.setHardwareBreakpoint(i, pc); hwOwners[owner] = {th: th, slot: i}; return i; } + try { th.setHardwareBreakpoint(i, pc); hwOwners[owner] = {th: th, slot: i}; bpEvent(owner, 'armed', {slot:i, tid:th.id, pc:pc.toString()}); return i; } catch (e) { errors.push(i + ':' + e.message); } } + bpEvent(owner, 'arm-error', {tid:th.id, pc:pc.toString(), reason:errors.join(',')}); throw new Error('no free hardware breakpoint: ' + errors.join(',')); } function hwClear(owner) { var a = hwOwners[owner]; if (!a) return; delete hwOwners[owner]; - try { a.th.unsetHardwareBreakpoint(a.slot); } catch (_) {} + try { a.th.unsetHardwareBreakpoint(a.slot); bpEvent(owner, 'disarmed', {slot:a.slot, tid:a.th.id}); } + catch (e) { bpEvent(owner, 'disarm-error', {slot:a.slot, tid:a.th.id, reason:String(e)}); } } var sessionBlockedUntil = 0, sessionGeneration = 0; +// Low-volume lifecycle diagnostics, no speculative memory dereferences. +var thisDiagSeen = {}; +function thisDiag(kind, owner, reason, address) { + var key = sessionGeneration + ':' + owner + ':' + kind + ':' + reason; + if (thisDiagSeen[key]) return; + thisDiagSeen[key] = true; + send({tag:'THIS-CONTEXT',hit:kind,owner:owner,reason:reason, + address:address ? address.toString() : null, + generation:sessionGeneration,ts:Date.now()}); +} + function sessionReady() { return Date.now() >= sessionBlockedUntil; } function resetNativeSession(reason, delayMs) { if (delayMs === undefined) delayMs = 1500; sessionGeneration++; + thisDiagSeen = {}; + if (scSend !== null && scSend['this'] !== null) + thisDiag('stale','SendPacket','session transition: '+reason,scSend['this']); + if (md !== null && md.thisp !== null) + thisDiag('stale','MoveDispatch','session transition: '+reason,md.thisp); scDemandScheduled = false; sessionBlockedUntil = Date.now() + delayMs; if (scSend !== null) { @@ -475,6 +531,24 @@ function scrPrologueBefore(addr) { // nearest } function scrTrap(d) { + if (d.type !== 'breakpoint') return false; + var started = Date.now(), owner = 'unknown', result = false; + try { + var pc = d.context.pc; + if (md !== null && ((md.capArmed && pc.equals(md.disp)) || (md.fireArmed && pc.equals(md.site)))) + owner = md.capArmed && pc.equals(md.disp) ? 'move-cap' : 'move-fire'; + else if (scrCall !== null && pc.equals(scrCall.pc)) owner = 'sendcall'; + else if (scr !== null && pc.equals(scr.pc)) owner = 'scramble'; + result = scrTrapCore(d); + return result; + } catch (e) { + bpEvent(owner, 'handler-error', {reason:String(e)}); + throw e; + } finally { + if (owner !== 'unknown') bpHit(owner, Date.now() - started, result ? 'handled' : 'unhandled'); + } +} +function scrTrapCore(d) { if (d.type !== 'breakpoint') return false; var ctx = d.context; if (md !== null && mdTrap(ctx)) return true; // native-dispatch (DR2/DR3) @@ -533,7 +607,9 @@ function scrTrap(d) { // nem assumir onde o XOR é aplicado: final = (valor_atual) ^ original0 ^ nosso0. if (scr._fix0) { var f0 = scr._fix0; scr._fix0 = null; - try { var c0 = f0.at.readU8(); f0.at.writeU8((c0 ^ f0.orig0 ^ f0.our0) & 0xff); } catch (e) {} + if (!overlapsRecvRing(f0.at, 1)) { + try { var c0 = f0.at.readU8(); f0.at.writeU8((c0 ^ f0.orig0 ^ f0.our0) & 0xff); } catch (e) {} + } else send({tag: 'SCRAMBLE', hit: 'blocked-rx-ring', phase: 'fix0'}); } // Find the slot pointer + which body to write. Two modes: // fixed (scr.reg is a register / sN): pointer = that reg, id at pointer+idoff (configured). @@ -570,6 +646,10 @@ function scrTrap(d) { else if (scr.body !== null && (scr.id === undefined || pid2 === scr.id)) { body = scr.body; } // legacy walk if (body === null) return true; // not a packet we're injecting } + if (overlapsRecvRing(bodyp, body.length)) { + send({tag: 'SCRAMBLE', hit: 'blocked-rx-ring', id: pid2}); + return true; // preserve one-shot body for a later genuine TX slot + } var was = null; try { was = toHex(bodyp, body.length); } catch (e) {} try { @@ -703,6 +783,7 @@ recv('sendcall', onSendCall); function onSendEmit(m) { if (scSend === null) send({tag: 'SENDCALL', hit: 'error', e: 'emit with no sendcall armed'}); else if (!sessionReady() || scSend.awaitRecapture) { + thisDiag('stale','SendPacket','emit before context recapture',scSend['this']); send({tag: 'SENDCALL', hit: 'stale-drop', e: 'waiting for a fresh SendPacket object', ts: Date.now()}); } else if (m.data) { var queued = Array.from(hexToBytes(m.data)); @@ -720,7 +801,10 @@ recv('sendcall-emit', onSendEmit); function drainSendCall() { // only runs on the game thread (SendPacket must) if (!sessionReady() || scDraining || scSend === null || scSend.fn === null || scSend['this'] === null) return; if (scSend.queue.length === 0) return; - if (scSend.tid && Process.getCurrentThreadId() !== scSend.tid) return; // so na thread que roda a SendPacket + if (scSend.tid && Process.getCurrentThreadId() !== scSend.tid) { + thisDiag('suspect','SendPacket','wrong execution thread',scSend['this']); + return; + } // so na thread que roda a SendPacket scDraining = true; try { while (scSend.queue.length) { @@ -817,7 +901,10 @@ function mdTrusted() { var obj = mdContext(); if (md === null || md.thisp === null || md.receiver === null || obj === null || !obj.equals(md.receiver)) { - if (md !== null && md.thisp !== null) mdInvalidate('receiver unavailable/changed'); + if (md !== null && md.thisp !== null) { + thisDiag('invalid','MoveDispatch','receiver unavailable or identity changed',md.thisp); + mdInvalidate('receiver unavailable/changed'); + } return false; } return true; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0f70031..d05db71 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Arquitetura técnica do RO-Client-Relay -> **Revisão local:** 2026-10-15, `main@524b0f05fa61872e140f98ace9e503d12356641e`. Implementação conferida estaticamente; nenhum experimento vivo repetido. Dados técnicos anteriores preservados em [HISTORY/ARCHITECTURE_LEGACY.md](HISTORY/ARCHITECTURE_LEGACY.md), com ressalvas sobre hipóteses históricas. Estado científico em [RESEARCH_STATUS.md](RESEARCH_STATUS.md). +> **Base documental:** revisão estática de 2026-10-15 (`main@524b0f05fa61872e140f98ace9e503d12356641e`); **atualizada em 2026-10-17 para o PR #20 e auditada novamente em 2026-10-20** contra `origin/main@9ee7ef5` e o checkout `aa69a6a` com alterações ainda não commitadas (integração Gamescope EIS, operação e diagnósticos). A revisão documental não realizou experimento vivo. Dados técnicos anteriores preservados em [HISTORY/ARCHITECTURE_LEGACY.md](HISTORY/ARCHITECTURE_LEGACY.md), com ressalvas sobre hipóteses históricas. Estado científico em [RESEARCH_STATUS.md](RESEARCH_STATUS.md). ## 1. Fronteiras de responsabilidade @@ -32,7 +32,9 @@ A figura é **esquemática**: nunca indica um canal direto de envio da rede pelo | `relay/runtime.py` | `Capture` instancia pipeline, despacha mensagens, coordena threads/estado | | `relay/wire_feed.py` | `WireFeed`: ordenação de trechos `RECV`, espera limitada por conteúdo verificado; `ClientSendFeed`: caminho de envio observado | | `relay/packets.py` | Tamanhos e fragmentação de pacote, `WireFilter`, `pack_move`, `unpack_pos2`, `MoveTracker` | -| `relay/actors.py` | `ActorDecrypt`: ring e aceitação somente de cópias decifradas verificadas (`09fd`/`09ff`) | +| `relay/actors.py` | `ActorDecrypt`: descoberta dinâmica do ring na sessão atual, aceitação conservadora de `09FD`/`09FF`/`0229`/`043F`/`0983` e telemetria | +| `relay/synth_input.py` e `relay/gamescope_eis.py` | Atuador de carriers de ações; backend selecionável `ydotool`/seat0 ou `gamescope-eis`/libei (socket explicitamente indicado, sem fallback ao KDE) | +| `scripts/gamescope-eis-session.sh` | Wrapper executado dentro do Gamescope: publica `LIBEI_SOCKET` num arquivo de localização privado e limpa-o ao fim da sessão | | `relay/relay.py` | Frames XKore, conexão/reconexão, alinhamento opcional | | `relay/writeback.py` | Merge de corpo para `035f`, `SendPacket` para ações da fila, autodescoberta e sondas pertinentes | | `relay/sendwatch.py` | Acompanhamento das escritas na fila, inferência conservadora de texto claro | @@ -42,13 +44,15 @@ A figura é **esquemática**: nunca indica um canal direto de envio da rede pelo | `agent/relay_hook_linux.js` | Instala e remove hooks/snapshots/instrumentação conforme mensagens; observa a própria thread do cliente | | `data/uaro_recvpackets.txt` | Referência de tamanhos para framing; **não** especifica por si só semântica de sessão | | `research/cipher/`, `research/movement_035f/` | Ferramentas e análises experimentais, separadas da lógica operacional | +| `research/recv_cipher/` | Sonda isolada de plaintext recebido, correlacionador, snapshots e auditorias; não incorpora automaticamente o pipeline de produção | +| `scripts/watch-uaro-stop.py`, `scripts/audit-game-stop-signals.sh` | Monitor de processo Linux e auditoria opcional de syscalls de sinal, iniciados por `run.sh` | ## 3. Recepção: servidor → cliente → quadro `R` 1. O cliente recebe dados em um socket; o agente reporta `RECV` e snapshots correspondentes da ring de recepção **antes** da modificação relevante. 2. `WireFeed.on_recv`/`WireFilter` acumulam segmentos por conexão e aplicam a tabela conhecida para delinear os pacotes; não buscar um ID plausível arbitrário para resincronizar um fluxo inválido. -3. Corpos reconhecidamente claros são encaminhados; `09fd`/`09ff`, cujos corpos exigem acesso às cópias do cliente, criam itens pendentes. -4. A thread alimentadora espera até um limite configurável (documentação original refere padrão `--actor-delay` de 0,15 s), durante o qual `ActorDecrypt` tenta ler/verificar a cópia já decifrada em `/proc//mem`. +3. Corpos reconhecidamente claros são encaminhados; `09FD`/`09FF`/`0229`/`043F`/`0983`, cujos corpos exigem uma cópia verificável do cliente, criam itens cifrados pendentes. +4. A thread alimentadora espera até um limite configurável (checkout de 2026-10-20: `--actor-delay` de **0,30 s**, mais eventual janela curta de confirmação), durante o qual `ActorDecrypt` tenta ler/verificar a cópia já decifrada em `/proc//mem`. 5. Se a verificação não se sustentar, **não fabricar o corpo decifrado**: descartar o pacote não confirmado; preservar ordem de chegada para os demais. 6. O `Relay` enquadra o fluxo como quadros `R` para o consumidor XKore 1. A identidade da conexão é mantida internamente para tokenização e reconstrução; o frame contém tipo, comprimento e payload, sem campo de ID de conexão (`relay/relay.py:14`). @@ -71,7 +75,7 @@ A figura é **esquemática**: nunca indica um canal direto de envio da rede pelo | `--inject` + `--scramble-bp` / merge | Substitui corpo de pacote **já montado pelo próprio cliente**, no store pré-transformação, normalmente com DR0; uso único por padrão | `035f` na pilha, 3 bytes de destino | **Reativo**: requer que o cliente produza naturalmente um `035f`; não sintetiza intenção autônoma | | Autodescoberta opcional | Watch de escrita em ações de fila para recuperar PC do store, depois migrar a BP de execução para a posição real | `0437`/`0438` conforme fluxo documentado | Endereços/faixas dependem do build; não tratar offsets antigos como universais | -O modo combinado distribui `035f` ao merge e os demais ao `SendPacket`, mantendo canais de debug distintos. `--inject-persist` é inadequado como regra automática para andar porque repetiria o destino em todos os passos naturais. Sem alteração explícita do responsável, preservar **`inject = true` e `inject-call = true` em `relay_capture.conf`**, apesar de defaults internos distintos. +Na configuração atual, `--move-dispatch` encaminha `035F` ao dispatcher nativo, `--action-merge` encaminha `0437/0438/0AF4` ao merge pré-XOR com carrier sintético, e os outros pacotes compatíveis podem seguir por `SendPacket` nativo. A versão antiga de `035F` via merge continua como caminho histórico, não como roteamento operacional atual. `--inject-persist` é inadequado como regra automática para andar porque repetiria o destino em todos os passos naturais. Sem alteração explícita do responsável, preservar **`inject = true` e `inject-call = true` em `relay_capture.conf`**, apesar de defaults internos distintos. **Cuidado terminológico:** chamar o breakpoint `gepard+0x18eff2` de *store de corpo* não prova que a operação criptográfica é realizada nessa própria instrução. A investigação mais recente encontrou uma cadeia de **cópias de bytes** que antecede o store sem identificar o produtor criptográfico ([CIPHER_INVESTIGATION](CIPHER_INVESTIGATION.md)). @@ -159,3 +163,83 @@ O fluxo de movimento atual usa `--move-dispatch` para levar o destino do OpenKor **Bug corrigido — primeiro byte:** `agent/relay_hook_linux.js:481-534` demonstra que a escrita `bodyp.writeByteArray(body)` precedia um store que ainda escrevia `bl` derivado do corpo **original** do cliente em `body[0]`. O `_fix0`, executado no hit seguinte, corrige o resultado por XOR `c0 ^ orig0 ^ our0`. Falha funcional anteriormente atribuída à seleção do alvo ou à coordenação de movimento foi **superada** pela correção desse byte. O responsável confirmou ataque e skills automáticos funcionando. **Fronteira intacta:** cliente responsável por criptografia, sessão e envio; o relay não gera o keystream. A investigação da cifra permanece aberta e é a única linha ativa. Detalhes: [ACTION_0437_0438_INVESTIGATION](HISTORY/ACTIONS_COMPLETE.md#source-docs-action-0437-0438-investigation-md), [CIPHER_INVESTIGATION](CIPHER_INVESTIGATION.md). + +## 14. Integração operacional Gamescope EIS 2026-10-17 + +**Fonte:** diff do PR de integração sobre `main@9ee7ef5`, código e +configuração da pasta original; confirmação funcional do responsável em +2026-10-17. O relatório do responsável **não é uma nova captura instrumental** +realizada pela revisão documental. Operação, comando Steam e flags: +[OPERATIONS](OPERATIONS.md#gamescope-eis-e-configuração-operacional-validada). + +**Entrada e carriers.** `relay/synth_input.py:196` escolhe o backend. No +`gamescope-eis`, `relay/gamescope_eis.py` usa o cliente libei para negociar +`KEYBOARD` (capacidade bitmask 4) com o socket UNIX especificado: um evento +`key-down`, `frame`, envio efetivo, espera padrão de 120 ms, `key-up` e +`frame`. O atuador **não ativa janela do KDE**, não usa `ydotool`, e falha +sem redirecionar eventos ao seat principal caso o socket esteja indisponível. +`scripts/gamescope-eis-session.sh` publica a identificação do socket em +`$XDG_RUNTIME_DIR/ro-relay-gamescope-eis.path` (0600). A janela Gamescope +Wayland com `-g --force-grab-cursor` permanece utilizável para login físico; +o EIS opera no compositor privado. O teste técnico de integração isolada +observou `KeyPress/KeyRelease` em um aplicativo XWayland **de teste**, +não em uma nova execução do uaRO nesta revisão. + +**Ação e sincronização.** Para `0x0439` com `--itemuse-input` ligado, +`relay/writeback.py:380` exige `synth.send_before_packet()` antes de +enfileirar `SendPacket`. A chamada executa tecla/soltura sob lock no +atuador (`relay/synth_input.py:150`) e bloqueia o `0x0439` se falhar; +não há envio parcial intencional da ação nesse caso. A produção de carriers +de `0x0437`/`0x0438`/`0x0AF4` segue assíncrona, com debounce e worker. + +**Ciclo de vida nativo.** `agent/relay_hook_linux.js:260-284` classifica +`THIS-CONTEXT` como `stale`, `suspect` ou `invalid`, com redução de +eventos repetidos por geração da sessão. O relato inclui contexto de +transição, emissão antes da recaptura, thread divergente e alteração do +receptor nativo. **A telemetria não prova que a causa de congelamentos +anteriores tenha sido identificada** e não garante validade de ponteiros em +situações não observadas. A política de recaptura e bloqueio preexistente +permanece separada desses diagnósticos. + +**Recepção verificável e logs.** `relay/wire_feed.py:162-187` só registra +`RECV-PLAIN` para itens cifrados `E` recuperados e verificados; dados +`B` já claros não são classificados como resultados de decifragem. +Falha de recuperação produz `RECV-PLAIN-UNAVAILABLE`, não um corpo +fabricado. `relay/console.py` serializa escrita e exibição sob um lock, +preservando a passagem por `relay/safety.py` para sanitização. Isso +é uma propriedade do caminho de log, **não** demonstra descoberta da cifra. + +**Portabilidade das ferramentas encerradas.** Sete scripts de +`research/movement_035f/` agora resolvem a tabela de pacotes como +`Path(__file__).resolve().parents[2] / "data/uaro_recvpackets.txt"` em +vez de caminho absoluto de outra cópia do projeto; não muda a técnica +científica, seus resultados históricos ou o fluxo de produção. + +**Limites:** a opção de fila `egress-queue = false` e +`sendcall-demand = false` são **configurações operacionais confirmadas pelo +responsável**, não novas restrições de protocolo. A trilha científica ativa +continua exclusivamente a cifra; evidência e distinções em +[RESEARCH_STATUS](RESEARCH_STATUS.md). + + +## 15. Recepção dinâmica, novos IDs e proveniência (revisão 2026-10-20) + +**OBSERVADO NO CÓDIGO:** `relay/actors.py` não carrega mais o arquivo antigo `.relay_actor_ring` na inicialização. A base do ring é descoberta por evidência da sessão atual: pesquisa assíncrona dentro de mapeamentos anônimos privados graváveis (inclusive RWX no Wine), com alinhamento da base em página, correspondência de cabeçalho e validação do candidato em plaintext. Endereço de sessão anterior ou início do mapeamento, isoladamente, não bastam. O mecanismo possui backoff, invalidade após 20 falhas não verificadas consecutivas e `ring_epoch` para impedir que uma pré-imagem de época anterior seja tomada por evidência atual. Ver [detalhes e sequência de resultados](DYNAMIC_ACTOR_RING.md). + +**IDs tratados como cifrados pelo pipeline:** `09FD`/`09FF` (variáveis, 4 bytes de cabeçalho em claro); `0229` (15 bytes), `043F` (25 bytes) e `0983` (29 bytes), para os quais o código considera somente o ID de 2 bytes como claro. Os três últimos exigem validação específica de tamanho/ID/alteração/GID não nulo **e** evidência contextual ou de pré-imagem/slot/tempo. Em especial, o GID de `0229` no wire está cifrado e não precisa ser igual ao GID plaintext. Estrutura plausível não basta para autenticar a cópia. Fontes: `relay/actors.py:215-231,484-681`, `relay/wire_feed.py`, `tests/test_0229_provenance.py`, `tests/test_043f_recovery.py`, `tests/test_0983_recovery.py`. + +**Validação de nome:** `cp1252` é a única decodificação candidata utilizada; a aplicação aceita letras, números, ASCII imprimível e um conjunto limitado de pontuação Unicode, com padding NUL estrito. `walk_speed` aceita `0..2000`. A exceção de nome totalmente vazio vale somente para `09FF`, com GID não nulo e leitura nova do slot previsto, pré-imagem segura, mesma base/época do ring e limite temporal anterior à recepção seguinte. Ela **não** relaxa a busca genérica ou a validação de snapshots. O servidor pode usar outra codificação: **NÃO VERIFICADO**. `tests/test_empty_actor_name.py` e `tests/test_actor_dynamic_ring.py` protegem esses contratos offline. + +**Telemetria:** `ACTOR-RECOVERY` diferencia recuperado/perdido, motivo, polls, snapshots, primeira/última leitura, validações e contagem de rejeições de mapeamento. `RECV-BOUNDARY` registra fronteiras no map server e fragmentos hexadecimais do fluxo; esses **são dados de sessão**, não publicáveis como documentação. A opção `--verbose` só seleciona saída de terminal; o JSONL sanitizado é mantido. Dados insuficientes produzem `RECV-PLAIN-UNAVAILABLE`, jamais plaintext inventado. + +**NÃO VERIFICADO:** correção semântica definitiva dos buffs/status e do estado `sitting` no consumidor. O reconhecimento de `043F`/`0983` no código não prova que cada campo de estado esteja correto em execuções reais. + +## 16. Instrumentação, proteção RX e supervisão externa (2026-10-20) + +O agente `agent/relay_hook_linux.js` inclui `overlapsRecvRing()`: bloqueia writes de merge TX cujo intervalo intersecte um ring RX registrado, inclusive a correção `_fix0`; registra `SCRAMBLE blocked-rx-ring`. Isso reduz escritas cruzadas **apenas quando o ring está conhecido**; ausência de ring registrado não prova ausência de região RX. Também emite `BP-TELEMETRY` de arme/desarme, falhas e resumos limitados de hits; PCs e TIDs dependem da sessão, e a medição não garante ausência de overhead. + +`run.sh` inicia um processo watchdog externo `scripts/watch-uaro-stop.py`, que observa o estado do **processo Linux** `T/t` (parado/trace-stopped), não a inatividade do personagem. Após 3 segundos envia um `SIGCONT` por episódio, rechecando processo/estado. Um processo retomado pode ficar com a sessão/cifra dessincronizada: isso **não é uma recuperação de protocolo**. Quando o cache sudo não interativo está disponível, o launcher também inicia auditoria de syscalls de sinal via `scripts/audit-game-stop-signals.sh`, limitada aos filtros e privilégios de `auditctl`. Encerramento normal tenta limpar monitores e regras. Detalhes em [OPERATIONS](OPERATIONS.md#watchdog-e-auditoria-de-sinais-linux). + +A trilha `research/recv_cipher/` investiga a captura **do plaintext já decifrado pelo cliente**, sem recuperar a função criptográfica. É uma sonda independente, com Frida, snapshots, correlacionador, possível watchpoint/hook de escrita explicitamente habilitado e auditoria. Amostras de 2026-10-18/19 incluem resultados quase completos e outras de 0 recuperação sob carga; não existe prova de captura perfeita. Ver [estado da trilha](../research/recv_cipher/STATE.md) e [panorama canônico](RESEARCH_STATUS.md). + +**Limite da revisão:** testes offline e leitura estática verificam contratos e sintaxe; não demonstram aceitação pelo servidor, segurança temporal de todos os hooks, ausência de regressão no uaRO ou geração independente do keystream Gepard. diff --git a/docs/CIPHER_INVESTIGATION.md b/docs/CIPHER_INVESTIGATION.md index b447c31..2206d89 100644 --- a/docs/CIPHER_INVESTIGATION.md +++ b/docs/CIPHER_INVESTIGATION.md @@ -5,6 +5,8 @@ +**Nota de escopo (auditoria 2026-10-20):** o trabalho recente em `research/recv_cipher/` recupera e correlaciona cópias de **plaintext já produzidas pelo cliente**; mesmo quando uma captura acerta todos os candidatos da sua janela, ela não identifica a função de keystream, seeds, estado interno nem a variante A/B do uaRO. A cifra independente continua **PENDENTE DE IDENTIFICAÇÃO**. [Estado](RESEARCH_STATUS.md#revisão-do-checkout--2026-10-20) e [sonda](../research/recv_cipher/STATE.md). As transcrições matemáticas A/B abaixo permanecem inalteradas. + ## Referências especializadas da família Gepard — prioridade vigente (2026-10-17) ### Procedência e alcance epistemológico diff --git a/docs/DYNAMIC_ACTOR_RING.md b/docs/DYNAMIC_ACTOR_RING.md new file mode 100644 index 0000000..2c0eaa9 --- /dev/null +++ b/docs/DYNAMIC_ACTOR_RING.md @@ -0,0 +1,193 @@ +# Dynamic actor receive-ring discovery (2026-10-19) + +## Goal +Use only evidence from the current uaRO process/session when choosing the memory +region containing decrypted 09FD, 09FF, and 0229 packets. Previously, the +`.relay_actor_ring` file was loaded at startup if its address was mapped; that +was not proof that the allocation still contained the current receive ring. + +## Implementation +`relay/actors.py`: startup no longer reads or writes `.relay_actor_ring` +(the existing file is left untouched and ignored). It starts with +`base = hi = region = None`. Current-session discovery remains through +`quick_discover` (matching current wire bytes outside encrypted spans at ring +`+0x40`) and `discover` (context-verified plaintext). Quick search only +triggers for chunks containing encrypted packets, with a one-second backoff +after each attempt, to limit `/proc/PID/mem` work. After 20 consecutive +unverified actor packets, the selected ring is invalidated in full +(`base`, `hi`, `region`), the discovery rate limit is reset, and future +received packets may discover it again. The TCP stream/chunk chain is +preserved across rediscovery. Counters: `ring-discovered`, +`ring-invalidated`. Reads remain read-only; this does not affect +packet-length tables, native encryption/decryption, injection or the relay +FIFO. Existing snapshots still require provenance and validation. + +## Evidence and limits +OBSERVED before change: current run had 0 recovered actors out of 611 encrypted +packets (09FD=210, 09FF=381, 0229=20); a ring address cache was changing +between sessions. The former startup trust in a mapped old address was +unjustified. INFERRED: refusing that trust prevents one important class of +misidentification; dynamic discovery alone does not guarantee higher recovery +under very fast ring overwrites or worker delays. + +Offline validation: `python3 tests/test_actor_dynamic_ring.py` and +`bash scripts/test-offline.sh`. No live test; the relay process already +running must be restarted to load these Python changes. + +## 2026-10-19 — discovery of isolated encrypted actor packets + +The previous quick discovery refused chunks with fewer than 64 clear bytes. +A standalone 0x09FF (108 bytes) or 0x09FD (114 bytes) contributes just 4 +clear header bytes, causing repeated discovery misses (42/71 observed encrypted +chunks were below the threshold in a diagnostic sample). + +`start_quick_discover` now sends complete, wholly-contained encrypted actor +candidates (relative offset + original wire packet) to `quick_discover`. +For chunks with fewer than 64 clear bytes, the candidate ring's predicted +`base+0x40+offset` slot **must** contain an actually different, plausibly +plaintext packet validated by `ActorDecrypt.copy_ok`, with the unchanged clear +header. Mere 4-byte coincidence or unchanged ciphertext is rejected. The +existing >=64-clear-byte context matcher remains; no packet body is fabricated +and no memory is written. Counters: `ring-quick-actor-verified`. + +Offline regression: `tests/test_actor_dynamic_ring.py` includes isolated +09FF and 09FD, and rejects matching headers without plaintext. This still +does not guarantee recovery when the client overwrites the ring earlier than +the delayed read, when ring layout differs, or when the character/NPC encoding +fails `valid_actor`. No live restart/test was done in this change. + +## 2026-10-19 — internal mapping search and recovery diagnostics + +`quick_discover()` now searches **within** writable mappings (bounded 1 MiB +reads, at most 64 MiB per mapping) for a current-session encrypted actor's +clear header. For each hit it computes `ring_base = header_address - 0x40 - +packet_offset_in_chunk` and verifies the exact candidate slot against the +whole chunk, masking encrypted body bytes. At least one decrypted actor must +pass `copy_ok()` at the predicted location; a matching header alone never +accepts an address. The routine no longer assumes `ring_base == mapping_lo`. +The slower `discover()` also derives the ring base from the verified packet +slot and chunk offset instead of persisting the mapping start. + +Read-only metrics, one `ACTOR-RECOVERY` record per encrypted actor packet: +`status`, `reason`, `attempts` (poll count), `snapshots`, `first_valid_ms`, +`elapsed_ms`, `ring_known`, connection identity and packet ID. No packet bytes, +actor names or private network addresses are added to these records. Failure +reasons distinguish `ring-not-found`, `overwritten-before-verification`, +`timeout-no-valid-copy`, `correlation-mismatch` and `no-valid-copy`, as well +as `unclassified` for unforeseen early exits. These are diagnostic categories, +not proof that the underlying race is conclusively identified. + +Regressions `tests/test_actor_dynamic_ring.py`: ring within mapping, +full discovery base calculation, isolated 09FD/09FF and false header rejection. +`bash scripts/test-offline.sh` passes 30 scripts. NOT RUN LIVE: restart +`./run.sh` when convenient to activate; compare `ACTOR-RECOVERY` records +before claiming improved NPC visibility. + +## 2026-10-19 — slot-read diagnostics and 300ms actor delay + +`ACTOR-RECOVERY` additionally records `first_read` and `last_read` for +actual live slot reads (elapsed milliseconds from queue arrival, bytes read, +clear header match, whether `copy_ok` passed, whether bytes differ from the +pre-decrypt image, and whether the sample was accepted as verified evidence). +`candidate_seen` tracks whether a valid copy appeared in the event evidence +set; `usable_snapshots` counts snapshots covering the packet offset; +`next_recv_before_decision` and `preimage_safe` help distinguish races from +correlation/provenance rejection. No actor bodies, GIDs or memory addresses +are logged in these new fields. Missing `first_read`/`last_read` means no +live slot read occurred (e.g. ring discovery or next recv closed evidence). + +`actor-delay` is 0.30 seconds in the CLI default and relay_capture.conf. +The extra 150ms may improve late-appearing copies but holds the ordered +feed longer for unresolved chunks (and every following chunk). The existing +settle confirmation adds up to ~30ms beyond this threshold. More polling +increases read work and the queue may grow. A later TCP recv closes the safe +read window regardless of this value, so increasing it does NOT preserve an +overwritten copy nor fix a wrong ring-slot correlation. No live validation +of the change is claimed. + +## 2026-10-19 — diagnostic decomposition of actor validation + +Each live slot observation (`ACTOR-RECOVERY.first_read` / `last_read`) now has a +`validation` object with *boolean-only* fields: `length_match`, `header_match`, +`body_changed`, `object_type_ok`, `walk_speed_ok`, `name_format_ok`, +`fixed_gid_ok` and `accepted`. The first three are shared checks; for 09FD/09FF, +object type, speed and name use exactly the existing `valid_actor` predicates. +For 0229 only the fixed packet's known checks apply; the unrelated actor fields +are null. The diagnostic function does not alter packet acceptance. + +`ACTOR-RECOVERY.evidence_validation` summarizes `observations`, number of +`structurally_valid` evidence copies and `snapshots_usable` at the packet's +calculated slot. These metrics help distinguish an apparently correct header +containing invalid actor fields from valid copies rejected later by evidence +provenance/context. They do NOT show packet bytes, actor names, GIDs, memory +addresses, keys or private data. An invalid body could also be a stale or +wrong-slot copy: metrics alone do not prove partial decryption. + +New tests compare the diagnostic `accepted` boolean to the actual `copy_ok` +behavior for valid and invalid 09FF samples and fixed 0229. Live processes +must be restarted before these extra fields appear; existing logs cannot be +retroactively classified at field level. + +## 2026-10-19 — remove per-poll diagnostics from timing-sensitive path + +The field-by-field `copy_validation()` metrics previously ran at EVERY live +slot polling iteration, repeating parse/allocations and creating diagnostic +dictionaries in the latency-sensitive wire-feed thread. This was not truly +passive instrumentation. The feed now performs only the original `copy_ok` +check during polling and retains its already-recorded live read samples. +On final decision, it computes the detailed validation exactly for the first +and last actual samples. The 300ms limit and packet acceptance criteria are +unchanged. `tests/test_actor_dynamic_ring.py` verifies the preserved first/ +last diagnostics; offline suite passes. Improvement to live recovery has NOT +been verified after restart. + +Separate observation, before this change was loaded: session beginning +16:46:49 recovered 1/37 actors at 16:47, but later recovered 41/46 at 16:49, +11/12 at 16:50 and 15/15 through 16:51:48, *without a relay restart*. This +means the apparent regression also involves transient state (ring discovery, +connection context, or live client timing), not just static validation rules. +Those causal hypotheses are unverified. + +## 2026-10-19 — immobile actors and walk_speed + +The lower speed boundary is no longer used to reject `09FD` / `09FF` actors: +`0 <= walk_speed <= 1000` replaces `20 <= walk_speed <= 1000` in both +`valid_actor()` and `copy_validation()`. Speeds of 0 or below 20 can be +legitimate for stationary monsters. Upper bound, packet header, body change, +object type, name, and evidence/provenance rules remain unchanged. +Tests cover 0, 1, 19, 20, 1000 (accepted) and 1001 (rejected). + +## 2026-10-19 — allow walk_speed 2000 + +Repeated rejected 09FF copies had structurally consistent headers, object +types and names with `walk_speed_value=2000`, so `valid_actor()` and diagnostic +`copy_validation()` now accept inclusive `0..2000`. Values 2001 and above +remain rejected. All existing ring timing, correlation, name, type and +provenance checks are retained. Live benefit requires restarting relay. + +## 2026-10-19 — reject undecoded actor names + +Inspection of the live session found 93/891 accepted 09FD/09FF actor names +containing high bytes (including apparent garbage), including the same actor GID +that later appeared as an unknown attacker. The former rule accepted all bytes +>=0x80. Both `valid_actor()` and `copy_validation()` now require only printable +7-bit ASCII (`0x20..0x7e`) before NUL padding. This intentionally rejects +characters like `§`/`¥` and other high bytes rather than treating them as +decryption evidence. **Tradeoff:** actors with legitimate non-ASCII names +(e.g. accented names) will be rejected until a verified character encoding is +implemented. Correlation and speed rules remain unchanged. Restart required. + + +## Consolidação do checkout — 2026-10-20 (auditoria documental; logs live existentes analisados) + +**Registro live passivo (20/10, 18:37–19:30 BRT):** o relay atualmente executado registrou 1.030/1.036 `09FD`, 1.001/1.016 `09FF`, 948/1.023 `0983`, 241/259 `043F` e 91/92 `0229` recuperados, num total de **3.311/3.426 candidatos (~96,6%)**, com 115 falhas classificadas (103 correlação, 7 sobrescrita antes de verificação, 4 ring não encontrado, 1 timeout). Isso é **OBSERVADO EM LOGS JÁ COLETADOS**, sem emitir pacotes novos; os logs não provam ausência de falsos positivos sem confronto independente de semântica. [Procedência e correlação com o OpenKore](RESEARCH_STATUS.md#verificação-passiva-dos-logs-de-produção--2026-10-20-18371930-brt). Os resultados positivos e negativos históricos abaixo continuam válidos em suas sessões específicas. + +**Atualização do código posterior aos parágrafos de 19/10:** a restrição transitória de nomes a ASCII foi substituída por decodificação estrita **CP1252**, permitindo letras/números e caracteres imprimíveis selecionados, inclusive acentos, mas rejeitando controle, padding inválido, limites com espaço e pontos de código não aprovados (`relay/actors.py:11-84`). Isso **não prova** qual codificação o servidor usa. A regra de `walk_speed` continua inclusiva `0..2000`; rejeições são classificadas em `ACTOR-RECOVERY.name_rejection`, sem publicar o nome. + +Uma exceção muito restrita para **nome vazio em `09FF`** foi adicionada: `fresh_empty_name_actor()` exige ID/tamanho/cabeçalho conservados, GID não nulo, tipo e velocidade plausíveis e nome NUL integral. O resultado só pode ser aceito por leitura **nova do slot previsto**, pré-imagem segura, mesma base e `ring_epoch`, antes do próximo `recv`; snapshots e busca geral continuam sujeitos ao validador de nome padrão. `tests/test_empty_actor_name.py` cobre nomes vazios, nome com `#`, invalidação de época, corridas de recepção e rejeição de evidência antiga. O ganho ou a segurança end-to-end dessa exceção **NÃO FORAM testados live nesta revisão**. + +O pipeline agora marca como cifrados `09FD/09FF` (variáveis), `0229` (15 bytes), `043F` (25 bytes) e `0983` (29 bytes). Para os últimos três só o ID inicial de dois bytes é confiável no wire; validação de plaintext usa o tamanho, ID, corpo modificado, GID não nulo e condições de **proveniência**, não correspondência de GID cifrado/decifrado. `0229` não é promovido com base apenas no slot esperado. Status de `sitting` ou buffs do consumidor **não são considerados semanticamente resolvidos** por esses testes. Testes: `test_0229_provenance.py`, `test_043f_recovery.py`, `test_0983_recovery.py`, `test_validate_0229.py`. + +A proteção de ring inclui agora `_ring_mapping_allowed()` (base alinhada em página, região anônima privada gravável, inclusive RWX do Wine, limite da leitura dentro da região) e geração `ring_epoch`. O diagnóstico registra `ring_mapping_rejections`, `ring_mapping_unavailable`, `failure_evidence`, categoria do nome e as validações finais de amostras em `ACTOR-RECOVERY`. O relatório `RECV-BOUNDARY` registra hex de **tráfego real**, limitado ao map server; trate logs como artefatos de sessão privados. Fonte: `relay/actors.py`, `relay/wire_feed.py`, `relay/packets.py`, `relay/runtime.py`. + +**Evidência disponível:** amostras documentadas na sonda independente variam de zero recuperação para 70 `09FF`/4 `09FD`/2 `0229` sob carga até 221/224 `09FF` e 14/16 `09FD` em outra janela (ver [STATE](../research/recv_cipher/STATE.md)). São **observações de sessões diferentes**, sem medição causal pareada. A confirmação do responsável sobre movimento/ataque em 17/10 não garante recuperação perfeita de atores no build/sessão atuais. O código e os testes offline passam, porém a condição de recuperação integral permanece **NÃO VERIFICADA**. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ccf1108..49077e9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,6 +1,6 @@ # Operação, configuração e validação — RO-Client-Relay -> Revisão documental: **2026-10-15**, `main@524b0f05fa61872e140f98ace9e503d12356641e`. Transcrição dos comandos publicados em [`../README.md`](../README.md), [`../ARCHITECTURE.md`](HISTORY/ARCHITECTURE_LEGACY.md), [`../relay_capture.conf`](../relay_capture.conf). **Não foram executados contra o cliente durante esta revisão.** Uso somente em ambientes com autorização explícita. +> Revisão documental: **2026-10-15**, `main@524b0f05fa61872e140f98ace9e503d12356641e`; atualização auditada em **2026-10-20** contra `origin/main@9ee7ef5`. Transcrição dos comandos publicados em [`../README.md`](../README.md), [`../ARCHITECTURE.md`](HISTORY/ARCHITECTURE_LEGACY.md), [`../relay_capture.conf`](../relay_capture.conf). **Não foram executados contra o cliente durante esta revisão.** Uso somente em ambientes com autorização explícita. ## Pré-requisitos e instalação @@ -38,22 +38,24 @@ O relay usa como padrão publicado `127.0.0.1:2350` e quadros `R` (servidor→co ```sh ./run.sh # lê relay_capture.conf quando opções não sobrescritas -./run.sh --no-inject # exemplo: leitura apenas, ignorando escrita da config +./run.sh --raw --no-inject # leitura apenas; o wrapper normal não recebe --no-inject # Com stdin mantido aberto, como documentado: tail -f /dev/null | ~/.venv-frida/bin/python relay_capture_linux.py # Troca do ambiente virtual: -VENV=/caminho/do/venv ./run.sh +FRIDA_PY=/caminho/do/python ./run.sh ``` A precedência declarada é **defaults internos CLI → `relay_capture.conf` → argumentos CLI**. O arquivo INI usa `[capture]`; `--config ARQUIVO` aponta alternativa. O driver encerra ao receber EOF no stdin; `run.sh` mantém stdin aberto. Encerramento normal por Ctrl-C/SIGTERM, liberando a instrumentação; evitar kill abrupto enquanto registradores de depuração permanecerem armados. -## Flags de pesquisa do CLI (fora da config; exigem autorização para uso live) +## Flags do CLI e valores operacionais do INI **Status corrigido 2026-10-17:** operação funcional de movimento, ataque automático e skills automáticas de alvo/chão **confirmada pelo responsável**. A falha anterior foi causada pelo primeiro byte sobregravado no agente Frida, corrigido em `agent/relay_hook_linux.js:481-534`; não foi seleção de alvo. Única pesquisa aberta: cifra. A tabela abaixo registra parâmetros de CLI (OFF por padrão onde indicado); **não** instrui teste live. -Estas flags **não** estão em `relay_capture.conf` (entram pelo CLI; `relay_capture.conf` permanece -inalterado). Uso live contra o cliente exige escopo explicitamente aprovado — validação offline por -padrão. +A tabela descreve os **defaults internos do CLI**, não os valores +efetivos de `relay_capture.conf`; o arquivo operacional contém diversas +dessas flags e prevalece sobre os defaults. Argumentos explícitos do CLI +sobrescrevem ambos. Uso live contra o cliente exige escopo autorizado; +validação de manutenção é offline por padrão. **Fluxo de identificação (resultado live confirmado em 2026-10-17):** os pacotes de saída `0368/0369/0193/0094/0360` do consumidor seguem normalmente via `SendPacket`, @@ -69,10 +71,14 @@ O `--drop-ids` do fluxo servidor→consumidor e o timeout da FIFO continuam inde | `--move-dispatch-site` | `0x4dcba0` | Fire-site de main-loop do `uaRO.exe` (hex). | | `--target-select` | OFF | Reativa o `0368` (seleção por GID) **antes** de ações com alvo (`0437` ataque / `0438` perícia). **Default OFF** (o cliente já emite o `0368` sozinho). A leitura de que o `0368` pré-ação bloqueava ataque/perícia foi **REFUTADA** pelo responsável em 2026-10-16: o `sendmsg` da ação sai com ou sem `0368`, e a ação continua **sem efeito** via SendPacket crua. Ligar/desligar a flag **não** resolve ataque/perícia (ver [GEPARD_CONTROL_PACKETS](GEPARD_CONTROL_PACKETS.md)). | | `--action-merge` | OFF | Arma corpo de `0437/0438/0AF4` do consumidor no store do scrambler, pré-XOR; requer carrier natural correspondente do cliente. Ações automáticas funcionais após `_fix0`. | -| `--synth-input` | OFF | Atuador interno de entrada sintética via `uinput`/`ydotool`; precisa de ambiente autorizado, janela focável e `ydotoold`. | +| `--synth-input` | OFF | Atuador interno de carriers de ação; a via depende de `--synth-backend`. Na configuração operacional validada está ON. | +| `--synth-backend` | `ydotool` | Seleciona `ydotool`/seat0 legado ou `gamescope-eis` via libei/socket privado; a config operacional validada usa `gamescope-eis`. | +| `--synth-eis-socket` / `--synth-eis-socket-file` | vazios | Indicação direta do socket EIS ou arquivo de localização 0600 criado pelo wrapper; não usar ambos. Erro se não houver EIS no backend selecionado, sem fallback ao KDE. | +| `--itemuse-input` / `--itemuse-input-key` | OFF / `END` | Com `0x0439`, pressiona e solta a tecla sincronicamente *antes* do pedido SendPacket; impede envio se não completar. Default do CLI: OFF / `END`; configuração local atual: ON / `F2` (mudança posterior, não presumir validação live). | | `--synth-key-0437` / `--synth-key-0438` / `--synth-key-0af4` | `INSERT` / `9` / `F2` | Teclas padrão para produzir o carrier; `9` corresponde à fileira superior. | | `--synth-debounce-ms` / `--synth-dry` | `500` / OFF | Antirrepetição em milissegundos; modo `dry` registra sem enviar input. | | `--egress-queue` | OFF | FIFO para quadros de egresso `S` (não `R`); ordenação, **não** causa/correção do defeito de byte zero. | +| `--sendcall-demand` | consulte CLI | Solicitação sob demanda do envio nativo; a configuração validada mantém `false`. Não confundir com `inject-call`, que permanece ligado. | | `--egress-interval-ms` / `--egress-action-gap-ms` / `--egress-max-age-ms` | `400` / `400` / `800` | Intervalos e descarte de quadro envelhecido na FIFO; não garantem por si só término do movimento. | **Orçamento de debug registers (x86 = 4 HW BP).** Com `--move-dispatch`, os 4 slots são: @@ -164,3 +170,214 @@ meio da sessão foi diagnóstico histórico, não prova de ausência de outros d Cuidados históricos completos (incluindo espera de ~6 s após SIGTERM, falsos positivos de `pgrep`, sondas e registradores) em [arquitetura histórica §§8–11](HISTORY/ARCHITECTURE_LEGACY.md). “Seguro” nesses registros significa apenas sobrevivência no recorte, sem garantia geral. + +## Input sintético em Gamescope isolado (backend experimental EIS) + +O atuador existente mantém **`--synth-backend ydotool` como padrão** (usa seat0, +altera foco do KDE). Para isolar eventos do mouse/teclado do desktop, selecionar +`--synth-backend gamescope-eis` em conjunto com `--synth-input`. +Esse backend usa **libei/EIS**, exige um socket do Gamescope **explicitamente** +selecionado, não usa `wmctrl`, `xdotool`, `ydotool` nem move o foco do host. +Não há fallback para seat0. O suporte libei no Gamescope depende da versão/build. + +Exemplo para as opções de inicialização do jogo no Steam (adapte o diretório +do repositório e do Gadget; **não use duas instâncias com o mesmo path file**): + +```sh +gamescope --backend wayland -g --force-grab-cursor -W 1280 -H 720 -w 1280 -h 720 -r 60 -- /var/home/bazzite/Documentos/Projetos/RO-Client-Relay/scripts/gamescope-eis-session.sh env LD_PRELOAD=/var/home/bazzite/Documentos/Projetos/RO-Client-Relay/relay/resources/frida-gadget-32.so %command% +``` + +O wrapper é executado **dentro do Gamescope** e escreve o valor `LIBEI_SOCKET` +num arquivo privado (0600) em `$XDG_RUNTIME_DIR/ro-relay-gamescope-eis.path`. +O arquivo é removido ao encerrar a sessão. Isso não concede acesso ao teclado +principal: apenas publica um nome de socket. Se necessário, defina +`RO_RELAY_EIS_FILE` para criar um arquivo distinto por sessão. + +Ao iniciar o relay na **versão que contém este backend**, acrescente +**às flags já utilizadas**: + +```sh +--synth-input --synth-backend gamescope-eis \ +--synth-eis-socket-file /run/user/1000/ro-relay-gamescope-eis.path +``` + +Também é possível passar `--synth-eis-socket /run/user/1000/gamescope-N-ei` +diretamente. Não combine as duas opções. `--synth-dry` permite confirmar +mapeamento sem conectar ao socket e sem produzir eventos. + +**Critérios de segurança e compatibilidade:** cada comando envia código evdev +para o *seat virtual do Gamescope* (pressão, frame, espera padrão 120 ms, +soltura, frame). A entrega ao **uaRO/Gepard** permanece a ser verificada em +teste autorizado: o fato de o Gamescope aceitar EIS não comprova que a +aplicação reconheça a tecla. Se a sessão Gamescope fechar ou o dispositivo +EIS não estiver disponível, a ação **falha e é registrada**, sem redirecionar +a entrada ao KDE. O cliente deve permanecer em foco **dentro** do Gamescope, +não necessariamente no KDE. Preferência operacional solicitada: +**`--backend wayland -g --force-grab-cursor`** em janela, para capturar teclado +e cursor enquanto o Gamescope estiver ativo e permitir a digitação no jogo. +O funcionamento do login deve ser validado na sessão real; os dois backends +já apresentaram dificuldades com teclado antes desse ajuste. A captura +pode restringir o uso dos periféricos enquanto a janela tiver foco; não +presumir que o EIS resolva a digitação manual. `Super+G` é interceptado +pelo KDE. O relay EIS envia ao Gamescope sem ativar a janela no KDE. Os testes offline de sintaxe/mocks não alteram +o estado do cliente. + +## Gamescope EIS e configuração operacional validada + +**CONFIRMADO PELO RESPONSÁVEL (2026-10-17):** as alterações e a +configuração presente na pasta original funcionam no ambiente utilizado. +A documentação e os testes offline não constituem uma repetição instrumental +dessa execução. [Arquitetura e limites](ARCHITECTURE.md#14-integração-operacional-gamescope-eis-2026-10-17). + +A seção `[capture]` do `relay_capture.conf` validado mantém: + +```ini +inject = true +inject-call = true +move-dispatch = true +action-merge = true +egress-queue = false +sendcall-demand = false +map-settle-ms = 1500 +synth-input = true +synth-backend = gamescope-eis +synth-eis-socket-file = /run/user/1000/ro-relay-gamescope-eis.path +synth-key-0437 = INSERT +synth-key-0438 = 9 +synth-key-0af4 = F2 +itemuse-input = true +itemuse-input-key = F2 +``` + +`synth-wid` permanece no INI como compatibilidade, porém é **ignorado** +quando `synth-backend = gamescope-eis`. Esta publicação **não recomenda** +substituir os valores validados por defaults internos. `egress-queue=false` +foi preservado; o teste de launcher foi corrigido para verificar apenas seu +contrato de inicialização em vez de impor `true`. + +**Sequência:** Steam abre Gamescope Wayland com `-g +--force-grab-cursor`, executa `scripts/gamescope-eis-session.sh` e +passa o Proton/uaRO ao wrapper. Dentro do compositor, `LIBEI_SOCKET` +é publicado num arquivo 0600 em `XDG_RUNTIME_DIR`; o arquivo é removido +ao final da sessão. O relay lê o socket por +`--synth-eis-socket-file`. Para login, usar mouse/teclado com a janela +ativa; depois, o relay pode enviar eventos ao compositor sem ativar o +KDE. A captura física com `-g` e `--force-grab-cursor` pode dificultar +temporariamente a troca de janelas. + +**Diagnóstico:** se o arquivo/socket EIS sumir, o envio de tecla +falhará explicitamente, **sem voltar ao ydotool**. `--synth-dry` +testa o mapeamento sem enviar eventos. Eventos `THIS-CONTEXT` +denunciam suspeita de contexto nativo inválido, sem identificar +automaticamente a causa. `RECV-PLAIN-UNAVAILABLE` indica conteúdo +cifrado sem cópia verificada; não representa dados descriptografados. + +**Teste e cobertura:** `tests/test_gamescope_eis.py` valida +isolamento do socket, permissões 0600, frame press/release e ausência +de side effect no KDE em `dry`. `tests/test_launcher_gadget_port.py` +verifica a descoberta da porta do Gadget sem impor política de +`egress-queue`. `bash scripts/test-offline.sh` cobre os contratos +offline, não valida transmissão criptográfica ou conformidade do uaRO. +`research/movement_035f/` usa agora caminhos relativos à raiz para +`data/uaro_recvpackets.txt`, sem modificar experimentos concluídos. + +## Auditoria regressiva de limites e falhas — 2026-10-18 + +**Escopo e procedência:** revisão estática do código Python do relay, da +CLI/configuração, do cliente EIS e dos contratos de eventos com o agente +Frida; testes **sintéticos offline**, sem execução do uaRO, sem rede do +servidor, sem acesso à memória do cliente. Além da suíte histórica, foram +adicionados **105 testes nomeados em nove módulos `tests/test_edge_*.py`**, +com milhares de entradas geradas deterministicamente para propriedades +de framing e coordenadas. `node --check` confirma somente **sintaxe** +do agente JS, não o comportamento em tempo de execução do Frida. + +| Superfície | Novos limites verificados | +|---|---| +| `relay/packets.py` | Length table, `VAR`, fragmentação em todos os pontos de corte, byte por byte, preâmbulo de seleção de personagem, desconhecidos, offsets e isolamento por conexão, milhares de round-trips de coordenadas | +| `relay/relay.py` | XKore com 0/1/65535/65536 bytes, 90 sequências de fragmentos aleatórios, desconexões em `sendall()`, reconexão, callback uma única vez, erro no consumidor | +| `relay/actors.py` / `wire_feed.py` | Intervalos de velocidade/tipo de ator, validação `09ff` e `0229`, bytes claros vs. cifrados, cópia anterior confiável, timeout, duplicação, stream limitado, snapshots e FIFO por conexão | +| `relay/gamescope_eis.py` / `synth_input.py` | Socket 0600, tipo de arquivo, input somente no Gamescope, rejeição de socket KDE e symlink, perda/pausa de dispositivo, timeout, soltura da tecla, falhas de pressão/foco, debounce, worker e descoberta tardia da janela | +| `relay/writeback.py` | `0439` de 8 bytes (tabela), barreira síncrona antes de `SendPacket`, drop em erro, roteamento exclusivo de `035f`, merge, SendPacket, GIDs, `0091/0092` e quadros não `S` | +| `relay/safety.py`, `console.py`, `sendwatch.py` | Sigilo de credenciais e tokens nas duas direções da conexão de login, HTTP, remoção de dados binários/pointer, logs concorrentes e rejeição de janelas de escrita inválidas | +| `relay/cli.py`, `protocol.py`, `agent.py` e JS | Precedência INI/CLI e booleanos, mensagens nativas, descoberta de Gadget, eventos Frida mockados e análise sintática do JS | + +**Defeitos confirmados por testes sintéticos e corrigidos no código:** +`Relay.send()` não disparava `on_disconnect` quando a desconexão era +detectada em `sendall()`; o legado `ydotool` podia sinalizar sucesso +após falha de `key-down` ou focar incorretamente, e não repetia a +descoberta de janela ausente; seu worker não observava código de erro +do sender. Em EIS, rejeitam-se agora nomes de socket do KDE, bytes +de controle em caminhos e reutilização de contexto após exceção. +`0x0439` malformado é descartado antes de tecla/SendPacket; login +`RECV` recebe redação de material sensível; `SendWatch` rejeita +metadados de escrita incompletos sem falhar; a descoberta rápida +de atores mascara o corpo `0229` a partir do byte +2, não +4. +A correção de isolamento por identidade de conexão veio também dos +commits paralelos `695a85a`/`7c039e5`, já presentes no checkout; +os novos testes a mantêm protegida. Esses resultados são +**OBSERVADOS EM TESTES OFFLINE**, não equivalem à confirmação em jogo. + +**Limitações e riscos remanescentes:** não há teste dinâmico da VM do +Gepard, validade de `this` sob realocação do cliente, ordenação +de instruções Frida/hardware breakpoints, foco real do KDE/Proton, +sincronização do algoritmo de cifra, nem cenário integral de +reconexão em servidor vivo. `PacketAligner.find_alignment` ainda +pode esperar um falso candidato incompleto antes de buscar outro +offset (contrato conservador de sincronização); alterá-lo exige +um discriminador de alinhamento validado para não descartar bytes +legítimos. A suíte detecta regressões **modeladas**, não garante +ausência de defeitos em execução real. + +**Execução:** `bash scripts/test-offline.sh`, +`python3 scripts/validate-research.py`, +`python3 scripts/validate-codex.py`, +`node --check agent/relay_hook_linux.js` (se Node estiver instalado), +`git diff --check`. O arquivo de sessão +`itemuse_input_marks.json` foi incluído no `.gitignore` para +evitar publicação acidental. Esta auditoria não altera +`inject=true`, `inject-call=true` ou os demais valores +operacionais validados em `relay_capture.conf`. + + +## Configuração e telemetria atuais — auditoria 2026-10-20 + +**OBSERVADO NO ARQUIVO LOCAL (não presume validação live desta revisão):** + +| Chave / recurso | Checkout atual | Consequência | +|---|---|---| +| `inject`, `inject-call` | `true`, `true` | Preservados conforme decisão do responsável | +| `move-dispatch`, `action-merge` | `true`, `true` | Movimento pela cadeia nativa; carriers de ações via merge | +| `egress-queue`, `sendcall-demand` | `false`, `false` | Sem FIFO adicional de egresso; sem drenagem nativa sob demanda | +| `synth-input`, `synth-backend` | `true`, `gamescope-eis` | Entrada direcionada ao socket privado do Gamescope, sem fallback automático ao KDE | +| `itemuse-input`, `itemuse-input-key` | `true`, **`F2`** | A tecla de uso de item mudou desde a confirmação de 2026-10-17 (`END`). **Não atribuir validação live anterior a F2.** O default interno da CLI ainda é `END` | +| `actor-delay` | **`0.30` s** (config e CLI) | Aumenta oportunidade de cópia, mas retém o fluxo ordenado e não impede overwrite | +| `verbose` | **`false`** | Terminal conciso; `--verbose` habilita diagnóstico amplo, sem alterar registros JSONL | +| `scramble-bp` | `18eff2` | Offset relativo ao Gepard no build estudado; não universal | +| `map-settle-ms` | `1500` | Tempo configurado para transição/recaptura nativa | + +A configuração permanece em `relay_capture.conf`; nenhuma chave foi modificada pela auditoria documental. A tabela histórica acima, sob **Configuração observada no checkout local**, documenta o recorte de 2026-10-15/17 e **não prevalece** sobre esta seção para valores mais recentes. + +**Execução real do wrapper:** `./run.sh` reconhece apenas um conjunto restrito de opções (`--port`, `--wid`, `--dry`, `--target-select` e suas formas documentadas de desativação, `--sendcall-demand` e `--no-sendcall-demand`). Para utilizar qualquer outra flag do driver, usar `./run.sh --raw --no-inject` ou `./run.sh --raw --verbose`. O comando antes documentado `./run.sh --no-inject` **não é aceito pelo wrapper normal**. A variável reconhecida para selecionar o Python é `FRIDA_PY`, não `VENV`. + +A opção `--verbose` habilita exibição detalhada do tráfego no terminal. O modo conciso omite linhas de alta frequência, mas não os erros relevantes; o arquivo `relay_log.jsonl` mantém eventos sanitizados. Os registros `RECV-BOUNDARY` podem conter prefixos/sufixos hexadecimais do tráfego real do mapa; não copiá-los para documentos públicos. `ACTOR-RECOVERY` classifica `status`, `reason`, `attempts`, `first_read`, `last_read`, `candidate_seen`, `usable_snapshots` e estatísticas de validação, sem corpos completos nesses campos. Ambos são diagnósticos, não provas de decifragem de todos os pacotes. + +**Caminho de recepção:** `09FD`/`09FF`/`0229`/`043F`/`0983` agora são roteados para recuperação de cópia cifrada, sob validação; as entradas de `drop-ids` só devem ser alteradas após conferir o contrato do tokenizer e a tabela. O estado de `sitting`, buffs e flags de `opt1/opt2/option` continuam sujeitos à verificação semântica: sucesso em teste de plaintext não significa que o consumidor interpretou corretamente o status. + +**Testes executados antes desta edição:** `bash scripts/test-offline.sh` PASS (37 scripts); `python3 scripts/validate-research.py`, `python3 scripts/validate-codex.py`, `git diff --check`, `bash -n` dos scripts de lançamento e `node --check` dos JS PASS. Além da suíte principal, `python3 research/recv_cipher/test_ring_correlator.py` e `test_audit_write_trace.py` PASS. `python3 research/recv_cipher/validate_0229.py` requer caminho de arquivo de log como argumento; não foi executado sobre captura privada nesta auditoria. Nenhuma rodada live foi iniciada. + +**Checagem passiva live de 20/10/2026:** os arquivos `relay_log.jsonl` e `../OpenKore-upstream/logs/console.txt` foram consultados **sem reiniciar nem instrumentar o cliente**; há registros simultâneos de atividade do OpenKore e recuperações de `09FD/09FF/0983/043F/0229` no relay de 18:37 a 19:30 BRT, com 115 misses preservados. [Tabela quantitativa, classificação e limitações](RESEARCH_STATUS.md#verificação-passiva-dos-logs-de-produção--2026-10-20-18371930-brt). As capturas permaneceram no computador e fora do Git. + +## Watchdog e auditoria de sinais Linux + +`./run.sh` inicia por padrão `scripts/watch-uaro-stop.py --threshold 3 --interval 0.25`. O monitor observa a **situação do processo Linux**, com códigos `T` (stopped) e `t` (traced/stopped), não uma personagem parado em jogo. Confere novamente nome `uaRO.exe` e estado antes de enviar `SIGCONT`; um único sinal por episódio, para não inundar a sessão. Esse mecanismo **não corrige congelamentos sem SIGSTOP** ou falta de progresso do game loop. Depois de `SIGCONT`, a cifra ou protocolo podem estar dessincronizados e exigir reconexão/relogin; não há garantia de re-sincronização automática. + +Se `sudo -n true` já passa, o launcher pode iniciar automaticamente `scripts/audit-game-stop-signals.sh` para registrar syscalls `kill`, `tgkill` e `tkill` por PID via `auditctl`, em b32/b64; sem credenciais sudo previamente disponíveis ele prossegue sem pedir senha. Trata-se de **instrumentação privilegiada**, que depende de auditd/auditctl e da compatibilidade dos filtros do kernel. O launcher tenta remover suas próprias regras no término regular, mas um término abrupto pode deixar regras instaladas; conferir o estado do sistema antes de reabilitar a sonda. A auditoria não observa todas as fontes de parada nem demonstra quem enviou `SIGSTOP` numa sessão passada. + +**Não confundir dois tipos de monitoramento:** `ACTOR-RECOVERY`/snapshots tratam de pacotes de recepção; o watchdog trata do estado de processo. `BP-TELEMETRY` informa arme/desarme/hits limitados de HW BP, não identifica por si só origem causal de freezes. O código `agent/relay_hook_linux.js` protege escritas TX de sobreposição **com rings RX registrados**; isso não prova isolamento quando a região ainda não foi encontrada. + +## Sonda independente de recepção e critérios de aceitação + +O caminho experimental `research/recv_cipher/` possui um correlacionador de ring por FD, snapshots pré-sobrescrita, adaptador de `ActorDecrypt`, auditoria de cobertura, telemetria opcional e sondas explícitas de escrita. **Não executar a partir de manutenção documental.** A execução live exige escopo autorizado, verificação de identidade do processo e exclusividade de instrumentação; os watchpoints e `RECV_WRITE_PC` não estão habilitados por padrão. Não transferir base de ring, PC ou snapshot entre reinícios. + +Amostras de 2026-10-19 variaram de **0/76** recuperados na coleta com 70 `09FF` até **221/224 `09FF` e 14/16 `09FD`** em outra janela. Outros rastros confirmaram escritas compatíveis no slot, mas não proveniência única nem ganho causal generalizável. Para considerar uma melhoria validada: comparar **mesma carga, build e critérios**, ordenar por identidade de conexão, contar vistos/recuperados/misses/duplicações e verificar que conteúdos ambíguos não chegam ao OpenKore. Fonte de execução: [STATE da sonda](../research/recv_cipher/STATE.md); métodos: [README da sonda](../research/recv_cipher/README.md); síntese de arquitetura: [Recuperação dinâmica](DYNAMIC_ACTOR_RING.md). diff --git a/docs/README.md b/docs/README.md index d15a158..62f5acc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,15 @@ # Documentação técnica — RO-Client-Relay -**Entrada principal de documentação.** Em 2026-10-17, as pesquisas de **movimento `0x035F` e ações `0x0437`/`0x0438`/`0x0AF4` foram encerradas como funcionais** pelo responsável. A **única investigação ativa é a cifra uaRO/Gepard**. O defeito do ataque automático era a reescrita incorreta do primeiro byte pelo store do scrambler, corrigida em `agent/relay_hook_linux.js:481-534` (não seleção de alvo). [Estado canônico](RESEARCH_STATUS.md). +**Entrada principal de documentação.** Em 2026-10-17, as pesquisas de **movimento `0x035F` e ações `0x0437`/`0x0438`/`0x0AF4` foram encerradas como funcionais** pelo responsável. A **única investigação criptográfica científica ativa é a cifra uaRO/Gepard**; a trilha de engenharia de recuperação do plaintext recebido é distinta e está documentada separadamente. O defeito do ataque automático era a reescrita incorreta do primeiro byte pelo store do scrambler, corrigida em `agent/relay_hook_linux.js:481-534` (não seleção de alvo). [Estado canônico](RESEARCH_STATUS.md). ## Ordem de leitura Para toda tarefa: [AGENTS.md do projeto](../AGENTS.md) → [Estado e decisões](RESEARCH_STATUS.md) → documento temático → fonte específica (`arquivo:linha`). A pesquisa ativa também lê [research/COORDINATION.md](../research/COORDINATION.md), [research/cipher/STATE.md](../research/cipher/STATE.md) e o [handoff corrente](../research/cipher/HANDOFF.md). +## Auditoria de atualização — 2026-10-20 + +A comparação recente foi feita contra `origin/main@9ee7ef5`, não contra a `main` local desatualizada. O estado atualizado de recepção, guardas TX/RX, novas sondas, watchdog e configurações está em [RESEARCH_STATUS](RESEARCH_STATUS.md#revisão-do-checkout--2026-10-20), com contratos em [ARCHITECTURE](ARCHITECTURE.md#15-recepção-dinâmica-novos-ids-e-proveniência-revisão-2026-10-20), operação em [OPERATIONS](OPERATIONS.md) e histórico técnico da ring em [DYNAMIC_ACTOR_RING](DYNAMIC_ACTOR_RING.md). A pesquisa de extração de plaintext em `research/recv_cipher/` é **instrumentação experimental separada** da identificação do algoritmo Gepard: esta continua a única investigação criptográfica científica ativa. Testes offline não equivalem à validação live. + ## Documentos ativos | Documento | Função atual | Consultar quando | @@ -13,6 +17,7 @@ Para toda tarefa: [AGENTS.md do projeto](../AGENTS.md) → [Estado e decisões]( | [RESEARCH_STATUS.md](RESEARCH_STATUS.md) | Único panorama de estado, decisões, classificação e evidências essenciais | Qualquer tarefa ou subagente | | [ARCHITECTURE.md](ARCHITECTURE.md) | Fluxos de cliente/Frida/driver/OpenKore; memória, threads, writeback e limites do xKore 0 | Arquitetura, programação e revisão | | [OPERATIONS.md](OPERATIONS.md) | Configuração, execução, flags, dependências e testes offline | Instalação, operação e diagnóstico | +| [DYNAMIC_ACTOR_RING.md](DYNAMIC_ACTOR_RING.md) | Registro técnico cronológico da descoberta e recuperação do ring de recepção, limites, validação de nomes e novos IDs | Bugs de atores, buffs e proveniência RX | | [CIPHER_INVESTIGATION.md](CIPHER_INVESTIGATION.md) | Pesquisa científica de cifra **ativa**; inclui integralmente o antigo manual `CIPHER_TOOLS.md` e fingerprint `PROTECTION_FINGERPRINT_DIE.md` | Agente CIFRA, verifier e pesquisa | | [GEPARD_CONTROL_PACKETS.md](GEPARD_CONTROL_PACKETS.md) | Inventário de protocolo, pacote periódico `0x8280`, distinção entre histórico e build atual | Protocolo, cifra e futuro plugin | | [AGENTS.md](AGENTS.md) | Regras breves de documentação e preservação de evidências | Documentador | diff --git a/docs/RESEARCH_STATUS.md b/docs/RESEARCH_STATUS.md index a46f47f..ea2bbd8 100644 --- a/docs/RESEARCH_STATUS.md +++ b/docs/RESEARCH_STATUS.md @@ -1,5 +1,47 @@ # Estado atual e decisões — RO-Client-Relay +## Verificação passiva dos logs de produção — 2026-10-20, 18:37–19:30 BRT + +**Procedência e método (OBSERVADO EM LOGS LIVE JÁ EXISTENTES):** análise **somente leitura** de `relay_log.jsonl` (JSONL local) e `../OpenKore-upstream/logs/console.txt`, correlacionando timestamps a partir de 20/10/2026 18:37:05. O cliente `uaRO.exe` estava ativo desde 16:00:46; o processo do relay foi iniciado às 18:37:05 e o OpenKore estava ativo desde 19/10 às 23:58:53. `relay/actors.py` (mtime 18:31:40), `wire_feed.py` (17:15), agente Frida (15:49), `run.sh` (16:42) e INI (19/10) tinham alterações anteriores à inicialização do relay; isso é **compatível com a versão atual em execução**, não uma prova byte a byte do código carregado. A amostra termina em **19:30:47**, e os arquivos continuam crescendo. Não houve reinício, nova instrumentação, input ou comunicação adicional com o servidor nesta auditoria. Nenhum log cru ou nome/ID de ator foi incorporado ao Git. + +| Indicador do relay | Total da janela | O que mostra / limite | +|---|---:|---| +| `ACTOR-RECOVERY`: `09FD` | 1.030 recuperados / 6 perdidos | Cópias verificadas em execução; os perdidos não foram sintetizados | +| `ACTOR-RECOVERY`: `09FF` | 1.001 / 15 | Recuperação majoritária, com perdas ainda reais | +| `ACTOR-RECOVERY`: `0983` | 948 / 75 | Maior grupo de misses; requer análise da proveniência e interpretação dos estados | +| `ACTOR-RECOVERY`: `043F` | 241 / 18 | Fluxo presente, mas não comprova semântica dos buffs | +| `ACTOR-RECOVERY`: `0229` | 91 / 1 | Reconstrução do corpo do status, não prova que `opt1/opt2/option` estão sempre corretos | +| **Total de candidatos** | **3.311 recuperados / 115 não recuperados = 3.426** | ~**96,6%** dos candidatos anotados no log desta sessão; não garantia global | +| Motivos dos 115 misses | 103 `correlation-mismatch`, 7 `overwritten-before-verification`, 4 `ring-not-found`, 1 `timeout-no-valid-copy` | Categorias de decisão do software, não causalidade confirmada | +| Outros eventos de diagnóstico | 391 `SENDCALL stale-drop`, 217 `THIS-CONTEXT stale`, 70 `THIS-CONTEXT suspect` | Guardas/descartes observados; não classificar automaticamente como falha fatal nem como sucesso da ação | + +**OpenKore-upstream no mesmo intervalo** (classificação passiva por regex de linhas de console, **não** contagem de ações exclusivas): 724 linhas de skills, 346 de ataque, 195 de movimento, 85 de itens/storage e 45 com marcadores de aviso/erro; 0 linhas identificadas por estes padrões como desconexões ou IDs de pacote desconhecidos a partir das 18:37. O log permaneceu ativo às 19:30:47. **INFERIDO:** há continuidade operacional compatível entre relay e consumidor no período; ausência de um padrão textual não exclui falhas não logadas, e sem inspeção semântica detalhada não se afirma que todo buff/status/`sitting` esteja correto. + +**Conclusão delimitada:** `09FD/09FF/0983/043F/0229` têm **recuperações confirmadas em registro live** da sessão de 20/10, com FIFO/entrega de plaintext verificado segundo as tags observadas; as falhas registradas impedem concluir 100%. O comportamento de movimento/ataque/skills no OpenKore é observado como **atividade**, não como confirmação instrumental de cada pacote aceito pelo servidor. Os **37 scripts offline** e os validadores documentais passaram separadamente; não misturar as duas classes de evidência. + +## Revisão do checkout — 2026-10-20 + +**Base revisada:** `origin/main@9ee7ef5` (atualizada por fetch); checkout `test/audit-edge-cases-20261018@aa69a6a` antes desta edição, com 47 arquivos rastreados diferentes de main e 22 arquivos novos não rastreados. Os últimos incluem testes, instrumentos da trilha de recepção e `docs/DYNAMIC_ACTOR_RING.md`. Esta contagem é o inventário **antes** das atualizações documentais; não significa 69 alterações aprovadas para PR. `main` local estava desatualizada em relação a `origin/main`. **Nenhuma instrumentação live foi executada nesta auditoria documental.** + +| Tema | Classificação / estado em 2026-10-20 | Evidência e limite | +|---|---|---| +| Movimento `035F`, ações `0437/0438/0AF4` | **FUNCIONAIS SEGUNDO RELATO DO RESPONSÁVEL (2026-10-17)** | Mecanismos já consolidados; código mudou posteriormente, **sem revalidação live aqui** | +| Gamescope EIS + uso de item `0439` | **IMPLEMENTADO; configuração local alterada** | `relay_capture.conf` mantém `inject=true`, `inject-call=true`, `gamescope-eis`, `egress-queue=false`, `sendcall-demand=false`; `itemuse-input-key` está agora **F2**, não END; não atribuir a confirmação anterior à mudança F2 | +| Ring RX e recuperação de atores | **IMPLEMENTADO; resultados de campo variáveis** | Descoberta dinâmica por sessão, isolamento de epoch/slot, `ACTOR-RECOVERY`; referências em [arquitetura](ARCHITECTURE.md#15-recepção-dinâmica-novos-ids-e-proveniência-revisão-2026-10-20) e [registro técnico](DYNAMIC_ACTOR_RING.md) | +| Status/buffs `0229`/`043F`/`0983` | **REGRAS/TESTES OFFLINE, sem garantia semântica live** | Somente cópias verificadas; o estado `sitting` incorreto relatado anteriormente não é considerado resolvido | +| RX/TX do scrambler e breakpoints | **OBSERVADO NO CÓDIGO** | Guarda de sobreposição nos rings registrados + telemetria `BP-TELEMETRY`; não prova separação quando o ring RX é desconhecido | +| Sonda `research/recv_cipher/` | **ENGENHARIA EXPERIMENTAL ATIVA, separada da pesquisa da cifra** | Descobre o plaintext produzido pelo próprio cliente, sem identificar algoritmo; [STATE](../research/recv_cipher/STATE.md) contém amostras positivas, negativas, limitações e limpeza | +| Watchdog de SIGCONT / auditoria de sinais | **IMPLEMENTADO, não validado live nesta rodada** | Só observa estados Linux `T/t` e usa um sinal por episódio após 3 s; risco de dessincronizar sessão permanece | +| Cifra independente uaRO/Gepard | **ÚNICA INVESTIGAÇÃO CRIPTOGRÁFICA CIENTÍFICA ABERTA** | Duas variantes especializadas A/B seguem referência; seeds, tabela real, funções e estado do uaRO **NÃO IDENTIFICADOS** | + +**Risco adicional OBSERVADO NO CÓDIGO:** `research/recv_cipher/sample_telemetry.py` mantém até 512 frames completos (padrão 16 KiB cada) em RAM, embora seu docstring afirme não armazenar conteúdo. Não há prova de exportação automática neste módulo, mas os frames e qualquer captura derivada são **dados privados de sessão**. `audit_coverage.py` conta somente `09FD/09FF/0229`; cobertura de `043F/0983` não deve ser inferida de seus percentuais. [Inventário e limites](../research/recv_cipher/README.md#inventário-dos-instrumentos-ainda-não-publicados--revisão-estática-2026-10-20). + +**Validação offline do checkout anterior às edições:** `bash scripts/test-offline.sh` **PASS (37 scripts)**; `python3 scripts/validate-research.py` **PASS**; `python3 scripts/validate-codex.py` **PASS**; `git diff --check` **PASS**; `bash -n` dos launchers e `node --check` do agente e sondas JS **PASS**; `python3 -m compileall` de fontes próprias **PASS**. Além disso, `research/recv_cipher/test_ring_correlator.py` e `test_audit_write_trace.py` **PASS**. Os testes não estabelecem cobertura total da recepção em jogo. + +**OBSERVADO nas amostras já registradas pela trilha de recepção:** em 2026-10-19 a coleta `182943` encontrou 70 `09FF`, 4 `09FD` e 2 `0229`, **0 recuperados**, enquanto `184839` encontrou 221/224 `09FF` e 14/16 `09FD` recuperados. Uma coleta sob escrita rastreada recuperou 7/7, mas não provou proveniência única. **Não calcular percentuais globais** combinando sessões distintas ou concluir 100% a partir de amostras favoráveis. A causa dos misses de alta carga permanece **NÃO VERIFICADA**. + +A documentação precedente de 2026-10-17 permanece abaixo como registro cronológico. `docs/AGENTS.md` preserva a política de uma fonte canônica por assunto, distingue evidência de código/teste/live e impede promover hipóteses ao protocolo. + @@ -31,6 +73,34 @@ A **confirmação funcional** acima foi fornecida pelo responsável; esta consol 5. **Limite operacional:** nada autoriza experimentos live, ferramentas de memória ou envio ao servidor. Preserve ordem de pacotes, identidade das conexões, sanitização e enquadramento XKore `R`/`S`. 6. **Documentação:** manter materiais ativos resumidos e detalhamento científico da cifra em [CIPHER_INVESTIGATION.md](CIPHER_INVESTIGATION.md). Preservar fontes aposentadas literalmente nos arquivos temáticos de [HISTORY/](README.md#arquivo-histórico-e-proveniência). +## Atualização de integração operacional — PR Gamescope EIS (2026-10-17) + +- **CONFIRMADO PELO RESPONSÁVEL:** as mudanças e os valores do + `relay_capture.conf` atual funcionam em sua execução do cliente; + `inject=true`, `inject-call=true`, `egress-queue=false`, + `sendcall-demand=false`, `synth-backend=gamescope-eis` e + `itemuse-input=true` são decisões operacionais mantidas. +- **OBSERVADO NO CÓDIGO:** a interface EIS conecta apenas ao socket + Gamescope explicitamente configurado, e não faz fallback a + `ydotool`/KDE; `0x0439` exige tecla síncrona antes de + `SendPacket`; as tags `THIS-CONTEXT` e + `RECV-PLAIN-UNAVAILABLE` permitem diagnóstico sem inventar + decifragem. [Arquitetura](ARCHITECTURE.md#14-integração-operacional-gamescope-eis-2026-10-17). +- **OBSERVADO EM TESTE TÉCNICO ISOLADO:** evento EIS chegou como + `KeyPress/KeyRelease` a uma aplicação XWayland de teste dentro de + Gamescope. A revisão documental não reproduziu entrada live no uaRO. +- **NÃO VERIFICADO NESTA RODADA:** causalidade de congelamentos + anteriores, validade de todos os ponteiros nativos sob transições + futuras e implementação independente da cifra. Os diagnósticos não + transformam essas hipóteses em fatos. +- **Portabilidade:** sete scripts de pesquisa histórica de movimento + corrigiram um caminho absoluto para referência relativa à tabela de + pacotes; não reabre uma pesquisa encerrada. + +Detalhes de instalação, flags e teste estão em [OPERATIONS](OPERATIONS.md#gamescope-eis-e-configuração-operacional-validada). +Esta integração não altera a decisão de manter a **cifra como única +pesquisa científica ativa**. + ## Cifra — questões científicas abertas **OBSERVADO:** a transformação final do corpo `0x035F` inclui XOR in-place byte a byte na instrumentação estudada. A pesquisa de CL/bytes identificou transportes intermediários e candidatos de fonte, incluindo `EBP+0x57` e `EBP+0x09` em rodadas CIFRA STACK-04…SLICE-12, **sem isolar operação geradora**. diff --git a/relay/actors.py b/relay/actors.py index da790b4..d535491 100644 --- a/relay/actors.py +++ b/relay/actors.py @@ -1,11 +1,77 @@ """ActorDecrypt: fills in the encrypted bodies of 09fd/09ff from the client's own decrypted copy.""" import collections -import os import threading import time +import unicodedata -from . import ROOT -from .memory import MAX_REGION, ProcessMemory, region_at, regions +from .memory import MAX_REGION, ProcessMemory, regions + + + +# Explicit legacy Windows encoding: do not try multiple codecs until one passes. +# Change only after confirming the character encoding used by this server. +ACTOR_NAME_ENCODING = 'cp1252' +# The client can receive NPC names containing printable ASCII symbols such as +# 'Kafra Employee#prt1'. Extended CP1252 remains limited to letters/digits and +# a few typography marks; controls, undefined codepoints and binary garbage fail. +ACTOR_NAME_PUNCTUATION = frozenset(' ‘’“”–—…') + + +def allowed_actor_name_character(c): + return (unicodedata.category(c)[0] in ('L', 'N') or + (c.isascii() and c.isprintable()) or + c in ACTOR_NAME_PUNCTUATION) + + +def valid_actor_name(raw): + """Validate a fixed-width (24-byte) NUL-padded name, never a cipher proof.""" + if len(raw) != 24: + return False + name_bytes, sep, padding = raw.partition(b'\x00') + if not name_bytes or (sep and any(padding)): + return False + try: + name = name_bytes.decode(ACTOR_NAME_ENCODING, errors='strict') + except UnicodeDecodeError: + return False + if name != name.strip(): + return False + return all(allowed_actor_name_character(c) for c in name) + + +def actor_name_rejection(raw): + """Diagnostic category only: never expose the actor's raw name or bytes.""" + if len(raw) != 24: + return 'invalid-length' + if not any(raw): + return 'empty' + name_bytes, sep, padding = raw.partition(b'\x00') + if sep and any(padding): + return 'invalid-padding' + try: + name = name_bytes.decode(ACTOR_NAME_ENCODING, errors='strict') + except UnicodeDecodeError: + return 'invalid-encoding' + if name != name.strip(): + return 'edge-whitespace' + if not all(allowed_actor_name_character(c) for c in name): + return 'forbidden-character' + return 'ok' + + +def fresh_empty_name_actor(pkt, plain): + """Structural exception for a *freshly observed* 09FF slot, not a generic copy_ok. + + The caller must separately establish same-ring safe preimage, a changed + post-recv copy in the expected slot, and that the read predates the next recv. + This function is deliberately never used by ring discovery or scoring. + """ + return (len(pkt) >= 90 and len(plain) == len(pkt) and + pkt[:2] == b'\xff\x09' and plain[:4] == pkt[:4] and + plain != pkt and plain[4] <= 12 and + 0 <= (plain[13] | (plain[14] << 8)) <= 2000 and + plain[5:9] != b'\x00' * 4 and + plain[-24:] == b'\x00' * 24) def valid_actor(P): @@ -14,10 +80,56 @@ def valid_actor(P): if len(P) < 60 or P[4] > 12: return False speed = P[13] | (P[14] << 8) - name = P[len(P) - 24:] if len(P) >= 90 else b'' - nm = name.split(b'\0', 1) - ok_name = all(32 <= c < 127 or c >= 0x80 for c in nm[0]) and (len(nm) == 1 or not nm[1].strip(b'\0')) - return 20 <= speed <= 1000 and ok_name + name = P[-24:] if len(P) >= 90 else b'' + return 0 <= speed <= 2000 and valid_actor_name(name) + + +def copy_validation(pkt, P): + """Boolean-only breakdown of the existing copy_ok rules; no packet content logged. + + For 0229, speed/type/name do not apply. `accepted` must exactly mirror + ActorDecrypt.copy_ok, including its special six-byte fixed-packet check. + """ + n = len(pkt) + same_length = len(P) == n + changed = P != pkt + pid = int.from_bytes(pkt[:2], 'little') if n >= 2 else None + hdrlen = 2 if pid in (0x0229, 0x043f, 0x0983) else 4 + header = n >= 2 and same_length and P[:hdrlen] == pkt[:hdrlen] + result = {'length_match': same_length, 'header_match': header, + 'body_changed': changed, 'object_type_ok': None, + 'walk_speed_ok': None, 'name_format_ok': None, + 'fixed_gid_ok': None} + if pid == 0x0983: + result['fixed_gid_ok'] = (same_length and n == 29 and header and + int.from_bytes(P[4:8], 'little') != 0) + result['accepted'] = bool(header and changed and result['fixed_gid_ok']) + return result + if pid == 0x043f: + result['fixed_gid_ok'] = (same_length and n == 25 and header and + int.from_bytes(P[4:8], 'little') != 0) + result['accepted'] = bool(header and changed and result['fixed_gid_ok']) + return result + if pid == 0x0229: + result['fixed_gid_ok'] = (same_length and n == 15 and header and + int.from_bytes(P[2:6], 'little') != 0) + result['accepted'] = bool(header and changed and result['fixed_gid_ok']) + return result + # Preserve the exact original valid_actor checks, including its short-packet + # early return. No bytes, names, IDs or addresses leave this function. + result['object_type_ok'] = len(P) >= 60 and P[4] <= 12 + if len(P) >= 15: + speed = P[13] | (P[14] << 8) + result['walk_speed_ok'] = 0 <= speed <= 2000 + # Diagnostic-only integer: populated for first/last observations, never per poll. + result['walk_speed_value'] = speed + if len(P) >= 60: + name = P[-24:] if len(P) >= 90 else b'' + result['name_format_ok'] = valid_actor_name(name) + result['accepted'] = bool(n >= 2 and same_length and changed and header + and result['object_type_ok'] and result['walk_speed_ok'] + and result['name_format_ok']) + return result class ActorDecrypt: @@ -45,7 +157,8 @@ class ActorDecrypt: location, stats and `fed`; code outside it reads `self.base` once into a local, because the ring can be dropped (set to None) for rediscovery at any moment. Code that needs both ends reads `self.region` instead: (base, hi) replaced in one assignment, never torn.""" - ENC = (0x09fd, 0x09ff) + ENC = (0x09fd, 0x09ff, 0x0229, 0x043f, 0x0983) + FIXED_ENC = {0x0229: 2, 0x043f: 2, 0x0983: 2} # fixed 15-byte packet: 2-byte clear ID only CTX = 64 # wire bytes compared on each side of a candidate HDR = 0x40 # ring offset of a chunk that starts clean STREAM_KEEP = 0x20000 # recent wire bytes kept per connection @@ -70,20 +183,10 @@ def __init__(self, pid): self.fails = 0 # consecutive unverified -> rediscover the ring self.quick_busy = False self.next_scan = 0.0 # earliest time for another full scan - self.cache = os.path.join(ROOT, '.relay_actor_ring') - self._load_cache() - - def _load_cache(self): - # safe to trust: every copy is verified, and a wrong base just fails -> rediscovery - try: - with open(self.cache) as f: - r = region_at(self.pid, int(f.read().strip(), 16)) - except (OSError, ValueError): - return - if r: - self.base, self.hi = r - self.region = r - print(f'[actor] recv ring 0x{r[0]:x}-0x{r[1]:x} (cached; verified per packet)') + # The ring address is process/session specific. Never trust an address + # from another launch just because /proc//maps still maps it. + # Discovery starts unconditionally from current-session packet evidence. + self.ring_epoch = 0 def read(self, a, n): return self.mem.read(a, n) @@ -105,11 +208,26 @@ def note_chunk(self, conn, C, left=0): self.chain[conn] = (d, len(C)) return start, d - def note_enc(self, conn, off, L): - self.spans[conn].append((off + 4, off + L)) # body only; id+len are cleartext + def note_enc(self, conn, off, L, pid=None): + head = self.FIXED_ENC.get(pid, 4) + self.spans[conn].append((off + head, off + L)) # body only; id+len are cleartext def copy_ok(self, pkt, P): - return len(P) == len(pkt) and P[:4] == pkt[:4] and P != pkt and valid_actor(P) + if len(pkt) < 2 or len(P) != len(pkt) or P == pkt: + return False + pid = pkt[0] | (pkt[1] << 8) + if pid == 0x0983: + # Partial ciphertext observed in 'type'; verify full plaintext from RX ring. + return len(pkt) == 29 and P[:2] == pkt[:2] and int.from_bytes(P[4:8], 'little') != 0 + if pid == 0x043f: + # Status ID/GID and payload must come from verified client plaintext. + return len(pkt) == 25 and P[:2] == pkt[:2] and int.from_bytes(P[4:8], 'little') != 0 + if pid == 0x0229: + # Only the two-byte ID is clear. GID and status flags are encrypted; + # equality with ciphertext GID would reject authentic plaintext. + # Field bit patterns are not sufficient proof of decryption. + return len(pkt) == 15 and P[:2] == pkt[:2] and int.from_bytes(P[2:6], 'little') != 0 + return P[:4] == pkt[:4] and valid_actor(P) def slot(self, d, off, cstart, base=None): """Ring address where this packet's decrypted copy should be.""" @@ -122,60 +240,140 @@ def before(self, e, pre=None): base = self.base if base is None: return + e['preimage_ring_base'] = base + e['preimage_ring_epoch'] = getattr(self, 'ring_epoch', 0) for i, it in enumerate(e['items']): if it[0] != 'E': continue a = self.slot(e['d'], it[3], e['cstart'], base) if pre is not None and pre[1] == base and 0 <= a - base and a - base + len(it[2]) <= len(pre[0]): e['B'][i] = pre[0][a - base:a - base + len(it[2])] + e.setdefault('Bsafe_items', set()).add(i) e['Bsafe'] = True else: e['B'][i] = self.read(a, len(it[2])) + def _ring_mapping_allowed(self, base, end): + """Require page-aligned ring base inside anonymous writable memory. + + Wine/Proton allocates legitimate RX rings in anonymous RWX mappings; + rejecting executable pages loses all actor/NPC plaintext. A coincidental + header inside the PE code range is instead excluded by base alignment + and file-backed mapping rules, before the usual plaintext validation. + """ + if base % 0x1000: + self.stats['ring-rejected-unsafe-mapping'] += 1 + return False + try: + with open(f'/proc/{self.pid}/maps', encoding='utf-8') as maps: + for line in maps: + fields = line.split() + lo, hi = (int(v, 16) for v in fields[0].split('-')) + if lo <= base < hi: + perms = fields[1] + path = fields[5] if len(fields) > 5 else '' + if not (perms.startswith('rw') and 'p' in perms + and not path and end <= hi): + self.stats['ring-rejected-unsafe-mapping'] += 1 + return False + return True + except (OSError, ValueError, IndexError): + self.stats['ring-mapping-unavailable'] += 1 + return False + self.stats['ring-mapping-unavailable'] += 1 + return False + # ---- finding the ring ------------------------------------------------------------------- def start_quick_discover(self, conn, C, items, cstart): """Kick a background quick_discover for a clean-start chunk, if none is running.""" if self.base is not None or self.quick_busy: return + if time.monotonic() < getattr(self, 'next_quick', 0.0): + return + if not any(it[0] == 'E' for it in items): + return self.quick_busy = True - rel = [(it[3] - cstart + 4, it[3] - cstart + len(it[2])) for it in items if it[0] == 'E'] - threading.Thread(target=self.quick_discover, args=(conn, C, rel), name='actor-quick', daemon=True).start() + # 0x0229 has only a 2-byte clear header; its payload starts at +2. + # Mask *all* encrypted body bytes during quick ring discovery. + candidates = [(it[3] - cstart, it[2]) for it in items if it[0] == 'E' + and it[3] >= cstart and it[3] - cstart + len(it[2]) <= len(C)] + rel = [(it[3] - cstart + self.FIXED_ENC.get(it[1], 4), + it[3] - cstart + len(it[2])) for it in items if it[0] == 'E'] + threading.Thread(target=self.quick_discover, args=(conn, C, rel, candidates), + name='actor-quick', daemon=True).start() - def quick_discover(self, conn, C, enc_rel): - """Cheap ring search: the region whose bytes at +0x40 equal this whole chunk (encrypted - bodies masked). Run ~20 ms after a clean-start chunk arrives, while its copy is there.""" + def quick_discover(self, conn, C, enc_rel, candidates=()): + """Read-only ring search against this session's chunk at +0x40. + + Large chunks use 64 matching clear bytes. For isolated encrypted actors, + require a plausible decrypted actor at the predicted position instead; + matching a 4-byte ciphertext header by itself is NOT sufficient. + """ try: mask = bytearray(len(C)) for a, b in enc_rel: - a, b = max(0, a), max(0, b) - mask[a:b] = b'\1' * (b - a) - if len(C) - sum(mask) < 64: + a, b = max(0, min(len(C), a)), max(0, min(len(C), b)) + if b > a: + mask[a:b] = b'\1' * (b - a) + clear = len(C) - sum(mask) + if clear < 64 and not candidates: + return False + self.stats['quick-attempts'] += 1 + # Search inside each writable mapping, not just at mapping+0x40. + # A packet at offset `off` identifies the ring at hit-off-HDR. + # Use an actual validated plaintext actor as anchor so that a + # coincidental clear header never establishes the ring. + anchor = next(((off, pkt) for off, pkt in candidates if len(pkt) >= 15), None) + if anchor is None: return False - time.sleep(0.02) - for lo, hi, _ in regions(self.pid): + off, pkt = anchor + header = pkt[:self.FIXED_ENC.get(int.from_bytes(pkt[:2], 'little'), 4)] + window = 0x100000 # bounded 1MiB reads; no giant allocation per mapping + for lo, hi, _ in regions(self.pid, max_size=MAX_REGION): if self.base is not None: return False if hi - lo < self.HDR + len(C): continue - R = self.read(lo + self.HDR, len(C)) - if len(R) == len(C) and all(m or r == c for r, c, m in zip(R, C, mask)): - with self.lock: - if self.base is None: - self._set_ring(lo, hi, f'quick: chunk of {len(C)} bytes at +0x40') - return True + pos = lo + while pos < hi: + n = min(window, hi - pos) + block = self.read(pos, n) + if not block: + pos += n + continue + i = block.find(header) + while i >= 0: + candidate = pos + i - self.HDR - off + if candidate >= lo and candidate + self.HDR + len(C) <= hi: + slot = candidate + self.HDR + R = self.read(slot, len(C)) + if (len(R) == len(C) and + all(m or r == c for r, c, m in zip(R, C, mask)) and + any(self.copy_ok(p, R[o:o + len(p)]) for o, p in candidates)): + if not self._ring_mapping_allowed(candidate, candidate + self.HDR + len(C)): + i = block.find(header, i + 1) + continue + with self.lock: + if self.base is None: + self._set_ring(candidate, hi, + f'quick: internal ring, clear={clear}, anchor={header.hex()}') + self.stats['ring-quick-actor-verified'] += 1 + return True + i = block.find(header, i + 1) + pos += max(1, len(block) - len(header) + 1) + self.stats['quick-miss'] += 1 + return False finally: self.quick_busy = False + self.next_quick = time.monotonic() + self.SCAN_BACKOFF return False def _set_ring(self, lo, hi, why): # caller holds self.lock self.base, self.hi = lo, hi self.region = (lo, hi) self.fails = 0 - try: - with open(self.cache, 'w') as f: - f.write(f'{lo:x}') - except OSError: - pass + self.ring_epoch = getattr(self, 'ring_epoch', 0) + 1 + self.stats['ring-discovered'] += 1 print(f'[actor] decrypted recv ring: 0x{lo:x}-0x{hi:x} ({why})') if self.on_ring: self.on_ring(lo) @@ -189,7 +387,16 @@ def discover(self, conn, pkt, off, cstart): if c and c[0][1] + c[0][2] >= 24 and (best is None or c[0][0] > best[0][0]): best = (c[0], lo, hi) if best: - self._set_ring(best[1], best[2], f'copy @0x{best[0][3]:x}, ctx pre{best[0][1]}/post{best[0][2]}') + # d is relative to `lo` only because the mapping was used as an + # initial coordinate; infer actual ring from verified packet slot. + slot = best[0][3] + ringbase = slot - self.HDR - (off - cstart) + if ringbase < best[1] or ringbase >= best[2]: + return False + if not self._ring_mapping_allowed(ringbase, slot + len(pkt)): + return False + self._set_ring(ringbase, best[2], + f'copy @0x{slot:x}, ctx pre{best[0][1]}/post{best[0][2]}') return best is not None def _scan_for_ring(self, conn, pkt, off, cstart): @@ -241,14 +448,15 @@ def _best(self, buf, bufbase, conn, pkt, off, cstart, exp_d=None): if exp_d is None: exp_d = self.HDR ring = self.base or bufbase - hdr = pkt[:4] + pid = pkt[0] | (pkt[1] << 8) + hdr = pkt[:self.FIXED_ENC.get(pid, 4)] i = buf.find(hdr) while i != -1: P = buf[i:i + L] - if len(P) == L and P != pkt and valid_actor(P): + if len(P) == L and self.copy_ok(pkt, P): pre, post = self._ctx(buf, i, conn, off, L) sc = pre + post - if P[5:9] in near: + if pid not in (0x0229, 0x043f, 0x0983) and P[5:9] in near: sc += 8 # neighbouring cleartext packets name this actor d = (bufbase + i - ring) - (off - cstart) if d == exp_d: @@ -289,7 +497,9 @@ def resolve(self, conn, pid, pkt, off, cstart, snap=None, exp_d=None, live=True, for buf, bufbase, src in bufs: if buf is None: buf = self.read(self.base, min(self.hi - self.base, self.LIVE_READ)) - c = [x for x in self._best(buf, bufbase, conn, pkt, off, cstart, exp_d) if x[5] != stale] + c = [x for x in self._best(buf, bufbase, conn, pkt, off, cstart, exp_d) + if x[5] != stale and (pid not in (0x0229, 0x043f, 0x0983) or + (x[1] + x[2] >= 8 and x[4] == (self.HDR if exp_d is None else exp_d)))] if c and c[0][0] >= 16: break second = c[1][0] if len(c) > 1 else 0 @@ -300,9 +510,10 @@ def resolve(self, conn, pid, pkt, off, cstart, snap=None, exp_d=None, live=True, self.fails += 1 if self.fails >= self.REDISCOVER_AFTER: print(f'[actor] {self.REDISCOVER_AFTER} unverified in a row - rediscovering the recv ring') - self.base = self.region = None + self.base = self.hi = self.region = None self.fails = 0 self.next_scan = 0.0 + self.stats['ring-invalidated'] += 1 if c: print(f'[actor] 0x{pid:04x} unverified: best {c[0][:3]} second {second} ({len(c)} cands)') return None @@ -316,12 +527,16 @@ def settle(self, e, now, delay, settle_wait=0.03): T (its agent timestamp > T, or none arrived settle_wait after T) - so it cannot be the next chunk's copy. Snapshots attached to the entry all predate the next chunk.""" E = [i for i, it in enumerate(e['items']) if it[0] == 'E'] + e['polls'] = e.get('polls', 0) + 1 base = self.base if base is None: # no ring yet: full discovery (score rule) if now - e['t'] < min(delay, self.DISCOVER_WAIT): return None - return {i: self.resolve(e['conn'], e['items'][i][1], e['items'][i][2], e['items'][i][3], + result = {i: self.resolve(e['conn'], e['items'][i][1], e['items'][i][2], e['items'][i][3], e['cstart'], snaps=e['snaps'], exp_d=e['d']) for i in E} + for i in E: + e.setdefault('reasons', {})[i] = ('ok' if result[i] else 'ring-not-found') + return result closed = e['closed'] # read once: the worker may set it any time if closed is None: fresh = True @@ -329,31 +544,123 @@ def settle(self, e, now, delay, settle_wait=0.03): _, pid, pkt, off = e['items'][i] P = self.read(self.slot(e['d'], off, e['cstart'], base), len(pkt)) e['ev'][i].append((now * 1000, P)) - fresh &= self.copy_ok(pkt, P) and P != e['B'].get(i) + raw_valid = self.copy_ok(pkt, P) + safe_empty_name = (i in e.get('Bsafe_items', set()) and + e.get('preimage_ring_base') == base and + e.get('preimage_ring_epoch') == getattr(self, 'ring_epoch', 0) and + P != e['B'].get(i) and + fresh_empty_name_actor(pkt, P)) + valid = (raw_valid or safe_empty_name) and P != e['B'].get(i) + # Diagnostic details are computed ONCE at decision time, not on + # every 5ms poll. The samples already live in e['ev'][i]. + if valid: + e.setdefault('first_valid_ms', {}).setdefault(i, round((now - e['t']) * 1000, 2)) + fresh &= valid if e['tfinal'] is None and (fresh or now - e['t'] >= delay): e['tfinal'] = now # evidence complete as of this read if e['tfinal'] is None or now - e['tfinal'] < settle_wait: return None cut = e['tfinal'] * 1000 + 1 + # The worker may have recorded a new receive during the read above. + # Never treat bytes observed after that receive as the old slot. + if e['closed'] is not None: + cut = min(cut, e['closed'] - 1) else: cut = closed - 1 # reads after the next chunk arrived: unsafe out = {} for i in E: _, pid, pkt, off = e['items'][i] ev = [(T, P) for T, P in e['ev'][i] if T < cut] + # Preserve the first/last actual live slot reads independently of + # evidence cutoff; compute diagnostics only after polling is over. + samples = e['ev'][i] + for key, sample in (('first_read', samples[0] if samples else None), + ('last_read', samples[-1] if samples else None)): + if sample is None: + continue + T, P = sample + details = copy_validation(pkt, P) + e.setdefault(key, {})[i] = { + 'ms': round(T - e['t'] * 1000, 2), + 'bytes_read': len(P), + 'header_match': details['header_match'], + 'copy_ok': details['accepted'], + 'different_from_before': P != e['B'][i] if i in e['B'] else None, + 'verified': details['accepted'] and P != e['B'].get(i), + 'validation': details, + } rel = e['d'] + off - e['cstart'] for data, _sbase, ts in list(e['snaps']): if 0 <= rel and rel + len(pkt) <= len(data): ev.append((ts, data[rel:rel + len(pkt)])) ev.sort(key=lambda x: x[0]) + # Diagnose rejected names by category, without logging raw name data. + if pid in (0x09ff, 0x09fd) and samples: + last = samples[-1][1] + details = copy_validation(pkt, last) + if (details['length_match'] and details['header_match'] and + details['object_type_ok'] and details['walk_speed_ok'] and + not details['name_format_ok']): + e.setdefault('name_rejection', {})[i] = actor_name_rejection(last[-24:]) + # Summarize validated/rejected evidence without recording packet + # bytes. This differentiates bad structure from bad provenance. + e.setdefault('evidence_validations', {})[i] = { + 'observations': len(ev), + 'structurally_valid': sum(self.copy_ok(pkt, P) for _, P in ev), + 'snapshots_usable': sum(0 <= rel and rel + len(pkt) <= len(data) + for data, _, _ in e['snaps']), + } V = next((P for T, P in reversed(ev) if self.copy_ok(pkt, P)), None) + # A valid candidate may be rejected later by provenance or + # context scoring. Count this separately from true timeouts. + e.setdefault('candidate_seen', {})[i] = V is not None + # Boolean-only failure taxonomy; no actor GID, name or packet bytes. + e.setdefault('failure_evidence', {})[i] = { + 'slot_observations': len(samples), + 'usable_observations': len(ev), + 'valid_observations': e['evidence_validations'][i]['structurally_valid'], + 'preimage_present': i in e['B'], + 'preimage_safe': i in e.get('Bsafe_items', set()), + 'slot_overwritten': closed is not None, + 'candidate_valid_but_not_accepted': V is not None, + } + e.setdefault('snapshot_read_count', {})[i] = sum( + 0 <= rel and rel + len(pkt) <= len(data) + for data, _base, _ts in e['snaps']) B = e['B'].get(i) - if V is not None and i in e['B'] and V != B: + # Only live reads of the predicted slot can use this exception. + # Snapshots and scored copies stay strictly name-validated; a read + # at or after the next recv is excluded by the existing cutoff. + empty_name = None + if (i in e.get('Bsafe_items', set()) and + e.get('preimage_ring_base') == base and + e.get('preimage_ring_epoch') == getattr(self, 'ring_epoch', 0) and + B is not None): + empty_name = next((P for T, P in reversed(samples) + if T < cut and P != B and fresh_empty_name_actor(pkt, P)), None) + # Prefer ordinary validated copies over the exceptional path. + if V is None and empty_name is not None: + with self.lock: + out[i] = self._accept(empty_name, 'fresh-empty-name', True) + e.setdefault('reasons', {})[i] = ('ok-empty-name-fresh' if out[i] + else 'duplicate-rejected') + continue + # A capture made BEFORE recv processing gives independent evidence of a + # transition from the pre-decrypt bytes. If the pre-image is unavailable, + # don't upgrade a plausible-looking cached ring slot to verified plaintext. + if (V is not None and i in e['B'] and V != B and + (pid not in (0x0229, 0x043f, 0x0983) or i in e.get('Bsafe_items', set()))): with self.lock: out[i] = self._accept(V, 'fresh', True) + e.setdefault('reasons', {})[i] = 'ok' if out[i] else 'duplicate-rejected' continue # the slot never changed: with a pre-decrypt image that's proof the copy there is stale out[i] = self.resolve(e['conn'], pid, pkt, off, e['cstart'], exp_d=e['d'], live=closed is None, snaps=e['snaps'], - stale=B if e.get('Bsafe') else None) + stale=B if i in e.get('Bsafe_items', set()) else None) + e.setdefault('reasons', {})[i] = ('ok' if out[i] else + 'ring-not-found' if self.base is None else + 'overwritten-before-verification' if closed is not None and not ev else + 'correlation-mismatch' if any(self.copy_ok(pkt, P) for T, P in ev) else + 'timeout-no-valid-copy' if now - e['t'] >= delay else 'no-valid-copy') return out diff --git a/relay/cli.py b/relay/cli.py index 18d647c..a1bdf9a 100644 --- a/relay/cli.py +++ b/relay/cli.py @@ -7,7 +7,7 @@ from . import ROOT from .agent import Agent, pick_port, pid_of_port from .config import DEFAULT_CONFIG, load_config_defaults -from .console import CaptureLog +from .console import CaptureLog, install_console_filter from .packets import WireFilter, load_lengths from .relay import Relay from .runtime import Capture @@ -23,6 +23,8 @@ def parse_args(argv=None): ap.add_argument('--name', default='uaRO.exe') ap.add_argument('--port', type=int) ap.add_argument('--out', default=os.path.join(ROOT, 'relay_log.jsonl')) + ap.add_argument('--verbose', action=argparse.BooleanOptionalAction, default=False, + help='show all console diagnostics and packet hex (default: concise console; JSONL unchanged)') ap.add_argument('--relay', default='127.0.0.1:2350', help='feed decrypted server->client packets to this host:port ("" disables). ' 'Default is the consumer XKore 1 listener (XKore_port, default 2350).') @@ -44,7 +46,7 @@ def parse_args(argv=None): ap.add_argument('--decrypt-actors', action=argparse.BooleanOptionalAction, default=True, help='--wire: fill in the encrypted 09fd/09ff bodies from the client\'s decrypted ' 'recv ring (verified against the cleartext stream; unverifiable ones are dropped)') - ap.add_argument('--actor-delay', type=float, default=0.15, + ap.add_argument('--actor-delay', type=float, default=0.30, help='max seconds a chunk with 09fd/09ff is held waiting for its decrypted copy ' '(later chunks queue behind it, so the consumer still gets the exact stream order)') ap.add_argument('--probe-actors', action='store_true', @@ -99,9 +101,21 @@ def parse_args(argv=None): '(uinput/ydotool, passa no Gepard) que faz o CLIENTE montar o carrier, que o ' 'scrambler reescreve. Substitui o sidecar action_trigger.py. Requer ydotoold ' 'rodando e a janela do cliente focável; efeito colateral = input real no seat0.') + ap.add_argument('--synth-backend', choices=('ydotool', 'gamescope-eis'), default='ydotool', + help='--synth-input: ydotool/seat0 (legado) ou Gamescope EIS isolado; ' + 'o backend EIS não foca janelas KDE nem usa ydotool.') + ap.add_argument('--synth-eis-socket', default='', + help='--synth-backend gamescope-eis: socket privado LIBEI_SOCKET da sessão Gamescope.') + ap.add_argument('--synth-eis-socket-file', default='', + help='--synth-backend gamescope-eis: arquivo privado 0600 contendo o ' + 'LIBEI_SOCKET criado pelo wrapper de inicialização.') ap.add_argument('--synth-wid', default='', help='--synth-input: id (hex wmctrl) da janela do cliente a focar; vazio = autodetecta ' 'pelo título (world of your dream / uaRO / gepard shield / steam_app_).') + ap.add_argument('--itemuse-input', action=argparse.BooleanOptionalAction, default=False, + help='Before each consumer 0439, synchronously press a game key and release it.') + ap.add_argument('--itemuse-input-key', default='END', + help='Game key pressed before SendPacket 0439 (default END).') ap.add_argument('--synth-key-0437', default='INSERT', help='--synth-input: tecla para armar o carrier do 0437 (ataque). Default INSERT.') ap.add_argument('--synth-key-0438', default='9', @@ -251,6 +265,7 @@ def build_feed(a, drop): def main(argv=None): a = parse_args(argv) + install_console_filter(a.verbose) relay, wire = build_feed(a, parse_ids(a.drop_ids)) port = a.port or pick_port(a.name) @@ -259,7 +274,7 @@ def main(argv=None): if pid is None and reads_memory: sys.exit(f'[!] no process found listening on gadget port {port} (needed to read its memory)') - log = CaptureLog(a.out) + log = CaptureLog(a.out, verbose=a.verbose) capture = None def on_message(payload): # Frida's thread: queue only diff --git a/relay/console.py b/relay/console.py index 3591840..c1a346a 100644 --- a/relay/console.py +++ b/relay/console.py @@ -1,5 +1,6 @@ """The sanitized JSONL capture log and the one-line console view of each message.""" import json +import threading import time from .safety import sanitize @@ -10,14 +11,82 @@ def _pid(v): return f'0x{v:04x}' if isinstance(v, int) else '?' -def show(m): +# Console filtering never changes the sanitized JSONL capture or packet delivery. +# Only known high-frequency success/debug lines are hidden. Unknown lines, failures, +# warnings and startup status remain visible by default. +_NOISY_PREFIXES = ('[inject] skip typ=', '[inject] inbound ', '[inject] target-select ', + '[inject] 035f -> native dispatch', '[synth] armed ', + '[sendcall] SENT ', '[move ]', '[actor] stats:', + '[send] stats:', '[move] stats:', '[synth/eis] DRY ', + ' $ ', '[itemuse] input ') + + +def concise_line(line): + clean = line.lstrip() + if clean.startswith(('[!]', '[frida error]', '[relay] connection dropped', + '[relay] consumer gone', '[egress] drop', '[egress] dispatch error', + '[actor] 20 unverified', '[actor] decrypted recv ring')): + return True + return not clean.startswith(_NOISY_PREFIXES) + + +class _FilteredStdout: + """Line-wise, thread-safe filter for diagnostic prints outside CaptureLog.""" + def __init__(self, stream): + self.stream = stream + self.lock = threading.RLock() + self.parts = {} + + def write(self, value): + tid = threading.get_ident() + with self.lock: + text = self.parts.pop(tid, '') + value + *lines, remainder = text.split('\n') + for line in lines: + if concise_line(line): + self.stream.write(line + '\n') + if remainder: + self.parts[tid] = remainder + return len(value) + + def flush(self): + with self.lock: + self.stream.flush() + + def __getattr__(self, key): + return getattr(self.stream, key) + + +def install_console_filter(verbose=False): + import sys + if isinstance(sys.stdout, _FilteredStdout): + sys.stdout = sys.stdout.stream + if not verbose: + sys.stdout = _FilteredStdout(sys.stdout) + + +def show(m, verbose=True): + if not verbose: + tag = m.get('tag') + if tag == 'RECV-PLAIN-UNAVAILABLE': + print(f"[recv] plaintext indisponível id=0x{m.get('id', '?')}") + elif tag == 'RECV-LOST': + print(f"[recv] pacote perdido id=0x{m.get('id', '?')} len={m.get('len', '?')}") + elif tag == 'SENDCALL' and m.get('hit') in ('error', 'failed', 'exception'): + print(f"[sendcall] {m.get('hit')}: {m.get('e', 'erro')}") + elif tag == 'TRACE' and m.get('erro'): + print(f"[trace] erro: {m.get('erro')}") + return tag, i = m.get('tag'), m.get('id', '????') if tag == 'ITEMUSE-STAGE': print(f"[itemuse] {m.get('ts')} {m.get('stage')} item_index={m.get('item_index')} " f"queue_len={m.get('queue_len', '-')} chunk_len={m.get('chunk_len', '-')}") return if tag == 'RECV-PLAIN': - print(f"[<= PLAIN{'?' if m.get('uncertain') else ' '}] id=0x{i} len={m['len']:<4} @{m.get('dest','?')} C={m['C'][:32]}... P={m['P'][:48]}...") + payload = m.get('P', m.get('C', '')) + print(f"[<= PLAIN{'?' if m.get('uncertain') else ' '}] id=0x{i} len={m['len']:<4} @{m.get('dest','?')} P={payload[:48]}...") + elif tag == 'RECV-PLAIN-UNAVAILABLE': + print(f'[<= PLAIN INDISPONIVEL] id=0x{i} — APENAS RECV CIFRADO; plaintext nao recuperado') elif tag == 'SEND': print(f"[=> SEND ] id=0x{i} len={m['len']:<4} {m['C'][:48]}...") elif tag == 'SEND-PLAIN': @@ -109,8 +178,10 @@ def show(m): class CaptureLog: """Writes each message, sanitized, as one JSON line, and prints it. Worker thread only.""" - def __init__(self, path): + def __init__(self, path, verbose=True): + self.verbose = verbose self.path = path + self._lock = threading.Lock() self.f = open(path, 'w', buffering=1) # line-buffered: survives os._exit on detach def write(self, m): @@ -118,5 +189,6 @@ def write(self, m): m.pop('data', None) # binary attachments never go to the log m.pop('ptr', None) sanitize(m) - self.f.write(json.dumps(m) + '\n') - show(m) + with self._lock: + self.f.write(json.dumps(m) + '\n') + show(m, verbose=self.verbose) diff --git a/relay/gamescope_eis.py b/relay/gamescope_eis.py new file mode 100644 index 0000000..d435cc0 --- /dev/null +++ b/relay/gamescope_eis.py @@ -0,0 +1,203 @@ +"""libei sender for a *specific* Gamescope EIS socket (never the KDE seat). + +Calls are restricted to one background actuator thread at a time by a lock. +Does not use xdotool, ydotool, KDE focus, portals or /dev/uinput. +""" +import ctypes +import os +import select +import stat +import threading +import time + +# libei 1.6+ capability values are bit flags; keyboard == 1 << 2. +_CAP_KEYBOARD = 4 + + +class GamescopeEISError(RuntimeError): + pass + + +def resolve_socket(path="", path_file=""): + """Resolve an explicitly selected Gamescope EIS socket, refusing implicit KDE input.""" + if path and path_file: + raise GamescopeEISError("provide only one of --synth-eis-socket / --synth-eis-socket-file") + if path_file: + try: + st = os.stat(path_file) + if st.st_uid != os.getuid() or st.st_mode & 0o077: + raise GamescopeEISError("EIS path file must be owned by this user and private (0600)") + with open(path_file, encoding="utf-8") as f: + path = f.read().strip() + except OSError as e: + raise GamescopeEISError("cannot read EIS path file: %s" % e) from e + if not path: + raise GamescopeEISError("Gamescope EIS socket not provided; refusing to send to desktop seat") + if chr(0) in path or chr(10) in path: + raise GamescopeEISError("invalid EIS socket path") + if not os.path.isabs(path): + runtime = os.environ.get("XDG_RUNTIME_DIR") + if not runtime: + raise GamescopeEISError("XDG_RUNTIME_DIR missing for relative EIS socket") + path = os.path.join(runtime, path) + # Never route to the KDE compositor's own EIS socket, even if a private + # locator file or a symlink mistakenly points at it. Fail closed. + if not os.path.basename(os.path.realpath(path)).startswith('gamescope-'): + raise GamescopeEISError('EIS target is not a Gamescope socket: %s' % path) + try: + if not stat.S_ISSOCK(os.stat(path).st_mode): + raise GamescopeEISError("EIS target is not a Unix socket: %s" % path) + except OSError as e: + raise GamescopeEISError("Gamescope EIS socket unavailable: %s" % path) from e + return path + + +class _LibEI: + def __init__(self, library=None): + if library is None: + try: + library = ctypes.CDLL("libei.so.1") + except OSError as e: + raise GamescopeEISError("libei.so.1 not installed") from e + self.lib = library + P, I, U, B = ctypes.c_void_p, ctypes.c_int, ctypes.c_uint32, ctypes.c_bool + def api(name, result, *args): + fn = getattr(library, name) + fn.restype, fn.argtypes = result, list(args) + return fn + self.new = api("ei_new_sender", P, P) + self.name = api("ei_configure_name", None, P, ctypes.c_char_p) + self.setup = api("ei_setup_backend_socket", I, P, ctypes.c_char_p) + self.fd = api("ei_get_fd", I, P) + self.dispatch = api("ei_dispatch", None, P) + self.get_event = api("ei_get_event", P, P) + self.event_type = api("ei_event_type_to_string", ctypes.c_char_p, I) + self.ev_kind = api("ei_event_get_type", I, P) + self.ev_seat = api("ei_event_get_seat", P, P) + self.ev_device = api("ei_event_get_device", P, P) + self.ev_unref = api("ei_event_unref", P, P) + self.bind = api("ei_seat_bind_capabilities", None, P) # varargs, NULL-terminated + self.has_cap = api("ei_device_has_capability", B, P, I) + self.device_ref = api("ei_device_ref", P, P) + self.device_unref = api("ei_device_unref", P, P) + self.emulate = api("ei_device_start_emulating", None, P, U) + self.key = api("ei_device_keyboard_key", None, P, U, B) + self.now = api("ei_now", ctypes.c_uint64, P) + self.frame = api("ei_device_frame", None, P, ctypes.c_uint64) + self.unref = api("ei_unref", P, P) + + +class GamescopeEIS: + """Keyboard on one nominated Gamescope socket. No seat0 fallback, ever.""" + + def __init__(self, socket="", socket_file="", library=None, connect_timeout=2.0): + self.socket = socket + self.socket_file = socket_file + self.timeout = connect_timeout + self._library = library + self.api = None # libei is loaded only for real sends, not --synth-dry + self._lock = threading.Lock() + self.ctx = None + self.dev = None + self.ready = False + + def close(self): + # Called while holding lock or when object no longer used. + if self.dev: + self.api.device_unref(self.dev) + self.dev = None + if self.ctx: + self.api.unref(self.ctx) + self.ctx = None + self.ready = False + + def _drain_events(self): + self.api.dispatch(self.ctx) + while True: + ev = self.api.get_event(self.ctx) + if not ev: + break + try: + kind = self.api.event_type(self.api.ev_kind(ev)) + if kind == b"EI_EVENT_DISCONNECT": + raise GamescopeEISError("Gamescope EIS disconnected") + if kind == b"EI_EVENT_SEAT_ADDED": + seat = self.api.ev_seat(ev) + if seat: + self.api.bind(seat, ctypes.c_int(_CAP_KEYBOARD), ctypes.c_void_p(None)) + elif kind == b"EI_EVENT_DEVICE_ADDED": + device = self.api.ev_device(ev) + if device and self.api.has_cap(device, _CAP_KEYBOARD) and not self.dev: + self.dev = self.api.device_ref(device) + elif kind == b"EI_EVENT_DEVICE_REMOVED": + if self.dev == self.api.ev_device(ev): + self.api.device_unref(self.dev) + self.dev, self.ready = None, False + elif kind == b"EI_EVENT_DEVICE_PAUSED": + if self.dev == self.api.ev_device(ev): + self.ready = False + elif kind == b"EI_EVENT_DEVICE_RESUMED": + if self.dev == self.api.ev_device(ev): + self.api.emulate(self.dev, 0) + self.ready = True + finally: + self.api.ev_unref(ev) + + def _connect(self): + path = resolve_socket(self.socket, self.socket_file) + if self.api is None: + self.api = _LibEI(self._library) + self.ctx = self.api.new(None) + if not self.ctx: + raise GamescopeEISError("ei_new_sender failed") + try: + self.api.name(self.ctx, b"RO-Client-Relay synth") + result = self.api.setup(self.ctx, os.fsencode(path)) + if result < 0: + raise GamescopeEISError("EIS connection refused: error %d" % result) + fd = self.api.fd(self.ctx) + if fd < 0: + raise GamescopeEISError("libei returned invalid descriptor") + deadline = time.monotonic() + self.timeout + while time.monotonic() < deadline: + self._drain_events() + if self.ready: + return + rem = max(0, deadline - time.monotonic()) + select.select([fd], [], [], min(rem, 0.15)) + raise GamescopeEISError("EIS keyboard device not ready (timeout)") + except Exception: + self.close() + raise + + def send_key(self, keycode, hold_ms=120): + with self._lock: + if self.ctx: + try: + self._drain_events() + except GamescopeEISError: + self.close() + if not self.ready: + self.close() + self._connect() + dev = self.dev + try: + # Always try key-up, including if an exception occurs after key-down. + try: + self.api.key(dev, int(keycode), True) + self.api.frame(dev, self.api.now(self.ctx)) + self.api.dispatch(self.ctx) # flush key-down BEFORE the 120ms hold + time.sleep(max(0, hold_ms) / 1000.0) + finally: + self.api.key(dev, int(keycode), False) + self.api.frame(dev, self.api.now(self.ctx)) + self.api.dispatch(self.ctx) # always flush key-up + self._drain_events() + if not self.ready: + raise GamescopeEISError("EIS keyboard disconnected during event") + return 0 + except BaseException: + # Transport/key failure must not leave a stale 'ready' context + # reused on the next synthetic action. + self.close() + raise diff --git a/relay/packets.py b/relay/packets.py index 6ebe56e..7c6a1f9 100644 --- a/relay/packets.py +++ b/relay/packets.py @@ -55,6 +55,8 @@ def __init__(self, lengths, drop): self.L, self.drop = lengths, drop self.conns = {} # conn -> {'buf', 'start', 'raw', 'off'} self.on_packet = None # callback(conn, pid, stream_offset, packet) + self.on_diag = None # optional passive packet-boundary log + self.diag_socks = {} # connection -> server socket for redaction self._map_seen = False # server phase: map traffic was observed self._char_return = set() # subsequent char TCP identities @@ -64,6 +66,7 @@ def observe_socket(self, conn, sock): Only suppress the bare four-byte AID preamble on this transition. Never suppress an actual recognized packet or any payload within it. """ + self.diag_socks[str(conn)] = sock if sock.endswith(':5121'): self._map_seen = True elif sock.endswith(':6121') and self._map_seen and conn not in self.conns: @@ -102,6 +105,14 @@ def push_items(self, conn, data, enc=()): pos = 4; st['start'] = False continue pid = buf[pos] | (buf[pos + 1] << 8) + if self.on_diag: + self.on_diag({'tag': 'RECV-BOUNDARY', 'event': 'unknown', + 'conn': str(conn), 'offset': st['off'] + pos, + 'id': f'{pid:04x}', 'previous': st.get('last_id'), + 'after_09ff': st.get('last_id') == '09ff', + 'remaining': len(buf)-pos, + 'prefix': buf[pos:pos+16].hex(), + 'preceding': buf[max(0,pos-16):pos].hex()}) print(f'[wire] conn {conn}: unknown id 0x{pid:04x} - passing this connection through raw') st['raw'] = True out += buf[pos:]; pos = len(buf) @@ -111,6 +122,15 @@ def push_items(self, conn, data, enc=()): st['start'] = False pid = buf[pos] | (buf[pos + 1] << 8) pkt = buf[pos:pos + L] + if self.on_diag and (pid == 0x09ff or st.get('last_id') == '09ff'): + self.on_diag({'tag': 'RECV-BOUNDARY', 'event': 'frame', + 'conn': str(conn), 'offset': st['off'] + pos, + 'id': f'{pid:04x}', 'previous': st.get('last_id'), + 'length': L, 'end': st['off'] + pos + L, + 'next_prefix': buf[pos+L:pos+L+16].hex(), + 'tail': pkt[-8:].hex(), + 'next_available': len(buf)-pos-L}) + st['last_id'] = f'{pid:04x}' if self.on_packet: self.on_packet(conn, pid, st['off'] + pos, pkt) if pid in enc: diff --git a/relay/relay.py b/relay/relay.py index b00dac7..027ed36 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -173,6 +173,7 @@ def frame(self, data, typ='R'): def send(self, data, typ='R'): data = self.frame(data, typ) + disconnected = False with self.lock: s = self.sock if s is None: @@ -188,11 +189,20 @@ def send(self, data, typ='R'): except OSError: pass self.sock = None - return - self.sent += len(data) - if self.dump: # exact framed bytes the consumer got, for offline replay - self.dump.write(data) - self.dump.flush() + disconnected = True + else: + self.sent += len(data) + if self.dump: # exact framed bytes the consumer got, for offline replay + self.dump.write(data) + self.dump.flush() + # The native-dispatch lifecycle relies on this callback even when the + # disconnect is discovered by sendall(), rather than recv()/select(). + # Call it outside the lock (the handler can post messages and recapture). + if disconnected and self.on_disconnect is not None: + try: + self.on_disconnect() + except Exception as e: + print(f'[relay] on_disconnect error: {e}') class PacketAligner: diff --git a/relay/runtime.py b/relay/runtime.py index 7f6683d..b09e733 100644 --- a/relay/runtime.py +++ b/relay/runtime.py @@ -50,6 +50,7 @@ def __init__(self, opts, pid, relay, wire, log, post): if self.dec: print(f'[*] --decrypt-actors: 09fd/09ff bodies from the client recv ring (hold <= {opts.actor_delay*1000:.0f}ms)') self.dec.on_ring = self._on_ring_found + wire.on_diag = (lambda m: log.write(m) if wire.diag_socks.get(m['conn'], '').endswith(':5121') else None) # map-only; no login bytes self.moves = MoveTracker(log) # decodifica o destino do 0087 (texto claro) recv_cbs = ([probe.on_packet] if probe else []) + [self.moves.on_recv_packet, self.on_map_boundary] wire.on_packet = recv_cbs[0] if len(recv_cbs) == 1 else \ @@ -138,6 +139,15 @@ def send_watch_waiting(self): return self.sendwatch is not None and self._sw_posted is None def report_stats(self): + if not getattr(self.opts, 'verbose', False): + if self.dec and self.dec.stats: + stats = self.dec.stats + recovered = sum(value for key, value in stats.items() if key.startswith('ok-')) + failures = stats.get('unverified', 0) + stats.get('no-ring', 0) + ring = 'ready' if self.dec.base is not None else 'searching' + print(f'[summary] ring={ring} recovered={recovered} ' + f'unverified={failures} invalid_rings={stats.get("ring-rejected-unsafe-mapping", 0)}') + return if self.dec and self.dec.stats: print(f'[actor] stats: {dict(self.dec.stats)}') if self.sendwatch and self.sendwatch.stats: diff --git a/relay/safety.py b/relay/safety.py index 058d7e3..67e556f 100644 --- a/relay/safety.py +++ b/relay/safety.py @@ -50,7 +50,9 @@ def _hex_head(h, n=8): def sanitize(m): """Strip credential-bearing bytes from a message before it is logged or printed. Login-socket sends and anything HTTP-shaped keep only their first 2 bytes.""" - sensitive = is_login_sock(m.get('sock', '')) and m.get('tag', '').startswith('SEND') + # Inbound login replies may contain session credentials/tokens too. + # Preserve only the non-sensitive prefix for both directions. + sensitive = is_login_sock(m.get('sock', '')) and m.get('tag', '').startswith(('SEND', 'RECV')) is_http = m.get('tag') == 'RECV-HTTP' or looks_like_http(_hex_head(m.get('C', ''))) if sensitive or is_http: # HTTP: by content, not just by port for k in ('C', 'P'): diff --git a/relay/sendwatch.py b/relay/sendwatch.py index a579c38..710d80f 100644 --- a/relay/sendwatch.py +++ b/relay/sendwatch.py @@ -15,17 +15,26 @@ def plaintext_guess(w, C, buf=None, base=None): """The encryptor overwrites the body in place, one byte per write, from one pc (the trailing run of >= 2 same-pc hits). The window right before that run's first write holds the plaintext, if its id matches the sent packet. None when the window doesn't have that shape. Hex in, hex out.""" + if not isinstance(w, dict) or not isinstance(C, str): + return None hits = w.get('hits') or [] - if len(hits) < 2: + if not isinstance(hits, (list, tuple)) or len(hits) < 2: + return None + if any(not isinstance(h, dict) or not isinstance(h.get('pc'), str) + or not isinstance(h.get('win'), str) for h in hits): return None - enc = hits[-1]['pc'] - i = len(hits) - 1 - while i > 0 and hits[i - 1]['pc'] == enc: - i -= 1 - last = packet_at(hits[-1]['win'], buf, base) - if i == 0 or len(hits) - i < 2 or last[:4] != C[:4]: # an encryptor run is >= 2 byte writes + try: + enc = hits[-1]['pc'] + i = len(hits) - 1 + while i > 0 and hits[i - 1]['pc'] == enc: + i -= 1 + last = packet_at(hits[-1]['win'], buf, base) + if i == 0 or len(hits) - i < 2 or last[:4] != C[:4]: + return None + P = packet_at(hits[i - 1]['win'], buf, base) + except (ValueError, TypeError, IndexError): + # An incomplete/malformed write window is not evidence of plaintext. return None - P = packet_at(hits[i - 1]['win'], buf, base) n = min(len(P), len(C)) return P if P[:4] == C[:4] and P[:n] != C[:n] else None diff --git a/relay/synth_input.py b/relay/synth_input.py index e2e7941..8be5f40 100644 --- a/relay/synth_input.py +++ b/relay/synth_input.py @@ -75,8 +75,11 @@ def focus(wid, dry): env = dict(os.environ) env.setdefault('DISPLAY', ':0') # Gerência de janela (não é XTEST input): ativa/levanta a janela XWayland do cliente. - _run(['wmctrl', '-i', '-a', wid], dry, env) - _run(['xdotool', 'windowactivate', wid], dry, env) + rc1, _, _ = _run(['wmctrl', '-i', '-a', wid], dry, env) + rc2, _, _ = _run(['xdotool', 'windowactivate', wid], dry, env) + # A failed focus must NOT be followed by a global seat0 key event. + if rc1 or rc2: + raise RuntimeError('não foi possível focar janela do cliente (%d/%d)' % (rc1, rc2)) if not dry: time.sleep(0.25) @@ -97,11 +100,12 @@ def send_key(name, dry, hold_ms=120): raise RuntimeError('socket ydotoold ausente: %s — inicie o ydotoold (ver cabeçalho)' % sock) # OBSERVADO (2026-10-16): press+release colados (0 ms) NÃO registram no cliente uaRO; é preciso # SEGURAR a tecla ~120 ms. Então: press, hold_ms, release (duas chamadas ydotool via uinput). - _run(['ydotool', 'key', '%d:1' % code], dry, env) - if not dry: + rc_press, _, _ = _run(['ydotool', 'key', '%d:1' % code], dry, env) + if not dry and rc_press == 0: time.sleep(hold_ms / 1000.0) - rc, _, _ = _run(['ydotool', 'key', '%d:0' % code], dry, env) - return rc + # Always attempt release, including when the press failed. + rc_release, _, _ = _run(['ydotool', 'key', '%d:0' % code], dry, env) + return rc_press or rc_release # ---- atuador integrado ---------------------------------------------------------------------- @@ -138,8 +142,10 @@ def start(self): def _resolve_wid(self): if self._wid_resolved: return self._wid - self._wid_resolved = True self._wid = find_wid(self._wid_explicit) + # A session may start before the client window exists: retry on a + # later action rather than caching 'not found' for the entire process. + self._wid_resolved = self._wid is not None if self._wid is None: print('[synth] janela do cliente não encontrada (passe --synth-wid; ' 'synth_input --list) — tecla não injetada', flush=True) @@ -147,6 +153,18 @@ def _resolve_wid(self): print('[synth] janela do cliente wid=%s' % self._wid, flush=True) return self._wid + def send_before_packet(self, key): + """Synchronous barrier: focus and finish key release before sendcall is queued.""" + with self._lock: + wid = self._resolve_wid() + if wid is None: + raise RuntimeError('game window unavailable') + self._focus(wid, self._dry) + rc = self._send(key, self._dry, self._hold_ms) + if rc != 0: + raise RuntimeError('synthetic key failed (exit %s)' % rc) + print('[itemuse] input %s completed before SendPacket' % key, flush=True) + def on_armed(self, pid): """Ponto de entrada do relay: se `pid` tem tecla mapeada e passou o debounce, enfileira o disparo. Ids sem tecla (ex.: 035f andar) são ignorados. Não bloqueia o chamador.""" @@ -172,7 +190,9 @@ def _worker(self): print('[synth] armed id=0x%04x -> tecla %s%s' % (pid, key, ' (DRY)' if self._dry else ''), flush=True) self._focus(wid, self._dry) - self._send(key, self._dry, self._hold_ms) + rc = self._send(key, self._dry, self._hold_ms) + if rc not in (None, 0): + raise RuntimeError('synthetic key failed (exit %s)' % rc) except Exception as e: print('[synth] erro ao injetar %s: %s: %s' % (key, type(e).__name__, e), flush=True) @@ -192,10 +212,31 @@ def build_actuator(opts): } debounce = int(getattr(opts, 'synth_debounce_ms', 500)) dry = bool(getattr(opts, 'synth_dry', False)) - act = SynthActuator(keymap, wid=getattr(opts, 'synth_wid', '') or None, - debounce_ms=debounce, dry=dry) + backend = getattr(opts, 'synth_backend', 'ydotool') + kwargs = {'wid': getattr(opts, 'synth_wid', '') or None} + if backend == 'gamescope-eis': + from .gamescope_eis import GamescopeEIS + conn = GamescopeEIS(socket=getattr(opts, 'synth_eis_socket', ''), + socket_file=getattr(opts, 'synth_eis_socket_file', '')) + # No wmctrl / windowactivate / KDE focus or seat0 input on this path. + kwargs['wid'] = 'gamescope-eis' + kwargs['focuser'] = lambda wid, dry: None + + def send_gamescope(key, dry_run, hold_ms): + code = KEYCODES.get(key.upper()) + if code is None: + raise ValueError('tecla desconhecida: %s' % key) + if dry_run: + print('[synth/eis] DRY key=%s (%d)' % (key, code), flush=True) + return 0 + return conn.send_key(code, hold_ms) + + kwargs['sender'] = send_gamescope + elif backend != 'ydotool': + raise ValueError('backend sintético desconhecido: %s' % backend) + act = SynthActuator(keymap, debounce_ms=debounce, dry=dry, **kwargs) act.start() shown = {('0x%04x' % k): v for k, v in keymap.items()} - print('[*] --synth-input: atuador de tecla integrado ao relay (uinput/ydotool, passa no Gepard); ' - 'keymap=%s debounce=%dms%s' % (shown, debounce, ' DRY' if dry else '')) + print('[*] --synth-input: backend=%s keymap=%s debounce=%dms%s' % + (backend, shown, debounce, ' DRY' if dry else '')) return act diff --git a/relay/wire_feed.py b/relay/wire_feed.py index 4379ed7..3434e1a 100644 --- a/relay/wire_feed.py +++ b/relay/wire_feed.py @@ -28,7 +28,7 @@ def new_chunk(sock, conn, items, cstart, d): res None while undecided, then {item index: decrypted packet or None (drop)}""" return {'sock': sock, 'conn': conn, 'items': items, 'cstart': cstart, 'd': d, 'enc': any(it[0] == 'E' for it in items), 't': time.time(), 'snaps': [], 'closed': None, - 'B': {}, 'tfinal': None, 'ev': collections.defaultdict(list), 'res': None} + 'B': {}, 'Bsafe_items': set(), 'tfinal': None, 'ev': collections.defaultdict(list), 'res': None} class WireFeed: @@ -84,7 +84,7 @@ def _queue(self, m, conn, C, pre): closed_at = m.get('ts') or time.time() * 1000 with self.cond: for p in self.pending: # the next chunk can now overwrite their slots - if p['sock'] == sock and p['closed'] is None: + if p['conn'] == conn and p['closed'] is None: p['closed'] = closed_at self.pending.append(e) self.cond.notify() @@ -99,7 +99,7 @@ def _split_for_decrypt(self, sock, conn, C): items = self.wire.push_items(conn, C, ActorDecrypt.ENC) for it in items: if it[0] == 'E': - dec.note_enc(conn, it[3], len(it[2])) + dec.note_enc(conn, it[3], len(it[2]), it[1]) if diag: diag.on_packet(conn, sock, it[1], it[2], it[3], cstart, left, t_in) base = dec.base @@ -140,7 +140,10 @@ def run(self): self.cond.wait(self.IDLE_WAIT) held = [e for e in self.pending if e['enc'] and e['res'] is None] for e in held: # poll every held chunk's slots, not just the head - e['res'] = self.dec.settle(e, time.time(), self.delay) + result = self.dec.settle(e, time.time(), self.delay) + with self.cond: + if e['res'] is None: + e['res'] = result if self._feed_ready(): time.sleep(self.POLL) # head chunk still waiting for its evidence if self.report_stats and time.time() - last_stats > self.STATS_EVERY: @@ -159,6 +162,49 @@ def _feed_ready(self): self.pending.popleft() data = b''.join(it[1] if it[0] == 'B' else (e['res'].get(i) or b'') for i, it in enumerate(e['items'])) + # Log plaintext ONLY for encrypted actor packets actually recovered. + # Cleartext ('B') packets are not decryption results. Unverified encrypted + # packets are dropped and explicitly reported as unavailable. + if not is_login_sock(e['sock']): + for i, item in enumerate(e['items']): + if item[0] != 'E': + continue + plain = (e['res'] or {}).get(i) + packet_id = f'{item[1]:04x}' + if plain: + self.log.write({ + 'tag': 'RECV-PLAIN', 'sock': e['sock'], 'conn': e['conn'], + 'id': packet_id, 'len': len(plain), 'P': plain.hex(), + 'stage': 'verified-post-decrypt', + }) + else: + self.log.write({ + 'tag': 'RECV-PLAIN-UNAVAILABLE', 'sock': e['sock'], + 'conn': e['conn'], 'id': packet_id, + 'reason': 'only-encrypted-recv-available', + }) + self.log.write({ + 'tag': 'ACTOR-RECOVERY', 'conn': e['conn'], 'id': packet_id, + 'status': 'recovered' if plain else 'missed', + 'reason': e.get('reasons', {}).get(i, 'unclassified'), + 'attempts': e.get('polls', 0), + 'snapshots': len(e['snaps']), + 'first_valid_ms': e.get('first_valid_ms', {}).get(i), + 'elapsed_ms': round((time.time() - e['t']) * 1000, 2), + 'ring_known': self.dec is not None and self.dec.base is not None, + 'first_read': e.get('first_read', {}).get(i), + 'last_read': e.get('last_read', {}).get(i), + 'candidate_seen': e.get('candidate_seen', {}).get(i), + 'usable_snapshots': e.get('snapshot_read_count', {}).get(i, 0), + 'next_recv_before_decision': e['closed'] is not None, + 'preimage_safe': i in e.get('Bsafe_items', set()), + 'evidence_validation': e.get('evidence_validations', {}).get(i), + 'name_rejection': e.get('name_rejection', {}).get(i), + 'failure_evidence': (e.get('failure_evidence', {}).get(i) + if not plain else None), + 'ring_mapping_rejections': (self.dec.stats.get('ring-rejected-unsafe-mapping', 0) if self.dec is not None else 0), + 'ring_mapping_unavailable': (self.dec.stats.get('ring-mapping-unavailable', 0) if self.dec is not None else 0), + }) if data: self.relay.feed(data) diff --git a/relay/writeback.py b/relay/writeback.py index a674089..98415e2 100644 --- a/relay/writeback.py +++ b/relay/writeback.py @@ -374,9 +374,24 @@ def _on_inbound_sendcall(self, payload, typ): print(f"[inject] skip typ={typ!r} len={len(payload)} (need an 'S' packet with an id)") return pid = payload[0] | payload[1] << 8 + # 0439 is fixed at 8 bytes in data/uaro_recvpackets.txt. Never + # trigger a key or native SendPacket with malformed item-use bytes. + if pid == 0x0439 and len(payload) != 8: + print(f'[itemuse] 0x0439 invalid length ({len(payload)}B): dropped') + return # O paceamento dos casts (0438/0af4) é feito no DRENO do agente, não aqui: o consumidor manda # re-tentativas espaçadas que passariam deste filtro mas, enfileiradas, drenam juntas e cada # cast cancela o anterior. O agente deixa passar um cast por janela (ver cast_ms em sendcall). + if pid == 0x0439 and getattr(self.opts, 'itemuse_input', False): + synth = getattr(self, 'synth', None) + if synth is None: + print('[itemuse] 0x0439 blocked: synth-input disabled') + return + try: + synth.send_before_packet(getattr(self.opts, 'itemuse_input_key', 'END')) + except Exception as e: + print(f'[itemuse] 0x0439 blocked: input failed: {type(e).__name__}: {e}') + return self._emit_target_select(pid, payload) # --target-select (OFF por padrão): 0368 antes print(f"[inject] inbound id=0x{pid:04x} len={len(payload)} -> SendPacket: {payload[:16].hex()}") self.post(sendcall_emit_msg(payload.hex())) diff --git a/relay_capture.conf b/relay_capture.conf index 499ead3..1da3213 100644 --- a/relay_capture.conf +++ b/relay_capture.conf @@ -38,6 +38,10 @@ relay-format = xkore1 # Sanitized JSONL capture log (credentials redacted). Relative to this folder. out = relay_log.jsonl +# Saída do terminal: false = eventos relevantes; true = todos os pacotes e diagnósticos. +# O arquivo JSONL mantém os mesmos eventos em ambos os modos. +verbose = false + # -- injection (WRITE path) --------------------------------------------------- # Master switch for making the client transmit packets the consumer chooses. # true -> every run arms the scrambler breakpoint below and may write to the @@ -78,14 +82,24 @@ inject-call = true # Configuracao operacional padrao (antes passada pelo run_action_merge.sh). move-dispatch = true action-merge = true -egress-queue = true +egress-queue = false synth-input = true +# Enviar teclas ao Gamescope via EIS privado, sem focar KDE/usar ydotool seat0. +# Inicie o jogo pelo Steam com scripts/gamescope-eis-session.sh (veja docs/OPERATIONS.md). +# Gamescope Wayland: -g --force-grab-cursor para teclado e cursor no login. +synth-backend = gamescope-eis +synth-eis-socket-file = /run/user/1000/ro-relay-gamescope-eis.path +# synth-eis-socket = ; alternativa ao arquivo (não usar as duas ao mesmo tempo) +# synth-wid abaixo é legado e é IGNORADO quando synth-backend = gamescope-eis. synth-wid = 0x05200001 synth-key-0437 = INSERT synth-key-0438 = 9 synth-key-0af4 = F2 +# Experimento 0x0439: input independente e síncrono antes do envio. +itemuse-input = true +itemuse-input-key = F2 # Ativar despacho sob demanda; --no-sendcall-demand na CLI desliga para diagnostico. -sendcall-demand = true +sendcall-demand = false # Bloqueio de recaptura de this apos 0091/0092 ou reconexao. map-settle-ms = 1500 @@ -130,6 +144,6 @@ scramble-id = 035f # # wire = true ; feed cleartext wire bytes (vs legacy memory path) # decrypt-actors = true ; fill in encrypted 09fd/09ff bodies from client memory -# actor-delay = 0.15 ; max seconds to hold a chunk waiting for its decrypted copy +actor-delay = 0.30 ; up to 300ms to observe decrypted actors (plus settle wait) # drop-ids = 4754,5395,09fd,09ff ; Gepard control omitted; actors fed only after verified decryption # recvpackets = uaro_recvpackets.txt ; packet-length table for boundary alignment diff --git a/research/movement_035f/dispatcher_trial.py b/research/movement_035f/dispatcher_trial.py index 25f068d..5af6984 100644 --- a/research/movement_035f/dispatcher_trial.py +++ b/research/movement_035f/dispatcher_trial.py @@ -25,7 +25,7 @@ import frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/dispatcher_trial13.py b/research/movement_035f/dispatcher_trial13.py index 63f39f4..69891be 100644 --- a/research/movement_035f/dispatcher_trial13.py +++ b/research/movement_035f/dispatcher_trial13.py @@ -27,7 +27,7 @@ import frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/dispatcher_trial_ctx.py b/research/movement_035f/dispatcher_trial_ctx.py index f6b04a4..7a20740 100644 --- a/research/movement_035f/dispatcher_trial_ctx.py +++ b/research/movement_035f/dispatcher_trial_ctx.py @@ -20,7 +20,7 @@ import frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/dispatcher_trial_onleave.py b/research/movement_035f/dispatcher_trial_onleave.py index 2187f8d..d71dc31 100644 --- a/research/movement_035f/dispatcher_trial_onleave.py +++ b/research/movement_035f/dispatcher_trial_onleave.py @@ -31,7 +31,7 @@ import sample_thread # mesmo diretório; amostrador /proc independente do Frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/dispatcher_trial_pesite.py b/research/movement_035f/dispatcher_trial_pesite.py index 5140dfb..bfe78c9 100644 --- a/research/movement_035f/dispatcher_trial_pesite.py +++ b/research/movement_035f/dispatcher_trial_pesite.py @@ -34,7 +34,7 @@ import sample_thread -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/native_trial.py b/research/movement_035f/native_trial.py index d9abb90..735e8ac 100644 --- a/research/movement_035f/native_trial.py +++ b/research/movement_035f/native_trial.py @@ -26,7 +26,7 @@ import frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/movement_035f/observe_path.py b/research/movement_035f/observe_path.py index bd4a344..9fdd5bb 100644 --- a/research/movement_035f/observe_path.py +++ b/research/movement_035f/observe_path.py @@ -36,7 +36,7 @@ import frida -TABLE = "/var/home/bazzite/Documentos/Projetos/improvement/data/uaro_recvpackets.txt" +TABLE = Path(__file__).resolve().parents[2] / "data" / "uaro_recvpackets.txt" def load_lengths(path): diff --git a/research/recv_cipher/.gitignore b/research/recv_cipher/.gitignore new file mode 100644 index 0000000..0219604 --- /dev/null +++ b/research/recv_cipher/.gitignore @@ -0,0 +1,2 @@ +artifacts/* +!artifacts/.gitkeep diff --git a/research/recv_cipher/HANDOFF.md b/research/recv_cipher/HANDOFF.md new file mode 100644 index 0000000..5c0e358 --- /dev/null +++ b/research/recv_cipher/HANDOFF.md @@ -0,0 +1,35 @@ +# HANDOFF — recv_cipher: extração de pacotes já decifrados + +**Atualização documental 2026-10-20 (sem nova sonda):** o bloqueio de fixture mencionado na seção histórica `Blocker / next step` foi resolvido na atualização de 03:18; resultados posteriores de 2026-10-19 constam no `STATE.md`. A sonda reutiliza descoberta assíncrona de ring do `ActorDecrypt`, teve 7/7 recuperados em amostra limitada com hardware BP e resultados divergentes sob carga; não concluiu captura perfeita. Testes offline específicos de correlacionador e atribuição de escritas **PASS** nesta revisão. Próxima dependência técnica: medir proveniência e backlog sob rajadas de `09FF` com critérios comparáveis e sem misturar bases/PCs de sessões diferentes. Nenhuma autorização live decorre desta nota. + +## Goal +Capturar **do próprio cliente uaRO** o plaintext de todos os pacotes recebidos que tenham corpo cifrado, preservando ordem, limites e integridade. **Não** recuperar algoritmo, chave ou keystream (pesquisa CIFRA independente). + +## Current state / evidence +- `20261018-021919`: `__recvmsg64` recebe wire com ID+length em claro e `09FD` cifrado; pacotes `0086` plausíveis perto de (155,184). +- `20261018-024630`: sete recuperações `09FD` no ring; correlacionador exige header, contexto claro posterior e corpo validado. Ring observado nessa sessão: `0x21020000` (não hardcodar). +- `20261018-025120`: PC de escrita in-place `Gepard+0x18EFF2`; **investigação de cifra encerrada nesta trilha**. +- `20261018-025841`: 5/5 `09FD`, 2/26 `09FF`, 0/5 `0229` recuperados; captura limitada era incompleta. +- **Último live** `artifacts/20261018-030335/recv_callsites.jsonl`: 120 receives, 12.923 bytes, 475 frames, 8 `09FD`, 46 `09FF`, **15 ring_match / 39 ring_miss**, sem erro de Frida, cleanup ok. Não demonstra 100% de cobertura. + +## Decisions / constraints +Não alterar relay de produção, nem executar ptrace, CEserver debugger/debug trace (congelam o jogo). Frida Gadget in-process, uma instância instrumentada; dumps em `artifacts/` gitignored. Watchpoint e disassembly de `ring_watch.js` pertencem ao histórico, **não estão ativos na sonda padrão**. + +## Changes pending verification — recorte inicial de 2026-10-18 +`recv_probe.py`: captura TCP completa via iovec; patch recente coleta snapshot de 16 KiB do ring em `onEnter` de recv, **antes da próxima sobrescrita**, após primeiro `ring_match` informar a base por RPC. `ring_correlator.py`: enquadramento por FD, fragmentos, tipos `09FD/09FF/0229`, `ring_coverage`, `ring_snapshot_match` e deduplicação. `audit_coverage.py`: auditoria retrospectiva. **Nenhuma rodada live nessa versão.** + +## Blocker / next step — recorte inicial de 2026-10-18 (resolvido posteriormente) +O teste `test_stream_detects_interior_and_fragmented` ainda falha: fixture feita com `RingCorrelator.__new__` não inicializa `snapshot_lock`, `awaiting`, `resolved`. Correções remotas tentadas foram bloqueadas. **Primeiro corrigir teste e exercitar snapshots/deduplicação offline; depois validar sintaxe Python/JS, diff e executar `./research/recv_cipher/run_probe.sh`.** Verificar se `ring_snapshot_match` reduz misses sem duplicar pacotes e se `0229` recebe validação suficiente. Não extrapolar para 100%. + +## Context files (ordem) +`research/recv_cipher/STATE.md` → `README.md` → `recv_probe.py` → `ring_correlator.py` → `test_ring_correlator.py` → `audit_coverage.py` → `artifacts/20261018-030335/recv_callsites.jsonl`. + +## Update 2026-10-18 03:18 +Fixture corrigida e testes sintéticos de snapshot/deduplicação PASS. Sonda de 12 s concluída: 3/3 `09FD`, 0/1 `0229`; nenhum `09FF` e nenhum `ring_snapshot_match`. Coleta privada `artifacts/20261018-031820/recv_callsites.jsonl`. Auditoria ampliada para snapshots/deduplicação; eventos de match/miss agora possuem `candidate_id`. **Próxima etapa:** provar ganho de cobertura da captura de snapshots em janela com tráfego `09FF`, validar `0229` e ordenação antes de qualquer integração no relay. + +## Adaptação ActorDecrypt — 2026-10-18 04:02 +- Criado `actor_adapter.py`: usa `relay.actors.ActorDecrypt.note_chunk`, `note_enc` e `_best` via subclass investigativa, sem alterar produção nem ler/gravar cache de ring da produção. Guarda contiguidade por FD, cabeçalho `0229` de 2 bytes e seleção por pontuação/slot esperado. +- `ring_correlator.py`: buscas diretas, snapshots e replay consultam o mesmo motor de candidatos se a sonda está ativa. `test_ring_correlator.py`: testes sintéticos para `09FD` e `0229` pela interface do motor real, PASS. +- Coleta live 12s `artifacts/20261018-040036`: 9/10 `09FD` recuperados, nenhum erro Frida. +- Coleta live 60s `artifacts/20261018-040159`: 1072 pacotes enquadrados; 37/37 `09FD`, 3/3 `0229`, 0 `09FF` observados; 36 `ring_match`, 4 `ring_snapshot_match`; nenhuma duplicação, sem erros Frida. 4 `ring_miss` intermediários, resolvidos posteriormente por snapshot. +- Limitação: recuperação de `09FF` e fragmentação com novo motor ainda não foram demonstradas nesta rodada; capturas não simultâneas com baseline, portanto não concluir ganho percentual causal. Próximo passo: obter coleta rica em `09FF` e testar forte identidade de conexão e slots de chunks fragmentados. diff --git a/research/recv_cipher/README.md b/research/recv_cipher/README.md new file mode 100644 index 0000000..ec436ae --- /dev/null +++ b/research/recv_cipher/README.md @@ -0,0 +1,61 @@ +# recv_cipher — extração de plaintext de pacotes recebidos + +**Estado atualizado em 2026-10-20:** os bloqueios antigos da fixture e da primeira prova de snapshot relatados abaixo foram resolvidos nas atualizações cronológicas de 2026-10-18/19. Testes locais `test_ring_correlator.py` e `test_audit_write_trace.py` passam nesta auditoria **offline**. Houve rodadas live registradas em `STATE.md` com recuperação parcial, quase total e zero sob carga; **não** há prova de 100% nem autorização nova para sondagem. O código da sonda usa instrumentação isolada (Frida Gadget, snapshots e opcionalmente hooks de escrita); não altera automaticamente o relay em produção. Consulte [STATE](STATE.md) e [HANDOFF](HANDOFF.md). Trechos de 2026-10-18 abaixo são históricos e não definem a condição mais recente. + +## Escopo +Esta trilha **não investiga o algoritmo de cifragem**. O objetivo é obter, diretamente do cliente uaRO após sua própria decifragem, pacotes `0x09FD`, `0x09FF`, `0x0229` e quaisquer outros que exijam recuperação, na ordem original, com validação e cobertura mensurável. A pesquisa da cifra é independente. Não alterar o relay de produção. + +## Fatos verificados +- O TCP recebido pelo cliente usa `__recvmsg64`; cabeçalho de `09FD` (ID e comprimento) permanece em claro e seu corpo chega cifrado. +- O cliente cria cópias em plaintext no ring de recepção. `ring_match` exige mesmo cabeçalho, corpo diferente e válido, mais contexto posterior idêntico; em `0229`, a validação é específica e ainda exige mais cobertura. +- O ring encontrado numa sessão foi `0x21020000`, **endereço não estável**. A posição real pode variar. +- O watchpoint histórico localizou uma escrita de decifragem em `Gepard+0x18EFF2`, mas não é necessário investigar a cifra nem manter o watchpoint ativo. +- Na última rodada validada (`artifacts/20261018-030335/recv_callsites.jsonl`): **8 `09FD` + 46 `09FF` vistos; somente 15 de 54 recuperados e 39 misses**. Portanto ainda não existe captura 100% funcional. Nenhum `0229` apareceu nessa rodada; outra coleta anterior encontrou cinco sem plaintext recuperado. + +## Sonda e execução +```bash +./research/recv_cipher/run_probe.sh +``` +Executar somente com cliente uaRO aberto e relay parado, **após corrigir o bloqueio de teste abaixo**. O launcher descobre PID/porta do Frida Gadget e verifica identidade do processo; duração padrão 12 segundos. Gera `artifacts//recv_callsites.jsonl` privado e ignorado pelo Git. + +- `recv_probe.py`: Frida Gadget in-process em `recvmsg/__recvmsg64/recv/recvfrom`, FD TCP classificado via `/proc`, payloads inclusive iovecs múltiplos até 4096 bytes por recepção. Patch **ainda não validado live**: captura snapshot binário de 16 KiB do ring no `onEnter` da **próxima** recepção, antes de sobrescrever o conteúdo anterior. A base só fica disponível depois do primeiro `ring_match`. +- `ring_correlator.py`: framing contínuo por FD, pacote cifrado em qualquer offset do stream, fragmentação, comparação wire/clear, `ring_match`, `ring_miss`, `ring_snapshot_match`, `ring_coverage` e controle de duplicações. Lê ring via `/proc/PID/mem` como alternativa; não escreve na memória. +- `audit_coverage.py`: audita contagens de pacotes, truncamentos e diferenças entre vistos/recuperados. +- `test_ring_correlator.py`: testes sintéticos, **PASS em 2026-10-20**. A falha antiga de fixture sem `snapshot_lock`, `awaiting`, `resolved` foi corrigida em 2026-10-18; mantida no registro histórico abaixo. +- `ring_watch.js`: sonda histórica de escrita, **desativada** na configuração padrão. + +## Bloqueio histórico de 2026-10-18 (resolvido; ver estado atualizado acima) +**Não executar live ainda.** Corrigir a fixture mencionada, criar teste para snapshot antes da sobrescrita e deduplicação, validar `python3 -m py_compile research/recv_cipher/*.py`, `python3 research/recv_cipher/test_ring_correlator.py`, `bash -n research/recv_cipher/run_probe.sh`, sintaxe do JS injetado e `git diff --check`. Em seguida executar a sonda e comparar `ring_snapshot_match` com `ring_miss` e `ring_coverage`. Priorizar perdas por overwrite/timing, ordenação e validação de `0229` — **não algoritmos de cifra**. + +Não usar `ptrace`, `cecli debug trace` ou attach do debugger CEserver, que congelam o processo Wine. Não versionar dumps/payloads nem presumir que um endereço de sessão seja constante. Alterações locais de pesquisa ainda não commitadas; nenhum push realizado. + +## Diagnóstico contínuo de 09FF / EF63 no relay (2026-10-19) + +O relay registra passivamente eventos `RECV-BOUNDARY` em `relay_log.jsonl` +(apenas stream do map server `:5121`). Cada `09FF` e o pacote seguinte incluem +`conn`, `offset`, `length`, `end`, `previous`, `tail` (últimos 8 bytes), +`next_prefix` (até 16 bytes posteriores) e `next_available`. Em um switch +inexistente, `event=unknown` registra também `prefix`, `preceding` e o último +ID reconhecido. Isso **não** altera o fluxo, não adiciona IDs à tabela e +não anexa outro debugger. Os bytes são amostras do **fluxo TCP observado pelo +relay**, não do cursor interno do Gepard. Ausência de `RECV-BOUNDARY` com +`EF63` não descarta erro local no cliente. + +Para inspecionar após uma falha: `grep '"tag": "RECV-BOUNDARY"' relay_log.jsonl | tail -80`. +Não publicar trechos hex de sessões reais sem revisão. + + +## Inventário dos instrumentos ainda não publicados — revisão estática 2026-10-20 + +| Arquivo | Papel atual | Limite / cuidado | +|---|---|---| +| `recv_probe.py`, `run_probe.sh` | Hook de leitura via Frida Gadget, verificação de PID, disparo e finalização da sonda | Uso live só após autorização e exclusividade; não anexar à mesma sessão instrumentada por outra sonda | +| `ring_correlator.py`, `actor_adapter.py` | Reconstrução por FD, fragmentos, matching de ring e reuso de `ActorDecrypt` | Cópia candidata sem proveniência única não deve virar plaintext entregue a consumidor | +| `ring_watch.js` | Observação histórica por HW watchpoint de transições no ring | Modo opcional e intrusivo; PC e base de uma sessão não são constantes | +| `write_trace.js`, `audit_write_trace.py` | Trace explícito de escritas + auditoria offline de consistência com plaintext final | Escritas compatíveis não provam autoria causal única; testes sintéticos `test_audit_write_trace.py` PASS | +| `audit_coverage.py` | Audita integridade do fluxo, chunks truncados, candidatos e duplicações | Alvos contados são **09FD, 09FF, 0229**; não mede automaticamente 043F/0983 | +| `validate_0229.py` | Verifica estrutura de 15 bytes e flags opt1/opt2/option/stance em log privado | Valores de referência **não são allowlist de protocolo**; exige contexto + slot fresco e não substitui a decisão do relay | +| `sample_telemetry.py` | Polling de memória somente leitura, intervalo inicial de 1 ms e buffer limitado a 512 frames | **ALERTA:** apesar do docstring “never stores buffer contents”, `RingSampleTelemetry._run` armazena cópias completas de até 16 KiB do ring **em memória RAM** em `frames`. Não exportar nem versionar frames; o limite é de retenção, não de privacidade, e polling de 1 ms não garante 1 ms efetivo | +| `test_ring_correlator.py`, `test_audit_write_trace.py` | Contratos reproduzíveis de correlação e escrita | **PASS offline** na auditoria 2026-10-20, sem leitura da sessão real | + +O conjunto é **misto**: `recv_probe.py` e `run_probe.sh` já são rastreados com mudanças locais; diversos auxiliares (`ring_correlator.py`, `actor_adapter.py`, rastreadores e testes) ainda não são rastreados por Git. A existência local ou o estado rastreado não equivalem a um PR publicado. `research/recv_cipher/STATE.md` é o registro cronológico das sessões efetivamente capturadas. A auditoria documental não modifica código nem executa a sonda. diff --git a/research/recv_cipher/STATE.md b/research/recv_cipher/STATE.md new file mode 100644 index 0000000..731fcad --- /dev/null +++ b/research/recv_cipher/STATE.md @@ -0,0 +1,156 @@ +# STATE — extração de pacotes recebidos em plaintext (2026-10-18) + +## Objetivo e limite +Extrair do cliente uaRO **os pacotes já decifrados**, com enquadramento, ordem e cobertura verificáveis. **Não** investigar algoritmo, chaves ou keystream: isso pertence à trilha CIFRA. Não modificar o relay de produção. Não usar ptrace, CEserver debugger ou debug trace (congelam o cliente). Uma sonda por instância; sem escrita na memória do jogo. + +## Evidência observada +- `artifacts/20261018-021919/recv_callsites.jsonl`: TCP via `__recvmsg64`; headers `09FD` com ID+length em claro e corpo incompatível com ator; pacotes `0086` com coordenadas compatíveis com (155,184). +- `artifacts/20261018-024630/recv_callsites.jsonl`: sete correlações de `09FD` cifrado com plaintext na memória; ring `0x21020000`, slots variáveis, cópias com mesmo cabeçalho/contexto e corpo de ator válido. +- `artifacts/20261018-025120/recv_callsites.jsonl`: escrita por `Gepard+0x18EFF2` (`mov byte ptr [ecx], bl`), observada por watchpoint. Descoberta histórica útil para localizar a fronteira, **não é objetivo continuar reverse engineering da cifra**. +- `artifacts/20261018-025841/recv_callsites.jsonl`: análise offline de fluxo identificou 5 `09FD`, 26 `09FF` e 5 `0229`; a sonda antiga recuperou 5, 2 e 0, respectivamente; existiam chunks incompletos por limite de payload. +- `artifacts/20261018-030335/recv_callsites.jsonl`: 120 chamadas, 12.923 bytes, 475 pacotes enquadrados, 8 `09FD` + 46 `09FF` detectados, 15 `ring_match`, 39 `ring_miss`, cleanup ok. **Não há 100% de recuperação.** `0229` não apareceu nessa janela. + +## Estado inicial de instrumentação — 2026-10-18 (histórico; ver atualizações abaixo) +- `run_probe.sh`: descobre e verifica identidade de uaRO, aciona `recv_probe.py` (12 s), artefatos privados. +- `recv_probe.py`: intercepta `recvmsg/__recvmsg64/recv/recvfrom` via Frida Gadget; classifica TCP e captura até 4096 bytes por chamada. Mudança mais recente adicionou **snapshot do ring no onEnter da próxima recepção**, antes da sobrescrita, com envio binário `ring_snapshot`. +- `ring_correlator.py`: tokeniza por FD, mantém fragmentos, seleciona `09FD/09FF/0229` em qualquer offset, valida corpo e contexto, lê ring via /proc como alternativa; recebeu método `snapshot` e estado para evitar duplicações. O snapshot só inicia **após descoberta do ring por correlação inicial**; perdas anteriores continuam possíveis. +- `audit_coverage.py`: contagem retrospectiva por pacote e lacunas; `test_ring_correlator.py`: fixtures sintéticas. +- `ring_watch.js`: artefato de investigação anterior, **desativado** na sonda padrão; não retomar investigação da cifra. + +## Bloqueio inicial e próximo passo de 2026-10-18 (superados na sequência cronológica) +A integração dos snapshots ainda **não passou nos testes offline**: `test_stream_detects_interior_and_fragmented` cria `RingCorrelator.__new__` sem `snapshot_lock`, `awaiting` e `resolved`, gerando `AttributeError`. Tentativas de corrigir no computador foram bloqueadas pela ferramenta remota. **Corrigir fixture, validar Python/JS e casos de snapshot/duplicação antes de qualquer teste live.** Só então executar `./research/recv_cipher/run_probe.sh`, avaliar `ring_snapshot_match`, `ring_match`, `ring_miss` e `ring_coverage`, e testar diferenças com intervenções na ordem e timing. + +## Estado de execução +Última rodada live validada: `20261018-030335`, antes da alteração de snapshots. Nenhum teste live da versão atual; não afirmar sucesso. Alterações locais ainda não commitadas; não houve push. A limpeza da rodada anterior confirmou `cleanup ok`; não há instrumentação permanente planejada. + +## Rodada de validação 2026-10-18 03:18 +- Corrigida fixture offline que não inicializava o correlacionador; testes de snapshot/contexto errado/FD errado/deduplicação PASS. Sintaxe Python, JS e bash; `git diff --check`: PASS. +- Live `artifacts/20261018-031820/recv_callsites.jsonl`: 98 `recv`, 6.643 bytes, 658 pacotes enquadrados, 3 `09FD` recuperados por `ring_match`, 1 `0229` com `ring_miss`, 0 `ring_snapshot_match`. Sem erros de Frida, `cleanup` presente. Amostra sem `09FF`; não há evidência de ganho por snapshot. +- `audit_coverage.py` agora conta `ring_snapshot_match` e detecta duplicações por `candidate_id`; `ring_correlator.py` inclui esse ID em `ring_match` e `ring_miss` nas próximas capturas. +- **Pendente:** capturar amostras com `09FF` e `0229`, investigar timing da habilitação de snapshots e qualidade da validação `0229` antes de integrar ao relay. Nenhuma mudança no relay de produção. + +## Adaptação ActorDecrypt — 2026-10-18 04:02 +- Criado `actor_adapter.py`: usa `relay.actors.ActorDecrypt.note_chunk`, `note_enc` e `_best` via subclass investigativa, sem alterar produção nem ler/gravar cache de ring da produção. Guarda contiguidade por FD, cabeçalho `0229` de 2 bytes e seleção por pontuação/slot esperado. +- `ring_correlator.py`: buscas diretas, snapshots e replay consultam o mesmo motor de candidatos se a sonda está ativa. `test_ring_correlator.py`: testes sintéticos para `09FD` e `0229` pela interface do motor real, PASS. +- Coleta live 12s `artifacts/20261018-040036`: 9/10 `09FD` recuperados, nenhum erro Frida. +- Coleta live 60s `artifacts/20261018-040159`: 1072 pacotes enquadrados; 37/37 `09FD`, 3/3 `0229`, 0 `09FF` observados; 36 `ring_match`, 4 `ring_snapshot_match`; nenhuma duplicação, sem erros Frida. 4 `ring_miss` intermediários, resolvidos posteriormente por snapshot. +- Limitação: recuperação de `09FF` e fragmentação com novo motor ainda não foram demonstradas nesta rodada; capturas não simultâneas com baseline, portanto não concluir ganho percentual causal. Próximo passo: obter coleta rica em `09FF` e testar forte identidade de conexão e slots de chunks fragmentados. + +## 2026-10-19 — sonda isolada, descoberta interna e snapshots (sem integração ao relay) + +- Observado `artifacts/20261019-182530`: 30 s, 10 pacotes 09FD, 0 recuperados, 10 `ring_miss`. +- Ajuste apenas em `ring_correlator.py`: reutilizar `ActorDecrypt.quick_discover` para localizar ring **dentro** dos mapeamentos, verificando corpo de ator decifrado e bytes claros adjacentes; a busca antiga limitava-se principalmente ao início das regiões. +- Observado `artifacts/20261019-182859`: 35 s, 16/16 09FD e 2/2 0229 recuperados; descoberta inicial `production_masked_discovery` e 17 leituras `known_ring`, sem duplicações. +- Observado `artifacts/20261019-182943`: 60 s, 70 09FF + 4 09FD + 2 0229 detectados, **nenhum recuperado**; descoberta inicial levou 90–235 ms por tentativa e causou fila crescente. Não extrapolar a taxa positiva para tráfego intenso. +- Introduzido `RECV_PROBE_RING_HINT` opcional, endereço somente como pista de uma captura validada **na mesma vida do processo uaRO**. Cada cópia precisa passar pela validação completa; se a pista falhar, ela é descartada e inicia-se redescoberta. Não hardcodar endereço em produção. +- Observado `artifacts/20261019-183115`: 60 s, 14/14 09FD recuperados (4 `ring_match`, 10 `ring_snapshot_match`), 0 duplicações, usando endereço de ring confirmado em captura anterior da mesma sessão. Houve 1 `ring_miss` intermediário posteriormente resolvido por snapshot. Nenhum 09FF ou 0229 nessa janela. +- **Próximo passo:** validar o caminho de hint + snapshot com 09FF em tráfego denso, medir backlog e impedir correlação indevida; só então considerar integração no relay. Nenhuma garantia de 100% em geral, não alterada produção. + +## 2026-10-19 — slot diagnostics for unresolved candidates + +Read-only `ring_slot_live_diagnostic` / `ring_slot_snapshot_diagnostic` records +for unmatched candidates include the predicted offset, a truncated SHA-256 +fingerprint, whether bytes equal wire, and the breakdown of the existing relay +`copy_validation()` checks. No decrypted name, GID or payload is logged by +these new diagnostic events. Up to three snapshot diagnostics per unresolved +candidate. Observed `artifacts/20261019-184839`: 60-second sample with 224 +09FF and 16 09FD; respectively 221 and 14 recovered. Five unresolved +candidates (09FF: 41/67/96; 09FD: 211/219) consistently had an invalid +header at the predicted slot. Their fingerprints sometimes changed between +snapshots; this cannot yet distinguish overwrite from incorrect slot mapping. +Production relay not modified. + +## 2026-10-19 — diagnóstico de deslocamentos alternativos + +A sonda registra agora, apenas em falhas de correspondência durante snapshots, +quantos cabeçalhos e cópias **estruturalmente válidas** aparecem em outros +offsets da região capturada, sem liberar plaintext nem aceitar esses candidatos. +`artifacts/20261019-185105`: 14/14 recuperados; `artifacts/20261019-185142`: +23/23 recuperados. No segundo teste o candidato 29 apresentou candidato válido +no offset 419 durante snapshots 244–246, mas sua recuperação confirmada ocorreu +posteriormente no offset esperado 64 (snapshot 259). Portanto o offset 419 não +pode ser atribuído ao pacote 29 apenas pela estrutura; correlação temporal segue +necessária. Não houve perdas definitivas nessas duas amostras, então a causa dos +cinco misses da coleta anterior permanece indeterminada. Produção inalterada. + +## 2026-10-19 — transição de escrita capturada com watchpoint + +Sonda isolada `RECV_WATCH_BP=1` usa o mecanismo histórico de watchpoint de +hardware no offset `ring+0x44`, armando na saída de recv e desarmando após +125 ms. Alteração apenas no arquivo de pesquisa `ring_watch.js`: os eventos +registram `initial_word`, `final_word` e `duration_ms`, além dos eventos de +escrita já existentes. Não alterou a produção nem escreve memória do cliente. + +Observado `artifacts/20261019-190226` (12 s): 31 janelas e 91 traps de escrita; +uma sequência incluiu o PC posterior à instrução nativa `mov byte ptr [ecx], bl`. +Observado `artifacts/20261019-190327` (30 s): 92 armaram, 92 janelas fechadas, +288 traps, alteração entre valor inicial/final nas 92, sem erros de sonda. +23/23 pacotes 09FD e 2/2 0229 recuperados, sem 09FF nessa amostra. +A captura **prova escrita nesse endereço**, mas não prova que todo evento +corresponde ao plaintext de um pacote específico nem resolve a causa dos +misses de 09FF. Próxima etapa é sincronizar armamento e associação temporal +por pacote/slot e coletar sob rajadas de 09FF, conservando validação de +proveniência. + +## 2026-10-19 — hook direto na instrução de escrita (sonda isolada) + +Foi criado `write_trace.js`, habilitado exclusivamente por `RECV_WRITE_PC` e +instalado após base do ring validada, somente se a instrução no endereço indicado +é exatamente `mov byte ptr [ecx], bl` em mapeamento executável. Para cada escrita +no intervalo `ring+0x40 .. ring+0x4000`, registra sequência da escrita, offset, +byte, thread e última recepção observada nessa thread. Buffer de até 64 eventos +por lote e limite máximo de 30.000 escritas; cleanup desinstala hook e despeja +lote restante. O endereço do PC não é constante após reiniciar cliente. + +`ring_correlator.py` emite `ring_candidate` (sem corpo) com `candidate_id`, +`recv_seq`, FD, thread, instante, ID, slot e tamanho. `audit_write_trace.py` +compara writes no slot e recepção correspondentes com plaintext efetivamente +recuperado; nunca classifica essa coincidência isolada como prova inequívoca de +proveniência. Testes sintéticos em `test_audit_write_trace.py`. + +Coleta `artifacts/20261019-191224` (8 s): 11.537 escritas capturadas, nenhuma +perdida pelo limite, sem erros do hook. Coleta +`artifacts/20261019-191350` (20 s): 984 escritas, 11 pacotes recuperados, +9 com todas as escritas observadas no intervalo do slot compatíveis com os +bytes do plaintext final, e 2 sem escrita na janela correspondente; 0 escrita +incompatível nos 9. **Classificação: correspondência consistente, ainda não +associação de origem demonstrada univocamente.** Nenhum 09FF nessa amostra. + +Tentativa seguinte de coleta em `artifacts/20261019-191503` não iniciou: +`ro_ops discover` retornou `unverified` com zero candidatos uaRO.exe/Gadget; +portanto não houve teste de alta carga nessa rodada. Nenhum endereço da sessão +antiga pode ser reutilizado após reinício do cliente. Produção não modificada, +sem commits. + +## 2026-10-19 — sonda reutiliza descoberta assíncrona de ring do relay + +`ring_correlator.py` agora reutiliza diretamente `ActorDecrypt.start_quick_discover` +com o chunk TCP **completo** de cada `recv`, offsets absolutos e itens cifrados +marcados `E`, em lugar de descobrir a partir de um único pacote isolado ou +varrer todas as regiões de memória no worker de correlação. O callback `on_ring` +da implementação de produção atualiza a base da sonda e arma a coleta de +snapshots/hardware breakpoint, sempre após verificação de plaintext. +Não houve alteração em `relay/actors.py`, nem persistência de endereço. + +Teste ao vivo sem endereço fixo de ring, no mesmo processo `uaRO.exe` +PID 1417583: `artifacts/20261019-193122` confirmou descoberta via +`ActorDecrypt.start_quick_discover` em 0x23410000. Sonda com watchpoint +`artifacts/20261019-193202` confirmou a instrução de escrita observada nessa +sessão (`0x76aaeff2`), também com descoberta automática. Sonda com hardware +execution breakpoint `artifacts/20261019-193230`: 528 escritas, 0 descartes +por limite do hook, 7/7 candidatos recuperados, 5 com escritas e bytes +consistentes na janela/slot e 2 sem escritas observadas nessa janela. Isso +**não prova** proveniência única e não valida recuperação sob rajadas de 09FF. +Teste offline `test_production_quick_discovery_receives_full_chunk` exercita +passagem integral do chunk, itens, callback e ausência de varredura síncrona. + +Produção inalterada. Nenhum commit ou push. + + +## Auditoria documental 2026-10-20 — estado do checkout, sem novas sondas + +**Procedência:** revisão dos arquivos locais ainda não commitados de `research/recv_cipher/` contra `origin/main@9ee7ef5`, e dos registros históricos acima. `test_ring_correlator.py` e `test_audit_write_trace.py` **PASS offline**, `bash -n run_probe.sh` e `node --check ring_watch.js/write_trace.js` **PASS**. `validate_0229.py` aceita caminho de log como entrada; não foi executado sobre uma captura privada. A suíte principal `bash scripts/test-offline.sh` passou **37 scripts**. Não houve cliente instrumentado nesta auditoria. + +**Estado científico/operacional:** o correlacionador com `ActorDecrypt.start_quick_discover`, snippets de contexto, snapshots, hashes de diagnóstico e coleta explícita de escritas é um instrumento **isolado**, não um produto que extraia todos os plaintexts garantidamente. Coletas anteriores positivas (ex.: `20261019-184839`: 221/224 `09FF` e 14/16 `09FD`) convivem com perdas completas sob carga (`20261019-182943`: 0 recuperados para 70 `09FF`, 4 `09FD` e 2 `0229`). Hooks de escrita mostraram compatibilidade com bytes recuperados em janelas delimitadas, mas não provaram atribuição unívoca do escritor a todo candidato. Falta comparação sob carga controlada, sem misturar process lifetime, offsets, ring hints e tempos. + +**Próximo discriminador, somente após autorização live separada:** isolar falhas por sobrescrita e correlação temporal de `09FF`, confrontar recebidos/recuperados, falsos positivos e duplicações no mesmo cenário; testar `0229` sem pressupor plaintext de GID igual ao cifrado. Endereços de sessão não são constantes. **CIFRA** (seed/gerador/estado) continua outra pesquisa, em `research/cipher/`. diff --git a/research/recv_cipher/actor_adapter.py b/research/recv_cipher/actor_adapter.py new file mode 100644 index 0000000..0943c94 --- /dev/null +++ b/research/recv_cipher/actor_adapter.py @@ -0,0 +1,55 @@ +"""Research-only adapter using the production ActorDecrypt's scoring and packet rules. + +No relay production changes, no actor ring cache writes. The adapter never feeds XKore. +""" +import threading +import os +from relay.actors import ActorDecrypt + + +class ProbeActorDecrypt(ActorDecrypt): + def _load_cache(self): + # The probe must verify the live ring, not trust a persisted address. + pass + + def _set_ring(self, lo, hi, why): + self.base, self.hi = lo, hi + self.region = (lo, hi) + self.fails = 0 + if self.on_ring: + self.on_ring(lo) + + +class ActorCoreAdapter: + def __init__(self, pid): + self.core = ProbeActorDecrypt(pid) + self.lock = threading.RLock() + + def note_chunk(self, fd, wire_chunk, carried): + with self.lock: + return self.core.note_chunk(fd, wire_chunk, carried) + + def note_packet(self, fd, off, packet): + with self.lock: + self.core.note_enc(fd, off, len(packet), int.from_bytes(packet[:2], 'little')) + + def find(self, data, base, item): + """Return a uniquely scored valid plaintext candidate using relay._best. + + Production confidence thresholds; do not treat ID-only matches as verified. + """ + with self.lock: + chunk = item['chunk'] + wire = chunk[:item['length']] + candidates = self.core._best(data, base, item['fd'], wire, + item['off'], item['cstart'], item['d']) + if not candidates: + return None + top = candidates[0] + runner = candidates[1][0] if len(candidates) > 1 else 0 + if top[0] < 16 or top[0] - runner < 8: + return None + return top[3], top[5] + + def close(self): + os.close(self.core.mem.fd) diff --git a/research/recv_cipher/artifacts/.gitkeep b/research/recv_cipher/artifacts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research/recv_cipher/audit_coverage.py b/research/recv_cipher/audit_coverage.py new file mode 100644 index 0000000..a848131 --- /dev/null +++ b/research/recv_cipher/audit_coverage.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Offline stream coverage audit; reads an existing private Frida JSONL capture.""" +import argparse +import collections +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from relay.packets import load_lengths, packet_len + +TARGETS = (0x09FD, 0x09FF, 0x0229) + + +def audit(events, lengths): + buffers = collections.defaultdict(bytearray) + summary = collections.Counter() + found = [] + issues = [] + for n, record in enumerate(events): + if record.get("kind") != "recv": + continue + fd = record.get("fd") + nbytes = record.get("bytes", 0) + payload = record.get("payload") + if not isinstance(payload, str) or len(payload) != 2 * nbytes: + summary["incomplete_recv_chunks"] += 1 + if buffers[fd]: + issues.append({"event": n, "fd": fd, "issue": "stream_gap_after_partial"}) + buffers[fd].clear() + continue + try: + raw = bytes.fromhex(payload) + except ValueError: + summary["invalid_hex_chunks"] += 1 + buffers[fd].clear() + continue + summary["capture_bytes"] += len(raw) + data = buffers[fd] + before = len(data) + data.extend(raw) + if before: + summary["chunks_with_carried_bytes"] += 1 + at = 0 + while len(data) - at >= 2: + pid = data[at] | (data[at+1] << 8) + size = packet_len(lengths, data, at) + if size is None: + summary["unknown_ids"] += 1 + issues.append({"event": n, "fd": fd, "issue": "unknown_id", "id": f"{pid:04X}"}) + # Do not claim correct framing after an unknown packet. + del data[:] + break + if not size: + break + if size > 65535: + issues.append({"event": n, "fd": fd, "issue": "oversize", "id": f"{pid:04X}"}) + del data[:] + break + if len(data) - at < size: + break + summary["parsed_packets"] += 1 + summary["parsed_bytes"] += size + if pid in TARGETS: + summary[f"wire_{pid:04X}"] += 1 + found.append({"id": f"{pid:04X}", "fd": fd, "length": size, + "fragmented": before > 0 and at == 0}) + at += size + if at: + del data[:at] + if data: + summary["chunks_ending_partial"] += 1 + summary["remaining_bytes"] = sum(map(len, buffers.values())) + matches = collections.Counter() + seen_ids = set() + duplicate_ids = 0 + for x in events: + if x.get("kind") not in ("ring_match", "ring_snapshot_match", "ring_replay_match"): + continue + cid = x.get("candidate_id") + if cid is not None: + if cid in seen_ids: + duplicate_ids += 1 + continue + seen_ids.add(cid) + pid = int.from_bytes(bytes.fromhex(x["packet_id"]), "little") + matches[f"plaintext_{pid:04X}"] += 1 + matches[f"{x['kind']}_{pid:04X}"] += 1 + result = dict(summary) + result.update(matches) + result["duplicate_candidate_matches"] = duplicate_ids + result["unresolved_by_type"] = {f"{pid:04X}": summary[f"wire_{pid:04X}"]-matches[f"plaintext_{pid:04X}"] + for pid in TARGETS} + return {"summary": result, "targets": found, "issues": issues[:30]} + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("capture", type=Path) + p.add_argument("--output", type=Path) + args = p.parse_args() + records = [json.loads(line) for line in args.capture.read_text().splitlines() if line.strip()] + report = audit(records, load_lengths(ROOT / "data/uaro_recvpackets.txt")) + content = json.dumps(report, indent=2) + if args.output: + args.output.write_text(content + "\n") + print(content) + + +if __name__ == "__main__": + main() diff --git a/research/recv_cipher/audit_write_trace.py b/research/recv_cipher/audit_write_trace.py new file mode 100644 index 0000000..4be999c --- /dev/null +++ b/research/recv_cipher/audit_write_trace.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Read-only write correlation audit; never upgrades candidates to verified.""" +import collections +import json +import sys + + +def audit(records): + candidates = {r['candidate_id']: r for r in records if r.get('kind') == 'ring_candidate'} + results = {r['candidate_id']: bytes.fromhex(r['plaintext']) for r in records + if r.get('kind') in ('ring_match', 'ring_snapshot_match', 'ring_replay_match')} + writes = [w for r in records if r.get('kind') == 'write_trace_batch' for w in r['events']] + classes = collections.Counter() + samples = [] + for cid, c in candidates.items(): + if cid not in results: + classes['unrecovered'] += 1 + continue + data = results[cid] + lo, hi = c['slot_offset'], c['slot_offset'] + c['length'] + same = [w for w in writes if w['recv_seq_hint'] == c.get('recv_seq') + and w['fd_hint'] == c['fd'] and lo <= w['offset'] < hi] + other_candidates = [k for k, d in candidates.items() if k != cid + and d.get('recv_seq') == c.get('recv_seq') and d['fd'] == c['fd'] + and max(lo, d['slot_offset']) < min(hi, d['slot_offset'] + d['length'])] + equal = sum(w['byte'] == data[w['offset'] - lo] for w in same) + mismatch = len(same) - equal + if other_candidates: + status = 'ambiguous_slot_overlap' + elif not same: + status = 'no_write_in_recv_window' + elif mismatch: + status = 'write_transient_or_mismatch' + else: + status = 'write_bytes_consistent_not_proven' + classes[status] += 1 + if len(samples) < 15: + samples.append({'candidate_id':cid,'packet_id':c['packet_id'], + 'recv_seq':c['recv_seq'],'writes_in_slot':len(same), + 'bytes_matching_final':equal,'transient_bytes':mismatch, + 'classification':status}) + return {'candidates':len(candidates),'recovered':len(results), + 'writer_events':len(writes),'classification':dict(classes), + 'examples':samples, + 'note':'Matching address, thread and bytes is not sufficient to prove unique reception provenance.'} + +if __name__ == '__main__': + with open(sys.argv[1]) as f: + data=[json.loads(l) for l in f if l.strip()] + print(json.dumps(audit(data),indent=2)) diff --git a/research/recv_cipher/recv_probe.py b/research/recv_cipher/recv_probe.py new file mode 100644 index 0000000..ca382fc --- /dev/null +++ b/research/recv_cipher/recv_probe.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Bounded Frida Gadget receive-callsite observation; no ptrace or production hooks.""" +import argparse +import json +import time +from pathlib import Path +import frida +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +import threading +from ring_correlator import RingCorrelator +from sample_telemetry import RingSampleTelemetry + +JS = r''' +'use strict'; +var calls=0, maxCalls=/*MAX_CALLS*/, hooks=[]; +var allowedFds=/*FD_ALLOWLIST*/; +var startMs=Date.now(), lastRecvMs=0; +var totalBytes=0, gapsShort=0, gapsLong=0, stackSamples=0; +var fdKinds={}; +var payloadSamples=0; +var ringBase=null, ringSnapCount=0; +function locate(addr){ + try { var m=Process.findModuleByAddress(addr); return m ? m.name+'+'+addr.sub(m.base).toString() : addr.toString(); } + catch(_) { return addr.toString(); } +} +function fdKind(fd){ + // FD numbers only; avoid file-system calls inside a recv hook. + return 'unclassified'; +} +function note(kind, data){ send(Object.assign({kind:kind,ms:Date.now(),tid:Process.getCurrentThreadId()},data)); } +/* WATCH_HELPERS */ +/* WRITE_HELPERS */ +var recvSequence=0, recvByThread={}; +var found=[]; +['recvmsg','__recvmsg64','recv','recvfrom'].forEach(function(name){ + try {var f=Module.findGlobalExportByName(name); if(!f || found.some(function(x){return x.equals(f)}))return;found.push(f); + var hook=Interceptor.attach(f,{onEnter:function(args){this.fd=args[0].toInt32();this.buf=args[1];this.msg=null;this.iov=null;this.iovcnt=0;if(name==='recvmsg'||name==='__recvmsg64'){try{this.msg=args[1];var off=Process.pointerSize===8?16:8;this.iov=this.msg.add(off).readPointer();this.iovcnt=this.msg.add(off+Process.pointerSize).readU32();}catch(_){}}this.retloc=this.returnAddress;this.caller=locate(this.retloc);this.wanted=calls=0; + if(this.wanted && ringBase!==null && ringSnapCount0 && this.iovcnt<=64){for(var j=0;j0;j++){var entry=this.iov.add(j*Process.pointerSize*2),b=entry.readPointer(),len=Process.pointerSize===8?entry.add(8).readU64().toNumber():entry.add(4).readU32(),take=Math.min(left,len);if(take<=0)continue;var bytes=new Uint8Array(b.readByteArray(take));for(var k=0;kidx: socket_map[fields[idx]]=kind + except OSError: pass + fd_classes={} + try: + for fd in os.listdir(f'/proc/{a.pid}/fd'): + try: + target=os.readlink(f'/proc/{a.pid}/fd/{fd}') + if target.startswith('socket:['): + inode=target[8:-1] + fd_classes[int(fd)]=socket_map.get(inode,'socket-unknown') + except (OSError,ValueError): pass + except OSError: pass + d=frida.get_device_manager().add_remote_device(f'127.0.0.1:{a.port}') + session=None; script=None; errors=[] + lock=threading.Lock() + with a.out.open('x') as f: + def emit(x): + with lock: + f.write(json.dumps(x)+'\n') + f.flush() + correlator=RingCorrelator(a.pid,emit,max_candidates=a.max_calls) + correlator.start() + emit({'kind':'actor_core_configuration','engine':'relay.actors.ActorDecrypt', + 'policy':'scored-context-plus-expected-slot','production_relay_modified':False}) + sampler=RingSampleTelemetry(a.pid) + sampler.start() + try: + emit({'kind':'fd_inventory','classes':fd_classes}) + session=d.attach('Gadget') + source=JS.replace('/*MAX_CALLS*/',str(a.max_calls)).replace('/*FD_ALLOWLIST*/',json.dumps([fd for fd,k in fd_classes.items() if k in ('tcp','tcp6')])) + import os + watch_enabled=os.environ.get('RECV_WATCH_BP')=='1' + watch_code=(Path(__file__).with_name('ring_watch.js').read_text() if watch_enabled else '') + source=source.replace('/* WATCH_HELPERS */',watch_code) + write_pc=os.environ.get('RECV_WRITE_PC', '').strip() + write_code=(Path(__file__).with_name('write_trace.js').read_text() if write_pc else '') + source=source.replace('/* WRITE_HELPERS */', write_code.replace('/*WRITE_ADDRESS*/',json.dumps(write_pc))) + emit({'kind':'write_hook_configuration','enabled':bool(write_pc),'address_source':'session-verified-address' if write_pc else None}) + emit({'kind':'ring_watch_configuration','enabled':watch_enabled}) + script=session.create_script(source) + def verified_ring(base): + sampler.set_base(base) + try: script.exports_sync.setring(hex(base)) + except Exception as e: emit({'kind':'ring_snapshot_error','error':str(e)[:200]}) + correlator.on_verified=verified_ring + def message(m,data): + if m['type']=='send': + x=m['payload']; + if x.get('kind')=='ring_snapshot': + correlator.snapshot(x,data) + return + if x.get('kind')=='recv': + x['fd_kind']=fd_classes.get(x.get('fd'),'unclassified') + if x['fd_kind'] not in ('tcp','tcp6'): return + emit(x) + if x.get('kind')=='recv': correlator.receive(x) + else: errors.append(str(m)[:300]) + script.on('message',message);script.load() + time.sleep(a.seconds) + finally: + sampling_stats=sampler.stop() + correlator.stop() + frames=sampler.take_frames() + correlator.replay_frames(frames) + emit({'kind':'ring_1ms_telemetry','interval_ms':1,'stats':sampling_stats, + 'replay_frames':len(frames),'replay_matched':correlator.stats['replay_matched']}) + if script: + try: + result=script.exports_sync.stop(); emit({'kind':'cleanup','result':result}) + except Exception as e: errors.append('stop:'+str(e)) + try:script.unload() + except Exception as e:errors.append('unload:'+str(e)) + if session: + try:session.detach() + except Exception as e:errors.append('detach:'+str(e)) + if errors: + emit({'kind':'errors','messages':errors}) + print('Frida recv-callsite observation finished; output:',a.out,'errors:',len(errors)) + if errors: raise SystemExit(2) +if __name__=='__main__':main() diff --git a/research/recv_cipher/ring_correlator.py b/research/recv_cipher/ring_correlator.py new file mode 100644 index 0000000..9e951b8 --- /dev/null +++ b/research/recv_cipher/ring_correlator.py @@ -0,0 +1,435 @@ +"""Correlate encrypted actor packets from recv with validated plaintext in the Wine recv ring. + +Read-only /proc/PID/mem; no ptrace, CEserver debugger or client writes. +Never accept ID/length alone as evidence: require the actor body to validate and +unmodified neighbouring packet bytes to agree. +""" +import json +import hashlib +import os +import queue +import threading +import time + +from relay.actors import valid_actor, copy_validation +from actor_adapter import ActorCoreAdapter +from relay.memory import ProcessMemory, regions, MAX_REGION +from relay.packets import load_lengths, packet_len + + +def actor_candidate(chunk): + """A whole encrypted 09FD/09FF at the head, followed by clear context.""" + if len(chunk) < 16 or chunk[:2] not in (b"\xfd\x09", b"\xff\x09", b"\x29\x02"): + return None + length = 15 if chunk[:2] == b"\x29\x02" else int.from_bytes(chunk[2:4], "little") + if not 15 <= length <= 1024 or len(chunk) < length + 8: + return None + return chunk[:length], chunk[length:length + min(32, len(chunk)-length)] + + +def matching_copy(buf, addr, wire, context, reasons=None): + """Locate a *decrypted* copy with exact header and following clear bytes.""" + length = len(wire) + header = wire[:6] if wire[:2] == b"\x29\x02" else wire[:4] + at = buf.find(header) + if at < 0 and reasons is not None: + reasons['header_absent'] += 1 + while at >= 0: + end = at + length + if end + len(context) > len(buf): + reason = 'outside_buffer' + elif buf[end:end + len(context)] != context: + reason = 'following_context_mismatch' + elif buf[at+4:end] == wire[4:]: + reason = 'unchanged_ciphertext' + elif wire[:2] != b"\x29\x02" and not valid_actor(buf[at:end]): + reason = 'invalid_actor_body' + else: + return addr + at, bytes(buf[at:end]) + if reasons is not None: + reasons[reason] += 1 + at = buf.find(header, at + 1) + return None + + +class RingCorrelator: + def __init__(self, pid, emit, max_candidates=64): + self.pid = pid + self.emit = emit + self.max_candidates = max_candidates + self.pending = queue.Queue(maxsize=128) + self.lengths = load_lengths(__import__("pathlib").Path(__file__).resolve().parents[2] / "data/uaro_recvpackets.txt") + self.streams = {} + self.awaiting = [] + self.resolved = set() + self.snapshot_lock = threading.Lock() + self.stats = __import__("collections").Counter() + self.thread = None + self.accepted = 0 + # Optional hint from a VERIFIED match in the SAME client process. + # It is not proof: _search checks full plaintext and context each time. + hint = os.environ.get('RECV_PROBE_RING_HINT', '').strip() + self.ring = int(hint, 0) if hint else None + self.closed = False + self.on_verified = None + self.memory = None + self.diagnose = None + self.mapping_cache = None + self.actor_adapter = None + + def start(self): + self.memory = ProcessMemory(self.pid) + self.actor_adapter = ActorCoreAdapter(self.pid) + self.actor_adapter.core.on_ring = self._production_ring_verified + self.thread = threading.Thread(target=self._run, daemon=True, name="ring-correlator") + self.thread.start() + + def _mapping_for_ring(self, base): + try: + with open(f'/proc/{self.pid}/maps') as maps: + return next((fields for line in maps + if (fields := line.split()) and + int(fields[0].split('-')[0], 16) <= base + < int(fields[0].split('-')[1], 16)), None) + except (OSError, ValueError): + return None + + def _production_ring_verified(self, base): + """Accept only a ring verified by the production ActorDecrypt core. + + Called by the core's background quick discovery, not the recv hook. + """ + # Research-only gate: a plaintext recv ring must not resolve inside + # executable code. Quick discovery can otherwise lock onto coincidental + # actor-like bytes in a writable/executable PE mapping. + mapping = self._mapping_for_ring(base) + if (mapping is None or 'r' not in mapping[1] or 'w' not in mapping[1] + or 'x' in mapping[1] + or base + 0x4040 > int(mapping[0].split('-')[1], 16)): + self.stats['production_quick_rejected_mapping'] += 1 + self.emit({'kind': 'ring_discovery_rejected', 'reason': 'unsafe_mapping', + 'mapping_permissions': mapping[1] if mapping else None}) + # This callback is invoked while the core holds its discovery lock; + # clear only the invalid speculative ring to allow later attempts. + if self.actor_adapter is not None: + core = self.actor_adapter.core + if getattr(core, 'base', None) == base: + core.base = None + core.hi = None + core.region = None + return + self.ring = base + self.stats['production_quick_discovered'] += 1 + self.emit({'kind':'ring_discovered','base':hex(base), + 'method':'ActorDecrypt.start_quick_discover'}) + if self.on_verified is not None: + self.on_verified(base) + + def receive(self, record): + """Frame the TCP stream; collect targets at *any* packet offset.""" + if self.closed: + return + fd = record.get("fd") + payload = record.get("payload") + if not payload or len(payload) != 2 * record.get("bytes", 0): + self.stats["truncated_recv"] += 1 + self.streams.pop(fd, None) + return + try: + chunk = bytes.fromhex(payload) + except ValueError: + self.stats["invalid_hex"] += 1 + self.streams.pop(fd, None) + return + stream = self.streams.setdefault(fd, bytearray()) + carried = len(stream) + if self.actor_adapter is not None: + cstart, d = self.actor_adapter.note_chunk(fd, chunk, carried) + else: + cstart, d = 0, 0x40 + stream.extend(chunk) + offset = 0 + discovery_items = [] + while len(stream) - offset >= 2: + pid = stream[offset] | (stream[offset + 1] << 8) + size = packet_len(self.lengths, stream, offset) + if size is None: + self.stats["unknown_id"] += 1 + stream.clear() + break + if not size or len(stream) - offset < size: + break + if pid in (0x09fd, 0x09ff, 0x0229) and len(stream)-offset < size+8: + break # retain complete packet until following context arrives + self.stats["framed_total"] += 1 + if (self.actor_adapter is not None and carried == 0 + and pid in self.actor_adapter.core.ENC + and offset + size <= len(chunk)): + # Match the tuple layout used in production wire_feed: + # (kind, packet_id, bytes, absolute_stream_offset). + discovery_items.append(('E', pid, bytes(stream[offset:offset+size]), cstart+offset)) + if pid in (0x09fd, 0x09ff, 0x0229): + self.stats[f"wire_{pid:04X}"] += 1 + if self.accepted < self.max_candidates and len(stream)-offset >= size+8: + self.accepted += 1 + packet = bytes(stream[offset:offset+size]) + absolute_off = (self.actor_adapter.core.coff[fd] - len(stream) + offset + if self.actor_adapter is not None else offset) + if self.actor_adapter is not None: + self.actor_adapter.note_packet(fd, absolute_off, packet) + item = {"chunk": bytes(stream[offset:offset+size+min(32,len(stream)-offset-size)]), + "length": size, "off": absolute_off, "cstart": cstart, "d": d, + "event_ms": record.get("ms"), "recv_seq": record.get("recv_seq"), + "fd": fd, "tid": record.get("tid"), + "queued_monotonic": time.monotonic()} + item['candidate_id'] = self.accepted + self.emit({'kind':'ring_candidate', 'candidate_id':item['candidate_id'], + 'recv_seq':item['recv_seq'], 'fd':fd, 'tid':item['tid'], + 'event_ms':item['event_ms'], 'packet_id':f'{pid:04x}', + 'slot_offset':d + absolute_off - cstart, 'length':size}) + with self.snapshot_lock: + self.awaiting.append(item) + try: + self.pending.put_nowait(item) + except queue.Full: + self.stats["queue_drops"] += 1 + else: + self.stats["candidate_no_context_or_cap"] += 1 + offset += size + if discovery_items and self.ring is None and self.actor_adapter is not None: + self.actor_adapter.core.start_quick_discover( + fd, chunk, discovery_items, cstart) + self.stats['production_quick_requested'] += 1 + if offset: + del stream[:offset] + + def _slot_diagnostic(self, item, data, base, source): + """Metadata-only diagnosis; never record actor payload, name or GID. + + The predicted location is derived from the same chunk/offset arithmetic + as production. Snapshot bytes are only observations, never acceptance. + """ + core = self.actor_adapter.core if self.actor_adapter is not None else None + if core is None or base is None: + return {'source': source, 'state': 'no_ring_base'} + offset = item['d'] + item['off'] - item['cstart'] + size = item['length'] + out = {'source': source, 'offset': offset, 'size': size} + if offset < 0 or offset + size > len(data): + out['state'] = 'outside_capture' + return out + current = bytes(data[offset:offset+size]); wire = item['chunk'][:size] + checks = copy_validation(wire, current) + out.update(state='candidate_structurally_valid' if checks['accepted'] else 'invalid_or_stale', + sha256_prefix=hashlib.sha256(current).hexdigest()[:16], + matches_wire=current == wire, + header_ok=checks['header_match'], + name_ok=checks['name_format_ok'], + walk_speed_ok=checks['walk_speed_ok'], + object_type_ok=checks['object_type_ok'], + changed=checks['body_changed']) + return out + + def _alternative_offsets(self, item, data): + """Inspect SAME snapshot for plausible actor copies at other offsets. + + Diagnostic only: structural validity does not prove packet provenance. + A short candidate list avoids exposing actor contents and heavy scans. + """ + wire = item['chunk'][:item['length']] + header = wire[:2] if wire[:2] in (b'\x29\x02', b'\x3f\x04', b'\x83\x09') else wire[:4] + expected = item['d'] + item['off'] - item['cstart'] + positions = [] + pos = data.find(header) + total_headers = 0 + valid_count = 0 + while pos >= 0: + total_headers += 1 + if pos + len(wire) <= len(data) and copy_validation(wire, bytes(data[pos:pos+len(wire)]))['accepted']: + valid_count += 1 + if len(positions) < 8: + positions.append({'offset':pos,'delta':pos-expected}) + pos = data.find(header,pos+1) + return {'expected_offset':expected,'header_hits':total_headers, + 'structurally_valid_at_other_offsets':valid_count, + 'candidate_offsets':positions} + + def snapshot(self, record, data): + """Capture the previous decrypted ring at the next recv entry, before overwrite.""" + if not data or record.get('fd') not in self.streams: + return + try: + base = int(record['base'], 16) + except (KeyError, TypeError, ValueError): + self.stats['invalid_snapshot_base'] += 1 + return + with self.snapshot_lock: + waiting = list(self.awaiting) + for item in waiting: + cid = item['candidate_id'] + if item['fd'] != record['fd'] or cid in self.resolved: + continue + candidate = actor_candidate(item['chunk']) + if candidate is None: + continue + wire, context = candidate + result = (self.actor_adapter.find(data, base, item) if self.actor_adapter is not None + else matching_copy(data, base, wire, context, self.diagnose)) + if result is None: + if item.get('slot_diag_count', 0) < 3: + item['slot_diag_count'] = item.get('slot_diag_count', 0) + 1 + self.emit({'kind':'ring_slot_snapshot_diagnostic', 'candidate_id':cid, + 'snapshot_seq':record.get('seq'), + 'detail':self._slot_diagnostic(item, data, base, 'before_next_recv'), + 'alternative_offsets':self._alternative_offsets(item, data)}) + continue + slot, plain = result + with self.snapshot_lock: + if cid in self.resolved: + continue + self.resolved.add(cid) + self.awaiting = [x for x in self.awaiting if x['candidate_id'] != cid] + self.stats['snapshot_matched'] += 1 + self.emit({'kind':'ring_snapshot_match','candidate_id':cid,'fd':item['fd'], + 'packet_id':wire[:2].hex(),'length':len(wire), + 'slot':hex(slot),'ring_base':hex(base), + 'wire':wire.hex(),'plaintext':plain.hex(), + 'snapshot_seq':record.get('seq')}) + + def replay_frames(self, frames): + """After collection, reconcile pending candidates against bounded 1 ms ring copies.""" + with self.snapshot_lock: + waiting = list(self.awaiting) + for item in waiting: + cid = item['candidate_id'] + with self.snapshot_lock: + if cid in self.resolved: + continue + candidate = actor_candidate(item['chunk']) + if candidate is None: + continue + wire, context = candidate + for base, sampled_at, data in frames: + if sampled_at < item.get("queued_monotonic", 0): + continue + result = (self.actor_adapter.find(data, base, item) if self.actor_adapter is not None + else matching_copy(data, base, wire, context, self.diagnose)) + if result is None: + continue + slot, plain = result + with self.snapshot_lock: + if cid in self.resolved: + break + self.resolved.add(cid) + self.awaiting = [x for x in self.awaiting if x['candidate_id'] != cid] + self.stats['replay_matched'] += 1 + self.emit({'kind': 'ring_replay_match', 'candidate_id': cid, + 'fd': item['fd'], 'packet_id': wire[:2].hex(), + 'length': len(wire), 'slot': hex(slot), + 'ring_base': hex(base), 'wire': wire.hex(), + 'plaintext': plain.hex(), 'sample_monotonic': sampled_at}) + break + + def _probe_region(self, region, wire, context, size, item=None): + lo, hi, _ = region + if hi - lo < 0x40 + len(wire) + len(context): + return None + buf = self.memory.read(lo, min(size, hi - lo)) + if item is not None and self.actor_adapter is not None: + return self.actor_adapter.find(buf, lo, item) + return matching_copy(buf, lo, wire, context, self.diagnose) + + def _search(self, wire, context, item=None): + # Existing ring base is only a *hint*, and must be reverified every time. + if self.ring is not None: + r = self.memory.read(self.ring, 0x20000) + found = (self.actor_adapter.find(r, self.ring, item) + if item is not None and self.actor_adapter is not None + else matching_copy(r, self.ring, wire, context, self.diagnose)) + if found: + return found, self.ring, "verified_ring_hint" + # A missed copy normally means the ring was overwritten, not moved. + # Do not block this single-threaded worker with a 2s full-memory scan + # per packet. Revalidate the hint on the next candidate; never accept + # an unverified copy. Ring re-discovery remains available when no hint + # has been established at startup. + return None, self.ring, "known_ring_miss" + # Ring discovery is driven by the full recv chunk in receive(), using + # the production core's asynchronous start_quick_discover. Never scan + # all writable mappings synchronously on the correlation worker. + return None, None, 'production_quick_pending' + + def _analyze(self, item): + """Check current slot quickly; snapshots asynchronously resolve overwritten copies. + + Do not sleep 110 ms per candidate: dense actor bursts may arrive in less + time than one polling window. Never perform a full-region scan while a + verified ring hint is available. + """ + cid = item['candidate_id'] + with self.snapshot_lock: + if cid in self.resolved: + return + wire, context = actor_candidate(item['chunk']) + if wire is None: + return + queued = item.get('queued_monotonic', time.monotonic()) + wait_ms = round((time.monotonic() - queued) * 1000, 1) + # On a known ring one bounded read is cheap and deterministic. A missed + # read must not invalidate the ring or trigger synchronous scanning. + found, base, method = self._search(wire, context, item) + if not found: + if base is not None: + offset = item['d'] + item['off'] - item['cstart'] + if 0 <= offset < 0x20000 and self.memory is not None: + live = self.memory.read(base + offset, item['length']) + # The helper takes a slice starting at the predicted offset. + diag = self._slot_diagnostic(dict(item, d=0, off=0, cstart=0), + live, base, 'after_recv') + self.emit({'kind':'ring_slot_live_diagnostic','candidate_id':cid, + 'detail':diag}) + self.emit({'kind':'ring_miss','candidate_id':cid,'fd':item['fd'], + 'packet_id':wire[:2].hex(),'length':len(wire), + 'method':method,'worker_queue_delay_ms':wait_ms}) + return + slot, plain = found + with self.snapshot_lock: + if cid in self.resolved: + return + self.resolved.add(cid) + self.awaiting = [x for x in self.awaiting if x['candidate_id'] != cid] + self.ring = base + if slot - base == 0x40 and self.on_verified is not None: + self.on_verified(base) + self.emit({'kind':'ring_match','candidate_id':cid,'fd':item['fd'], + 'packet_id':wire[:2].hex(),'length':len(wire), + 'ring_base':hex(base),'slot':hex(slot),'method':method, + 'worker_queue_delay_ms':wait_ms, + 'wire':wire.hex(),'plaintext':plain.hex()}) + + def _run(self): + while True: + item = self.pending.get() + if item is None: + self.pending.task_done() + break + try: + self._analyze(item) + except Exception as e: + self.emit({"kind": "ring_error", "error": str(e)[:200]}) + finally: + self.pending.task_done() + + def stop(self): + self.closed = True + if self.thread: + # Preserve queued captures before returning. No new hooks are installed. + self.pending.put(None) + self.thread.join(timeout=12) + self.emit({"kind":"ring_coverage", "stats":dict(self.stats), "remaining_bytes":sum(len(x) for x in self.streams.values())}) + if self.thread is not None and self.thread.is_alive(): + self.emit({"kind":"ring_worker_timeout", "detail":"worker still running"}) + elif self.memory is not None: + os.close(self.memory.fd) + if self.actor_adapter is not None: + self.actor_adapter.close() diff --git a/research/recv_cipher/ring_watch.js b/research/recv_cipher/ring_watch.js new file mode 100644 index 0000000..938d9a8 --- /dev/null +++ b/research/recv_cipher/ring_watch.js @@ -0,0 +1,109 @@ +// Injected into the Frida recv probe after plaintext ring verification. +// Hardware watchpoint is per receiving thread, armed only on later 09FD chunks. +var verifiedRing = null, ringWatch = null, ringHandlerReady = false; +var watchWords = new Uint32Array(256); // 64 records (pc, value, ecx, ebx) +var watchCount = 0, watchEpoch = 0; + +function ringTrap(details) { + var w = ringWatch; + if (!w || (details.type !== 'single-step' && details.type !== 'breakpoint') || + Process.getCurrentThreadId() !== w.tid) return false; + var ix = (watchCount % 64) * 4; + watchWords[ix] = details.context.pc.toUInt32(); + try {watchWords[ix + 1] = w.addr.readU32();} catch (_) {watchWords[ix + 1] = 0;} + watchWords[ix + 2] = details.context.ecx.toUInt32(); + watchWords[ix + 3] = details.context.ebx.toUInt32(); + watchCount++; + return true; +} + +function describeWriter(address) { + var pc = ptr(address); + var desc = {pc:locate(pc)}; + try { + var range=Process.findRangeByAddress(pc); + if(range)desc.range={base:range.base.toString(),size:range.size,protection:range.protection,file:range.file?range.file.path:null}; + } catch(_) {} + try {desc.next_instruction=Instruction.parse(pc).toString();} catch(_) {} + try {desc.previous_2=Instruction.parse(pc.sub(2)).toString();} catch(_) {} + try {desc.previous_4=Instruction.parse(pc.sub(4)).toString();} catch(_) {} + try {desc.near_bytes=Array.from(new Uint8Array(pc.sub(12).readByteArray(36))).map(function(v){return ('0'+v.toString(16)).slice(-2)}).join('');} catch(_) {} + return desc; +} +function ringDisarm(reason) { + var w = ringWatch; + if (!w) return; + ringWatch = null; + try {w.thread.unsetHardwareWatchpoint(0);} catch (_) {} + var n = Math.min(64, watchCount), events = []; + for (var j = watchCount - n; j < watchCount; j++) { + var k = (j % 64) * 4; + events.push({pc: locate(ptr(watchWords[k])), word: '0x' + watchWords[k + 1].toString(16), + ecx: '0x' + watchWords[k + 2].toString(16), ebx: '0x' + watchWords[k + 3].toString(16)}); + } + var pcs=[]; + events.forEach(function(e){if(!pcs.some(function(z){return z.pc===e.pc;}))pcs.push(describeWriter(e.pc));}); + events.forEach(function(e){ + try {var previous=ptr(e.pc).sub(2); + if(Instruction.parse(previous).toString()==='mov byte ptr [ecx], bl')reportWriterCode(previous); + } catch (_) {} + }); + var finalWord = null; + try {finalWord = '0x' + w.addr.readU32().toString(16);} catch (_) {} + note('ring_watch', {writers:pcs,reason:reason, fd:w.fd, tid:w.tid, + address:w.addr.toString(), duration_ms:Date.now()-w.started, + initial_word:w.initial, final_word:finalWord, total_hits:watchCount, events:events}); +} + +function ringArm(fd) { + if (!verifiedRing || ringWatch) return; + var tid = Process.getCurrentThreadId(); + try { + var th = Process.enumerateThreads().filter(function(t){return t.id === tid;})[0]; + if (!th) return; + var addr = verifiedRing.add(0x44); // body bytes 4..7 after ring + 0x40 + th.setHardwareWatchpoint(0, addr, 4, 'w'); + var initialWord = null; + try {initialWord = '0x' + addr.readU32().toString(16);} catch (_) {} + ringWatch = {thread:th, tid:tid, addr:addr, fd:fd, started:Date.now(), initial:initialWord}; + watchCount = 0; + var epoch = ++watchEpoch; + setTimeout(function(){if(epoch === watchEpoch)ringDisarm('timeout_125ms');},125); + note('ring_watch_armed',{fd:fd,tid:tid,address:addr.toString()}); + } catch (e) { + ringDisarm('failed_arm'); + note('ring_watch_error',{message:String(e).slice(0,180)}); + } +} + +var writerCodeReported = false; +function reportWriterCode(pc) { + if (writerCodeReported) return; + writerCodeReported = true; + // Resolve the writer from the live watchpoint PC, not a previous session address. + try { + var range = Process.findRangeByAddress(pc); + if (!range || range.protection.indexOf('x') < 0) { + note('writer_code_error', {reason:'target_not_executable'});return; + } + var start = pc.sub(128), code = new Uint8Array(start.readByteArray(256)); + var raw = Array.from(code).map(function(b){return ('0'+b.toString(16)).slice(-2)}).join(''); + var instructions = []; + // Decode confirmed boundary and forward, never claim arbitrary pre-store byte boundaries. + var walk = pc; + for (var i=0;i<36;i++) { + try {var inst=Instruction.parse(walk);instructions.push({pc:locate(walk),text:inst.toString()});walk=inst.next;} + catch (_) {break;} + } + note('writer_code', {pc:pc.toString(),mapping_base:range.base.toString(), + region_start:start.toString(),bytes:raw, decoded_forward:instructions}); + } catch(e) {note('writer_code_error',{reason:String(e).slice(0,180)});} +} + +function ringVerified(base) { + verifiedRing = ptr(base); + if (!ringHandlerReady) { + Process.setExceptionHandler(ringTrap); + ringHandlerReady = true; + } +} diff --git a/research/recv_cipher/run_probe.sh b/research/recv_cipher/run_probe.sh new file mode 100755 index 0000000..0dfff4b --- /dev/null +++ b/research/recv_cipher/run_probe.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Diagnostico delimitado do uaRO: nao altera relay e nao instala watchpoints. +set -Eeuo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" +umask 077 +command -v python3 >/dev/null || exit 2 +if pgrep -f '[/]relay_capture_linux.py' >/dev/null; then + echo 'ERRO: relay em execucao. Feche o relay antes da sondagem.' >&2; exit 3 +fi +OUT="$ROOT/research/recv_cipher/artifacts/$(date +%Y%m%d-%H%M%S)" +mkdir -m 700 "$OUT" +python3 -m ro_ops discover > "$OUT/discover.json" +python3 - "$OUT/discover.json" > "$OUT/identity.env" <<'PY' +import json,sys +j=json.load(open(sys.argv[1])); d=j.get('data',{}) +if j.get('status')!='verified' or len(d.get('candidates',[]))!=1: + raise SystemExit('ERRO: identidade unica do cliente nao confirmada') +import re +c=d['candidates'][0] +name=c.get('argv0','').replace('\\','/').split('/')[-1] +if not re.fullmatch(r'uar[o]\.exe',name,re.I): + raise SystemExit('ERRO: candidato nao e uaRO.exe: '+name) +if c.get('pid') != d.get('pid') or not isinstance(d.get('starttime_ticks'),int): + raise SystemExit('ERRO: identidade inconsistente') +pid=int(d['pid']) +print('PID='+str(pid));print('STARTTIME='+str(d['starttime_ticks'])) +PY +# identity.env is locally generated with validated numeric values only. +source "$OUT/identity.env" +echo "Cliente uaRO.exe (Wine/Proton) identificado dinamicamente: PID=$PID; resultados: $OUT" +# Prevent PID reuse or a relaunch between discovery and debugger attach. +check_identity() { + python3 - "$PID" "$STARTTIME" <<'PYID' +import sys +pid=int(sys.argv[1]); start=int(sys.argv[2]) +try: + fields=open(f'/proc/{pid}/stat').read().rsplit(')',1)[1].split() + current=int(fields[19]); state=fields[0] + cmd=open(f'/proc/{pid}/cmdline','rb').read().decode('utf-8','replace').lower() +except (OSError,ValueError,IndexError): + raise SystemExit('ERRO: processo nao esta mais acessivel') +if current != start or state in ('Z','X') or 'uaro.exe' not in cmd: + raise SystemExit('ERRO: uaRO.exe reiniciou, encerrou ou PID foi reutilizado') +PYID +} +check_identity +# Nunca anexar depurador CEserver/ptrace: congela o uaRO sob Wine/Proton. +# Inventario e correlacao somente leitura via /proc; sem ptrace ou escrita de memoria. +check_identity +python3 - "$PID" > "$OUT/proc_status.json" <<'PYSTATUS' +import json,sys +pid=int(sys.argv[1]); result={'pid':pid} +for line in open(f'/proc/{pid}/status'): + if line.startswith(('Name:', 'State:', 'Threads:', 'TracerPid:')): + key,value=line.split(':',1) + result[key]=value.strip() +print(json.dumps(result)) +PYSTATUS +if python3 - "$OUT/proc_status.json" <<'PYTRACER' +import json,sys +j=json.load(open(sys.argv[1])) +raise SystemExit(0 if j.get('TracerPid') == '0' else 1) +PYTRACER +then + echo 'Inventario concluido; nenhum depurador foi anexado.' +else + echo 'ATENCAO: processo possui TracerPid nao-zero; nenhuma sonda executada.' >&2 + exit 4 +fi +echo "Coleta encerrada. Compartilhe apenas o resumo; revise os arquivos em: $OUT" +# Passive libc/Wine recv callsite observation via Frida Gadget; no ptrace. +if [[ "${RECV_FRIDA_PROBE:-1}" == "1" ]]; then + PORT="$(python3 - "$OUT/discover.json" <<'PYPORT' +import json,sys +j=json.load(open(sys.argv[1]));print(j['data']['port']) +PYPORT +)" + check_identity + python3 research/recv_cipher/recv_probe.py --pid "$PID" --port "$PORT" --out "$OUT/recv_callsites.jsonl" --seconds "${RECV_PROBE_SECONDS:-12}" --max-calls "${RECV_PROBE_MAX_CALLS:-160}" +fi diff --git a/research/recv_cipher/sample_telemetry.py b/research/recv_cipher/sample_telemetry.py new file mode 100644 index 0000000..024246d --- /dev/null +++ b/research/recv_cipher/sample_telemetry.py @@ -0,0 +1,88 @@ +"""Bounded read-only 1 ms ring sampling telemetry; never stores buffer contents.""" +import hashlib +from collections import deque +import os +import threading +import time + + +class RingSampleTelemetry: + def __init__(self, pid, interval_s=0.001, size=16384, capacity=512): + self.pid = pid + self.interval_s = interval_s + self.size = size + self.base = None + self.frames = deque(maxlen=capacity) + self.frames_lock = threading.Lock() + self.stop_event = threading.Event() + self.thread = None + self.stats = {'samples': 0, 'changed': 0, 'read_errors': 0, + 'late_ticks': 0, 'max_gap_ms': 0.0, 'base_changes': 0, + 'stored_frames': 0, 'evicted_frames': 0} + + def set_base(self, base): + if self.base != base: + self.base = base + self.stats['base_changes'] += 1 + + def start(self): + self.thread = threading.Thread(target=self._run, name='ring-1ms-telemetry', daemon=True) + self.thread.start() + + def _run(self): + try: + fd = os.open(f'/proc/{self.pid}/mem', os.O_RDONLY) + except OSError: + self.stats['read_errors'] += 1 + return + last_digest = None + previous = None + deadline = time.monotonic() + try: + while not self.stop_event.is_set(): + now = time.monotonic() + if previous is not None: + gap = (now - previous) * 1000 + self.stats['max_gap_ms'] = max(self.stats['max_gap_ms'], round(gap, 3)) + if gap > self.interval_s * 1500: + self.stats['late_ticks'] += 1 + previous = now + base = self.base + if base is not None: + try: + data = os.pread(fd, self.size, base) + if len(data) != self.size: + raise OSError('short read') + digest = hashlib.blake2s(data, digest_size=16).digest() + self.stats['samples'] += 1 + if digest != last_digest: + if last_digest is not None: + self.stats['changed'] += 1 + with self.frames_lock: + if len(self.frames) == self.frames.maxlen: + self.stats['evicted_frames'] += 1 + self.frames.append((base, now, data)) + self.stats['stored_frames'] += 1 + last_digest = digest + except OSError: + self.stats['read_errors'] += 1 + deadline += self.interval_s + remaining = deadline - time.monotonic() + if remaining < 0: + deadline = time.monotonic() + remaining = 0 + self.stop_event.wait(remaining) + finally: + os.close(fd) + + def take_frames(self): + with self.frames_lock: + frames = list(self.frames) + self.frames.clear() + return frames + + def stop(self): + self.stop_event.set() + if self.thread is not None: + self.thread.join(timeout=2) + return dict(self.stats) diff --git a/research/recv_cipher/test_audit_write_trace.py b/research/recv_cipher/test_audit_write_trace.py new file mode 100644 index 0000000..f13e959 --- /dev/null +++ b/research/recv_cipher/test_audit_write_trace.py @@ -0,0 +1,37 @@ +"""Pure offline tests for write-to-candidate attribution.""" +from audit_write_trace import audit + + +def test_exact_write_bytes_are_consistent_not_proof(): + records = [ + dict(kind='ring_candidate', candidate_id=1, recv_seq=20, fd=9, + slot_offset=64, length=4, packet_id='09ff'), + dict(kind='ring_match', candidate_id=1, plaintext='11223344'), + dict(kind='write_trace_batch', events=[ + dict(recv_seq_hint=20, fd_hint=9, offset=64+i, byte=v) + for i, v in enumerate((0x11, 0x22, 0x33, 0x44))]), + ] + result = audit(records) + assert result['classification'] == {'write_bytes_consistent_not_proven': 1} + records[-1]['events'][2]['byte'] = 0xff + result = audit(records) + assert result['classification'] == {'write_transient_or_mismatch': 1} + + +def test_overlap_remains_ambiguous(): + records = [ + dict(kind='ring_candidate', candidate_id=1, recv_seq=20, fd=9, + slot_offset=64, length=4, packet_id='09ff'), + dict(kind='ring_candidate', candidate_id=2, recv_seq=20, fd=9, + slot_offset=66, length=4, packet_id='09fd'), + dict(kind='ring_match', candidate_id=1, plaintext='11223344'), + dict(kind='ring_match', candidate_id=2, plaintext='33445566'), + dict(kind='write_trace_batch', events=[ + dict(recv_seq_hint=20, fd_hint=9, offset=66, byte=0x33)])] + assert audit(records)['classification'] == {'ambiguous_slot_overlap': 2} + + +if __name__ == '__main__': + test_exact_write_bytes_are_consistent_not_proof() + test_overlap_remains_ambiguous() + print('write-trace attribution offline tests: PASS') diff --git a/research/recv_cipher/test_ring_correlator.py b/research/recv_cipher/test_ring_correlator.py new file mode 100644 index 0000000..243412e --- /dev/null +++ b/research/recv_cipher/test_ring_correlator.py @@ -0,0 +1,239 @@ +"""Offline verification for bounded wire->ring correlation.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from ring_correlator import actor_candidate, matching_copy, RingCorrelator +from relay.packets import load_lengths + + +def make_pair(): + wire = bytearray(b"\xfd\x09\x72\x00" + b"\x83" * 110) + clear = bytearray(wire) + clear[4] = 1 + clear[13:15] = (150).to_bytes(2, "little") + clear[-24:] = b"Nearby Player\x00" + b"\x00" * 10 + context = bytes.fromhex("d70163781f0007f100000000000000") + return bytes(wire), bytes(clear), context + + +def test_accepts_verified_changed_body(): + wire, plain, tail = make_pair() + assert actor_candidate(wire + tail) == (wire, tail) + assert matching_copy(b"z" * 64 + plain + tail, 0x100000, wire, tail) == (0x100040, plain) + + +def test_rejects_false_positives(): + wire, plain, tail = make_pair() + assert matching_copy(b"z" * 64 + wire + tail, 0, wire, tail) is None + assert matching_copy(b"z" * 64 + plain + b"x" * len(tail), 0, wire, tail) is None + corrupted = bytearray(plain) + corrupted[13:15] = (65535).to_bytes(2, "little") + assert matching_copy(b"z" * 64 + corrupted + tail, 0, wire, tail) is None + assert actor_candidate(wire) is None + assert actor_candidate(b"\xfd\x09\x03\x00" + b"a" * 20) is None + + +def test_stream_detects_interior_and_fragmented(): + probe = RingCorrelator(pid=0, emit=lambda event: None, max_candidates=10) + packet = b'\x29\x02' + b'A' * 13 + plain = b'\x80\x00' + b'B' * 5 + tail = b'\x80\x00' + b'C' * 5 + stream = plain + packet + tail + tail + for seg in (stream[:12], stream[12:23], stream[23:]): + probe.receive({'kind':'recv','fd':194,'bytes':len(seg),'payload':seg.hex()}) + assert probe.stats['wire_0229'] == 1, probe.stats + item = probe.pending.get_nowait() + assert item['chunk'].startswith(packet), item + assert len(item['chunk']) >= 23 + + +def test_slot_diagnostic_distinguishes_valid_and_wrong_header(): + probe = RingCorrelator(pid=0, emit=lambda event: None) + wire, plain, context = make_pair() + item = {'chunk': wire + context, 'length': len(wire), + 'd': 0x40, 'off': 0, 'cstart': 0} + probe.actor_adapter = type('Adapter', (), {'core': object()})() + good = probe._slot_diagnostic(item, b'z' * 0x40 + plain + context, + 0x100000, 'test') + assert good['state'] == 'candidate_structurally_valid', good + bad = probe._slot_diagnostic(item, b'z' * 0x40 + b'x' * len(plain), + 0x100000, 'test') + assert bad['state'] == 'invalid_or_stale' and not bad['header_ok'], bad + assert 'plaintext' not in good and 'gid' not in good and 'name' not in good + + +def test_production_quick_discovery_receives_full_chunk(): + events = [] + probe = RingCorrelator(pid=0, emit=events.append, max_candidates=10) + calls = [] + class Core: + ENC = (0x09fd, 0x09ff, 0x0229, 0x043f, 0x0983) + coff = {42: 0} + def start_quick_discover(self, fd, chunk, items, start): + calls.append((fd, chunk, items, start)) + class Adapter: + core = Core() + def note_chunk(self, fd, chunk, carried): + self.core.coff[fd] += len(chunk) + return 0, 0x40 + def note_packet(self, fd, off, packet): + pass + probe.actor_adapter = Adapter() + wire, _, context = make_pair() + tail = b'\x80\x00\x00\x00\x00\x00\x00\x00' + chunk = wire + context + tail + probe.receive({'fd':42,'bytes':len(chunk),'payload':chunk.hex()}) + assert len(calls) == 1, calls + fd, source, items, start = calls[0] + assert (fd, source, start) == (42, chunk, 0) + assert items == [('E', 0x09fd, wire, 0)], items + assert probe._search(wire, context)[2] == 'production_quick_pending' + probe.on_verified = lambda base: events.append({'kind':'verified_callback','base':base}) + probe._mapping_for_ring = lambda base: ['12340000-12400000', 'rw-p'] + probe._production_ring_verified(0x12340000) + assert probe.ring == 0x12340000 + assert any(e.get('kind') == 'verified_callback' for e in events) + + + +def test_quick_discovery_rejects_executable_mapping(): + events = [] + probe = RingCorrelator(pid=0, emit=events.append) + class Core: + base = 0x66b20d + hi = 0x1607000 + region = (base, hi) + core = Core() + probe.actor_adapter = type('Adapter', (), {'core':core})() + probe._mapping_for_ring = lambda base: ['00401000-01607000', 'rwxp'] + probe._production_ring_verified(core.base) + assert probe.ring is None + assert core.base is None + assert any(e.get('kind') == 'ring_discovery_rejected' for e in events) + +def test_snapshot_match_and_no_duplicate(): + events = [] + probe = RingCorrelator(pid=0, emit=events.append, max_candidates=10) + wire, plain, context = make_pair() + probe.receive({'kind': 'recv', 'fd': 42, 'bytes': len(wire + context), + 'payload': (wire + context).hex()}) + assert len(probe.awaiting) == 1 + record = {'fd': 42, 'base': '0x100000', 'seq': 1} + probe.snapshot({'fd': 43, 'base': '0x100000', 'seq': 0}, b'z' * 64 + plain + context) + assert not [e for e in events if e['kind'].endswith('_match')] + probe.snapshot(record, b'z' * 64 + plain + b'x' * len(context)) + assert not [e for e in events if e['kind'].endswith('_match')] + events.clear() + probe.snapshot(record, b'z' * 64 + plain + context) + assert len(events) == 1, events + assert events[0]['kind'] == 'ring_snapshot_match' + assert events[0]['plaintext'] == plain.hex() + assert events[0]['candidate_id'] == 1 + probe.snapshot(record, b'z' * 64 + plain + context) + assert len(events) == 1, 'snapshot duplicated an already resolved packet' + assert not probe.awaiting + + +def test_snapshot_rejects_unchanged_wire(): + events = [] + probe = RingCorrelator(pid=0, emit=events.append) + wire, plain, context = make_pair() + probe.receive({'kind': 'recv', 'fd': 5, 'bytes': len(wire + context), + 'payload': (wire + context).hex()}) + probe.snapshot({'fd': 5, 'base': '0x100000'}, b'z' * 64 + wire + context) + assert not [e for e in events if e['kind'].endswith('_match')] + assert len(probe.awaiting) == 1 + + +def test_snapshot_invalid_metadata_is_safe(): + events = [] + probe = RingCorrelator(pid=0, emit=events.append) + wire, plain, context = make_pair() + probe.receive({'kind': 'recv', 'fd': 42, 'bytes': len(wire + context), + 'payload': (wire + context).hex()}) + probe.snapshot({'fd': 42, 'base': 'not-an-address'}, b'z' * 64 + plain + context) + assert probe.stats['invalid_snapshot_base'] == 1 + assert not [e for e in events if e['kind'].endswith('_match')] and len(probe.awaiting) == 1 + + +def test_replay_resolves_only_later_valid_frame(): + import time + events = [] + probe = RingCorrelator(pid=0, emit=events.append) + wire, plain, context = make_pair() + before = time.monotonic() - 1 + probe.receive({'kind':'recv', 'fd':42, 'bytes':len(wire+context), + 'payload':(wire+context).hex()}) + after = time.monotonic() + 1 + valid = b'z'*64 + plain + context + probe.replay_frames([(0x100000, before, valid)]) + assert not [e for e in events if e['kind'].endswith('_match')] and len(probe.awaiting) == 1 + probe.replay_frames([(0x100000, after, valid)]) + events = [e for e in events if e['kind'] != 'ring_candidate'] + assert len(events) == 1 and events[0]['kind'] == 'ring_replay_match' + assert events[0]['candidate_id'] == 1 + probe.replay_frames([(0x100000, after, valid)]) + assert len(events) == 1 + + +def test_miss_reason_classification(): + from collections import Counter + wire, plain, context = make_pair() + cases = [(b'z'*256, 'header_absent'), + (b'z'*64 + wire + context, 'unchanged_ciphertext'), + (b'z'*64 + plain + b'x'*len(context), 'following_context_mismatch')] + for data, reason in cases: + reasons = Counter() + assert matching_copy(data, 0, wire, context, reasons) is None + assert reasons[reason] > 0, (reason, reasons) + + +def test_actor_core_adapter_real_scoring(): + from actor_adapter import ActorCoreAdapter + wire, plain, context = make_pair() + adapter = ActorCoreAdapter(__import__("os").getpid()) + try: + start, delta = adapter.note_chunk(194, wire + context, 0) + adapter.note_packet(194, 0, wire) + item = {'chunk': wire + context, 'length': len(wire), 'fd':194, + 'off':0, 'cstart':start, 'd':delta} + assert adapter.find(b'X' * 64 + plain + context, 0x100000, item) == (0x100040, plain) + assert adapter.find(b'X' * 64 + wire + context, 0x100000, item) is None + finally: + adapter.close() + + +def test_actor_core_0229_two_byte_header(): + from actor_adapter import ActorCoreAdapter + wire = b'\x29\x02' + b'\x12\x34\x56\x78' + b'C' * 9 + plain = wire[:6] + b'P' * 9 + tail = b'\x80\x00' + b'Z' * 5 + adapter = ActorCoreAdapter(__import__("os").getpid()) + try: + start, delta = adapter.note_chunk(194, wire + tail, 0) + adapter.note_packet(194, 0, wire) + item = {'chunk': wire + tail, 'length':len(wire), 'fd':194, + 'off':0, 'cstart':start, 'd':delta} + assert adapter.find(b'X' * 64 + plain + tail, 0x100000, item) == (0x100040, plain) + finally: + adapter.close() + + +if __name__ == "__main__": + test_accepts_verified_changed_body() + test_rejects_false_positives() + test_stream_detects_interior_and_fragmented() + test_slot_diagnostic_distinguishes_valid_and_wrong_header() + test_production_quick_discovery_receives_full_chunk() + test_snapshot_match_and_no_duplicate() + test_snapshot_rejects_unchanged_wire() + test_snapshot_invalid_metadata_is_safe() + test_replay_resolves_only_later_valid_frame() + test_miss_reason_classification() + test_actor_core_adapter_real_scoring() + test_actor_core_0229_two_byte_header() + print("ring-correlator offline tests: PASS") + +test_quick_discovery_rejects_executable_mapping() diff --git a/research/recv_cipher/validate_0229.py b/research/recv_cipher/validate_0229.py new file mode 100644 index 0000000..6a021ba --- /dev/null +++ b/research/recv_cipher/validate_0229.py @@ -0,0 +1,76 @@ +"""Offline, read-only 0229 candidate assessment. Never authorizes delivery by itself. + +OpenKore ServerType0: ID(a4), opt1(v), opt2(v), option(V), stance(C). +The wire packet ID is two clear bytes; GID and flags are in the encrypted body. +""" +import collections +import json +import struct +from pathlib import Path + +PACKET_ID = b'\x29\x02' +STRUCT = struct.Struct('<2sIHHIB') + +# Descriptive reference values from a local sample, NOT protocol allowlists. +REFERENCE_OPT1 = frozenset((0,)) +REFERENCE_OPT2 = frozenset((0, 16)) +REFERENCE_OPTION = frozenset((0, 16, 32)) +REFERENCE_STANCE = frozenset((0,)) + + +def assess(wire, candidate, *, matched_context=False, fresh_slot=False, ambiguous=False): + """Conservative gate requiring independent ring provenance, not merely plausible flags. + + Return a metadata-only decision (no GIDs or packet bodies). + """ + flags = {'length': len(wire) == 15 and len(candidate) == 15, + 'header': len(wire) >= 2 and len(candidate) >= 2 + and wire[:2] == candidate[:2] == PACKET_ID, + 'changed': wire != candidate} + if not flags['length'] or not flags['header']: + return {'deliver': False, 'reason': 'invalid-structure', 'checks': flags} + _, gid, opt1, opt2, option, stance = STRUCT.unpack(candidate) + # A zero GID is flagged as suspicious, not claimed impossible on every server. + flags['gid_nonzero'] = gid != 0 + flags['opt1_reference'] = opt1 in REFERENCE_OPT1 + flags['opt2_reference'] = opt2 in REFERENCE_OPT2 + flags['option_reference'] = option in REFERENCE_OPTION + flags['stance_reference'] = stance in REFERENCE_STANCE + flags['provenance'] = bool(matched_context and fresh_slot and not ambiguous) + # Neither matching encrypted GID nor known-looking status fields proves decryption. + allowed = flags['changed'] and flags['gid_nonzero'] and flags['provenance'] + return {'deliver': bool(allowed), + 'reason': ('verified-context' if allowed else 'insufficient-evidence'), + 'checks': flags} + + +def audit_log(path): + counts = collections.Counter() + for line in Path(path).open(encoding='utf-8', errors='replace'): + try: + event = json.loads(line) + if event.get('tag') != 'RECV-PLAIN' or event.get('id') != '0229': + continue + candidate = bytes.fromhex(event['P']) + if len(candidate) != 15 or candidate[:2] != PACKET_ID: + counts['invalid-structure'] += 1 + continue + _, gid, opt1, opt2, option, stance = STRUCT.unpack(candidate) + counts['total'] += 1 + counts['zero-gid' if not gid else 'nonzero-gid'] += 1 + for name, value, reference in (('opt1', opt1, REFERENCE_OPT1), + ('opt2', opt2, REFERENCE_OPT2), + ('option', option, REFERENCE_OPTION), + ('stance', stance, REFERENCE_STANCE)): + counts[name + ('-seen-reference' if value in reference else '-outside-reference')] += 1 + except (ValueError, KeyError, TypeError, json.JSONDecodeError): + counts['malformed-record'] += 1 + return dict(sorted(counts.items())) + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('log', type=Path) + args = parser.parse_args() + print(json.dumps(audit_log(args.log), indent=2)) diff --git a/research/recv_cipher/write_trace.js b/research/recv_cipher/write_trace.js new file mode 100644 index 0000000..dd932ad --- /dev/null +++ b/research/recv_cipher/write_trace.js @@ -0,0 +1,59 @@ +// Research-only trace of the previously observed `mov byte ptr [ecx], bl`. +// Installed only after a ring address has been verified by a plaintext match. +// Do not assume an observed writer is the unique writer for any packet. +// Hardware EXECUTE breakpoints, without modifying executable instructions. +var writeTraceRing=null, writeTraceThreads=[], writeTraceInstalled=false; +var writeTraceEvents=[],writeTraceTotal=0,writeTraceDropped=0; +var writeTraceBatch=0,writeTraceMax=30000; +var writeTraceAddress=ptr(/*WRITE_ADDRESS*/); +function writeTraceFlush(){ + if(!writeTraceEvents.length)return; + send({kind:'write_trace_batch',batch:++writeTraceBatch,events:writeTraceEvents}); + writeTraceEvents=[]; +} +function writeTraceException(details){ + if(!writeTraceInstalled || details.type!=='breakpoint' || + !details.address.equals(writeTraceAddress))return false; + var tid=Process.getCurrentThreadId(); + if(!writeTraceThreads.some(function(t){return t.id===tid;}))return false; + if(writeTraceTotal >= writeTraceMax){writeTraceDropped++;return true;} + var relative=details.context.ecx.sub(writeTraceRing); + if(relative.compare(ptr('0x40'))>=0 && relative.compare(ptr('0x4000'))<0){ + var recv=recvByThread[tid]||null; + writeTraceTotal++; + writeTraceEvents.push({n:writeTraceTotal,tid:tid, + offset:relative.toUInt32(),byte:details.context.ebx.toUInt32()&255, + recv_seq_hint:recv?recv.seq:null,fd_hint:recv?recv.fd:null, + age_ms:recv?Date.now()-recv.ms:null}); + if(writeTraceEvents.length>=64)writeTraceFlush(); + } + return true; +} +function writerStart(){ + if(writeTraceInstalled || ringBase===null)return; + try{ + var range=Process.findRangeByAddress(writeTraceAddress); + if(!range || range.protection.indexOf('x')<0)throw Error('not_executable'); + var ins=Instruction.parse(writeTraceAddress); + if(ins.toString()!=='mov byte ptr [ecx], bl')throw Error('unexpected_instruction:'+ins.toString()); + writeTraceRing=ringBase; + Process.setExceptionHandler(writeTraceException); + // Execution breakpoint is thread-local: arm every existing thread. + // Do not claim coverage for threads created after this snapshot. + Process.enumerateThreads().forEach(function(t){ + try{ t.setHardwareBreakpoint(1,writeTraceAddress); writeTraceThreads.push(t); } + catch(e){note('write_trace_thread_error',{tid:t.id,reason:String(e).slice(0,100)});} + }); + writeTraceInstalled=writeTraceThreads.length>0; + note('write_trace_armed',{mode:'hardware_execute',instruction:ins.toString(), + thread_count:writeTraceThreads.length,ring:writeTraceRing.toString()}); + }catch(e){note('write_trace_error',{reason:String(e).slice(0,200)});} +} +function writerStop(){ + writeTraceInstalled=false; + writeTraceThreads.forEach(function(t){try{t.unsetHardwareBreakpoint(1);}catch(_){}}); + writeTraceThreads=[]; + writeTraceFlush(); + note('write_trace_summary',{mode:'hardware_execute',total:writeTraceTotal, + dropped:writeTraceDropped,batches:writeTraceBatch}); +} diff --git a/run.sh b/run.sh index 26d4411..f927a39 100755 --- a/run.sh +++ b/run.sh @@ -34,6 +34,70 @@ if [ "${1:-}" = "--raw" ]; then RAW=1; shift; fi PY="${FRIDA_PY:-$HOME/.venv-frida/bin/python}" [ -x "$PY" ] || PY="python3" +# Auditoria opcional: nunca solicitar senha interativamente ou impedir o jogo. +# Para ativar: sudo -v antes de ./run.sh; caso contrário seguimos sem auditoria. +# O monitor acompanha reinícios do uaRO e remove somente regras que conseguiu instalar. +AUDIT_SCRIPT="$HERE/scripts/audit-game-stop-signals.sh" +AUDIT_MONITOR_PID='' +audit_monitor() { + local pid='' newpid='' audit_ready=0 + audit_monitor_cleanup() { + if [[ -n "$pid" && "$audit_ready" -eq 1 ]]; then + sudo -n "$AUDIT_SCRIPT" report "$pid" 2>&1 || true + sudo -n "$AUDIT_SCRIPT" stop "$pid" 2>&1 || true + fi + } + trap audit_monitor_cleanup EXIT + trap 'exit 0' TERM INT + while :; do + newpid="$(pgrep -u "$(id -u)" -x 'uaRO.exe' | tail -n 1 || true)" + if [[ "$newpid" != "$pid" ]]; then + if [[ -n "$pid" && "$audit_ready" -eq 1 ]]; then + sudo -n "$AUDIT_SCRIPT" report "$pid" 2>&1 || true + sudo -n "$AUDIT_SCRIPT" stop "$pid" 2>&1 || true + fi + audit_ready=0; pid="$newpid" + if [[ -n "$pid" ]]; then + if sudo -n "$AUDIT_SCRIPT" start "$pid"; then + audit_ready=1 + echo "[audit] monitorando sinais enviados ao uaRO PID=$pid" + else + echo '[audit] sem privilégios não interativos; auditoria desativada (execute sudo -v antes de ./run.sh)' >&2 + return 0 + fi + fi + fi + sleep 1 + done +} +audit_cleanup() { + if [[ -n "$AUDIT_MONITOR_PID" ]]; then + kill "$AUDIT_MONITOR_PID" 2>/dev/null || true + wait "$AUDIT_MONITOR_PID" 2>/dev/null || true + fi +} +# A execução com sudo -n não altera a exigência de senha do sistema e não trava o launcher. +if [[ -x "$AUDIT_SCRIPT" ]] && sudo -n true 2>/dev/null; then + audit_monitor & AUDIT_MONITOR_PID=$! + trap audit_cleanup EXIT +else + echo '[audit] inativa; para habilitar auditoria de sinais, execute sudo -v antes de ./run.sh' +fi + +# Prevent a Wine process-wide SIGSTOP from becoming an indefinite freeze. +# Separate process, independent of relay/Frida message flow; one SIGCONT per stop episode. +WATCHDOG_PID='' +"$PY" -u "$HERE/scripts/watch-uaro-stop.py" --threshold 3 --interval 0.25 & +WATCHDOG_PID=$! +run_cleanup() { + if [[ -n "$WATCHDOG_PID" ]]; then + kill "$WATCHDOG_PID" 2>/dev/null || true + wait "$WATCHDOG_PID" 2>/dev/null || true + fi + audit_cleanup +} +trap run_cleanup EXIT + if [ "$RAW" -eq 1 ]; then echo "[run] modo raw: argumentos repassados diretamente; defaults em relay_capture.conf" tail -f /dev/null | "$PY" -u "$HERE/relay_capture_linux.py" "$@" diff --git a/scripts/audit-game-stop-signals.sh b/scripts/audit-game-stop-signals.sh new file mode 100755 index 0000000..1b18446 --- /dev/null +++ b/scripts/audit-game-stop-signals.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# PID-scoped Linux audit rules: records which process sends kill/tgkill to uaRO. +# Run as root; does not send signals or attach to the game. +set -euo pipefail +mode=${1:-}; pid=${2:-} +if [[ ! $mode =~ ^(start|stop|report)$ || ! $pid =~ ^[1-9][0-9]*$ ]]; then + echo "Usage: sudo $0 {start|stop|report} uaRO_PID" >&2; exit 2 +fi +if (( EUID != 0 )); then echo 'Run as root (audit rules require CAP_AUDIT_CONTROL)' >&2; exit 1; fi +key="ro_stop_${pid}" +if [[ $mode == report ]]; then + ausearch -k "$key" -i + exit +fi +op=-a; [[ $mode == stop ]] && op=-d +for arch in b64 b32; do + # kill(pid, signal): a0 = target PID; tgkill(tgid, tid, signal): a0 = process PID. + for syscall in kill tgkill; do + auditctl "$op" always,exit -F "arch=$arch" -S "$syscall" -F "a0=$pid" -k "$key" + done + # tkill(tid, signal) may target the main thread. Other TIDs need separate rules. + auditctl "$op" always,exit -F "arch=$arch" -S tkill -F "a0=$pid" -k "$key" +done +printf '%s audit rules for PID %s (key %s)\n' "$mode" "$pid" "$key" diff --git a/scripts/gamescope-eis-session.sh b/scripts/gamescope-eis-session.sh new file mode 100755 index 0000000..c0656da --- /dev/null +++ b/scripts/gamescope-eis-session.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Run *inside* gamescope, which exports LIBEI_SOCKET to its children. +# Writes only a private path pointer; never creates/injects a host input device. +set -euo pipefail +if [[ -z "${LIBEI_SOCKET:-}" || -z "${XDG_RUNTIME_DIR:-}" ]]; then + echo "[gamescope-eis] requires LIBEI_SOCKET and XDG_RUNTIME_DIR from gamescope" >&2 + exit 2 +fi +if [[ $# -eq 0 ]]; then + echo "usage: gamescope-eis-session.sh COMMAND [ARG...]" >&2 + exit 2 +fi +socket="${LIBEI_SOCKET}" +if [[ "$socket" != /* ]]; then + socket="${XDG_RUNTIME_DIR}/$socket" +fi +if [[ ! -S "$socket" ]]; then + echo "[gamescope-eis] not a socket: $socket" >&2 + exit 3 +fi +path_file="${RO_RELAY_EIS_FILE:-${XDG_RUNTIME_DIR}/ro-relay-gamescope-eis.path}" +umask 077 +tmp="$(mktemp "${path_file}.XXXXXX")" +printf '%s\n' "$socket" > "$tmp" +mv -f -- "$tmp" "$path_file" +echo "[gamescope-eis] socket path file: $path_file" >&2 +cleanup() { + if [[ -f "$path_file" ]] && [[ "$(cat "$path_file")" == "$socket" ]]; then + rm -f -- "$path_file" + fi +} +trap cleanup EXIT +"$@" diff --git a/scripts/watch-uaro-stop.py b/scripts/watch-uaro-stop.py new file mode 100644 index 0000000..f2678b3 --- /dev/null +++ b/scripts/watch-uaro-stop.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Resume uaRO after >=3s in Linux process-stopped state (not gameplay inactivity).""" +import argparse +import os +import signal +import time +from pathlib import Path + + +def process_state(pid): + try: + raw = Path(f'/proc/{pid}/stat').read_text() + state = raw[raw.rfind(')') + 2] + return state, Path(f'/proc/{pid}/comm').read_text().strip() + except (FileNotFoundError, ProcessLookupError, PermissionError): + return None, None + + +def ua_pids(): + for entry in Path('/proc').iterdir(): + if entry.name.isdigit(): + state, name = process_state(int(entry.name)) + if name == 'uaRO.exe' and state is not None: + yield int(entry.name), state + + +def watch(interval=0.25, threshold=3.0): + stopped = {} + resumed = set() + print(f'[watchdog] uaRO SIGCONT enabled; stopped threshold={threshold:.1f}s', flush=True) + while True: + now = time.monotonic() + current = dict(ua_pids()) + for pid, state in current.items(): + if state not in ('T', 't'): + stopped.pop(pid, None) + resumed.discard(pid) + continue + if pid in resumed: + continue # do not flood SIGCONT if some component re-stops it + if pid not in stopped: + stopped[pid] = now + print(f'[watchdog] uaRO pid={pid} stopped state={state}; observing', flush=True) + elif now - stopped[pid] >= threshold: + elapsed = now - stopped[pid] + try: + # Recheck process identity to avoid signaling an unrelated reused PID. + state2, name2 = process_state(pid) + if name2 == 'uaRO.exe' and state2 in ('T', 't'): + os.kill(pid, signal.SIGCONT) + print(f'[watchdog] SIGCONT sent pid={pid} stopped_for={elapsed:.2f}s; session cipher may require reconnect', flush=True) + resumed.add(pid) + except (PermissionError, ProcessLookupError) as e: + print(f'[watchdog] SIGCONT failed pid={pid}: {e}', flush=True) + resumed.add(pid) + for pid in list(stopped): + if pid not in current: + stopped.pop(pid, None) + resumed.discard(pid) + time.sleep(interval) + + +if __name__ == '__main__': + p = argparse.ArgumentParser() + p.add_argument('--threshold', type=float, default=3.0) + p.add_argument('--interval', type=float, default=0.25) + a = p.parse_args() + if a.threshold <= 0 or a.interval <= 0: + p.error('threshold and interval must be positive') + watch(a.interval, a.threshold) diff --git a/tests/test_0229_provenance.py b/tests/test_0229_provenance.py new file mode 100644 index 0000000..28e8644 --- /dev/null +++ b/tests/test_0229_provenance.py @@ -0,0 +1,35 @@ +"""0229 must not gain trust from a header or expected ring position alone.""" +import collections +import threading +from _boot import g +from relay.actors import ActorDecrypt, copy_validation + + +def dec(): + d = ActorDecrypt.__new__(ActorDecrypt) + d.base = 0x1000 + d.hi = 0x4000 + d.stream = {'c': [0, b'\xaa' * 20 + b'\x29\x02' + b'\xee' * 13 + b'\xbb' * 20]} + d.spans = collections.defaultdict(list) + d.spans['c'] = [(22, 35)] + d.fed = {} + d.stats = collections.Counter() + d.fails = 0 + d.lock = threading.Lock() + return d + + +d = dec() +wire = b'\x29\x02' + b'\xee' * 13 +plain = b'\x29\x02' + b'\x78\x56\x34\x12' + b'\x00' * 9 +assert d.copy_ok(wire, plain) +assert copy_validation(wire, plain)['accepted'] +assert d.copy_ok(wire, b'\x29\x02' + b'\x78\x56\x34\x12' + b'\xff' * 9) +assert not d.copy_ok(wire, b'\x29\x02' + b'\x00' * 13) +# No adjacent context: expected slot must not be enough for resolve(). +d._ctx = lambda *args: (0, 0) +d.read = lambda addr, n: plain if n == 15 else b'\x00' * n +d._best = lambda buf, base, conn, pkt, off, cstart, exp_d=None: [ + (16, 0, 0, d.base + 0x40 + off, 0x40, plain)] +assert d.resolve('c', 0x0229, wire, 0, 0, exp_d=0x40, live=True) is None +print('0229 provenance gate PASS') diff --git a/tests/test_043f_recovery.py b/tests/test_043f_recovery.py new file mode 100644 index 0000000..130b6a0 --- /dev/null +++ b/tests/test_043f_recovery.py @@ -0,0 +1,38 @@ +"""043F status packets require ring provenance and cannot pass ciphertext unchanged.""" +import collections +import threading +from _boot import g +from relay.actors import ActorDecrypt, copy_validation +from relay.packets import WireFilter + + +def fake_dec(): + d = ActorDecrypt.__new__(ActorDecrypt) + d.base = 0x1000 + d.hi = 0x5000 + d.region = (0x1000, 0x5000) + d.stream = {} + d.spans = collections.defaultdict(list) + d.fed = {} + d.stats = collections.Counter() + d.fails = 0 + d.lock = threading.Lock() + return d + + +wire = b'\x3f\x04' + bytes(range(23)) +plain = b'\x3f\x04' + b'\x7b\x00' + b'\x78\x56\x34\x12' + bytes([1]) + (9999).to_bytes(4, 'little') + b'\x00' * 12 +assert len(plain) == 25 +d = fake_dec() +assert 0x043f in d.ENC and d.FIXED_ENC[0x043f] == 2 +assert d.copy_ok(wire, plain) +assert copy_validation(wire, plain)['accepted'] +assert not d.copy_ok(wire, wire) +assert not d.copy_ok(wire, plain[:-1]) +assert not d.copy_ok(wire, b'\x3f\x04' + b'\x00' * 23) +assert not d.copy_ok(wire, b'\x29\x02' + plain[2:]) +assert WireFilter({0x043f: 25}, set()).push_items('c', wire, enc=d.ENC) == [('E', 0x043f, wire, 0)] +d._best = lambda *args: [(16, 0, 0, 0x1040, 0x40, plain)] +d.read = lambda address, n: plain if n == 25 else b'\x00' * n +assert d.resolve('c', 0x043f, wire, 0, 0, exp_d=0x40) is None +print('043f recovery checks PASS') diff --git a/tests/test_0983_recovery.py b/tests/test_0983_recovery.py new file mode 100644 index 0000000..7afc279 --- /dev/null +++ b/tests/test_0983_recovery.py @@ -0,0 +1,24 @@ +"""Offline routing and status recovery tests for 0983.""" +import collections +import threading +from _boot import g +from relay.actors import ActorDecrypt, copy_validation +from relay.packets import WireFilter +d=ActorDecrypt.__new__(ActorDecrypt) +d.base=0x1000;d.hi=0x5000;d.region=(0x1000,0x5000) +d.stream={};d.spans=collections.defaultdict(list) +d.fed={};d.stats=collections.Counter();d.fails=0;d.lock=threading.Lock() +wire=b'\x83\x09'+bytes(range(27)) +plain=b'\x83\x09'+(824).to_bytes(2,'little')+(0x20f073).to_bytes(4,'little')+bytes([1])+(9999).to_bytes(4,'little')+bytes(16) +assert len(plain)==29 +assert 0x0983 in d.ENC and d.FIXED_ENC[0x0983]==2 +assert d.copy_ok(wire,plain) and copy_validation(wire,plain)['accepted'] +assert not d.copy_ok(wire,wire) +assert not d.copy_ok(wire,plain[:-1]) +assert not d.copy_ok(wire,b'\x83\x09'+b'\x00'*27) +assert not d.copy_ok(wire,b'\x3f\x04'+plain[2:]) +assert WireFilter({0x0983:29},set()).push_items('c',wire,enc=d.ENC)==[('E',0x0983,wire,0)] +d._best=lambda *a:[(16,0,0,0x1040,0x40,plain)] +d.read=lambda a,n: plain if n==29 else b'\x00'*n +assert d.resolve('c',0x0983,wire,0,0,exp_d=0x40) is None +print('0983 recovery PASS') diff --git a/tests/test_actor_dynamic_ring.py b/tests/test_actor_dynamic_ring.py new file mode 100644 index 0000000..eca9147 --- /dev/null +++ b/tests/test_actor_dynamic_ring.py @@ -0,0 +1,232 @@ +"""Dynamic ring discovery must never trust a persisted address.""" +import collections +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +import _boot +from relay.actors import ActorDecrypt, copy_validation + + +def actor(name=b'Fabre', gid=b'ABCD'): + b = bytearray(108) + b[:4] = bytes.fromhex('ff096c00') + b[4] = 5 + b[5:9] = gid + b[13:15] = (200).to_bytes(2, 'little') + b[-24:] = name.ljust(24, bytes(1)) + return bytes(b) + + +class DynamicRingTests(unittest.TestCase): + def test_stale_cache_not_accepted_on_startup(self): + with tempfile.TemporaryDirectory() as tmp: + with patch('relay.actors.ProcessMemory') as memory: + dec = ActorDecrypt(999999) + self.assertIsNone(dec.region) + self.assertIsNone(dec.base) + self.assertEqual(dec.stats['ring-discovered'], 0) + memory.assert_called_once_with(999999) + + def _memory(self, dec, mapping_lo, ringbase, blob, mapping_hi=None): + hi = mapping_hi or mapping_lo + 0x5000 + memory = bytearray(hi-mapping_lo) + offset = ringbase + 0x40 - mapping_lo + memory[offset:offset+len(blob)] = blob + dec.read = lambda addr, n: (bytes(memory[addr-mapping_lo:addr-mapping_lo+n]) + if mapping_lo <= addr < hi else b'') + return [(mapping_lo, hi, 'rw')] + + def test_ring_mapping_gate_allows_wine_anonymous_rwx_and_rejects_code(self): + from unittest.mock import mock_open + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + # Real uaRO ring: Wine's anonymous RWX mapping is legitimate. + sample = '231f0000-234a2000 rwxp 00000000 00:00 0\n' + with patch('builtins.open', mock_open(read_data=sample)): + self.assertTrue(dec._ring_mapping_allowed(0x23410000, 0x23414080)) + self.assertFalse(dec._ring_mapping_allowed(0x23410001, 0x23414080)) + # Historical false discovery inside anonymous PE image: unaligned. + sample = '00401000-01607000 rwxp 00000000 00:00 0\n' + with patch('builtins.open', mock_open(read_data=sample)): + self.assertFalse(dec._ring_mapping_allowed(0x66b20d, 0x66f20d)) + # File-backed mappings cannot contain the ephemeral RX ring. + sample = '20000000-20010000 rwxp 00000000 00:01 17 /tmp/executable\n' + with patch('builtins.open', mock_open(read_data=sample)): + self.assertFalse(dec._ring_mapping_allowed(0x20001000, 0x20005000)) + sample = '20000000-20010000 rw-p 00000000 00:00 0\n' + with patch('builtins.open', mock_open(read_data=sample)): + self.assertTrue(dec._ring_mapping_allowed(0x20001000, 0x20005000)) + self.assertFalse(dec._ring_mapping_allowed(0x2000f000, 0x20011000)) + + def test_quick_discover_from_current_chunk_and_throttle(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + C = bytes(range(64)) + actor() + bytes(range(64)) + mask = [(68, 64 + 108)] + plain = actor(b'Poring') + ring = C[:68] + plain[4:] + C[64 + 108:] + maps = self._memory(dec, 0x1000, 0x1000, ring) + found = [] + dec.on_ring = lambda x: found.append(x) + with patch('relay.actors.regions', return_value=maps), patch.object(dec, '_ring_mapping_allowed', return_value=True): + self.assertTrue(dec.quick_discover('conn', C, mask, [(64,C[64:172])])) + self.assertEqual(dec.base, 0x1000) + self.assertEqual(found, [0x1000]) + self.assertEqual(dec.stats['ring-discovered'], 1) + + def test_isolated_09ff_with_only_four_clear_bytes_discovers_ring(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + wire = actor(b'Cipher')[:4] + bytes([0xe7]) * 104 + plaintext = actor(b'Poring') + maps = self._memory(dec, 0x2000, 0x2700, plaintext) + with patch('relay.actors.regions', return_value=maps), patch.object(dec, '_ring_mapping_allowed', return_value=True): + self.assertTrue(dec.quick_discover('conn', wire, [(4, 108)], [(0, wire)])) + self.assertEqual(dec.base, 0x2700) + self.assertEqual(dec.stats['ring-quick-actor-verified'], 1) + + def test_sparse_header_without_verified_plaintext_must_not_pick_ring(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + wire = actor()[:4] + bytes([0xe7]) * 104 + maps = self._memory(dec, 0x2000, 0x2500, wire) + with patch('relay.actors.regions', return_value=maps), patch.object(dec, '_ring_mapping_allowed', return_value=True): + self.assertFalse(dec.quick_discover('conn', wire, [(4, 108)], [(0, wire)])) + self.assertIsNone(dec.base) + + def test_sparse_09fd_also_discovers_valid_actor(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + wire = bytearray(actor(b'Zipper')) + bytes(6) + wire[:4] = bytes.fromhex('fd097200') + plaintext = bytearray(actor(b'Peach')) + bytes(6) + plaintext[-24:] = b'Peach'.ljust(24, b'\x00') + plaintext[:4] = wire[:4] + wire[4:] = bytes([0xeb]) * (len(wire) - 4) + maps = self._memory(dec, 0x3000, 0x3700, plaintext) + with patch('relay.actors.regions', return_value=maps), patch.object(dec, '_ring_mapping_allowed', return_value=True): + self.assertTrue(dec.quick_discover('conn', bytes(wire), [(4,114)], [(0,bytes(wire))])) + self.assertEqual(dec.base, 0x3700) + + def test_full_discover_infers_internal_base_not_mapping_start(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + wire = actor(b'Cipher')[:4] + bytes([0xe3])*104 + plaintext = actor(b'Peach') + mapping_lo, ringbase, mapping_hi = 0x1000, 0x2800, 0x6000 + mem = bytearray(mapping_hi - mapping_lo) + mem[ringbase + 0x40 - mapping_lo:ringbase + 0x40 - mapping_lo+108] = plaintext + dec.read = lambda addr,n: bytes(mem[addr-mapping_lo:addr-mapping_lo+n]) + # Valid neighbor context outside the encrypted actor body. + dec.stream['A'] = [0, wire] + dec.spans['A'].append((4,108)) + dec._ctx = lambda buf, i, conn, off, L: (24, 0) + with patch('relay.actors.regions', return_value=[(mapping_lo,mapping_hi,'rw')]), patch.object(dec, '_ring_mapping_allowed', return_value=True): + self.assertTrue(dec.discover('A',wire,0,0)) + self.assertEqual(dec.base, ringbase) + self.assertEqual(dec.region, (ringbase,mapping_hi)) + + def test_first_and_last_slot_read_preserved_without_plaintext(self): + from relay.wire_feed import new_chunk + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + dec.base, dec.hi, dec.region = 0x1000, 0x8000, (0x1000, 0x8000) + plain = actor(b'Peach') + wire = plain[:4] + bytes([0xee]) * 104 + e = new_chunk('server:5121', 'A', [('E', 0x09ff, wire, 0)], 0, 0x40) + e['B'][0] = wire + data = iter((wire, plain, plain)) + dec.read = lambda addr, n: next(data) + t = e['t'] + self.assertIsNone(dec.settle(e, t + 0.001, 0.30, settle_wait=0.03)) + self.assertIsNone(dec.settle(e, t + 0.010, 0.30, settle_wait=0.03)) + result = dec.settle(e, t + 0.050, 0.30, settle_wait=0.03) + self.assertEqual(result, {0: plain}) + self.assertFalse(e['first_read'][0]['copy_ok']) + self.assertTrue(e['last_read'][0]['copy_ok']) + self.assertEqual(e['first_read'][0]['bytes_read'], 108) + self.assertLess(e['first_read'][0]['ms'], e['last_read'][0]['ms']) + self.assertEqual(e['candidate_seen'][0], True) + self.assertNotIn('plaintext', str(e['first_read'])) + + def test_immobile_actor_walk_speed_zero_and_under_twenty(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + for speed in (0, 1, 19, 20, 1000, 2000): + for pid, length in ((0x09ff, 108), (0x09fd, 114)): + with self.subTest(speed=speed, pid=hex(pid)): + plain = bytearray(actor(b'Immobile')) + bytearray(length - 108) + plain[0:4] = pid.to_bytes(2, 'little') + length.to_bytes(2, 'little') + plain[13:15] = speed.to_bytes(2, 'little') + wire = bytes(plain[:4]) + bytes([0xe7]) * (length - 4) + self.assertTrue(dec.copy_ok(wire, bytes(plain))) + result = copy_validation(wire, bytes(plain)) + self.assertTrue(result['walk_speed_ok']) + self.assertTrue(result['accepted']) + plain[13:15] = (2001).to_bytes(2, 'little') + self.assertFalse(dec.copy_ok(wire, bytes(plain))) + self.assertFalse(copy_validation(wire, bytes(plain))['walk_speed_ok']) + + def test_copy_validation_matches_existing_rules_without_leaking_bytes(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + wire = actor()[:4] + b'\xe4' * 104 + good = actor(b'Peach') + variants = { + 'good': good, + 'unchanged': wire, + 'wrong_header': b'\x00' + good[1:], + 'wrong_size': good[:-1], + 'bad_type': good[:4] + b'\xff' + good[5:], + 'bad_speed': good[:13] + (2001).to_bytes(2, 'little') + good[15:], + 'bad_name': good[:-24] + b'A\x00B' + bytes(21), + 'bad_high_byte_name': good[:-24] + b'Fake\xa7\xa5'.ljust(24, b'\0'), + } + for label, sample in variants.items(): + with self.subTest(label=label): + details = copy_validation(wire, sample) + self.assertEqual(details['accepted'], dec.copy_ok(wire, sample)) + self.assertTrue(all(v is None or isinstance(v, bool) + for k,v in details.items() if k != 'walk_speed_value')) + self.assertTrue(copy_validation(wire, variants['good'])['accepted']) + self.assertEqual(copy_validation(wire, variants['good'])['walk_speed_value'], 200) + self.assertEqual(copy_validation(wire, variants['bad_speed'])['walk_speed_value'], 2001) + for label in ('unchanged','wrong_header','wrong_size','bad_type','bad_speed','bad_name','bad_high_byte_name'): + self.assertFalse(copy_validation(wire, variants[label])['accepted'], label) + fixed = bytes.fromhex('290263ef2000') + bytes.fromhex('010203040506070809') + decrypted = fixed[:6] + bytes(9) + self.assertEqual(copy_validation(fixed, decrypted)['accepted'], dec.copy_ok(fixed, decrypted)) + self.assertIsNone(copy_validation(fixed, decrypted)['walk_speed_ok']) + + def test_actor_delay_default_300ms(self): + from relay.cli import parse_args + self.assertEqual(parse_args(['--config', '/nonexistent', '--no-inject']).actor_delay, .30) + + def test_invalidated_ring_can_be_rediscovered(self): + with patch('relay.actors.ProcessMemory'): + dec = ActorDecrypt(999999) + dec.base = 0x1000 + dec.hi = 0x5000 + dec.region = (dec.base, dec.hi) + dec.fails = dec.REDISCOVER_AFTER - 1 + dec.stream['conn'] = [0, bytes(200)] + dec.spans = collections.defaultdict(lambda: collections.deque(maxlen=256)) + dec._best = lambda *args, **kwargs: [] + dec.read = lambda at, n: bytes(n) + pkt = actor() + dec.resolve('conn', 0x09ff, pkt, 0, 0, exp_d=0x40, live=True) + self.assertIsNone(dec.base) + self.assertIsNone(dec.region) + self.assertEqual(dec.stats['ring-invalidated'], 1) + self.assertEqual(dec.fails, 0) + self.assertFalse(dec.quick_busy) + with patch('relay.actors.threading.Thread') as thread: + dec.start_quick_discover('conn', pkt, [('E',0x09ff,pkt,0)], 0) + self.assertTrue(dec.quick_busy) + thread.return_value.start.assert_called_once() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_actors.py b/tests/test_actors.py index 34e6157..8b5fb0f 100644 --- a/tests/test_actors.py +++ b/tests/test_actors.py @@ -161,3 +161,12 @@ def land(t): print('chained (left>0) ->', [p[-24:].rstrip(b'\0') for p in got if p], dict(d.stats)) assert got == [a1, a2] print('ALL PASS') + +# Fixed-length encrypted 0229 is recognized independently of actor validators. +p229 = bytes.fromhex('290263ef2000') + bytes.fromhex('010203040506070809') +plain229 = bytes.fromhex('290263ef2000') + bytes.fromhex('000000001000000000') +assert len(p229) == 15 == len(plain229) +assert 0x0229 in g.ActorDecrypt.ENC +assert d.copy_ok(p229, plain229) +assert not d.copy_ok(p229, p229) +assert not d.copy_ok(p229, bytes.fromhex('290200000000') + plain229[6:]) diff --git a/tests/test_console_verbose.py b/tests/test_console_verbose.py new file mode 100644 index 0000000..02fa114 --- /dev/null +++ b/tests/test_console_verbose.py @@ -0,0 +1,49 @@ +"""Console verbosity is presentation-only; JSONL remains complete.""" +import contextlib +import io +import json +import os +import tempfile +import unittest +import _boot +from relay.cli import parse_args +from relay.console import CaptureLog, _FilteredStdout, concise_line, show + +class ConsoleVerboseTests(unittest.TestCase): + def test_config_and_cli_override(self): + self.assertFalse(parse_args([]).verbose) + self.assertTrue(parse_args(['--verbose']).verbose) + self.assertFalse(parse_args(['--verbose', '--no-verbose']).verbose) + + def test_line_filter(self): + self.assertFalse(concise_line('[inject] inbound id=0x0437 len=7 -> SendPacket')) + self.assertFalse(concise_line("[inject] skip typ='R' len=7 (need an 'S' packet with an id)")) + self.assertTrue(concise_line('[!] critical warning')) + self.assertTrue(concise_line('[relay] connection dropped; will reconnect')) + self.assertTrue(concise_line('[egress] drop 0x035f (stale)')) + self.assertTrue(concise_line('[actor] decrypted recv ring: 0x1000-0x2000')) + + def test_stream_fragmentation(self): + output = io.StringIO() + filt = _FilteredStdout(output) + filt.write('[inject] inbound abc') + filt.write('\n[!] important\n') + self.assertEqual(output.getvalue(), '[!] important\n') + + def test_jsonl_unchanged_in_quiet_mode(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, 'log.jsonl') + with contextlib.redirect_stdout(io.StringIO()) as output: + log = CaptureLog(path, verbose=False) + log.write({'tag':'RECV-WIRE','id':'09ff','C':'09ffdeadbeef','len':6}) + log.write({'tag':'RECV-PLAIN-UNAVAILABLE','id':'09ff'}) + log.f.close() + with open(path) as captured: + lines = [json.loads(line) for line in captured] + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0]['C'], '09ffdeadbeef') + self.assertNotIn('deadbeef', output.getvalue()) + self.assertIn('indisponível', output.getvalue()) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_edge_actors.py b/tests/test_edge_actors.py new file mode 100644 index 0000000..f402c14 --- /dev/null +++ b/tests/test_edge_actors.py @@ -0,0 +1,191 @@ +"""Encrypted actor checks without opening /proc memory or connecting to Frida.""" +import collections +import random +import struct +import threading +import unittest +from unittest.mock import patch +import _boot +from relay.actors import ActorDecrypt, copy_validation, valid_actor, valid_actor_name +from relay.wire_feed import new_chunk + + +def plain_actor(name=b'Poring', aid=bytes([1,2,3,4])): + P=bytearray(114) + P[:4]=bytes.fromhex('ff097200') + P[4]=5 + P[5:9]=aid + P[13:15]=(200).to_bytes(2,'little') + P[-24:]=name.ljust(24,bytes([0])) + return bytes(P) + + +def fake_dec(): + d=ActorDecrypt.__new__(ActorDecrypt) + d.base=0x1000;d.hi=0x5000;d.region=(0x1000,0x5000) + d.chain={};d.stream={} + d.spans=collections.defaultdict(lambda: collections.deque(maxlen=256)) + d.coff=collections.Counter();d.stats=collections.Counter() + d.fed={};d.fails=0;d.lock=threading.Lock() + return d + + +class ActorBoundaries(unittest.TestCase): + def test_actor_min_length_and_speed_edges(self): + P=plain_actor() + self.assertTrue(valid_actor(P)) + for n in (0,4,13,14,59): + self.assertFalse(valid_actor(P[:n])) + for speed in (2001,65535): + q=bytearray(P);q[13:15]=speed.to_bytes(2,'little') + self.assertFalse(valid_actor(q),speed) + for speed in (0,1,19,20,200,1000,2000): + q=bytearray(P);q[13:15]=speed.to_bytes(2,'little') + self.assertTrue(valid_actor(q),speed) + + def test_actor_type_and_invalid_name(self): + P=plain_actor() + for typ in (13,128,255): + q=bytearray(P);q[4]=typ + self.assertFalse(valid_actor(q)) + for name in (b'a\n',b'a\t',b'a\x01',b'\xa7',b'\xa5',b'Fake\xa7Name',b'Fake\xa5Name',bytes([0x93,0xb5,0xa7,0xa0])*5): + q=bytearray(P) + q[-24:]=name.ljust(24,bytes([0])) + self.assertFalse(valid_actor(q)) + q=bytearray(P);q[-24:]=b'a\x00b'.ljust(24,bytes([0])) + self.assertFalse(valid_actor(q)) + + def test_accented_names_and_strict_padding(self): + for name in ('João, o Ferreiro', 'Guardiã da Floresta', + 'Éden - Funcionária', 'Capitão André', 'Kafra Employee'): + with self.subTest(name=name): + plain = plain_actor(name.encode('cp1252')) + cipher = plain[:4] + b'\xe7' * (len(plain) - 4) + self.assertTrue(valid_actor(plain)) + self.assertTrue(valid_actor_name(plain[-24:])) + self.assertTrue(copy_validation(cipher, plain)['name_format_ok']) + self.assertTrue(copy_validation(cipher, plain)['accepted']) + for name in (b'', b' A', b'A ', b'A\x00B', b'A\x01', b'A\x81', + b'A\x8d', b'A\xa7', b'A\xa5', b'A\xa0'): + with self.subTest(name=name): + self.assertFalse(valid_actor_name(name.ljust(24, b'\x00'))) + self.assertFalse(valid_actor_name(b'A' * 25)) + + def test_npc_name_ascii_punctuation_without_accepting_binary_data(self): + # rAthena uses '#' as a separator in NPC display/internal names. + for name in (b'Kafra Employee#prt1', b'Kafra#alde', + b'Cash Shop [VIP] @2', b'Sign: 50% + 1', + b'A_B=C|D\\E~F', 'João#praça'.encode('cp1252')): + with self.subTest(name=name): + plain = plain_actor(name) + wire = plain[:4] + b'\xe7' * (len(plain) - 4) + self.assertTrue(valid_actor_name(plain[-24:])) + self.assertTrue(valid_actor(plain)) + self.assertTrue(copy_validation(wire, plain)['accepted']) + for name in (b'A\x01', b'A\x7f', b'A\x81', b'A\xa7', + b'A\xa0', b'A\x00B'): + with self.subTest(name=name): + self.assertFalse(valid_actor_name(name.ljust(24, b'\x00'))) + + def test_actor_09ff_copy_requires_same_header_and_modified_body(self): + d=fake_dec(); plain=plain_actor() + cipher=plain[:4]+bytes([0xee])*(len(plain)-4) + self.assertTrue(d.copy_ok(cipher,plain)) + self.assertFalse(d.copy_ok(plain,plain)) + self.assertFalse(d.copy_ok(cipher,plain[:50])) + self.assertFalse(d.copy_ok(cipher,bytes([0])*len(plain))) + self.assertFalse(d.copy_ok(b'',plain)) + + def test_fixed_0229_requires_unchanged_gid_and_id(self): + d=fake_dec() + pkt=bytes.fromhex('2902')+bytes([1,2,3,4])+bytes([5])*9 + p=pkt[:6]+bytes([6])*9 + self.assertEqual(len(pkt),15) + self.assertTrue(d.copy_ok(pkt,p)) + self.assertFalse(d.copy_ok(pkt,pkt)) + self.assertFalse(d.copy_ok(pkt,p[:2]+bytes([0])*4+p[6:])) + self.assertTrue(d.copy_ok(pkt,p[:2]+bytes([8,7,6,5])+p[6:])) # ciphertext GID differs + self.assertFalse(d.copy_ok(pkt,p[:-1])) + self.assertFalse(d.copy_ok(pkt[:14],p[:14])) + + def test_partial_chunk_offsets_independent_by_connection(self): + d=fake_dec() + self.assertEqual(d.note_chunk('A',b'a'*25),(0,0x40)) + self.assertEqual(d.note_chunk('B',b'b'*10),(0,0x40)) + self.assertEqual(d.note_chunk('A',b'c'*7,left=2),(25,0x40+25)) + self.assertEqual(d.note_chunk('B',b'd'*3,left=0),(10,0x40)) + self.assertEqual(d.coff['A'],32) + self.assertEqual(d.coff['B'],13) + self.assertEqual(d.slot(0x40,15,5),0x1000+0x40+10) + + def test_stream_bounded_to_memory_limit(self): + d=fake_dec() + d.STREAM_KEEP=100 + d.note_chunk('c',bytes(range(200))) + off,data=d.stream['c'] + self.assertEqual((off,len(data)),(100,100)) + self.assertEqual(data,bytes(range(100,200))) + + def test_encrypted_body_spans_fixed_and_variable(self): + d=fake_dec() + d.note_enc('c',100,15,pid=0x0229) + d.note_enc('c',200,114,pid=0x09ff) + self.assertEqual(list(d.spans['c']),[(102,115),(204,314)]) + + def test_quick_discovery_mask_starts_after_two_clear_bytes_for_0229(self): + d=fake_dec() + d.base=None + d.quick_busy=False + tasks=[] + class ThreadFake: + def __init__(self, target, args, name, daemon): + tasks.append((target,args)) + def start(self):pass + items=[('E',0x0229,b'X'*15,5)] + with patch('relay.actors.threading.Thread',ThreadFake): + d.start_quick_discover('c',b'-'*30,items,cstart=0) + self.assertTrue(d.quick_busy) + self.assertEqual(tasks[0][1][2],[(7,20)]) + + def test_same_decrypted_actor_duplicate_rejected_when_weak(self): + d=fake_dec() + P=plain_actor() + with patch('relay.actors.time.time',side_effect=[100.0,101.0,111.0]): + self.assertEqual(d._accept(P,'snap',False),P) + self.assertIsNone(d._accept(P,'snap',False)) + self.assertEqual(d._accept(P,'snap',False),P) + self.assertEqual(d.stats['dup-rejected'],1) + + def test_strong_evidence_allows_repeat(self): + d=fake_dec();P=plain_actor() + with patch('relay.actors.time.time',return_value=100): + self.assertEqual(d._accept(P,'fresh',True),P) + self.assertEqual(d._accept(P,'fresh',True),P) + self.assertEqual(d.stats['ok-fresh'],2) + + def test_stale_before_image_only_marked_strong_with_matching_base(self): + d=fake_dec() + pkt=b'abcd' + e=new_chunk('server:5121','c',[('E',0x09ff,pkt,4)],0,0x40) + image=bytes(128) + d.read=lambda a,n:bytes([1])*n + d.before(e,(image,d.base)) + self.assertEqual(e['B'][0],image[0x44:0x48]) + self.assertTrue(e['Bsafe']) + self.assertIn(0,e['Bsafe_items']) + e2=new_chunk('server:5121','c',[('E',0x09ff,pkt,4)],0,0x40) + d.before(e2,(image,d.base+64)) + self.assertEqual(e2['B'][0],bytes([1])*4) + self.assertNotIn('Bsafe',e2) + + def test_no_ring_waits_before_discovery(self): + d=fake_dec() + d.base=None + e=new_chunk('server:5121','c',[('E',0x09ff,b'abc',0)],0,0x40) + d.resolve=lambda *a,**kw: b'copy' + self.assertIsNone(d.settle(e,e['t']+0.001,0.15)) + self.assertEqual(d.settle(e,e['t']+0.04,0.15),{0:b'copy'}) + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_edge_agent_js.py b/tests/test_edge_agent_js.py new file mode 100644 index 0000000..5824485 --- /dev/null +++ b/tests/test_edge_agent_js.py @@ -0,0 +1,58 @@ +"""Agent message contracts and JS parse validation. No Frida attach or process memory.""" +import io +import shutil +import subprocess +import sys +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch, mock_open +import _boot +from relay.agent import Agent, AGENT_JS, gadget_ports, pick_port, pid_of_port + + +class AgentAndJSContracts(unittest.TestCase): + def test_frida_agent_js_parses_without_runtime(self): + node=shutil.which('node') + if not node: + self.skipTest('node not installed; Frida script syntax cannot be checked') + r=subprocess.run([node,'--check',AGENT_JS],capture_output=True,text=True,timeout=20) + self.assertEqual(r.returncode,0,r.stderr) + + def test_agent_message_bytes_attach_only_on_send(self): + delivered=[] + a=Agent.__new__(Agent) + a._on_message=delivered.append + m={'type':'send','payload':{'tag':'RECV','sock':'host:5121'}} + a._message(m,b'abc') + self.assertEqual(delivered[0]['data'],b'abc') + with redirect_stdout(io.StringIO()): + a._message({'type':'error','description':'mock syntax issue'},None) + self.assertEqual(len(delivered),1) + + def test_gadget_discovery_filters_range_and_pids(self): + mock_lines=('LISTEN 0 4096 127.0.0.1:27052 0.0.0.0:* users:(("foo",pid=131,fd=7))\n' + 'LISTEN 0 4096 127.0.0.1:27101 0.0.0.0:* users:(("bar",pid=132,fd=7))\n' + 'LISTEN 0 4096 127.0.0.1:27053 0.0.0.0:* users:(("uaRO",pid=133,fd=7))\n') + with patch('relay.agent.subprocess.run',return_value=type('Proc',(),{'stdout':mock_lines})()), \ + patch('builtins.open',mock_open(read_data=b'/games/uaRO.exe\x00--arg\x00')): + ports=gadget_ports() + self.assertEqual([(port,pid) for port,pid,_ in ports],[(27052,131),(27053,133)]) + + def test_pick_port_chooses_exact_game_process(self): + with patch('relay.agent.gadget_ports',return_value=[ + (27052,41,'/other/uaRO.exe-helper'), + (27053,42,'Z:\\games\\uaRO.exe'), + (27054,43,'/other/uaRO2.exe')]): + self.assertEqual(pick_port('uaRO.exe'),27053) + with patch('relay.agent.gadget_ports',return_value=[(27052,1,'other.exe')]): + with self.assertRaises(SystemExit): + pick_port('uaRO.exe') + + def test_port_pid_identity(self): + with patch('relay.agent.gadget_ports',return_value=[(27052,44,'a'),(27053,45,'b')]): + self.assertEqual(pid_of_port(27053),45) + self.assertIsNone(pid_of_port(27100)) + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_edge_contracts.py b/tests/test_edge_contracts.py new file mode 100644 index 0000000..c655632 --- /dev/null +++ b/tests/test_edge_contracts.py @@ -0,0 +1,223 @@ +"""Deterministic config/protocol/safety/log/sendwatch contracts, no live connections.""" +import io +import json +import random +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch +from types import SimpleNamespace + +import _boot +from relay.cli import parse_args, parse_ids +from relay.protocol import (ring_msg,sendwatch_msg,scramble_msg,scramble_set_msg, + sendcall_msg,sendcall_emit_msg,move_dispatch_msg,conn_of) +from relay.sendwatch import SendWatch, send_queue_range, plaintext_guess, packet_at +from relay.safety import sanitize, client_packet_to_feed +from relay.console import CaptureLog +from relay.agent import Agent, pick_port, pid_of_port + + +class ConfigContracts(unittest.TestCase): + def make_conf(self,s): + self.d=tempfile.TemporaryDirectory() + self.addCleanup(self.d.cleanup) + path=Path(self.d.name)/'test.ini';path.write_text(s) + return str(path) + + def test_missing_configuration_uses_cli_defaults(self): + p=self.make_conf('') + a=parse_args(['--config',p]) + self.assertFalse(a.synth_input) + self.assertEqual(a.synth_backend,'ydotool') + self.assertFalse(a.inject) + self.assertFalse(a.egress_queue) + + def test_ini_and_cli_precedence_and_bool_variants(self): + combos=[('true',True),('FALSE',False),('yes',True),('NO',False), + ('on',True),('off',False),('1',True),('0',False)] + for val,expected in combos: + with self.subTest(val=val): + path=self.make_conf('[capture]\nsynth-input = '+val+'\n') + a=parse_args(['--config',path]) + self.assertEqual(a.synth_input,expected) + a=parse_args(['--config',path,'--no-synth-input']) + self.assertFalse(a.synth_input) + a=parse_args(['--config',path,'--synth-input']) + self.assertTrue(a.synth_input) + + def test_bad_ini_is_ignored(self): + p=self.make_conf('[capture]\nsynth-input = invalid\negress-queue = false\n' + 'synth-debounce-ms = letters\n') + a=parse_args(['--config',p]) + self.assertFalse(a.synth_input) + self.assertFalse(a.egress_queue) + self.assertEqual(a.synth_debounce_ms,500) + + def test_valid_gamescope_selection_and_no_ydotool_fallback(self): + p=self.make_conf('[capture]\nsynth-input = yes\nsynth-backend=gamescope-eis\n' + 'synth-eis-socket-file = /run/user/1000/file\n') + a=parse_args(['--config',p]) + self.assertTrue(a.synth_input) + self.assertEqual(a.synth_backend,'gamescope-eis') + self.assertEqual(a.synth_eis_socket_file,'/run/user/1000/file') + + def test_id_parser_robust_to_blanks_and_duplicate(self): + self.assertEqual(parse_ids('035f, 035F, 09ff ,,,junk,0073'),{0x035f,0x09ff,0x73}) + + def test_default_config_still_intentional(self): + a=parse_args([]) + self.assertTrue(a.inject) + self.assertTrue(a.inject_call) + self.assertTrue(a.synth_input) + self.assertEqual(a.synth_backend,'gamescope-eis') + self.assertFalse(a.egress_queue) + self.assertFalse(a.sendcall_demand) + self.assertTrue(a.itemuse_input) + + +class ProtocolContracts(unittest.TestCase): + def test_ring_span_zero_and_positive(self): + self.assertEqual(ring_msg('c',0x123),{'type':'ring','sock':'c','base':'0x123'}) + self.assertEqual(ring_msg('c',0x123,99)['span'],99) + self.assertNotIn('span',ring_msg('c',0x123,0)) + + def test_sendwatch_disable_and_valid_ranges(self): + self.assertEqual(sendwatch_msg(),{'type':'sendwatch'}) + self.assertEqual(sendwatch_msg(32,64,(5121,))['ports'],[5121]) + + def test_scramble_config_and_disarm(self): + self.assertEqual(scramble_msg(),{'type':'scramble'}) + m=scramble_msg(0x10,pid=0x437,body='aabb',dry=False,lo=1,hi=2,ids=[0x437],trace=12) + self.assertEqual(m['off'],0x10) + self.assertEqual(m['body'],'aabb') + self.assertFalse(m['dry']) + self.assertEqual(m['traceMax'],12) + self.assertEqual(m['ids'],[0x437]) + self.assertEqual(scramble_set_msg(0x438,body='') ,{'type':'scramble-set','id':0x438,'body':''}) + + def test_native_dispatch_modes(self): + self.assertEqual(move_dispatch_msg(disarm=True),{'type':'native-dispatch','disarm':True}) + self.assertEqual(move_dispatch_msg(recapture=True),{'type':'native-dispatch','recapture':True}) + arm=move_dispatch_msg(arm=True,dispatcher='0xd473b0',site='0x4dcba0') + self.assertEqual((arm['dispatcher'],arm['site']),(0xd473b0,0x4dcba0)) + self.assertEqual(move_dispatch_msg(1023,0)['args'],{'x':1023,'y':0}) + self.assertEqual(move_dispatch_msg(arm=True,disarm=True),{'type':'native-dispatch','disarm':True}) + + def test_sendcall_enabled_and_disabled(self): + self.assertEqual(sendcall_msg(),{'type':'sendcall'}) + self.assertEqual(sendcall_msg(0x10,cast_ms=1500,demand=True)['demand'],True) + self.assertEqual(sendcall_emit_msg('ab')['data'],'ab') + + def test_connection_selection(self): + self.assertEqual(conn_of({'sock':'s','conn':'c'}),'c') + self.assertEqual(conn_of({'sock':'s'}),'s') + + +class SafetyAndLogs(unittest.TestCase): + def test_login_user_password_never_fed(self): + login=b'\x64\x00'+bytes(range(4))+b'private-user'.ljust(24,b'\0')+b'private-secret'.ljust(24,b'\0')+b'\x07' + for sock in ('localhost:6900','localhost:3000'): + clean=client_packet_to_feed(sock,login) + self.assertEqual(clean[6:54],bytes(48)) + self.assertNotIn(b'private',clean) + self.assertIsNone(client_packet_to_feed(sock,b'\x64\x00'+bytes(10))) + + def test_http_redaction_works_on_login_or_game_socket(self): + for sock in ('127.0.0.1:6900','127.0.0.1:5121'): + for proto in (b'POST /key', b'GET /auth', b'HTTP/1.1 200', b'PUT /auth'): + self.assertIsNone(client_packet_to_feed(sock,proto)) + m={'sock':sock,'tag':'RECV-WIRE','C':proto.hex(),'P':proto.hex()} + sanitize(m) + self.assertNotIn(proto.hex(),m['C']) + self.assertNotIn(proto.hex(),m['P']) + + def test_receive_from_login_port_never_logs_auth_material(self): + secret=bytes([17,0])+b'auth-token-password' + m={'tag':'RECV-WIRE','sock':'127.0.0.1:6900','C':secret.hex(),'P':secret.hex()} + sanitize(m) + self.assertNotIn(secret.hex(),m['C']) + self.assertNotIn(secret.hex(),m['P']) + + def test_capture_log_strips_binary_and_ptr(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'log' + with patch('relay.console.show'): + log=CaptureLog(p) + record={'tag':'SEND','sock':'localhost:6900', + 'C':b'usersecret'.hex(),'data':b'rawsecret','ptr':'0xdead'} + log.write(record) + log.f.close() + out=json.loads(p.read_text()) + self.assertNotIn('data',out) + self.assertNotIn('ptr',out) + self.assertNotIn('usersecret'.encode().hex(),out['C']) + + def test_capture_log_atomic_multithread(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'log' + with patch('relay.console.show'): + log=CaptureLog(p) + def submit(k): + for i in range(40): + log.write({'tag':'noop','id':'x','seq':k*1000+i}) + jobs=[threading.Thread(target=submit,args=(k,)) for k in range(8)] + for t in jobs:t.start() + for t in jobs:t.join(timeout=2) + self.assertTrue(all(not t.is_alive() for t in jobs)) + log.f.close() + lines=[json.loads(t) for t in p.read_text().splitlines()] + self.assertEqual(len(lines),320) + self.assertEqual(len({a['seq'] for a in lines}),320) + self.assertTrue(all('ts' in a for a in lines)) + + +class SendwatchContracts(unittest.TestCase): + def test_packet_at_window_alignment(self): + self.assertEqual(packet_at('0011223344','0x1001','0x1000'),'11223344') + self.assertEqual(packet_at('0011223344',None,'0x1000'),'0011223344') + + def test_no_encryption_run_never_guesses(self): + c='37040102030407' + for hits in ([],[{'pc':'p','win':c}],[{'pc':'a','win':c},{'pc':'b','win':c}]): + self.assertIsNone(plaintext_guess({'hits':hits},c)) + + def test_malformed_write_watch_never_crashes_recovery(self): + c='37040102030407' + malformed=[ + {}, + {'hits':'bad-type'}, + {'hits':[{},{}]}, + {'hits':[{'pc':'1'}, {'pc':'1'}]}, + {'hits':[{'pc':'1','win':'zz'}, {'pc':'1','win':'xx'}]}, + {'hits':[{'pc':'1','win':c}, {'pc':'1','win':c}]}, + ] + for watch in malformed: + with self.subTest(watch=watch): + self.assertIsNone(plaintext_guess(watch,c,buf='not-hex',base='0x1000')) + + def test_unverified_action_is_dropped(self): + s=SendWatch() + for pid,body in [(0x437,b'\x01\x02\x03\x04\x07'), + (0x438,bytes(8))]: + P=pid.to_bytes(2,'little')+body + self.assertIsNone(s.resolve({'C':P.hex()},P,None)) + self.assertEqual(sum(s.stats.values()),2) + + def test_plain_packet_not_transformed(self): + s=SendWatch() + for pid in (0x73,0x85,0x94): + P=pid.to_bytes(2,'little')+b'hi' + self.assertEqual(s.resolve({'C':P.hex()},P,None),P) + self.assertEqual(sum(s.stats.values()),0) + + def test_queue_range_selection_and_fallback(self): + self.assertIsNone(send_queue_range('auto',None,True)) + self.assertEqual(send_queue_range('100-200',(5,10),True)[:2],(256,512)) + self.assertEqual(send_queue_range('auto',(128,256),True)[:2],(128,256)) + self.assertIsNotNone(send_queue_range('auto',None,False)) + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_edge_eis.py b/tests/test_edge_eis.py new file mode 100644 index 0000000..aee8963 --- /dev/null +++ b/tests/test_edge_eis.py @@ -0,0 +1,183 @@ +"""State machine and input safety for libei, with a fully fake libei library.""" +import os +import socket +import tempfile +import unittest +from unittest.mock import patch +from pathlib import Path + +import _boot +from relay.gamescope_eis import GamescopeEIS, GamescopeEISError, resolve_socket + + +class FakeEI: + TYPES={1:b'EI_EVENT_CONNECT',2:b'EI_EVENT_SEAT_ADDED', + 3:b'EI_EVENT_DEVICE_ADDED',4:b'EI_EVENT_DEVICE_RESUMED', + 5:b'EI_EVENT_DEVICE_PAUSED',6:b'EI_EVENT_DEVICE_REMOVED', + 7:b'EI_EVENT_DISCONNECT'} + def __init__(self,events=(),setup_result=0): + self.events=list(events);self.setup_result=setup_result + self.keys=[];self.frames=[];self.binds=[];self.unrefs=[] + self.emulations=[];self.dispatches=0 + self.failure=None + def new(self,*a):return 11 + def name(self,*a):pass + def setup(self,*a):return self.setup_result + def fd(self,*a):return 0 + def dispatch(self,*a): + self.dispatches+=1 + if self.failure=='dispatch': + raise OSError('fake EIS transport failure') + def get_event(self,*a): + return self.events.pop(0) if self.events else None + def event_type(self,n):return self.TYPES[n] + def ev_kind(self,event):return event[0] + def ev_seat(self,event):return event[1] + def ev_device(self,event):return event[1] + def ev_unref(self,event):pass + def bind(self,*args):self.binds.append(args) + def has_cap(self,dev,cap):return bool(cap==4 and dev==30) + def device_ref(self,dev):return dev + def device_unref(self,dev):self.unrefs.append(('device',dev)) + def emulate(self,dev,seq):self.emulations.append((dev,seq)) + def key(self,dev,code,pressed): + self.keys.append((dev,code,pressed)) + if pressed and self.failure=='keydown':raise OSError('fake keydown failure') + if not pressed and self.failure=='keyup':raise OSError('fake keyup failure') + def now(self,ctx):return 12345 + def frame(self,dev,t):self.frames.append((dev,t)) + def unref(self,ctx):self.unrefs.append(('ctx',ctx)) + + +class EISBoundaryTests(unittest.TestCase): + def test_path_file_permissions_and_type(self): + with tempfile.TemporaryDirectory() as d: + with socket.socket(socket.AF_UNIX) as s: + p=os.path.join(d,'gamescope-4-ei');s.bind(p) + locator=os.path.join(d,'locator') + Path(locator).write_text('gamescope-4-ei\n') + os.chmod(locator,0o600) + with patch.dict(os.environ,{'XDG_RUNTIME_DIR':d}): + self.assertEqual(resolve_socket(path_file=locator),p) + with self.assertRaises(GamescopeEISError): + resolve_socket('anything',locator) + for mode in (0o640,0o604,0o666): + os.chmod(locator,mode) + with self.subTest(mode=mode),self.assertRaises(GamescopeEISError): + resolve_socket(path_file=locator) + + def test_reject_non_socket_and_missing_env(self): + with tempfile.TemporaryDirectory() as d: + p=os.path.join(d,'gamescope-1-ei') + Path(p).write_text('not a socket') + with self.assertRaises(GamescopeEISError): + resolve_socket(p) + with patch.dict(os.environ,{'XDG_RUNTIME_DIR':''}): + with self.assertRaises(GamescopeEISError): + resolve_socket('gamescope-1-ei') + with self.assertRaises(GamescopeEISError): + resolve_socket('') + + def test_reject_host_compositor_socket_even_if_explicit(self): + with tempfile.TemporaryDirectory() as d: + with socket.socket(socket.AF_UNIX) as s: + host=os.path.join(d,'kwin-eis-socket') + s.bind(host) + with self.assertRaises(GamescopeEISError): + resolve_socket(host) + alias=os.path.join(d,'gamescope-9-ei') + os.symlink(host,alias) + with self.assertRaises(GamescopeEISError): + resolve_socket(alias) + + def test_reject_control_characters(self): + with self.assertRaises(GamescopeEISError): + resolve_socket('gamescope\n-0-ei') + with self.assertRaises(GamescopeEISError): + resolve_socket('gamescope\x00-0-ei') + + def make_connected(self,api=None): + obj=GamescopeEIS(socket='gamescope-0-ei') + obj.api=api or FakeEI() + obj.ctx=11;obj.dev=30;obj.ready=True + return obj + + def test_event_negotiation_real_keyboard_capability(self): + api=FakeEI(events=[(2,20),(3,30),(4,30)]) + obj=self.make_connected(api) + obj.dev=None;obj.ready=False + obj._drain_events() + self.assertTrue(obj.ready) + self.assertEqual(obj.dev,30) + self.assertEqual(api.binds[0][1].value,4) + self.assertEqual(api.emulations,[(30,0)]) + obj.close() + self.assertIn(('device',30),api.unrefs) + + def test_other_device_never_used_for_keyboard(self): + api=FakeEI(events=[(3,31),(4,31)]) + obj=self.make_connected(api) + obj.dev=None;obj.ready=False + obj._drain_events() + self.assertIsNone(obj.dev) + self.assertFalse(obj.ready) + + def test_pause_and_remove_disarm_keyboard(self): + api=FakeEI(events=[(5,30)]) + obj=self.make_connected(api) + obj._drain_events() + self.assertFalse(obj.ready) + api.events.append((6,30)) + obj._drain_events() + self.assertIsNone(obj.dev) + + def test_disconnect_raises(self): + api=FakeEI(events=[(7,0)]) + obj=self.make_connected(api) + with self.assertRaisesRegex(GamescopeEISError,'disconnected'): + obj._drain_events() + + def test_send_key_frames_and_release_even_on_failure(self): + api=FakeEI() + obj=self.make_connected(api) + obj._drain_events=lambda:None + self.assertEqual(obj.send_key(110,hold_ms=0),0) + self.assertEqual(api.keys,[(30,110,True),(30,110,False)]) + self.assertEqual(api.frames,[(30,12345),(30,12345)]) + obj.close() + self.assertFalse(obj.ready) + + def test_failed_send_closes_stale_context(self): + api=FakeEI();api.failure='keydown' + obj=self.make_connected(api) + obj._drain_events=lambda:None + with self.assertRaises(OSError): + obj.send_key(110,hold_ms=0) + self.assertFalse(obj.ready) + self.assertIsNone(obj.dev) + self.assertIsNone(obj.ctx) + self.assertEqual(api.keys,[(30,110,True),(30,110,False)]) + + def test_failed_connection_closes_context(self): + api=FakeEI(setup_result=-9) + obj=GamescopeEIS(socket='gamescope-0-ei') + with patch('relay.gamescope_eis.resolve_socket',return_value='/tmp/fake'), \ + patch('relay.gamescope_eis._LibEI',return_value=api): + with self.assertRaisesRegex(GamescopeEISError,'refused'): + obj.send_key(110,hold_ms=0) + self.assertFalse(obj.ready) + self.assertIsNone(obj.ctx) + + def test_no_device_ready_fails_without_key_event(self): + api=FakeEI() + obj=GamescopeEIS(socket='gamescope-0-ei',connect_timeout=0) + with patch('relay.gamescope_eis.resolve_socket',return_value='/tmp/fake'), \ + patch('relay.gamescope_eis._LibEI',return_value=api): + with self.assertRaisesRegex(GamescopeEISError,'timeout'): + obj.send_key(110,hold_ms=0) + self.assertEqual(api.keys,[]) + self.assertFalse(obj.ready) + + +if __name__=='__main__': + unittest.main(verbosity=2) diff --git a/tests/test_edge_packets.py b/tests/test_edge_packets.py new file mode 100644 index 0000000..23ba715 --- /dev/null +++ b/tests/test_edge_packets.py @@ -0,0 +1,147 @@ +"""Boundary and metamorphic tests for RO framing; synthetic bytes only.""" +import random +import tempfile +import unittest +from pathlib import Path + +import _boot +from relay.packets import (MoveTracker, WireFilter, load_lengths, pack_move, + unpack_move, unpack_pos2, packet_len) + + +def pkt(pid, size): + return pid.to_bytes(2, 'little') + bytes((i * 37) % 256 for i in range(size - 2)) + + +class PacketBoundaryTests(unittest.TestCase): + def test_load_lengths_malformed_table(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / 'table' + p.write_text("0073 2\n0087 0\n0088 1\n0099 -1\n00AB -2\nBADLINE\nXY 8\n" + "00CD 8 x\n00EF 12\n", encoding='ascii') + self.assertEqual(load_lengths(p), {0x73:2, 0x99:-1, 0xcd:8, 0xef:12}) + + def test_packet_len_boundaries(self): + lens={0x100: 2, 0x101: -1} + for frame, expected in [(b'\x00\x01',2),(b'\x01\x01',0), + (b'\x01\x01\x04',0),(b'\x01\x01\x00\x00',None), + (b'\x01\x01\x03\x00',None), + (b'\x01\x01\x04\x00',4), + (b'\x01\x01\xff\xff',65535), + (b'\xff\xfe',None)]: + with self.subTest(frame=frame): + self.assertEqual(packet_len(lens,frame,0), expected) + + def test_move_1024_exhaustive_corner_coordinates(self): + rng=random.Random(31819) + coords=[0,1,2,3,4,15,16,63,64,127,255,256,511,512,1022,1023] + samples=[(x,y,d) for x in coords for y in coords for d in (0,1,7,15)] + samples += [(rng.randrange(1024),rng.randrange(1024),rng.randrange(16)) + for _ in range(3000)] + for x,y,d in samples: + self.assertEqual(unpack_move(pack_move(x,y,d)),(x,y,d)) + with self.assertRaises(ValueError): + unpack_move(b'') + with self.assertRaises(ValueError): + unpack_move(b'\x01\x02') + + def test_0087_and_move_tracker_short_and_nonmove(self): + tracker=MoveTracker() + bad=b'\x87\x00'+bytes(9) + self.assertIsNone(tracker.on_recv_packet('c',0x87,0,bad)) + self.assertIsNone(tracker.on_recv_packet('c',0x86,0,bad+b'x')) + self.assertEqual(sum(tracker.stats.values()),0) + self.assertEqual(unpack_pos2(bytes(6)),(0,0,0,0)) + + def test_tokenizer_every_two_piece_cut(self): + L={0x73:5,0x87:12,0x99:-1,0x9ff:-1} + a=pkt(0x73,5); b=pkt(0x87,12) + v=b'\x99\x00\x06\x00ZZ'; encrypted=b'\xff\x09\x09\x00hello' + stream=a+v+encrypted+b + expected=[('B',a+v),('E',0x9ff,encrypted,len(a+v)),('B',b)] + for cut in range(len(stream)+1): + w=WireFilter(L,set()) + events=w.push_items('c',stream[:cut],enc={0x9ff}) + events+=w.push_items('c',stream[cut:],enc={0x9ff}) + normalized=[] + for ev in events: + if ev[0]=='B' and normalized and normalized[-1][0]=='B': + normalized[-1]=('B',normalized[-1][1]+ev[1]) + else: + normalized.append(ev) + self.assertEqual(normalized,expected,(cut,events)) + self.assertEqual(w.carried('c'),0) + w=WireFilter(L,{0x73,0x99,0x87}) + events=w.push_items('c',stream,enc={0x9ff}) + self.assertEqual(events,[('E',0x9ff,encrypted,len(a+v))]) + + def test_tokenizer_byte_at_a_time(self): + L={0x73:5,0x87:12,0x99:-1} + a=pkt(0x73,5);v=b'\x99\x00\x04\x00';b=pkt(0x87,12) + w=WireFilter(L,set()) + data=a+v+b; chunks=[] + for byte in data: + chunks.extend(w.push_items('x',bytes([byte]))) + self.assertEqual(b''.join(c[1] for c in chunks if c[0]=='B'),data) + self.assertEqual(w.carried('x'),0) + + def test_connection_fragments_do_not_cross(self): + L={0x73:5,0x87:12} + a=pkt(0x73,5);b=pkt(0x87,12);w=WireFilter(L,set()) + self.assertEqual(w.push_items('connA',a[:3]),[]) + self.assertEqual(w.push_items('connB',b),[('B',b)]) + self.assertEqual(w.carried('connA'),3) + self.assertEqual(w.push_items('connA',a[3:]),[('B',a)]) + self.assertEqual(w.carried('connB'),0) + + def test_unknown_midstream_only_affects_one_connection(self): + L={0x73:5} + a=pkt(0x73,5);w=WireFilter(L,set()) + self.assertEqual(w.push_items('A',a+b'\xde\xadxx'),[('B',a+b'\xde\xadxx')]) + self.assertTrue(w.conns['A']['raw']) + self.assertEqual(w.push_items('B',a),[('B',a)]) + self.assertFalse(w.conns['B']['raw']) + self.assertEqual(w.push_items('A',b'fake'),[('B',b'fake')]) + + def test_09ff_boundary_diagnostics_fragmented_and_unknown(self): + lengths = {0x09ff: -1, 0x0087: 12} + wire = WireFilter(lengths, set()) + events = [] + wire.on_diag = lambda entry: events.append(entry) + a = b'\xff\x09\x6c\x00' + bytes(104) + b = b'\x87\x00' + bytes(10) + stream = a + b + b'\x63\xef' + b'XXXX' + for cut in range(len(stream) + 1): + wire = WireFilter(lengths, set()) + events = [] + wire.on_diag = lambda entry: events.append(entry) + wire.push_items('map', stream[:cut], enc={0x09ff}) + wire.push_items('map', stream[cut:], enc={0x09ff}) + self.assertEqual([x['event'] for x in events], ['frame', 'frame', 'unknown'], cut) + self.assertEqual(events[0]['id'], '09ff') + self.assertEqual(events[0]['end'], 108) + self.assertEqual(events[1]['offset'], 108) + self.assertEqual(events[1]['previous'], '09ff') + self.assertEqual(events[2]['offset'], 120) + self.assertEqual(events[2]['id'], 'ef63') + + def test_char_return_account_preamble_suppressed(self): + L={0x73:5};w=WireFilter(L,set()) + w.observe_socket('map','127.0.0.1:5121') + w.observe_socket('char2','127.0.0.1:6121') + pre=b'\xde\xad\xbe\xef'; a=pkt(0x73,5) + self.assertEqual(w.push_items('char2',pre[:2]),[]) + self.assertEqual(w.push_items('char2',pre[2:]+a),[('B',a)]) + self.assertEqual(w.push_items('char3',pre+a),[('B',pre+a)]) + + def test_packet_offsets_ignore_drops(self): + L={0x73:5,0x87:12};w=WireFilter(L,{0x73}) + got=[];w.on_packet=lambda *a:got.append(a) + a=pkt(0x73,5);b=pkt(0x87,12) + w.push_items('conn',a[:3]) + self.assertEqual(w.push_items('conn',a[3:]+b),[('B',b)]) + self.assertEqual([(t[1],t[2]) for t in got],[(0x73,0),(0x87,5)]) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_edge_relay.py b/tests/test_edge_relay.py new file mode 100644 index 0000000..24f4842 --- /dev/null +++ b/tests/test_edge_relay.py @@ -0,0 +1,170 @@ +"""Pure offline XKore connection, framing and error-state tests.""" +import random +import threading +import unittest +from unittest.mock import patch + +import _boot +from relay.relay import Relay, PacketAligner + + +class FakeSocket: + def __init__(self, fail=False): + self.fail=fail + self.sent=[] + self.closed=False + self.recv_items=[] + self.timeout=None + self.opts=[] + def sendall(self, data): + if self.fail: + raise BrokenPipeError('synthetic client disconnect') + self.sent.append(data) + def close(self): + self.closed=True + def recv(self, size, flags): + o=self.recv_items.pop(0) + if isinstance(o,BaseException):raise o + return o + def settimeout(self, value): + self.timeout=value + def setsockopt(self,*a): + self.opts.append(a) + + +def relay(fmt='xkore1', sock=None): + r=Relay.__new__(Relay) + r.lock=threading.Lock() + r.fmt=fmt; r._in=b'' + r.sock=sock + r.sent=r.dropped=0 + r.dump=None + r.on_disconnect=None + r.on_connect=None + r.on_inbound=None + r._connected_once=False + r.aligner=None + r.align=False + r.host='127.0.0.1';r.port=2 + return r + + +class RelayEdgeTests(unittest.TestCase): + def test_frame_max_and_exact_roundtrips(self): + r=relay() + for n in (0,1,2,255,256,65534,65535,65536,131071,131072): + data=bytes((i*31)%256 for i in range(n)) + framed=r.frame(data,'R') + payload=[]; pos=0 + while pos", args) - # Egress queue is optional in the local launcher; test only Gadget discovery. def test_explicit_port_override_is_preserved(self): args = self.launcher_args("--port", "27053") diff --git a/tests/test_packets.py b/tests/test_packets.py index 2c6e482..99f4d32 100644 --- a/tests/test_packets.py +++ b/tests/test_packets.py @@ -134,3 +134,9 @@ def unpack3(b): return ((b[0] << 2) | (b[1] >> 6), ((b[1] & 0x3f) << 4) | (b[2] assert w.push_items('s', p87[7:]) == [('B', p87)] and w.carried('s') == 0 print('WireFilter ok') print('ALL PASS') + +# Fixed-size encrypted 0229 must be held intact across TCP fragments. +w229 = g.WireFilter({0x0229: 15, 0x0087: 12}, set()) +p229 = bytes.fromhex('290263ef2000010203040506070809') +assert w229.push_items('p229', p229[:7], enc={0x0229}) == [] +assert w229.push_items('p229', p229[7:], enc={0x0229}) == [('E', 0x0229, p229, 0)] diff --git a/tests/test_scramble_rx_guard.py b/tests/test_scramble_rx_guard.py new file mode 100644 index 0000000..00d55c7 --- /dev/null +++ b/tests/test_scramble_rx_guard.py @@ -0,0 +1,11 @@ +"""Regression: both scramble merge writes must reject overlap with registered RX rings.""" +from pathlib import Path + +src = (Path(__file__).resolve().parents[1] / "agent/relay_hook_linux.js").read_text() +assert "function overlapsRecvRing(at, length)" in src +assert "at.compare(ringEnd) < 0 && end.compare(base) > 0" in src +assert "if (!overlapsRecvRing(f0.at, 1))" in src +assert "if (overlapsRecvRing(bodyp, body.length))" in src +assert src.index("if (overlapsRecvRing(bodyp, body.length))") < src.index("bodyp.writeByteArray(body)") +assert src.index("if (!overlapsRecvRing(f0.at, 1))") < src.index("f0.at.writeU8") +print("scramble RX ring guard contract ok") diff --git a/tests/test_uaro_stop_watchdog.py b/tests/test_uaro_stop_watchdog.py new file mode 100644 index 0000000..2715bc4 --- /dev/null +++ b/tests/test_uaro_stop_watchdog.py @@ -0,0 +1,15 @@ +"""Watchdog parsing and safe command-line validation, without signaling a process.""" +import importlib.util +from pathlib import Path +from unittest.mock import patch + +path = Path(__file__).resolve().parents[1] / 'scripts/watch-uaro-stop.py' +spec = importlib.util.spec_from_file_location('uaro_stop_watchdog', path) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + +with patch.object(mod.Path, 'read_text', side_effect=['1808818 (uaRO.exe) T 0 0 0', 'uaRO.exe\n']): + assert mod.process_state(1808818) == ('T', 'uaRO.exe') +with patch.object(mod.Path, 'read_text', side_effect=FileNotFoundError): + assert mod.process_state(1808818) == (None, None) +print('uaRO stop watchdog parsing PASS') diff --git a/tests/test_validate_0229.py b/tests/test_validate_0229.py new file mode 100644 index 0000000..4fb14dc --- /dev/null +++ b/tests/test_validate_0229.py @@ -0,0 +1,27 @@ +"""Isolated 0229 validation regressions; no process memory or relay writes.""" +import struct +from _boot import g +from research.recv_cipher.validate_0229 import assess + +def packet(gid, opt1=0, opt2=0, option=0, stance=0): + return struct.pack('<2sIHHIB', b'\x29\x02', gid, opt1, opt2, option, stance) + +wire = b'\x29\x02' + bytes(range(13)) +good = packet(0x12345678, 0, 16, 32) +# Different plaintext GID from the encrypted wire: must remain eligible. +assert good[2:6] != wire[2:6] +assert assess(wire, good, matched_context=True, fresh_slot=True)['deliver'] +# Header alone, stale memory, ambiguous copies cannot make a packet trusted. +assert not assess(wire, good)['deliver'] +assert not assess(wire, good, matched_context=True)['deliver'] +assert not assess(wire, good, matched_context=True, fresh_slot=True, ambiguous=True)['deliver'] +assert not assess(wire, packet(0), matched_context=True, fresh_slot=True)['deliver'] +assert not assess(wire, good[:-1], matched_context=True, fresh_slot=True)['deliver'] +assert not assess(wire, wire, matched_context=True, fresh_slot=True)['deliver'] +assert not assess(wire, b'\xff\x09' + good[2:], matched_context=True, fresh_slot=True)['deliver'] +# Unknown option bit-combinations are warnings, not arbitrary rejection. +rare = packet(0x12345678, opt1=7, opt2=0x8000, option=0xfedcba98, stance=2) +r = assess(wire, rare, matched_context=True, fresh_slot=True) +assert r['deliver'] and not r['checks']['option_reference'] +assert not r['checks']['opt1_reference'] and not r['checks']['opt2_reference'] +print('0229 isolated validation PASS')