[MG_Remote, MG_Test, ci] (P7): one constant-time token comparison serves both server policy sites, a configured token shorter than 16 bytes refuses the listen by name, and CI builds flatc so the TCP supervisor smoke runs and must pass

This commit is contained in:
2026-09-22 21:00:57 -04:00
parent 8a445a3098
commit 8fa1821ab2
10 changed files with 387 additions and 26 deletions
+24
View File
@@ -1377,6 +1377,27 @@ jobs:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)"
# P7 wave 2-F, PH-7 (1). THE TCP SUPERVISOR LANE NEEDS flatc, AND CI HAD NONE.
#
# `TcpLane.SupervisorProtocolControls` carries LABELS integration-tcp, so it has been in the
# selection below since wave 0 - as a SKIP, every run. The script builds the protocol's
# Python bindings with flatc, flatc is deliberately absent from the build graph
# (gen_protocol.py's header says why), and with none found it exits 77 and ctest reports
# the entry skipped. Skipped is what the entry looked like for the whole time it did not
# exist, which is the failure mode the plan's own row named ("referenced by NOTHING").
#
# So the job builds the SAME pinned flatc that flatc-check builds, from the same submodule,
# and the lane step below names the entry to junit_tally.py --require-entry-passed. If the
# build ever stops producing one, the lane reds by name rather than going green having run
# the supervisor's Busy / token / listen / version / build-policy / reap controls zero times.
# --check is passed so this step cannot quietly regenerate a different committed header;
# keeping that honest is flatc-check's job and this step must not overlap with it.
- name: Check out the FlatBuffers submodule for the TCP supervisor lane
run: git submodule update --init 3rdparty/flatbuffers
- name: Build the pinned flatc for the TCP supervisor lane
run: python3 scripts/gen_protocol.py --build-dir "${RUNNER_TEMP}/flatc-build" --check
# --no-tests=error is half the gate, exactly as in integration-verify: the integration-split
# entries exist only when the library was configured with -DMOBILEGL_BUILD_DISAGGREGATED=ON,
# so a build that lost the option matches nothing and reds here instead of reporting a green
@@ -1392,6 +1413,8 @@ jobs:
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
export MOBILEGL_FLATC_EXECUTABLE="${RUNNER_TEMP}/flatc-build/flatc"
test -x "${MOBILEGL_FLATC_EXECUTABLE}"
ctest --output-on-failure -L integration-split --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/split-baseline.xml"
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/split-baseline.xml" --require-split-ran
# Exit gate 9.3: the spawn arm green at the same count. It runs the same cases through a
@@ -1402,6 +1425,7 @@ jobs:
ctest --show-only=json-v1 -L '^integration-tcp$' > "${RUNNER_TEMP}/tcp-discovery.json"
ctest --output-on-failure -L '^integration-tcp$' --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/tcp-baseline.xml"
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-tcp-ran --require-run-ahead --discovery "${RUNNER_TEMP}/tcp-discovery.json"
python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-entry-passed TcpLane.SupervisorProtocolControls
# Both full transport runs are hard gates. The per-case comparison below
# remains diagnostic because explicit mechanism controls retain their own mode.
+31
View File
@@ -2,8 +2,10 @@
#pragma once
#include "CapsCodec.h"
#include "Protocol/generated/protocol_generated.h"
#include "Transport/AuthToken.h"
#include "Transport/ITransport.h"
#include <MG_Util/Debug/Log.h>
#include <cstddef>
#include <cstdlib>
#include <cstring>
@@ -37,6 +39,35 @@ namespace MobileGL::MG_Remote {
return true;
}
// PH-7 (1)(2), ID-P7-3. THE TOKEN POLICY, ONCE, FOR BOTH SERVER-SIDE SITES.
//
// ServerSession::Accept and ServerMain::RunSession each had their own reading of it (see
// Transport/AuthToken.h for what the three readings were). They now call this, so the answer
// to "is this peer allowed to open a session" is one function with one comparison, and it is
// the constant-time one.
//
// THE FOUR STATES, stated here because they were implicit and therefore different at each
// site:
// in-process - there is no peer; the "connection" is this process. Never authenticated.
// no token - not an empty credential: the LISTEN policy has already confined this
// server to loopback (SocketTransport.cpp's ListenTcp), so there is nothing
// left for a token to add. A peer that sends one anyway is not refused for
// it; ServerMain used to do that and no document ever said so.
// token, match - welcomed.
// token, mismatch - `Refuse{Authentication}`, detail "token mismatch", and the value is
// not in the line. A missing token field is a mismatch, not a separate case.
inline MobileGLResult AuthenticatePeerToken(Transport::ITransport& transport,
const ::flatbuffers::String* presented) {
if (transport.Role() == Transport::TransportRole::InProcess) return MOBILEGL_OK;
const char* expected = Transport::ConfiguredAuthToken();
if (expected == nullptr) return MOBILEGL_OK;
const char* bytes = presented == nullptr ? "" : presented->c_str();
const std::size_t size = presented == nullptr ? 0u : presented->size();
if (Transport::ConstantTimeTokenMatch(expected, bytes, size)) return MOBILEGL_OK;
return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::Authentication,
"token mismatch");
}
inline MobileGLResult ValidatePeerHandshake(Transport::ITransport& transport,
Uint32 major, Uint32 minor, Uint64 fingerprint,
const char* stamp, ::MobileGL::Wire::DialMode dial) {
+8 -8
View File
@@ -87,14 +87,14 @@ void SendLogAck(void* pointer) {
if (ValidatePeerHandshake(*control, hello->abiMajor(), hello->abiMinor(),
hello->wireFingerprint(), hello->buildFingerprint() ? hello->buildFingerprint()->c_str() : nullptr,
hello->dialMode()) != MOBILEGL_OK) ::_exit(0);
if (tcp) {
const char* expected = std::getenv("MOBILEGL_IPC_TOKEN");
const std::string token = hello->token() ? hello->token()->str() : std::string();
if (token != (expected ? expected : "")) {
Refuse(*control, Protocol::RefuseCode::Authentication, "TCP token mismatch");
::_exit(0);
}
}
// PH-7 (1). The SAME policy ServerSession::Accept applies, from Handshake.h, so the
// supervisor's pre-fork answer and the session's post-fork answer cannot drift. What went:
// a `std::string` comparison (byte-at-a-time, early-returning) that ALSO refused a peer for
// presenting a token to a server that had configured none, and a `tcp` guard that made the
// unix control socket exempt by construction rather than by the policy's own reasoning.
// RefuseHandshake logs `Refuse{Authentication}` and sends the peer the frame; the supervisor's
// local Refuse() helper stays for the refusals that are its own (Busy, backend).
if (AuthenticatePeerToken(*control, hello->token()) != MOBILEGL_OK) ::_exit(0);
MobileGL::MG_ConfigLoader::Init();
MobileGL::MG_Config::Transport = MobileGL::MG_Config::TransportMode::Spawn;
MobileGL::MG_Pipe::MGPipeSetServerProcessRole(true);
+5 -6
View File
@@ -545,12 +545,11 @@ namespace MobileGL::MG_Remote::Server {
if (stream && transport.Role() == Transport::TransportRole::InProcess)
return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::LinkTerms,
"stream requires a data connection");
const char* expectedToken = std::getenv("MOBILEGL_IPC_TOKEN");
if (transport.Role() != Transport::TransportRole::InProcess && expectedToken != nullptr &&
expectedToken[0] != '\0' && (hello->token() == nullptr ||
std::strcmp(hello->token()->c_str(), expectedToken) != 0))
return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::Authentication,
"token mismatch");
// PH-7 (1). One policy, one constant-time comparison, shared with ServerMain's supervisor
// child (Handshake.h AuthenticatePeerToken). This site used std::strcmp, which returns at
// the first differing byte.
const MobileGLResult authenticated = AuthenticatePeerToken(transport, hello->token());
if (authenticated != MOBILEGL_OK) return authenticated;
if (hello->backendType() >= static_cast<Uint32>(BackendType::BackendTypeCount) ||
(m_backend != nullptr && hello->backendType() != static_cast<Uint32>(m_backend->GetBackendType())))
return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::Backend,
+90
View File
@@ -0,0 +1,90 @@
// MobileGL - MobileGL/MG_Remote/Transport/AuthToken.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// PH-7 (1)(2)(3), ID-P7-3. THE ONE PLACE THAT KNOWS WHAT `MOBILEGL_IPC_TOKEN` MEANS.
//
// It was three places, and the three did not agree:
//
// SocketTransport.cpp's ListenTcp asked only "is it non-empty", and a one-byte token
// therefore unlocked a `tcp://0.0.0.0` listen.
// ServerSession.cpp compared with `std::strcmp` under "the role is not InProcess AND a token
// is configured".
// ServerMain.cpp compared with `std::string::operator==` under "the control connection is
// TCP", which additionally demanded that a peer present an EMPTY token to a server that had
// configured none - a rule the other site did not have and no document states.
//
// Two of the three compared byte-at-a-time and returned at the first differing byte. That is the
// textbook remote timing oracle: an attacker who can retry learns the token one byte at a time
// instead of guessing 2^128. Whether it is exploitable over a LAN at these timings is beside the
// point; the comparison costs the same either way, and a security property nobody has to
// re-derive is worth more than a measurement.
//
// WHY A MINIMUM LENGTH IS A LISTEN-TIME REFUSAL AND NOT A WARNING. Ph's threat model (ID-P7-3)
// is "anybody who can reach the port has a remote code path". The answer to it is the token, so
// a token an attacker can enumerate is not an answer - it is the appearance of one, which is
// worse than no token at all, because `no token` is already handled: it confines the listener to
// loopback. A four-byte token, before this file, did not confine anything. 16 bytes is the
// floor, it is checked where the listener is opened, and a short token FAILS THE LISTEN rather
// than silently degrading to loopback-only: an operator who set a token and got a loopback
// server would have no way to tell that from success, which is the exact silence Ph exists to
// remove.
//
// The token is never logged, at any level, on any path. The refusals name the policy, not the
// secret (CONTRACT-P65: "错令牌返回 Refuse,不打印令牌值").
#pragma once
#include <cstddef>
#include <cstdlib>
#include <cstring>
namespace MobileGL::MG_Remote::Transport {
// 128 bits of secret, in the encoding an environment variable can carry. The same width as
// `Welcome.dataNonce` (PH-7 (4)) for the same reason: below it, guessing is cheaper than
// attacking anything else in this design.
inline constexpr std::size_t kMinimumAuthTokenBytes = 16;
// The configured token, or nullptr when none is configured. An EMPTY value is "none": every
// TCP lane in this tree sets `MOBILEGL_IPC_TOKEN=` precisely to say so (see
// MG_IntegrationTest/CMakeLists.txt's three arm environments), and an empty string is not a
// secret under any reading.
inline const char* ConfiguredAuthToken() {
const char* value = std::getenv("MOBILEGL_IPC_TOKEN");
return value != nullptr && value[0] != '\0' ? value : nullptr;
}
// Long enough to be a credential. Asked only of a CONFIGURED token; "none" is a separate
// state with its own policy (loopback-only), not a zero-length token.
inline bool AuthTokenIsLongEnough(const char* token) {
return token != nullptr && std::strlen(token) >= kMinimumAuthTokenBytes;
}
// Constant time in the CONTENTS of both operands.
//
// The loop runs over the expected token's length and never leaves early, so the time depends
// on that length - which is this process's own configuration, fixed before any peer connects
// and not attacker-controlled - and on nothing the peer sent. The two lengths are folded into
// the accumulator rather than branched on, so a peer cannot learn the token's length from a
// faster answer either. Past the presented token's end the comparison reads a zero, which is
// a value, not a short-circuit: `presented` is never indexed out of its own bounds.
inline bool ConstantTimeTokenMatch(const char* expected, const char* presented,
std::size_t presentedSize) {
if (expected == nullptr) return false;
const std::size_t expectedSize = std::strlen(expected);
std::size_t difference = expectedSize ^ presentedSize;
for (std::size_t index = 0; index < expectedSize; ++index) {
const unsigned char theirs =
index < presentedSize ? static_cast<unsigned char>(presented[index]) : 0u;
difference |= static_cast<unsigned char>(
static_cast<unsigned char>(expected[index]) ^ theirs);
}
return difference == 0;
}
} // namespace MobileGL::MG_Remote::Transport
@@ -8,6 +8,7 @@
#include "SocketTransport.h"
#include "AuthToken.h"
#include "Doorbell.h" // kWaitForever
#include "FdPassing.h"
#include "WireLog.h"
@@ -153,8 +154,24 @@ namespace MobileGL::MG_Remote::Transport {
MobileGLResult ListenTcp(const std::string& name, int* outFd) {
addrinfo* addresses = nullptr;
if (!TcpAddress(name, &addresses)) return MOBILEGL_ERR_INVALID_ARGUMENT;
const char* token = std::getenv("MOBILEGL_IPC_TOKEN");
const bool authenticated = token != nullptr && token[0] != '\0';
// PH-7 (3), ID-P7-3. A CONFIGURED TOKEN IS EITHER A CREDENTIAL OR A REFUSAL.
//
// This read `getenv(...) != nullptr && token[0] != '\0'`, so ANY non-empty value -
// one byte - opened a `tcp://0.0.0.0` listen. The listen is the only place that can
// still say no cheaply and to the operator rather than to a peer, so the length is
// asked here, and a short token fails the listen outright instead of quietly
// becoming a loopback-only server that looks exactly like a working one.
// AuthToken.h holds the constant and the reason.
const char* token = ConfiguredAuthToken();
if (token != nullptr && !AuthTokenIsLongEnough(token)) {
WireLogError("MG_Remote: Refuse{Authentication} MOBILEGL_IPC_TOKEN is %zu bytes "
"and the minimum is %zu (PH-7 (3)); refusing to listen on %s rather "
"than authenticating with a guessable secret",
std::strlen(token), kMinimumAuthTokenBytes, name.c_str());
::freeaddrinfo(addresses);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
const bool authenticated = token != nullptr;
MobileGLResult result = MOBILEGL_ERR_UNSUPPORTED;
for (auto* address = addresses; address; address = address->ai_next) {
if (!authenticated && !Loopback(address->ai_addr)) {
@@ -36,6 +36,7 @@
// is one this side already reassembled out of bytes nobody else can re-deliver.
#include <MG_Remote/Protocol/generated/protocol_generated.h> // RefuseCode, for the word below
#include <MG_Remote/Transport/AuthToken.h> // PH-7 (3): the minimum and the comparison
#include <MG_Remote/Transport/Doorbell.h> // kWaitForever
#include <MG_Remote/Transport/FdPassing.h>
#include <MG_Remote/Transport/SocketTransport.h>
@@ -131,6 +132,55 @@ TEST(SocketTransportTest, TcpRejectsMalformedNamesAndUnauthenticatedWildcard) {
if (wasSet) ::setenv("MOBILEGL_IPC_TOKEN", saved.c_str(), 1);
}
// PH-7 (3), ID-P7-3. A CONFIGURED TOKEN IS EITHER A CREDENTIAL OR A REFUSAL.
//
// The listen policy used to ask only "non-empty", so a one-byte MOBILEGL_IPC_TOKEN opened a
// wildcard listen. The end-to-end half of this - the supervisor PROCESS dying with a named
// reason rather than degrading to a loopback server that looks like success - is
// TcpLane.SupervisorProtocolControls; this is the cheap half, and it also pins that the refusal
// applies on LOOPBACK, where a short token would otherwise be harmless. Silently accepting it
// there is what would let a short token reach a deployment.
TEST(SocketTransportTest, TcpRefusesToListenWithATokenShorterThanTheMinimum) {
const char* prior = std::getenv("MOBILEGL_IPC_TOKEN");
const std::string saved = prior ? prior : "";
const bool wasSet = prior != nullptr;
const std::string tooShort(kMinimumAuthTokenBytes - 1, 'x');
::setenv("MOBILEGL_IPC_TOKEN", tooShort.c_str(), 1);
int listener = -1;
EXPECT_EQ(SocketTransport::Listen("tcp://127.0.0.1:40697", &listener),
MOBILEGL_ERR_PROTOCOL_MISMATCH);
EXPECT_EQ(listener, -1);
// One more byte and the same endpoint comes up, so the refusal above is the LENGTH and not
// the port, the address family or the environment variable's mere presence.
const std::string longEnough(kMinimumAuthTokenBytes, 'x');
::setenv("MOBILEGL_IPC_TOKEN", longEnough.c_str(), 1);
EXPECT_EQ(SocketTransport::Listen("tcp://127.0.0.1:40697", &listener), MOBILEGL_OK);
EXPECT_GE(listener, 0);
if (listener >= 0) ::close(listener);
if (wasSet) ::setenv("MOBILEGL_IPC_TOKEN", saved.c_str(), 1);
else ::unsetenv("MOBILEGL_IPC_TOKEN");
}
// The comparison itself. `std::strcmp` and `std::string::operator==` - the two this replaced -
// both stop at the first differing byte; these cases pin the answers, and the loop in
// AuthToken.h is what makes the time not depend on WHERE the difference is.
TEST(SocketTransportTest, TheTokenComparisonAnswersLengthAndPrefixTheSameWay) {
const char* expected = "0123456789abcdef";
EXPECT_TRUE(ConstantTimeTokenMatch(expected, "0123456789abcdef", 16));
EXPECT_FALSE(ConstantTimeTokenMatch(expected, "0123456789abcdeF", 16));
EXPECT_FALSE(ConstantTimeTokenMatch(expected, "Z123456789abcdef", 16));
// A correct PREFIX is not a match, and neither is a correct token with anything after it.
EXPECT_FALSE(ConstantTimeTokenMatch(expected, "0123456789abcde", 15));
EXPECT_FALSE(ConstantTimeTokenMatch(expected, "0123456789abcdefg", 17));
EXPECT_FALSE(ConstantTimeTokenMatch(expected, "", 0));
// An embedded NUL is compared, not truncated at: `presented` carries its own length because
// a flatbuffers string can hold one and c_str()-plus-strcmp would have stopped there.
const char embedded[] = {'0', '\0', '2', '3', '4', '5', '6', '7',
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
EXPECT_FALSE(ConstantTimeTokenMatch(expected, embedded, sizeof(embedded)));
EXPECT_FALSE(ConstantTimeTokenMatch(nullptr, "anything", 8));
}
namespace {
MobileGLByteSpan Span(const std::string& text) {
+32
View File
@@ -61,6 +61,34 @@ def require_tcp_proof(path, prefix, discovery, require_run_ahead=False):
raise ValueError(f"{case.get('name')}: TCP runtime did not keep requested run-ahead armed")
def require_entries_passed(path, names):
"""P7 wave 2-F, PH-7 (1). Named entries must have RUN and passed - a SKIP is a failure here.
`TcpLane.SupervisorProtocolControls` is registered with SKIP_RETURN_CODE 77 because the
script it runs needs flatc, which is deliberately not in the build graph. That is the right
behaviour for a developer who has not built one; it is the wrong behaviour for CI, where the
lane's whole job is to run the supervisor's protocol controls and "skipped" is
indistinguishable from the three commits the entry did not exist. The workflow builds flatc
and then names the entry here, so a CI run in which the tool went missing reds by name
instead of reporting a green lane that measured less than it says.
"""
seen = {}
for case in ET.parse(path).getroot().iter('testcase'):
name = case.get('name')
if name not in names:
continue
if case.find('failure') is not None or case.find('error') is not None:
seen[name] = 'failed'
elif case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'):
seen[name] = 'skipped'
else:
seen[name] = 'passed'
for name in names:
state = seen.get(name, 'absent')
if state != 'passed':
raise ValueError(f'{name}: required to pass, was {state}')
def tally(path, prefix=None):
passed = failed = skipped = 0
for case in ET.parse(path).getroot().iter('testcase'):
@@ -84,6 +112,8 @@ def main():
parser.add_argument('--discovery', help='CTest --show-only=json-v1 output (required for TCP proof)')
parser.add_argument('--require-run-ahead', action='store_true',
help='Additionally prove actual run-ahead, without fallback or demotion, on each TCP case')
parser.add_argument('--require-entry-passed', action='append', default=[], metavar='NAME',
help='This ctest entry must be present and passed; a SKIP fails (repeatable)')
args = parser.parse_args()
if args.prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.') and not args.discovery:
parser.error('TCP arm proof requires --discovery to locate each private role log')
@@ -94,6 +124,8 @@ def main():
passed, failed, skipped = tally(args.junit, prefix=prefix)
if prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.'):
require_tcp_proof(args.junit, prefix, args.discovery, args.require_run_ahead)
if args.require_entry_passed:
require_entries_passed(args.junit, args.require_entry_passed)
except Exception as exc: # a malformed file is not "zero of everything"
print(f"junit_tally: cannot prove {args.junit}: {exc}", file=sys.stderr)
return 1
+29
View File
@@ -87,6 +87,35 @@ class Accounting(unittest.TestCase):
with self.assertRaises(ValueError):
tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery)
def test_a_skipped_required_entry_is_not_a_pass(self):
"""P7 wave 2-F, PH-7 (1). The four states of a NAMED entry, and only one of them passes.
`TcpLane.SupervisorProtocolControls` skips itself (exit 77) when flatc is absent, which
is correct for a developer and wrong for CI - the workflow now builds flatc and names the
entry, so `skipped` has to red here exactly as `failed` and `absent` do. Written as four
cases rather than one because the one that matters is `skipped`, and a check that only
looked for <failure> would have let it through, which is the whole defect.
"""
tally = module('junit_tally')
name = 'TcpLane.SupervisorProtocolControls'
bodies = {
'passed': f'<testcase name="{name}" />',
'skipped': f'<testcase name="{name}"><skipped /></testcase>',
'notrun': f'<testcase name="{name}" status="notrun" />',
'failed': f'<testcase name="{name}"><failure /></testcase>',
'absent': '<testcase name="TcpLane.AccountingControls" />',
}
with tempfile.TemporaryDirectory() as directory:
junit = Path(directory) / 'junit.xml'
for state, body in bodies.items():
junit.write_text(f'<testsuite>{body}</testsuite>')
if state == 'passed':
tally.require_entries_passed(junit, [name])
continue
with self.assertRaises(ValueError, msg=state) as raised:
tally.require_entries_passed(junit, [name])
self.assertIn(name, str(raised.exception))
def test_equal_counts_do_not_hide_a_missing_case(self):
parity = module('spawn_lane_parity')
a, b = 'TriangleScenario.Draw', 'TriangleScenario.Read'
+99 -10
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env python3
"""Exercise a real TCP supervisor's Busy, token, version, build-policy and REAP controls.
"""Exercise a real TCP supervisor's LISTEN, Busy, token, version, build-policy and REAP controls.
P7 wave 0 registered this script as `TcpLane.SupervisorProtocolControls` in
MG_IntegrationTest/CMakeLists.txt. Until then it was referenced by NOTHING in the tree - no
ctest entry, no workflow step - so the four controls it asserts had been green or red for an
unknown number of commits and nobody would have known which.
P7 wave 2-F, PH-7 (1)(2)(3) (ID-P7-3), added the two LISTEN controls at the top of main(). They
are the negative controls for the policy AuthToken.h now owns, and both are end-to-end against a
real supervisor process because that is the only place the policy is reachable: an in-process
unit test of `SocketTransport::Listen` can assert the return code but not that an operator who
misconfigured a token gets a dead server with a named reason rather than a live one that silently
serves loopback only.
It needs flatc, which is deliberately absent from the default build graph (gen_protocol.py's
header block says why). Resolution order is gen_protocol.py's, minus the build-it-for-you arm:
--flatc, then $MOBILEGL_FLATC_EXECUTABLE, then <repo>/../flatc-build/flatc. With none of them
@@ -34,6 +41,18 @@ ROOT = Path(__file__).resolve().parents[2]
# uses and ctest has no opinion of its own.
kSkipExit = 77
# PH-7 (3): >= AuthToken.h's kMinimumAuthTokenBytes (16). The old value, `p65-smoke-token`, was
# fifteen bytes - one short - which is a pleasing accident and exactly the kind of token the
# minimum exists to refuse. The value is arbitrary; its LENGTH is the fixture.
kToken = 'p7-ph-f-supervisor-token'
assert len(kToken) >= 16, kToken
# A token that is a credential in every respect except length.
kShortToken = 'p7-ph-f-short'
assert len(kShortToken) < 16, kShortToken
# ServerMain's `SocketTransport::Listen(...) != MOBILEGL_OK` arm.
kListenRefusedExit = 72
def resolve_flatc(explicit):
"""gen_protocol.py's order, without its build-it-for-you arm. None when there is none."""
@@ -77,7 +96,7 @@ def receive(peer, schema):
raise RuntimeError(f'unexpected first control reply {kind}')
def hello(schema, flatbuffers, fingerprint=0, major=1, token='p65-smoke-token', identifier=b'MGLC'):
def hello(schema, flatbuffers, fingerprint=0, major=1, token=kToken, identifier=b'MGLC'):
builder = flatbuffers.Builder(512)
build = builder.CreateString('intentionally-different-build-for-p65-control')
auth = builder.CreateString(token)
@@ -108,18 +127,62 @@ def hello(schema, flatbuffers, fingerprint=0, major=1, token='p65-smoke-token',
return struct.pack('<4sI', b'MGLF', len(payload)) + payload
@contextmanager
def supervisor(server, log, same_build=False):
with socket.socket() as reserve:
reserve.bind(('127.0.0.1', 0))
port = reserve.getsockname()[1]
base = log.with_suffix('.library.log')
def supervisor_environment(log, token, same_build=False):
"""The env a supervisor is started with, minus everything ServerMain's scrub catches."""
env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
MOBILEGL_IPC_TOKEN='p65-smoke-token', MOBILEGL_IPC_REQUIRE_SAME_BUILD=str(int(same_build)),
MOBILEGL_IPC_LOG_FORWARD='0', MOBILEGL_LOG_FILE_PATH=str(base))
MOBILEGL_IPC_REQUIRE_SAME_BUILD=str(int(same_build)),
MOBILEGL_IPC_LOG_FORWARD='0', MOBILEGL_LOG_FILE_PATH=str(log))
if token is None:
env.pop('MOBILEGL_IPC_TOKEN', None)
else:
env['MOBILEGL_IPC_TOKEN'] = token
for name in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB',
'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'):
env.pop(name, None)
return env
def free_loopback_port():
with socket.socket() as reserve:
reserve.bind(('127.0.0.1', 0))
return reserve.getsockname()[1]
def refused_listen(server, log, endpoint, token):
"""Start a supervisor that must NOT come up, and return (exit code, everything it said).
PH-7 (2)(3), ID-P7-3. Both listen refusals are asserted this way rather than through
SocketTransport::Listen's return code, because the property is not "the function returns
PROTOCOL_MISMATCH" - it is that the PROCESS dies, says which policy killed it, and does not
leave a half-configured server listening. A supervisor that degraded to loopback-only after a
misconfigured token would pass a return-code test and fail an operator.
"""
base = log.with_suffix('.library.log')
env = supervisor_environment(base, token)
with log.open('wb') as output:
child = subprocess.Popen([server, endpoint, '--serve'], env=env,
stdout=output, stderr=output, start_new_session=True)
try:
code = child.wait(timeout=30)
except subprocess.TimeoutExpired:
# THE RED SHAPE OF THIS CONTROL IS A SUPERVISOR THAT CAME UP, so it is still listening
# and would answer the next control's peers. Killed here rather than left for the
# assertion below, which is what makes the red a red and not a cascade.
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=10)
code = None
text = log.read_text(errors='replace')
role = base.with_name(base.stem + '.server' + base.suffix)
if role.is_file():
text += role.read_text(errors='replace')
return code, text
@contextmanager
def supervisor(server, log, same_build=False):
port = free_loopback_port()
base = log.with_suffix('.library.log')
env = supervisor_environment(base, kToken, same_build=same_build)
with log.open('wb') as output:
child = subprocess.Popen([server, f'tcp://127.0.0.1:{port}', '--serve'], env=env,
stdout=output, stderr=output, start_new_session=True)
@@ -247,6 +310,32 @@ def main():
schema = {name: importlib.import_module('MobileGL.Wire.' + name)
for name in ('CtrlEnvelope', 'CtrlMsg', 'Hello', 'LinkTerms', 'Refuse', 'Welcome')}
evidence = {}
# ---- PH-7 (2)(3), ID-P7-3: THE TWO LISTEN REFUSALS -------------------------------
#
# Both are the SAME policy from two directions: a listener that can be reached from off
# this machine has to have a credential, and a credential has to be one.
#
# (a) No token, non-loopback bind. The mechanism has been in the tree since P6.5
# (SocketTransport.cpp's ListenTcp) and the plan's own gate row called it "done
# (mechanism)" with the caveat that nothing referenced it. This is the reference.
# (b) A token shorter than 16 bytes, on LOOPBACK, where a short token is otherwise
# harmless. It still fails, and that is the ruling: a configured token is either a
# credential or a refusal, never a warning. Before this package a one-byte token
# unlocked `tcp://0.0.0.0`.
code, said = refused_listen(args.server, args.out / 'wildcard-no-token.log',
'tcp://0.0.0.0:40699', None)
assert code == kListenRefusedExit, (code, said)
assert 'Refuse{Authentication}' in said, said
evidence['wildcard_listen_without_a_token'] = {'exit': code, 'line': next(
l.strip() for l in said.splitlines() if 'Refuse{Authentication}' in l)}
short = f'tcp://127.0.0.1:{free_loopback_port()}'
code, said = refused_listen(args.server, args.out / 'short-token.log', short, kShortToken)
assert code == kListenRefusedExit, (code, said)
assert 'Refuse{Authentication}' in said and 'minimum is 16' in said, said
assert kShortToken not in said, 'the refusal printed the token'
evidence['short_token_listen'] = {'exit': code, 'line': next(
l.strip() for l in said.splitlines() if 'Refuse{Authentication}' in l)}
with supervisor(args.server, args.out / 'supervisor.log') as (port, process, serverLog):
with pair(port) as held:
with pair(port) as second: