From 95c6334ff393ac3575c5c1335e5cae19dc33fab5 Mon Sep 17 00:00:00 2001 From: 3720 Date: Thu, 10 Sep 2026 08:20:45 +0000 Subject: [PATCH 1/8] Add expiring agent support shell invitations --- .github/workflows/check.yml | 11 ++- README.md | 75 ++++++++++++++++++++ SECURITY.md | 14 ++++ include/session-deadline.h | 23 ++++++ objects/ssh-session.o | Bin 0 -> 53968 bytes scripts/test.sh | 3 + src/ssh-session.c | 3 + src/ssh-shell.c | 42 +++++++++-- tests/node-auth.c | 3 + tests/session-deadline.c | 41 +++++++++++ tests/session-tools.py | 50 +++++++++++++ tools/make-session.py | 116 ++++++++++++++++++++++++++++++ tools/session-client.py | 137 ++++++++++++++++++++++++++++++++++++ 13 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 include/session-deadline.h create mode 100644 objects/ssh-session.o create mode 100644 src/ssh-session.c create mode 100644 tests/session-deadline.c create mode 100644 tests/session-tools.py create mode 100644 tools/make-session.py create mode 100644 tools/session-client.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fc73081..821ab93 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,4 +10,13 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: ./scripts/test.sh - - run: ./scripts/build.sh --check + - run: ./scripts/build.sh + id: build + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: canonical-objects + path: objects/ + - name: Verify committed artifacts and complete file set + run: | + git diff --exit-code -- objects/ + test -z "$(git ls-files --others --exclude-standard objects/)" diff --git a/README.md b/README.md index 0ff53e5..029da94 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ them. These run inside Synchronicity's socket runtime, not the Linux kernel. | `echo` | Echoes binary streams with backpressure; 30-second idle timeout | Any caller able to connect; 16 concurrent streams | | `whoami` | Prints authenticated origin, device key and peer kind, then closes | Any caller able to connect; 8 concurrent streams | | `tcp-proxy` | Bidirectional TCP forwarding to `127.0.0.1` on a configured port | Same peer allowlist as SSH; 32 concurrent connections | +| `ssh-session` | Expiring interactive shell; no SFTP; existing connections close at the deadline | Pinned agent key; generated private invitation, default 10 minutes | | `ssh-shell` | Interactive `/bin/bash` with PTY; read/write SFTP under `files` | Configured origins or node public keys; 4 concurrent connections, one session per connection | Built against the SDK revision in [UPSTREAM.md](UPSTREAM.md). Review the @@ -127,6 +128,80 @@ an activated socket path: replacing its bytes deploys a new program immediately. Activation configuration stays on the serving node; it is not embedded in the published object. +## Temporary agent support sessions + +An agent can generate a script for a consenting user, then open an interactive +shell on that user's machine until a fixed expiry. This is a **temporary session**, +not an exactly-once command executor. SSH `exec` is still rejected; use the PTY +shell to run several commands. Existing `ssh-shell` activations are unchanged. + +On an enrolled agent node with Synchronicity running and Python 3 available: + +```sh +python3 tools/make-session.py --seconds 600 --output /private/path/support.py +``` + +The generator checks the agent's identity, creates a new isolated device key, +trusts only the issuing agent, and gives that key a delegation for a **new empty +space** until the invitation expiry. It does not grant access to the agent's +existing file spaces or add a permanent network member. No control-plane API key, +join key or agent private key is included. `--data-dir` selects an existing agent +node; `--synch` selects its binary. The output is created exclusively, mode 0600. + +**Send the generated script privately to the intended user. It embeds a temporary +device secret.** Anyone holding a copy can impersonate that temporary node until +the delegation expires. It is an expiring invitation, not a replay-proof ticket: +do not reuse it or post it to a public thread. Generate a fresh one for each user. +The lifetime is 30–3600 seconds **from generation**, including download/setup time; +the default is 600. Delayed delivery does not extend the grant. + +The user runs: + +```sh +python3 support.py +``` + +After explicit `yes` consent, it downloads the official runtime matching the +agent's version, verifies an embedded SHA-256 checksum, restores only the temporary +identity into a private temporary directory, and starts a foreground-owned daemon. +It publishes/activates `ssh-session.o` as `temporary-shell`, allowing only the +agent's public key, and prints `READY`. No sudo, permanent binary installation, +shell-profile change, SSH configuration change or startup service is needed. +Requires Python 3, curl, `/bin/bash`, macOS or glibc Linux; v0.1.10 Linux archives +require glibc 2.39. The generator uses release checksums directly, not the +rate-limited unauthenticated `releases/latest` API. + +The generator prints the exact agent-side SSH command and an early network +revocation command. Keep SSH host-key verification enabled. The user can press +Ctrl-C or close the terminal to stop access. The supervisor stops the temporary +daemon and removes its data/binary on normal expiry, SIGINT, SIGHUP and SIGTERM. +Delete the invitation file after use; the script does not delete itself. + +`ssh-session` independently requires an `expires_at` Unix-second activation +setting, rejects expired/malformed/missing or >1-hour deadlines, and closes +**existing** sessions at the deadline as well as refusing new ones. Its admitted +session timer is monotonic, so moving the wall clock backwards does not extend +it. This is not just a white-list edit or a timer around the SSH client. The +outer supervisor also bounds its setup and lifetime using wall and monotonic time. +The variant declares a process capability only: no SFTP, tree writes or egress. + +### Security and cleanup boundaries + +The shell runs as the user's account, **not in a sandbox**. It can modify files, +read that account's secrets and start detached processes. Expiry closes the +connection and the runtime terminates/reaps its direct shell with best-effort +process-group cleanup; it cannot undo changes or guarantee cleanup of detached +descendants. Do not use it to execute untrusted agent code. + +The grant expires in signed delegation state without a cleanup service on the +agent. `synch delegate rm ` revokes early network access; user-side +Ctrl-C is the explicit local shutdown. Removing/expiring delegation does not +serve as the session timer: the program and supervisor enforce that separately. +SIGKILL, machine crashes and power loss can prevent supervisor cleanup. The +program's own deadline still prevents a surviving daemon from providing a new +unexpired shell after the invitation deadline; leftover local temporary files +may require manual removal. Local clock integrity is part of admission trust. + ## TCP reverse proxy `tcp-proxy` forwards an authenticated Synchronicity stream to a TCP service on diff --git a/SECURITY.md b/SECURITY.md index eb02382..88f0fb5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -79,3 +79,17 @@ Tests run the committed object against loopback TCP to check denied dials, invalid configuration, host egress enforcement, backpressure, both half-close orders and asynchronous connection failure. Local listener clients inherit the forwarding node's identity and receive the selected service's protocol access. + +## Expiring support invitations + +`ssh-session` shares the SSH state machine with `ssh-shell`, but has no SFTP or +tree-write capability and requires a bounded absolute expiry. The user-side +supervisor independently enforces expiry and stops the isolated daemon. See +README's temporary-session section for the explicit consent, temporary secret, +clock, replay, detached-child and crash-cleanup boundaries. This is authorized +remote administration, not sandboxed or exactly-once execution. + +`make-session.py` never embeds the issuing agent's private key. The private output +contains only a fresh temporary device identity and a short-lived space-scoped +delegation is issued separately by the agent. The public-key allowlist pins the +agent; no wildcard or arbitrary network member is admitted to the shell. diff --git a/include/session-deadline.h b/include/session-deadline.h new file mode 100644 index 0000000..2f593b9 --- /dev/null +++ b/include/session-deadline.h @@ -0,0 +1,23 @@ +#ifndef SYKIT_SESSION_DEADLINE_H +#define SYKIT_SESSION_DEADLINE_H +/* expires_at is an absolute Unix second. Invitations last at most one hour; + * the creator normally issues ten minutes. Wall time admits, monotonic time + * prevents a backwards clock adjustment extending an established session. */ +static sy_u64 session_deadline(void) { + char value[12]; + sy_s64 n = sy_config_get(SY_STR("expires_at"), value, sizeof value); + if (n <= 0 || n > 11) return 0; + sy_u64 seconds = 0; + for (sy_s64 i = 0; i < n; ++i) { + if (value[i] < '0' || value[i] > '9') return 0; + seconds = seconds * 10 + (sy_u64)(value[i] - '0'); + } + sy_u64 now = sy_now_ms(), expires = seconds * 1000; + if (expires <= now || expires - now > 3600000) return 0; + return sy_monotonic_ns() / 1000000 + expires - now; +} +static sy_s64 session_remaining(sy_u64 deadline) { + sy_u64 now = sy_monotonic_ns() / 1000000; + return now < deadline ? (sy_s64)(deadline - now) : 0; +} +#endif diff --git a/objects/ssh-session.o b/objects/ssh-session.o new file mode 100644 index 0000000000000000000000000000000000000000..bb076e0bba4390181434357bb40abea45ffb26bb GIT binary patch literal 53968 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

bjS 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 >= 0 || s->sftp >= 0 || s->process >= 0 || !event_string_is(event, "subsystem", "sftp")) @@ -140,6 +151,8 @@ static sy_s64 handle_request(sy_s64 event, sy_u64 id, struct session *s) { return finish(event, id, 1); } +#endif + /* exec, other subsystems, env, forwarding: not this socket's policy. The refusal costs nothing — none of those names could have started anything. */ return finish(event, id, 0); @@ -208,6 +221,12 @@ static void collect_status(struct session *s) { } SY_ENTRY sy_s64 entry(void) { +#ifdef SHELL_SESSION + /* Absolute invitation expiry; once admitted, monotonic time also bounds it. + Missing/malformed/expired config is never an unlimited session. */ + sy_u64 deadline = session_deadline(); + if (!deadline) return 1; +#endif /* Gate the authenticated transport identity before starting SSH or a backend. Inner SSH none is safe only because this check is mandatory. */ if (!node_is_authorized()) { @@ -215,6 +234,9 @@ SY_ENTRY sy_s64 entry(void) { return 1; } +#ifdef SHELL_SESSION + if (!session_remaining(deadline)) return 1; +#endif sy_s64 methods = sy_json_parse(SY_STR("[\"none\"]")); if (methods < 0) return 1; sy_s64 started = sy_ssh_start(SY_SELF, methods); @@ -261,11 +283,20 @@ SY_ENTRY sy_s64 entry(void) { fds[nfds++] = (struct sy_pollfd){s.channel, SY_POLL_RDHUP, 0}; } - if (sy_poll(fds, nfds, -1) <= 0) break; /* idle deadline, or all quiet */ +#ifdef SHELL_SESSION + sy_s64 left = session_remaining(deadline); + if (!left || sy_poll(fds, nfds, left) <= 0) break; + if (!session_remaining(deadline)) break; +#else + if (sy_poll(fds, nfds, -1) <= 0) break; +#endif /* idle deadline, or all quiet */ if (fds[0].revents & SY_POLL_IN) { sy_s64 event; while ((event = sy_ssh_next(SY_SELF)) > 0) { +#ifdef SHELL_SESSION + if (!session_remaining(deadline)) { sy_close(event); goto expired; } +#endif sy_s64 id = 0; if (sy_json_get_i64(event, SY_STR("id"), &id) < 0 || id <= 0) { sy_close(event); @@ -340,6 +371,9 @@ SY_ENTRY sy_s64 entry(void) { if (fds[0].revents & (SY_POLL_ERR | SY_POLL_HUP)) break; } +#ifdef SHELL_SESSION +expired: +#endif close_session(&s); return 0; } diff --git a/tests/node-auth.c b/tests/node-auth.c index bd885f3..41cee3a 100644 --- a/tests/node-auth.c +++ b/tests/node-auth.c @@ -1,6 +1,9 @@ #include #include #include +#undef memcpy +#undef memset +#undef memmove #define memcpy sdk_memcpy #define memset sdk_memset #define memmove sdk_memmove diff --git a/tests/session-deadline.c b/tests/session-deadline.c new file mode 100644 index 0000000..924e582 --- /dev/null +++ b/tests/session-deadline.c @@ -0,0 +1,41 @@ +#include +#include +#undef memcpy +#undef memset +#undef memmove +#define memcpy sdk_memcpy +#define memset sdk_memset +#define memmove sdk_memmove +#include +#undef memcpy +#undef memset +#undef memmove +#include "session-deadline.h" +static const char *config; +static sy_u64 wall, mono; +sy_s64 sy_config_get(const char *key, sy_u64 klen, char *out, sy_u64 len) { + assert(klen == 10 && memcmp(key, "expires_at", 10) == 0); + if (!config) return SY_ENOENT; + size_t n = strlen(config); + memcpy(out, config, n < len ? n : len); + return (sy_s64)n; +} +sy_u64 sy_now_ms(void) { return wall; } +sy_u64 sy_monotonic_ns(void) { return mono * 1000000; } +int main(void) { + wall = 1000000; mono = 50; + const char *bad[] = {NULL, "", "0", "1000", "999", "1001x", "-1", "+1001", " 1001", "4601", "9999999999999999999999"}; + for (unsigned i = 0; i < sizeof bad / sizeof *bad; ++i) { + config = bad[i]; assert(session_deadline() == 0); + } + config = "1001"; + sy_u64 deadline = session_deadline(); + assert(deadline == 1050); + assert(session_remaining(deadline) == 1000); + wall = 0; mono = 1049; + assert(session_remaining(deadline) == 1); /* backwards wall time cannot extend */ + mono = 1050; assert(session_remaining(deadline) == 0); + mono = 999999; assert(session_remaining(deadline) == 0); + wall = 1000000; config = "4600"; + assert(session_deadline() != 0); /* exact one-hour maximum */ +} diff --git a/tests/session-tools.py b/tests/session-tools.py new file mode 100644 index 0000000..9ad7d7a --- /dev/null +++ b/tests/session-tools.py @@ -0,0 +1,50 @@ +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('session_client', ROOT / 'tools/session-client.py') +client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(client) + + +class SessionTests(unittest.TestCase): + def test_expired_invitation_never_downloads_or_starts(self): + with patch.object(client.time, 'time', return_value=100), patch.object(client.subprocess, 'run') as run: + with self.assertRaises(SystemExit): + client.main({'expires_at': 100}) + run.assert_not_called() + + def test_refusing_consent_does_not_touch_machine(self): + invitation = dict(expires_at=150, agent_key='agent') + with patch.object(client.time, 'time', return_value=100), patch('builtins.input', return_value='no'), \ + patch.object(client.subprocess, 'run') as run, patch('sys.stdout', new=io.StringIO()): + with self.assertRaises(SystemExit): + client.main(invitation) + run.assert_not_called() + + def test_remaining_is_bounded_by_wall_and_monotonic_time(self): + invitation = {'expires_at': 200} + with patch.object(client.time, 'time', return_value=50), patch.object(client.time, 'monotonic', return_value=150): + self.assertEqual(client.remaining(invitation, 170), 20) + with patch.object(client.time, 'time', return_value=201), patch.object(client.time, 'monotonic', return_value=150): + self.assertEqual(client.remaining(invitation, 170), 0) + + def test_stop_waits_for_real_foreground_process(self): + import subprocess + import sys + process = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']) + try: + client.stop_node(['/nonexistent/synch'], process, {}) + self.assertIsNotNone(process.poll()) + finally: + if process.poll() is None: + process.kill() + process.wait() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/make-session.py b/tools/make-session.py new file mode 100644 index 0000000..bfdb9bf --- /dev/null +++ b/tools/make-session.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Create a private, short-lived remote-shell invitation from a member node.""" +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import sqlite3 +import subprocess +import tempfile +import time +import zlib + +ROOT = Path(__file__).resolve().parents[1] +KEY = r'[ybndrfg8ejkmcpqxot1uwisza345h769]{52}' + + +def run(command, **kwargs): + return subprocess.check_output(command, text=True, timeout=30, **kwargs) + + +def generate(args): + agent = [args.synch] + if args.data_dir: + agent += ['--data-dir', args.data_dir] + identity = run(agent + ['id']) + # A delegation issuer must be a named network member, not a delegate. + if not re.search(r'^origin: [^\n]+@[^\n]+$', identity, re.M): + raise RuntimeError('Run from an enrolled, named agent node.') + key = re.search(r'^ (' + KEY + r') \(active\)$', identity, re.M) + if not key: + raise RuntimeError('Could not read agent public key.') + version = 'v' + run(agent + ['--version']).strip().split()[-1] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version): + raise RuntimeError('Use an official release binary.') + # No unauthenticated releases/latest API: match the agent runtime version. + sums = run(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', + 'https://github.com/AFK-surf/synchronicity/releases/download/' + version + '/SHA256SUMS']) + checksums = {} + for line in sums.splitlines(): + digest, name = line.split() + if not re.fullmatch('[0-9a-f]{64}', digest): + raise RuntimeError('Malformed release checksum.') + checksums[name.lstrip('*')] = digest + program = ROOT / 'objects/ssh-session.o' + run(agent + ['socket', 'inspect', str(program)]) + output = Path(args.output) + # Refuse overwrite before creating any identity or delegation. + with output.open('x') as out: + os.chmod(output, 0o600) + granted = False + device = None + try: + env = {k: v for k, v in os.environ.items() if not k.startswith('SYNCH_')} + with tempfile.TemporaryDirectory(prefix='sykit-invitation-') as directory: + data = Path(directory) / 'node' + temp = [args.synch, '--data-dir', str(data)] + initialized = run(temp + ['init'], env=env) + match = re.search(r'^device key: (' + KEY + r')$', initialized, re.M) + if not match: + raise RuntimeError('Could not read temporary public key.') + device = match[1] + started = False + try: + run(temp + ['--offline', 'daemon', 'start'], env=env) + started = True + # Trust only the issuing agent; no permanent fleet membership. + run(temp + ['trust', 'add', key[1]], env=env) + finally: + if started: + run(temp + ['daemon', 'stop'], env=env) + # SQLite backup includes all WAL content without copying control tokens. + backup = Path(directory) / 'invitation.db' + with sqlite3.connect(data / 'synchronicity.db') as db, sqlite3.connect(backup) as dst: + db.backup(dst) + space = 'temporary-shell-' + secrets.token_hex(12) + expires = int(time.time()) + args.seconds + payload = dict(version=version, checksums=checksums, agent_key=key[1], device_key=device, + expires_at=expires, space=space, + database=base64.b64encode(zlib.compress(backup.read_bytes())).decode(), + object=base64.b64encode(program.read_bytes()).decode()) + run(agent + ['delegate', 'add', device, '--space', space, '--until', str(args.seconds) + 's']) + granted = True + template = (ROOT / 'tools/session-client.py').read_text() + out.write(template.replace('INVITATION = None', 'INVITATION = ' + repr(payload), 1)) + out.flush() + os.fsync(out.fileno()) + except BaseException: + output.unlink(missing_ok=True) + if granted: + run(agent + ['delegate', 'rm', device]) + raise + proxy = shlex.join(agent + ['socket', 'connect', 'key:' + device + ':temporary-shell']) + print('PRIVATE invitation:', output) + print('Send only to the intended user; it contains an expiring temporary device key.') + print('User runs: python3', shlex.quote(str(output))) + print('Expires at Unix second:', expires) + print('Agent connects after READY:') + print(shlex.join(['ssh', '-tt', '-o', 'ProxyCommand=' + proxy, 'temporary-shell-' + device])) + print('Revoke network access early:', shlex.join(agent + ['delegate', 'rm', device])) + print('The user stops the local node with Ctrl-C. Delete the invitation after use.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--synch', default='synch') + parser.add_argument('--data-dir', help='existing agent node data directory') + parser.add_argument('--seconds', type=int, default=600, help='lifetime from generation (30–3600 seconds)') + parser.add_argument('--output', required=True, help='new PRIVATE invitation .py file (0600)') + args = parser.parse_args() + if not 30 <= args.seconds <= 3600: + parser.error('--seconds must be between 30 and 3600') + generate(args) diff --git a/tools/session-client.py b/tools/session-client.py new file mode 100644 index 0000000..80ea01c --- /dev/null +++ b/tools/session-client.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Template embedded in a private, expiring invitation by make-session.py.""" +import base64 +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import signal +import subprocess +import tarfile +import tempfile +import time +import zlib + +# Replaced by the generator. The database contains a TEMPORARY device secret. +INVITATION = None + + +def remaining(invitation, deadline): + return max(0, min(invitation['expires_at'] - time.time(), deadline - time.monotonic())) + + +def stop_node(command, process, env): + if process is None or process.poll() is not None: + return + try: + subprocess.run(command + ['daemon', 'stop'], env=env, timeout=5, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + process.wait(timeout=5) + except (subprocess.TimeoutExpired, OSError): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def main(invitation): + left = invitation['expires_at'] - time.time() + if not 0 < left <= 3600: + raise SystemExit('Invitation expired or clock incorrect; ask the agent for a new one.') + deadline = time.monotonic() + left + print('TEMPORARY REMOTE SHELL: the named agent can run commands as your OS user.') + print('Agent key:', invitation['agent_key']) + print('Expires:', time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(invitation['expires_at']))) + print('Not a sandbox: commands may change files or start detached background processes.') + print('Keep this terminal open. Ctrl-C stops the connection. No permanent service is installed.') + if input('Allow this agent until the expiry above? Type yes: ').strip() != 'yes': + raise SystemExit('Cancelled.') + targets = {('Darwin', 'arm64'): 'aarch64-apple-darwin', + ('Darwin', 'x86_64'): 'x86_64-apple-darwin', + ('Linux', 'aarch64'): 'aarch64-unknown-linux-gnu', + ('Linux', 'x86_64'): 'x86_64-unknown-linux-gnu'} + target = targets.get((platform.system(), platform.machine())) + if not target: + raise SystemExit('Unsupported platform; requires macOS or glibc Linux, Python 3 and curl.') + name = 'synchronicity-' + invitation['version'] + '-' + target + env = {k: v for k, v in os.environ.items() if not k.startswith('SYNCH_')} + process = None + command = [] + def interrupted(_signum, _frame): + raise KeyboardInterrupt + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, interrupted) + with tempfile.TemporaryDirectory(prefix='sykit-session-') as directory: + root = Path(directory) + try: + archive = root / 'release.tar.gz' + left = remaining(invitation, deadline) + if not left: + raise RuntimeError('Invitation expired before download.') + subprocess.run(['curl', '-fL', '--connect-timeout', '15', '--max-time', str(min(120, int(left))), + 'https://github.com/AFK-surf/synchronicity/releases/download/' + + invitation['version'] + '/' + name + '.tar.gz', '-o', str(archive)], + check=True, timeout=left) + expected = invitation['checksums'][name + '.tar.gz'] + if hashlib.sha256(archive.read_bytes()).hexdigest() != expected: + raise RuntimeError('Release checksum mismatch.') + with tarfile.open(archive, 'r:gz') as tar: + member = tar.getmember(name + '/synch') + if not member.isfile(): + raise RuntimeError('Expected a regular binary in the archive.') + with tar.extractfile(member) as src, (root / 'synch').open('wb') as dst: + shutil.copyfileobj(src, dst) + binary = root / 'synch' + binary.chmod(0o700) + data = root / 'node' + data.mkdir(mode=0o700) + (data / 'synchronicity.db').write_bytes(zlib.decompress(base64.b64decode(invitation['database']))) + (data / 'synchronicity.db').chmod(0o600) + source = root / 'source' + source.mkdir(mode=0o700) + (source / 'ssh-session.o').write_bytes(base64.b64decode(invitation['object'])) + command = [str(binary), '--data-dir', str(data)] + def call(*args): + while True: + left = remaining(invitation, deadline) + if not left: + raise RuntimeError('Invitation expired.') + result = subprocess.run(command + list(args), env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, timeout=min(10, left)) + if result.returncode == 0: + return result.stdout + if 'daemon starting:' not in result.stderr or process.poll() is not None: + raise RuntimeError(' '.join(args) + ': ' + result.stderr.strip()) + time.sleep(min(0.1, left)) + if not remaining(invitation, deadline): + raise RuntimeError('Invitation expired during setup.') + with (root / 'daemon.log').open('w') as log: + process = subprocess.Popen(command + ['daemon', 'run'], env=env, stdout=log, stderr=log) + while not (data / 'control.sock').exists(): + if process.poll() is not None or not remaining(invitation, deadline): + raise RuntimeError('Daemon did not become ready: ' + (root / 'daemon.log').read_text()[-2000:]) + time.sleep(0.05) + call('source', 'add', invitation['space'], str(source)) + call('source', 'scan', invitation['space']) + call('socket', 'activate', 'temporary-shell', '--program', invitation['space'] + '/ssh-session.o', + '--config', 'allowed_peers=' + invitation['agent_key'], + '--config', 'expires_at=' + str(invitation['expires_at'])) + print('READY:', invitation['device_key'], flush=True) + while remaining(invitation, deadline) > 0 and process.poll() is None: + time.sleep(min(0.2, remaining(invitation, deadline))) + except KeyboardInterrupt: + print('\nStopping temporary access…') + finally: + stop_node(command, process, env) + print('Temporary node stopped; local session files removed. Delete this invitation script too.') + + +if __name__ == '__main__': + if INVITATION is None: + raise SystemExit('Use tools/make-session.py to generate a private invitation first.') + main(INVITATION) From cf0e9e9cd37419eae2be09cce635e6131839c389 Mon Sep 17 00:00:00 2001 From: 3720 Date: Thu, 10 Sep 2026 08:26:58 +0000 Subject: [PATCH 2/8] Use canonical objects and harden temporary-node teardown --- .gitignore | 1 + objects/SHA256SUMS | 1 + objects/ssh-session.o | Bin 53968 -> 30776 bytes tests/session-tools.py | 64 ++++++++++++++++++++++++++++++++++++++++ tools/session-client.py | 13 ++++++-- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 84c048a..4ea31d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /build/ +__pycache__/ diff --git a/objects/SHA256SUMS b/objects/SHA256SUMS index 2b526d5..c3bd9f6 100644 --- a/objects/SHA256SUMS +++ b/objects/SHA256SUMS @@ -1,4 +1,5 @@ 794391490778ce7c870a27d40c5e0d64b26b74b68a6d8c7c67612b0c431a891a ./echo.o +b6b926aa902199589eb568ee66310b086e7888296b44384bb17ee221607cc7fa ./ssh-session.o 201f113b11affda8b32f13d523efdb42dd885ae4f3549bb11582ee761044b91a ./ssh-shell.o a123028c6ad3a09cc6d23720e18f108d5d2bf236bb6d777bbf3a199863e50081 ./tcp-proxy.o 0a6b1f3e3bc2cdc082a7d8130002aa6ad77bb07febc3c3514c9af22530973363 ./whoami.o diff --git a/objects/ssh-session.o b/objects/ssh-session.o index bb076e0bba4390181434357bb40abea45ffb26bb..e426e0e719159b32e40fc6b476c2a069d3b18f8d 100644 GIT binary patch literal 30776 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

bjS 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 Date: Thu, 10 Sep 2026 08:52:40 +0000 Subject: [PATCH 3/8] Add expiring short-command invitation downloads --- README.md | 53 ++++++++++++ SECURITY.md | 9 ++ scripts/test.sh | 1 + tests/invitation-downloads.py | 158 ++++++++++++++++++++++++++++++++++ tools/invitation_store.py | 97 +++++++++++++++++++++ tools/make-session.py | 34 ++++++-- tools/serve-invitations.py | 99 +++++++++++++++++++++ 7 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 tests/invitation-downloads.py create mode 100644 tools/invitation_store.py create mode 100644 tools/serve-invitations.py diff --git a/README.md b/README.md index 029da94..e4be05f 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,59 @@ it. This is not just a white-list edit or a timer around the SSH client. The outer supervisor also bounds its setup and lifetime using wall and monotonic time. The variant declares a process capability only: no SFTP, tree writes or egress. +### Short commands (at most 100 characters) + +The optional download origin turns a generated invitation into one copyable +`curl -fsSL https://.../s/ | sh` command. The generator validates the +**entire command length**, including shell quoting, before creating a key or +delegation; a public base URL that cannot fit is rejected. Tickets have 192 bits +of randomness. They are short-lived bearer URLs, not permanent public scripts. + +A human operator first provisions HTTPS and runs this loopback-only origin on +the same machine/private storage as the issuing agent: + +```sh +python3 tools/serve-invitations.py --directory /private/sykit-invitations --port 8787 +``` + +An HTTPS reverse proxy routes `/s/` to `127.0.0.1:8787`. If the public URL has a +path prefix, strip that prefix before forwarding. Disable access logs and caches +for this route, and enforce connection/request-rate limits at the proxy. Do not +expose the Python origin directly to the Internet. It has a 32-handler cap and +five-second socket timeout, but is not a substitute for the public edge. This +repository supplies code, not a deployed domain, TLS certificate or service. + +Then the agent issues an invitation: + +```sh +python3 tools/make-session.py --seconds 600 \ + --publish-dir /private/sykit-invitations --public-base https://support.example +``` + +It prints the ready-to-copy command (under 100 characters for this example), +expiry and the agent's SSH command. `--output` can additionally save the original +private Python invitation; it is no longer required when publishing. The public +base is operator configuration, **not a caller-chosen redirect**. Only HTTPS +bases without credentials, query strings or fragments are accepted. + +The endpoint only downloads existing invitations: there is **no remote issuance +or management API**, and no HTTP request can create a shell grant. Store access +is local and private (directory 0700, records 0600); records are published +atomically without overwrites. Missing, malformed, symlinked and expired tickets +return 404. Responses are `no-store`/`no-referrer`; the origin never logs ticket +paths. Valid expired records are removed within the next 30-second sweep while +the origin is running. The embedded script still checks its own expiry, even if +someone saved a response or a misconfigured proxy cached it. + +The shell wrapper opens `/dev/tty` for the user's explicit `yes`: piped script +bytes cannot count as consent. It still requires Python 3 and curl. A headless +execution without a controlling terminal fails rather than granting access. +Download retries before expiry are allowed; this is not single-use retrieval. +Treat a command in shell history/chat like the private invitation it downloads. +To disable a download early, remove its private `.json` record. This does +not revoke a copy already downloaded or an active shell; use the existing +network-revocation/local-stop controls for those. + ### Security and cleanup boundaries The shell runs as the user's account, **not in a sandbox**. It can modify files, diff --git a/SECURITY.md b/SECURITY.md index 88f0fb5..03cd523 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -93,3 +93,12 @@ remote administration, not sandboxed or exactly-once execution. contains only a fresh temporary device identity and a short-lived space-scoped delegation is issued separately by the agent. The public-key allowlist pins the agent; no wildcard or arbitrary network member is admitted to the shell. + +The optional short-command origin is read-only and loopback-bound. Issuance +requires local access to the agent and its private store; downloads use +unpredictable, expiring bearer tickets. A production HTTPS proxy must disable +path logging/caching and apply rate/connection limits. The origin checks expiry +on each request and prunes expired records, while the script independently +checks expiry after retrieval. Short URLs do not make temporary device secrets +public, single-use, or safe to forward. No public service is deployed by these +tools. See README for deployment and revocation boundaries. diff --git a/scripts/test.sh b/scripts/test.sh index 07ca43d..b7516ca 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -9,4 +9,5 @@ echo 'Node authentication tests passed.' ${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/session-deadline.c -o "$tmp/session-deadline" "$tmp/session-deadline" python3 tests/session-tools.py +python3 tests/invitation-downloads.py python3 tests/build.py diff --git a/tests/invitation-downloads.py b/tests/invitation-downloads.py new file mode 100644 index 0000000..43c7218 --- /dev/null +++ b/tests/invitation-downloads.py @@ -0,0 +1,158 @@ +import contextlib +import http.client +import importlib.util +import io +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from invitation_store import private_store, publish, read_record, shell_script, short_command, prune +spec = importlib.util.spec_from_file_location('downloads', ROOT / 'tools/serve-invitations.py') +downloads = importlib.util.module_from_spec(spec) +spec.loader.exec_module(downloads) + + +class DownloadTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / 'invitations' + self.server = downloads.Downloads(('127.0.0.1', 0), self.root) + self.thread = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}) + self.thread.start() + self.token = secrets.token_urlsafe(24) + self.script = '#!/bin/sh\nprintf SHORT_COMMAND_OK\\n' + + def tearDown(self): + self.server.shutdown() + self.thread.join() + self.server.server_close() + self.temporary.cleanup() + + def request(self, path, method='GET'): + connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=3) + try: + connection.request(method, path) + reply = connection.getresponse() + return reply.status, dict(reply.getheaders()), reply.read() + finally: + connection.close() + + def test_download_short_command_headers_and_execution(self): + publish(self.root, self.token, self.script, int(time.time()) + 30) + _, command = short_command('https://support.example', self.token) + self.assertLessEqual(len(command), 100) + status, headers, body = self.request('/s/' + self.token) + self.assertEqual(status, 200) + self.assertEqual(body, self.script.encode()) + self.assertIn('no-store', headers['Cache-Control']) + self.assertEqual(headers['Referrer-Policy'], 'no-referrer') + self.assertEqual(self.request('/s/' + self.token, 'HEAD')[2], b'') + # Exercise the real curl | sh shape locally without a production server. + url = 'http://127.0.0.1:' + str(self.server.server_port) + '/s/' + self.token + result = subprocess.run(['sh', '-c', 'curl -fsSL ' + url + ' | sh'], capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0) + self.assertIn('SHORT_COMMAND_OK', result.stdout) + with self.assertRaises(FileExistsError): + publish(self.root, self.token, '#!/bin/sh\nchanged', int(time.time()) + 30) + self.assertEqual(read_record(self.root, self.token)['script'], self.script) + + def test_invalid_missing_expired_and_symlink_tickets_fail_closed(self): + for path in ('/', '/s/', '/s/../secret', '/s/%2e%2e/secret', '/s/' + self.token, + '/s/' + self.token + '?copy=1'): + self.assertEqual(self.request(path)[0], 404) + publish(self.root, self.token, self.script, int(time.time()) + 30) + path = self.root / (self.token + '.json') + record = json.loads(path.read_text()); record['expires_at'] = int(time.time()) - 1 + path.write_text(json.dumps(record)) + self.assertEqual(self.request('/s/' + self.token)[0], 404) + prune(self.root) + self.assertFalse(path.exists()) + outside = Path(self.temporary.name) / 'secret' + outside.write_text(json.dumps(dict(expires_at=int(time.time()) + 30, script=self.script))) + path.symlink_to(outside) + self.assertEqual(self.request('/s/' + self.token)[0], 404) + self.assertTrue(outside.exists()) + + def test_service_has_no_remote_issue_api_or_ticket_logs(self): + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + self.assertEqual(self.request('/s/' + self.token, 'POST')[0], 405) + self.assertEqual(self.request('/s/' + self.token)[0], 404) + self.assertNotIn(self.token, captured.getvalue()) + + +class IssuanceTests(unittest.TestCase): + def test_command_length_and_secure_base_validation(self): + token = secrets.token_urlsafe(24) + for base in ('http://example.com', 'https://u:p@example.com', 'https://example.com?a=1', + 'https://example.com#x', 'https://example.com/white space', 'https://' + 'a' * 100 + '.com'): + with self.assertRaises(ValueError): + short_command(base, token) + with self.assertRaises(ValueError): + short_command('https://example.com', '../secret') + + def test_permissions_and_lifetime_bounds(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / 'store' + private_store(root) + token = secrets.token_urlsafe(24) + for expiry in (True, int(time.time()) - 1, int(time.time()) + 3601): + with self.assertRaises(ValueError): + publish(root, token, '#!/bin/sh\nexit', expiry) + publish(root, token, '#!/bin/sh\nexit', int(time.time()) + 30) + self.assertEqual((root / (token + '.json')).stat().st_mode & 0o777, 0o600) + root.chmod(0o755) + with self.assertRaises(ValueError): + private_store(root) + + def test_shell_wrapper_reads_consent_from_tty_and_is_valid_shell(self): + source = "INVITATION = {}\ndef main(invitation):\n pass\nif __name__ == '__main__':\n main(INVITATION)\n" + wrapper = shell_script(source) + self.assertIn("open('/dev/tty')", wrapper) + subprocess.run(['sh', '-n'], input=wrapper, text=True, check=True) + # Run the generated wrapper on a real PTY, not a mocked stdin. + import pty + source = source.replace(' pass', " print('CONSENT=' + input('yes? '), flush=True)") + with tempfile.TemporaryDirectory() as directory: + script = Path(directory) / 'invitation.sh' + script.write_text(shell_script(source)) + pid, fd = pty.fork() + if pid == 0: + os.execl('/bin/sh', 'sh', str(script)) + os.write(fd, b'yes\n') + output = b'' + try: + import select + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]: + break + try: + chunk = os.read(fd, 4096) + except OSError: + break + if not chunk: + break + output += chunk + self.assertIn(b'CONSENT=yes', output) + finally: + os.close(fd) + try: + os.kill(pid, 15) + except ProcessLookupError: + pass + os.waitpid(pid, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/invitation_store.py b/tools/invitation_store.py new file mode 100644 index 0000000..1f25422 --- /dev/null +++ b/tools/invitation_store.py @@ -0,0 +1,97 @@ +"""Private local invitation storage; HTTP only reads short-lived bearer URLs.""" +import json +import os +from pathlib import Path +import re +import shlex +import stat +import tempfile +import time +from urllib.parse import urlsplit + +TOKEN = re.compile(r'[A-Za-z0-9_-]{32}') +MAX_RECORD = 256 * 1024 + + +def short_command(public_base, token): + parts = urlsplit(public_base) + _ = parts.port # reject malformed/out-of-range ports before issuing a grant + if (parts.scheme != 'https' or not parts.hostname or parts.username is not None or parts.password is not None + or parts.query or parts.fragment or not public_base.isascii() + or any(c.isspace() for c in public_base)): + raise ValueError('public-base must be an HTTPS URL without credentials, query or fragment') + if not TOKEN.fullmatch(token): + raise ValueError('invalid invitation token') + url = public_base.rstrip('/') + '/s/' + token + command = 'curl -fsSL ' + shlex.quote(url) + ' | sh' + if len(command) > 100: + raise ValueError('public-base is too long: command would exceed 100 characters') + return url, command + + +def private_store(directory): + path = Path(directory) + path.mkdir(mode=0o700, parents=True, exist_ok=True) + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077: + raise ValueError('invitation store must be a non-symlink directory with mode 0700') + return path + + +def shell_script(source): + # The downloaded stream is program text, NOT consent input. Open the TTY + # before entering main, so curl | sh cannot accidentally authorize access. + marker = " main(INVITATION)" + if source.count(marker) != 1: + raise ValueError('unexpected invitation template') + source = source.replace(marker, " import sys\n with open('/dev/tty') as terminal:\n sys.stdin = terminal\n main(INVITATION)") + return "#!/bin/sh\nexec python3 - <<'SYKIT_PYTHON'\n" + source + '\nSYKIT_PYTHON\n' + + +def publish(directory, token, script, expires_at): + root = private_store(directory) + if not TOKEN.fullmatch(token): + raise ValueError('invalid invitation token') + if type(expires_at) is not int or not time.time() < expires_at <= time.time() + 3600: + raise ValueError('invitation must expire within one hour') + body = json.dumps(dict(expires_at=expires_at, script=script)).encode() + if len(body) > MAX_RECORD: + raise ValueError('invitation record too large') + fd, temporary = tempfile.mkstemp(prefix='.pending-', dir=root) + try: + with os.fdopen(fd, 'wb') as out: + out.write(body) + out.flush() + os.fsync(out.fileno()) + # Atomic create, never overwrite another live ticket (even a symlink). + os.link(temporary, root / (token + '.json')) + finally: + Path(temporary).unlink(missing_ok=True) + + +def read_record(root, token): + if not TOKEN.fullmatch(token): + raise ValueError('invalid invitation token') + fd = os.open(Path(root) / (token + '.json'), os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as source: + info = os.fstat(source.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_RECORD: + raise ValueError('invalid invitation record') + record = json.loads(source.read(MAX_RECORD + 1)) + if (not isinstance(record, dict) or type(record.get('expires_at')) is not int + or not isinstance(record.get('script'), str) + or not record['script'].startswith('#!/bin/sh\n')): + raise ValueError('invalid invitation record') + return record + + +def prune(root): + for path in Path(root).glob('*.json'): + if not TOKEN.fullmatch(path.stem): + continue + try: + if read_record(root, path.stem)['expires_at'] <= time.time(): + path.unlink(missing_ok=True) + except (OSError, ValueError): + # Never serve malformed records. Do not erase unrelated operator files. + continue diff --git a/tools/make-session.py b/tools/make-session.py index bfdb9bf..e2b9d49 100644 --- a/tools/make-session.py +++ b/tools/make-session.py @@ -13,6 +13,7 @@ import tempfile import time import zlib +from invitation_store import private_store, publish, shell_script, short_command ROOT = Path(__file__).resolve().parents[1] KEY = r'[ybndrfg8ejkmcpqxot1uwisza345h769]{52}' @@ -23,6 +24,21 @@ def run(command, **kwargs): def generate(args): + if bool(args.publish_dir) != bool(args.public_base): + raise ValueError('--publish-dir and --public-base must be supplied together') + token = secrets.token_urlsafe(24) if args.publish_dir else None + command = None + if token: + _, command = short_command(args.public_base, token) # fail before issuing a grant + private_store(args.publish_dir) + if not args.output and not token: + raise ValueError('provide --output or --publish-dir with --public-base') + with tempfile.TemporaryDirectory(prefix='sykit-output-') as temporary: + output = Path(args.output) if args.output else Path(temporary) / 'invitation.py' + generate_to(args, output, token, command) + + +def generate_to(args, output, token, command): agent = [args.synch] if args.data_dir: agent += ['--data-dir', args.data_dir] @@ -47,7 +63,6 @@ def generate(args): checksums[name.lstrip('*')] = digest program = ROOT / 'objects/ssh-session.o' run(agent + ['socket', 'inspect', str(program)]) - output = Path(args.output) # Refuse overwrite before creating any identity or delegation. with output.open('x') as out: os.chmod(output, 0o600) @@ -88,15 +103,22 @@ def generate(args): out.write(template.replace('INVITATION = None', 'INVITATION = ' + repr(payload), 1)) out.flush() os.fsync(out.fileno()) + if token: + publish(args.publish_dir, token, shell_script(output.read_text()), expires) except BaseException: output.unlink(missing_ok=True) if granted: run(agent + ['delegate', 'rm', device]) raise proxy = shlex.join(agent + ['socket', 'connect', 'key:' + device + ':temporary-shell']) - print('PRIVATE invitation:', output) - print('Send only to the intended user; it contains an expiring temporary device key.') - print('User runs: python3', shlex.quote(str(output))) + if command: + print('PRIVATE command (' + str(len(command)) + ' characters):') + print(command) + print('The URL is an expiring bearer ticket; share only with the intended user.') + if args.output: + print('PRIVATE invitation:', output) + print('User runs: python3', shlex.quote(str(output))) + print('The invitation contains an expiring temporary device key.') print('Expires at Unix second:', expires) print('Agent connects after READY:') print(shlex.join(['ssh', '-tt', '-o', 'ProxyCommand=' + proxy, 'temporary-shell-' + device])) @@ -109,7 +131,9 @@ def generate(args): parser.add_argument('--synch', default='synch') parser.add_argument('--data-dir', help='existing agent node data directory') parser.add_argument('--seconds', type=int, default=600, help='lifetime from generation (30–3600 seconds)') - parser.add_argument('--output', required=True, help='new PRIVATE invitation .py file (0600)') + parser.add_argument('--output', help='optional new PRIVATE invitation .py file (0600)') + parser.add_argument('--publish-dir', help='private local store read by serve-invitations.py') + parser.add_argument('--public-base', help='HTTPS public URL prefix; complete command must fit in 100 characters') args = parser.parse_args() if not 30 <= args.seconds <= 3600: parser.error('--seconds must be between 30 and 3600') diff --git a/tools/serve-invitations.py b/tools/serve-invitations.py new file mode 100644 index 0000000..8079eaa --- /dev/null +++ b/tools/serve-invitations.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Loopback download origin. A human-operated HTTPS proxy exposes /s/.""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import re +import threading +import time +from invitation_store import private_store, prune, read_record + + +class Downloads(ThreadingHTTPServer): + daemon_threads = True + def __init__(self, address, root): + self.root = private_store(root) + self.slots = threading.BoundedSemaphore(32) + self.next_prune = 0 + super().__init__(address, Handler) + + def process_request(self, request, address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, address): + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + def service_actions(self): + if time.monotonic() >= self.next_prune: + prune(self.root) + self.next_prune = time.monotonic() + 30 + + +class Handler(BaseHTTPRequestHandler): + server_version = 'sykit' + sys_version = '' + def setup(self): + self.request.settimeout(5) + super().setup() + + def log_message(self, _format, *args): + pass # Paths contain bearer tickets: never log them. + + def reply(self, status, body, content_type='text/plain; charset=utf-8', head=False): + self.send_response(status) + self.send_header('Content-Type', content_type) + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-store, private') + self.send_header('Referrer-Policy', 'no-referrer') + self.send_header('X-Content-Type-Options', 'nosniff') + self.end_headers() + if not head: + self.wfile.write(body) + + def download(self, head=False): + match = re.fullmatch(r'/s/([A-Za-z0-9_-]{32})', self.path) + if not match: + self.reply(404, b'Not found\n', head=head) + return + try: + record = read_record(self.server.root, match[1]) + except (OSError, ValueError): + self.reply(404, b'Not found\n', head=head) + return + if record['expires_at'] <= time.time(): + self.reply(404, b'Not found\n', head=head) + return + self.reply(200, record['script'].encode(), 'text/x-shellscript; charset=utf-8', head=head) + + def do_GET(self): + self.download() + + def do_HEAD(self): + self.download(head=True) + + def do_POST(self): + self.reply(405, b'Read-only download service\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--directory', required=True, help='private 0700 store shared with make-session.py') + parser.add_argument('--port', type=int, default=8787) + args = parser.parse_args() + server = Downloads(('127.0.0.1', args.port), args.directory) + print('Loopback invitation origin on 127.0.0.1:' + str(server.server_port), flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() From 0a200f07313e324d8bf4932ac220183ce11a3d2d Mon Sep 17 00:00:00 2001 From: 3720 Date: Thu, 10 Sep 2026 08:54:59 +0000 Subject: [PATCH 4/8] Treat short command length as a usability target --- README.md | 11 ++++++----- tests/invitation-downloads.py | 4 +++- tools/invitation_store.py | 2 -- tools/make-session.py | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e4be05f..8eecf1b 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,14 @@ it. This is not just a white-list edit or a timer around the SSH client. The outer supervisor also bounds its setup and lifetime using wall and monotonic time. The variant declares a process capability only: no SFTP, tree writes or egress. -### Short commands (at most 100 characters) +### Short commands The optional download origin turns a generated invitation into one copyable -`curl -fsSL https://.../s/ | sh` command. The generator validates the -**entire command length**, including shell quoting, before creating a key or -delegation; a public base URL that cannot fit is rejected. Tickets have 192 bits -of randomness. They are short-lived bearer URLs, not permanent public scripts. +`curl -fsSL https://.../s/ | sh` command. The generator prints the +actual command length, including shell quoting. Around 100 characters is a +usability target, **not a limit**: a longer public URL still generates normally. +Use a short HTTPS domain/path where practical; do not reduce ticket entropy to +shorten the command. Tickets have 192 bits of randomness. They are short-lived bearer URLs, not permanent public scripts. A human operator first provisions HTTPS and runs this loopback-only origin on the same machine/private storage as the issuing agent: diff --git a/tests/invitation-downloads.py b/tests/invitation-downloads.py index 43c7218..40e54e6 100644 --- a/tests/invitation-downloads.py +++ b/tests/invitation-downloads.py @@ -95,9 +95,11 @@ class IssuanceTests(unittest.TestCase): def test_command_length_and_secure_base_validation(self): token = secrets.token_urlsafe(24) for base in ('http://example.com', 'https://u:p@example.com', 'https://example.com?a=1', - 'https://example.com#x', 'https://example.com/white space', 'https://' + 'a' * 100 + '.com'): + 'https://example.com#x', 'https://example.com/white space'): with self.assertRaises(ValueError): short_command(base, token) + _, command = short_command('https://' + 'a' * 50 + '.example/long-prefix', token) + self.assertGreater(len(command), 100) # length is a usability target, never an issuance gate with self.assertRaises(ValueError): short_command('https://example.com', '../secret') diff --git a/tools/invitation_store.py b/tools/invitation_store.py index 1f25422..b1b5df8 100644 --- a/tools/invitation_store.py +++ b/tools/invitation_store.py @@ -24,8 +24,6 @@ def short_command(public_base, token): raise ValueError('invalid invitation token') url = public_base.rstrip('/') + '/s/' + token command = 'curl -fsSL ' + shlex.quote(url) + ' | sh' - if len(command) > 100: - raise ValueError('public-base is too long: command would exceed 100 characters') return url, command diff --git a/tools/make-session.py b/tools/make-session.py index e2b9d49..ea10da7 100644 --- a/tools/make-session.py +++ b/tools/make-session.py @@ -29,7 +29,7 @@ def generate(args): token = secrets.token_urlsafe(24) if args.publish_dir else None command = None if token: - _, command = short_command(args.public_base, token) # fail before issuing a grant + _, command = short_command(args.public_base, token) # validate the URL before issuing a grant private_store(args.publish_dir) if not args.output and not token: raise ValueError('provide --output or --publish-dir with --public-base') @@ -133,7 +133,7 @@ def generate_to(args, output, token, command): parser.add_argument('--seconds', type=int, default=600, help='lifetime from generation (30–3600 seconds)') parser.add_argument('--output', help='optional new PRIVATE invitation .py file (0600)') parser.add_argument('--publish-dir', help='private local store read by serve-invitations.py') - parser.add_argument('--public-base', help='HTTPS public URL prefix; complete command must fit in 100 characters') + parser.add_argument('--public-base', help='HTTPS public URL prefix; a short domain makes the command easier to copy') args = parser.parse_args() if not 30 <= args.seconds <= 3600: parser.error('--seconds must be between 30 and 3600') From 648c05942f8a678be8d6a3ae602fd645cff9f08d Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Fri, 11 Sep 2026 02:27:24 +0000 Subject: [PATCH 5/8] Keep ssh-session capabilities identical to ssh-shell Co-authored-by: 3720 --- README.md | 6 ++++-- scripts/test.sh | 1 + src/ssh-session.c | 2 +- src/ssh-shell.c | 5 ----- tests/session-parity.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 tests/session-parity.py diff --git a/README.md b/README.md index 8eecf1b..24ff82c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ them. These run inside Synchronicity's socket runtime, not the Linux kernel. | `echo` | Echoes binary streams with backpressure; 30-second idle timeout | Any caller able to connect; 16 concurrent streams | | `whoami` | Prints authenticated origin, device key and peer kind, then closes | Any caller able to connect; 8 concurrent streams | | `tcp-proxy` | Bidirectional TCP forwarding to `127.0.0.1` on a configured port | Same peer allowlist as SSH; 32 concurrent connections | -| `ssh-session` | Expiring interactive shell; no SFTP; existing connections close at the deadline | Pinned agent key; generated private invitation, default 10 minutes | +| `ssh-session` | Same shell and SFTP as ssh-shell; existing connections close at the deadline | Pinned agent key; generated private invitation, default 10 minutes | | `ssh-shell` | Interactive `/bin/bash` with PTY; read/write SFTP under `files` | Configured origins or node public keys; 4 concurrent connections, one session per connection | Built against the SDK revision in [UPSTREAM.md](UPSTREAM.md). Review the @@ -183,7 +183,9 @@ setting, rejects expired/malformed/missing or >1-hour deadlines, and closes session timer is monotonic, so moving the wall clock backwards does not extend it. This is not just a white-list edit or a timer around the SSH client. The outer supervisor also bounds its setup and lifetime using wall and monotonic time. -The variant declares a process capability only: no SFTP, tree writes or egress. +Apart from its deadline checks and program name, the variant has the same +capabilities and request handling as `ssh-shell`, including read/write SFTP +under `files` and the corresponding tree-write grants. ### Short commands diff --git a/scripts/test.sh b/scripts/test.sh index b7516ca..752efc4 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,6 +8,7 @@ ${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/node-auth.c -o "$tm echo 'Node authentication tests passed.' ${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/session-deadline.c -o "$tmp/session-deadline" "$tmp/session-deadline" +python3 tests/session-parity.py python3 tests/session-tools.py python3 tests/invitation-downloads.py python3 tests/build.py diff --git a/src/ssh-session.c b/src/ssh-session.c index 092b1d5..f4f9eae 100644 --- a/src/ssh-session.c +++ b/src/ssh-session.c @@ -1,3 +1,3 @@ -/* Time-bounded shell-only variant; shared SSH lifecycle, no SFTP grants. */ +/* ssh-shell with deadline admission and active-session expiry checks. */ #define SHELL_SESSION 1 #include "ssh-shell.c" diff --git a/src/ssh-shell.c b/src/ssh-shell.c index 67dbaea..d031c05 100644 --- a/src/ssh-shell.c +++ b/src/ssh-shell.c @@ -35,14 +35,12 @@ SY_MANIFEST("{\"manifest\":1,\"name\":\"" PROGRAM_NAME "\",\"max_streams\":4," "\"executable\":\"" SHELL_EXECUTABLE "\"," "\"argv\":[\"" SHELL_ARGV0 "\"]," "\"allowed_signals\":[\"HUP\",\"INT\",\"TERM\"]}]" -#ifndef SHELL_SESSION "," "\"file_transfers\":[{\"id\":1,\"protocol\":\"sftp\"," "\"access\":[\"read\",\"write\",\"recursive\"]," "\"scope\":\"" SFTP_SCOPE "\"}]," "\"tree_writes\":[{\"id\":1,\"prefix\":\"" SFTP_SCOPE "\"," "\"allow\":[\"create\",\"replace\",\"delete\"]}]" -#endif "}"); /* JSON strings are length-delimited; strlen would accept "sftp\0suffix". @@ -140,7 +138,6 @@ static sy_s64 handle_request(sy_s64 event, sy_u64 id, struct session *s) { return finish(event, id, sent < 0 ? 0 : 1); } -#ifndef SHELL_SESSION if (event_string_is(event, "request_type", "subsystem")) { if (s->pty >= 0 || s->sftp >= 0 || s->process >= 0 || !event_string_is(event, "subsystem", "sftp")) @@ -151,8 +148,6 @@ static sy_s64 handle_request(sy_s64 event, sy_u64 id, struct session *s) { return finish(event, id, 1); } -#endif - /* exec, other subsystems, env, forwarding: not this socket's policy. The refusal costs nothing — none of those names could have started anything. */ return finish(event, id, 0); diff --git a/tests/session-parity.py b/tests/session-parity.py new file mode 100644 index 0000000..400638e --- /dev/null +++ b/tests/session-parity.py @@ -0,0 +1,31 @@ +"""Both variants must retain identical capabilities and SSH request handling.""" +import os +from pathlib import Path +import re +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def preprocess(name): + return subprocess.check_output( + [os.environ.get("CC", "cc"), "-E", "-P", "-I", str(ROOT / "include"), + str(ROOT / "src" / (name + ".c"))], text=True) + + +class SessionParity(unittest.TestCase): + def test_manifest_and_handlers_match(self): + shell, session = (preprocess(n) for n in ("ssh-shell", "ssh-session")) + # Adjacent C strings may remain separate; normalize the program name only. + manifest = lambda s: re.search(r'static const char[^;]*sy_manifest[^;]*;', s).group() + self.assertEqual(manifest(shell), manifest(session).replace('ssh-session', 'ssh-shell')) + # This entire shared region includes channel admission, shell and SFTP requests. + def handlers(source): + return source[source.index("static sy_s64 handle_open"): + source.index("static void move_terminal")] + self.assertEqual(handlers(shell), handlers(session)) + + +if __name__ == '__main__': + unittest.main() From 531c9c068f887a6d07a717f66da7d84a6160e550 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Fri, 11 Sep 2026 02:28:42 +0000 Subject: [PATCH 6/8] Refresh canonical session artifact and security documentation Co-authored-by: 3720 --- SECURITY.md | 4 ++-- objects/SHA256SUMS | 2 +- objects/ssh-session.o | Bin 30776 -> 32440 bytes 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 03cd523..fec3a29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -82,8 +82,8 @@ forwarding node's identity and receive the selected service's protocol access. ## Expiring support invitations -`ssh-session` shares the SSH state machine with `ssh-shell`, but has no SFTP or -tree-write capability and requires a bounded absolute expiry. The user-side +`ssh-session` shares the SSH state machine and capabilities with `ssh-shell`, +including scoped SFTP and tree writes, and adds a bounded absolute expiry. The user-side supervisor independently enforces expiry and stops the isolated daemon. See README's temporary-session section for the explicit consent, temporary secret, clock, replay, detached-child and crash-cleanup boundaries. This is authorized diff --git a/objects/SHA256SUMS b/objects/SHA256SUMS index c3bd9f6..16fc46a 100644 --- a/objects/SHA256SUMS +++ b/objects/SHA256SUMS @@ -1,5 +1,5 @@ 794391490778ce7c870a27d40c5e0d64b26b74b68a6d8c7c67612b0c431a891a ./echo.o -b6b926aa902199589eb568ee66310b086e7888296b44384bb17ee221607cc7fa ./ssh-session.o +6f41f4cd89f640a1a36f5625a6c85306ca3778850ec425a8575450fec8a50f72 ./ssh-session.o 201f113b11affda8b32f13d523efdb42dd885ae4f3549bb11582ee761044b91a ./ssh-shell.o a123028c6ad3a09cc6d23720e18f108d5d2bf236bb6d777bbf3a199863e50081 ./tcp-proxy.o 0a6b1f3e3bc2cdc082a7d8130002aa6ad77bb07febc3c3514c9af22530973363 ./whoami.o diff --git a/objects/ssh-session.o b/objects/ssh-session.o index e426e0e719159b32e40fc6b476c2a069d3b18f8d..e8757c4f3612005492afed1feedb5b1d8ec5dc52 100644 GIT binary patch literal 32440 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 Date: Fri, 11 Sep 2026 02:56:32 +0000 Subject: [PATCH 7/8] Replace private invitations with hosted delegate registration Co-authored-by: 3720 --- README.md | 170 ++++++++++++---------------------- SECURITY.md | 39 ++++---- scripts/test.sh | 1 - tests/invitation-downloads.py | 160 -------------------------------- tests/session-tools.py | 44 +++++---- tools/invitation_store.py | 95 ------------------- tools/make-session.py | 140 ---------------------------- tools/serve-invitations.py | 99 -------------------- tools/session-client.py | 120 +++++++++++++++--------- 9 files changed, 179 insertions(+), 689 deletions(-) delete mode 100644 tests/invitation-downloads.py delete mode 100644 tools/invitation_store.py delete mode 100644 tools/make-session.py delete mode 100644 tools/serve-invitations.py diff --git a/README.md b/README.md index 24ff82c..fd653cf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ them. These run inside Synchronicity's socket runtime, not the Linux kernel. | `echo` | Echoes binary streams with backpressure; 30-second idle timeout | Any caller able to connect; 16 concurrent streams | | `whoami` | Prints authenticated origin, device key and peer kind, then closes | Any caller able to connect; 8 concurrent streams | | `tcp-proxy` | Bidirectional TCP forwarding to `127.0.0.1` on a configured port | Same peer allowlist as SSH; 32 concurrent connections | -| `ssh-session` | Same shell and SFTP as ssh-shell; existing connections close at the deadline | Pinned agent key; generated private invitation, default 10 minutes | +| `ssh-session` | Same shell and SFTP as ssh-shell; existing connections close at the deadline | Pinned agent key; hosted delegate registration, default 10 minutes | | `ssh-shell` | Interactive `/bin/bash` with PTY; read/write SFTP under `files` | Configured origins or node public keys; 4 concurrent connections, one session per connection | Built against the SDK revision in [UPSTREAM.md](UPSTREAM.md). Review the @@ -130,133 +130,83 @@ published object. ## Temporary agent support sessions -An agent can generate a script for a consenting user, then open an interactive -shell on that user's machine until a fixed expiry. This is a **temporary session**, -not an exactly-once command executor. SSH `exec` is still rejected; use the PTY -shell to run several commands. Existing `ssh-shell` activations are unchanged. +`ssh-session` is `ssh-shell` with deadline checks: the same shell, PTY and scoped +SFTP/tree-write capabilities. It requires an absolute `expires_at` Unix second, +rejects missing/malformed/expired or >1-hour deadlines, and closes existing +sessions at the deadline. Its admitted timer is monotonic. SSH `exec` remains +rejected, just as in `ssh-shell`; use the interactive PTY shell. -On an enrolled agent node with Synchronicity running and Python 3 available: +### User: start a temporary device + +The only helper is a standalone bootstrap, not a private invitation generator: ```sh -python3 tools/make-session.py --seconds 600 --output /private/path/support.py +python3 tools/session-client.py --agent-key --seconds 600 ``` -The generator checks the agent's identity, creates a new isolated device key, -trusts only the issuing agent, and gives that key a delegation for a **new empty -space** until the invitation expiry. It does not grant access to the agent's -existing file spaces or add a permanent network member. No control-plane API key, -join key or agent private key is included. `--data-dir` selects an existing agent -node; `--synch` selects its binary. The output is created exclusively, mode 0600. - -**Send the generated script privately to the intended user. It embeds a temporary -device secret.** Anyone holding a copy can impersonate that temporary node until -the delegation expires. It is an expiring invitation, not a replay-proof ticket: -do not reuse it or post it to a public thread. Generate a fresh one for each user. -The lifetime is 30–3600 seconds **from generation**, including download/setup time; -the default is 600. Delayed delivery does not extend the grant. - -The user runs: +After explicit `yes` consent, it downloads pinned Synchronicity v0.1.10 and +checks the release SHA-256, creates a device key **locally**, trusts only the +controller, and activates `temporary-shell` with the controller's public-key +allowlist and deadline. The program comes from this checkout or a pinned, +checksum-verified artifact when the script is used standalone. The script prints +the temporary public key and the exact registration JSON. Send these public +values to the operator; the private key never leaves the device. + +Python 3, curl, `/bin/bash`, and macOS or glibc Linux are required. v0.1.10 Linux +needs glibc 2.39. The lifetime includes consent/download/setup time, defaults to +600 seconds and accepts 30–3600. No permanent service, installation or profile +change is made. Keep the terminal open; Ctrl-C stops the node. There is no +short-link server, bearer ticket store or embedded database to distribute. +Command length is not restricted; applications may provide their own public +bootstrap distribution or UI. + +### Operator: register through the managed control plane + +**Requires [synchronicity PR #148](https://github.com/AFK-surf/synchronicity/pull/148) +on the control plane and managed data plane (write-tunnel v2). This PR does not +deploy that API.** The controller must be a named member of that hosted network. +Use an org member/admin API key only on the operator's side, never in the user's +script. Set `CP`, `ORG`, `NET`, `DEVICE_KEY`, and `REGISTRATION_JSON` from the +chosen network and the bootstrap's output: ```sh -python3 support.py +curl -fsS -X PUT "$CP/api/orgs/$ORG/networks/$NET/delegations/$DEVICE_KEY" \ + -H "Authorization: Bearer $CP_API_KEY" -H 'content-type: application/json' \ + --data "$REGISTRATION_JSON" ``` -After explicit `yes` consent, it downloads the official runtime matching the -agent's version, verifies an embedded SHA-256 checksum, restores only the temporary -identity into a private temporary directory, and starts a foreground-owned daemon. -It publishes/activates `ssh-session.o` as `temporary-shell`, allowing only the -agent's public key, and prints `READY`. No sudo, permanent binary installation, -shell-profile change, SSH configuration change or startup service is needed. -Requires Python 3, curl, `/bin/bash`, macOS or glibc Linux; v0.1.10 Linux archives -require glibc 2.39. The generator uses release checksums directly, not the -rate-limited unauthenticated `releases/latest` API. - -The generator prints the exact agent-side SSH command and an early network -revocation command. Keep SSH host-key verification enabled. The user can press -Ctrl-C or close the terminal to stop access. The supervisor stops the temporary -daemon and removes its data/binary on normal expiry, SIGINT, SIGHUP and SIGTERM. -Delete the invitation file after use; the script does not delete itself. - -`ssh-session` independently requires an `expires_at` Unix-second activation -setting, rejects expired/malformed/missing or >1-hour deadlines, and closes -**existing** sessions at the deadline as well as refusing new ones. Its admitted -session timer is monotonic, so moving the wall clock backwards does not extend -it. This is not just a white-list edit or a timer around the SSH client. The -outer supervisor also bounds its setup and lifetime using wall and monotonic time. -Apart from its deadline checks and program name, the variant has the same -capabilities and request handling as `ssh-shell`, including read/write SFTP -under `files` and the corresponding tree-write grants. - -### Short commands - -The optional download origin turns a generated invitation into one copyable -`curl -fsSL https://.../s/ | sh` command. The generator prints the -actual command length, including shell quoting. Around 100 characters is a -usability target, **not a limit**: a longer public URL still generates normally. -Use a short HTTPS domain/path where practical; do not reduce ticket entropy to -shorten the command. Tickets have 192 bits of randomness. They are short-lived bearer URLs, not permanent public scripts. - -A human operator first provisions HTTPS and runs this loopback-only origin on -the same machine/private storage as the issuing agent: +The data plane signs an ordinary delegation for the fresh temporary space. The +agent no longer creates device keys, copies SQLite databases or signs grants +from its local daemon. Wait for ordinary grant replication before connecting: ```sh -python3 tools/serve-invitations.py --directory /private/sykit-invitations --port 8787 +ssh -tt -o "ProxyCommand=synch socket connect key:$DEVICE_KEY:temporary-shell" temporary-shell ``` -An HTTPS reverse proxy routes `/s/` to `127.0.0.1:8787`. If the public URL has a -path prefix, strip that prefix before forwarding. Disable access logs and caches -for this route, and enforce connection/request-rate limits at the proxy. Do not -expose the Python origin directly to the Internet. It has a 32-handler cap and -five-second socket timeout, but is not a substitute for the public edge. This -repository supplies code, not a deployed domain, TLS certificate or service. +Keep host-key verification enabled. `LOCAL READY` means the local socket is +active, not that API registration or replication has completed. SFTP retains +`ssh-shell`'s `files` scope; this bootstrap does not grant access to any existing +network file spaces or automatically populate that scope. -Then the agent issues an invitation: +Early network revocation uses the same issuer, not local `synch delegate rm`: ```sh -python3 tools/make-session.py --seconds 600 \ - --publish-dir /private/sykit-invitations --public-base https://support.example +curl -fsS -X DELETE "$CP/api/orgs/$ORG/networks/$NET/delegations/$DEVICE_KEY" \ + -H "Authorization: Bearer $CP_API_KEY" ``` -It prints the ready-to-copy command (under 100 characters for this example), -expiry and the agent's SSH command. `--output` can additionally save the original -private Python invitation; it is no longer required when publishing. The public -base is operator configuration, **not a caller-chosen redirect**. Only HTTPS -bases without credentials, query strings or fragments are accepted. - -The endpoint only downloads existing invitations: there is **no remote issuance -or management API**, and no HTTP request can create a shell grant. Store access -is local and private (directory 0700, records 0600); records are published -atomically without overwrites. Missing, malformed, symlinked and expired tickets -return 404. Responses are `no-store`/`no-referrer`; the origin never logs ticket -paths. Valid expired records are removed within the next 30-second sweep while -the origin is running. The embedded script still checks its own expiry, even if -someone saved a response or a misconfigured proxy cached it. - -The shell wrapper opens `/dev/tty` for the user's explicit `yes`: piped script -bytes cannot count as consent. It still requires Python 3 and curl. A headless -execution without a controlling terminal fails rather than granting access. -Download retries before expiry are allowed; this is not single-use retrieval. -Treat a command in shell history/chat like the private invitation it downloads. -To disable a download early, remove its private `.json` record. This does -not revoke a copy already downloaded or an active shell; use the existing -network-revocation/local-stop controls for those. - -### Security and cleanup boundaries - -The shell runs as the user's account, **not in a sandbox**. It can modify files, -read that account's secrets and start detached processes. Expiry closes the -connection and the runtime terminates/reaps its direct shell with best-effort -process-group cleanup; it cannot undo changes or guarantee cleanup of detached -descendants. Do not use it to execute untrusted agent code. - -The grant expires in signed delegation state without a cleanup service on the -agent. `synch delegate rm ` revokes early network access; user-side -Ctrl-C is the explicit local shutdown. Removing/expiring delegation does not -serve as the session timer: the program and supervisor enforce that separately. -SIGKILL, machine crashes and power loss can prevent supervisor cleanup. The -program's own deadline still prevents a surviving daemon from providing a new -unexpired shell after the invitation deadline; leftover local temporary files -may require manual removal. Local clock integrity is part of admission trust. +A timeout has an unknown outcome; retry the same absolute-expiry registration, +not a new relative TTL. Do not retry registration after deciding to revoke. + +### Security and cleanup + +The shell runs as the user's account, **not in a sandbox**: it may read secrets, +change files and launch detached processes. Expiry cannot undo those effects or +guarantee removal of detached descendants. Delegation expiry/revocation governs +network admission; the socket deadline and supervisor separately stop active +access. Ctrl-C, SIGHUP, SIGTERM and normal expiry stop the owned daemon and remove +temporary data/binary files. SIGKILL, crashes or power loss may leave local files +requiring manual cleanup. Local clock integrity is part of admission trust. ## TCP reverse proxy diff --git a/SECURITY.md b/SECURITY.md index fec3a29..7a90eda 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,25 +80,20 @@ invalid configuration, host egress enforcement, backpressure, both half-close orders and asynchronous connection failure. Local listener clients inherit the forwarding node's identity and receive the selected service's protocol access. -## Expiring support invitations - -`ssh-session` shares the SSH state machine and capabilities with `ssh-shell`, -including scoped SFTP and tree writes, and adds a bounded absolute expiry. The user-side -supervisor independently enforces expiry and stops the isolated daemon. See -README's temporary-session section for the explicit consent, temporary secret, -clock, replay, detached-child and crash-cleanup boundaries. This is authorized -remote administration, not sandboxed or exactly-once execution. - -`make-session.py` never embeds the issuing agent's private key. The private output -contains only a fresh temporary device identity and a short-lived space-scoped -delegation is issued separately by the agent. The public-key allowlist pins the -agent; no wildcard or arbitrary network member is admitted to the shell. - -The optional short-command origin is read-only and loopback-bound. Issuance -requires local access to the agent and its private store; downloads use -unpredictable, expiring bearer tickets. A production HTTPS proxy must disable -path logging/caching and apply rate/connection limits. The origin checks expiry -on each request and prunes expired records, while the script independently -checks expiry after retrieval. Short URLs do not make temporary device secrets -public, single-use, or safe to forward. No public service is deployed by these -tools. See README for deployment and revocation boundaries. +## Temporary support devices + +`ssh-session` shares the capabilities and request handling of `ssh-shell`, with +additional absolute-expiry admission and active-session deadline checks. The +bootstrap creates a private device identity locally and prints only its public +key and registration scope/deadline. It neither accepts nor embeds control-plane +credentials and never distributes a device database or private invitation. + +An authorized operator registers/revokes the key through the hosted delegation +API; the data plane signs the existing delegation record. The socket separately +pins the controller's public key. Network admission is not application access, +and delegation revocation is not the timer for an existing connection. + +The bootstrap checks runtime/program downloads, asks explicit consent before +starting, and owns the temporary daemon's normal shutdown and cleanup. See +README for clock, direct-process, detached-child and crash-cleanup boundaries. +This is authorized remote administration, not sandboxed or exactly-once execution. diff --git a/scripts/test.sh b/scripts/test.sh index 752efc4..af3070d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -10,5 +10,4 @@ ${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/session-deadline.c "$tmp/session-deadline" python3 tests/session-parity.py python3 tests/session-tools.py -python3 tests/invitation-downloads.py python3 tests/build.py diff --git a/tests/invitation-downloads.py b/tests/invitation-downloads.py deleted file mode 100644 index 40e54e6..0000000 --- a/tests/invitation-downloads.py +++ /dev/null @@ -1,160 +0,0 @@ -import contextlib -import http.client -import importlib.util -import io -import json -import os -from pathlib import Path -import secrets -import subprocess -import sys -import tempfile -import threading -import time -import unittest -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / 'tools')) -from invitation_store import private_store, publish, read_record, shell_script, short_command, prune -spec = importlib.util.spec_from_file_location('downloads', ROOT / 'tools/serve-invitations.py') -downloads = importlib.util.module_from_spec(spec) -spec.loader.exec_module(downloads) - - -class DownloadTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) / 'invitations' - self.server = downloads.Downloads(('127.0.0.1', 0), self.root) - self.thread = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}) - self.thread.start() - self.token = secrets.token_urlsafe(24) - self.script = '#!/bin/sh\nprintf SHORT_COMMAND_OK\\n' - - def tearDown(self): - self.server.shutdown() - self.thread.join() - self.server.server_close() - self.temporary.cleanup() - - def request(self, path, method='GET'): - connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=3) - try: - connection.request(method, path) - reply = connection.getresponse() - return reply.status, dict(reply.getheaders()), reply.read() - finally: - connection.close() - - def test_download_short_command_headers_and_execution(self): - publish(self.root, self.token, self.script, int(time.time()) + 30) - _, command = short_command('https://support.example', self.token) - self.assertLessEqual(len(command), 100) - status, headers, body = self.request('/s/' + self.token) - self.assertEqual(status, 200) - self.assertEqual(body, self.script.encode()) - self.assertIn('no-store', headers['Cache-Control']) - self.assertEqual(headers['Referrer-Policy'], 'no-referrer') - self.assertEqual(self.request('/s/' + self.token, 'HEAD')[2], b'') - # Exercise the real curl | sh shape locally without a production server. - url = 'http://127.0.0.1:' + str(self.server.server_port) + '/s/' + self.token - result = subprocess.run(['sh', '-c', 'curl -fsSL ' + url + ' | sh'], capture_output=True, text=True, timeout=5) - self.assertEqual(result.returncode, 0) - self.assertIn('SHORT_COMMAND_OK', result.stdout) - with self.assertRaises(FileExistsError): - publish(self.root, self.token, '#!/bin/sh\nchanged', int(time.time()) + 30) - self.assertEqual(read_record(self.root, self.token)['script'], self.script) - - def test_invalid_missing_expired_and_symlink_tickets_fail_closed(self): - for path in ('/', '/s/', '/s/../secret', '/s/%2e%2e/secret', '/s/' + self.token, - '/s/' + self.token + '?copy=1'): - self.assertEqual(self.request(path)[0], 404) - publish(self.root, self.token, self.script, int(time.time()) + 30) - path = self.root / (self.token + '.json') - record = json.loads(path.read_text()); record['expires_at'] = int(time.time()) - 1 - path.write_text(json.dumps(record)) - self.assertEqual(self.request('/s/' + self.token)[0], 404) - prune(self.root) - self.assertFalse(path.exists()) - outside = Path(self.temporary.name) / 'secret' - outside.write_text(json.dumps(dict(expires_at=int(time.time()) + 30, script=self.script))) - path.symlink_to(outside) - self.assertEqual(self.request('/s/' + self.token)[0], 404) - self.assertTrue(outside.exists()) - - def test_service_has_no_remote_issue_api_or_ticket_logs(self): - captured = io.StringIO() - with contextlib.redirect_stderr(captured): - self.assertEqual(self.request('/s/' + self.token, 'POST')[0], 405) - self.assertEqual(self.request('/s/' + self.token)[0], 404) - self.assertNotIn(self.token, captured.getvalue()) - - -class IssuanceTests(unittest.TestCase): - def test_command_length_and_secure_base_validation(self): - token = secrets.token_urlsafe(24) - for base in ('http://example.com', 'https://u:p@example.com', 'https://example.com?a=1', - 'https://example.com#x', 'https://example.com/white space'): - with self.assertRaises(ValueError): - short_command(base, token) - _, command = short_command('https://' + 'a' * 50 + '.example/long-prefix', token) - self.assertGreater(len(command), 100) # length is a usability target, never an issuance gate - with self.assertRaises(ValueError): - short_command('https://example.com', '../secret') - - def test_permissions_and_lifetime_bounds(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / 'store' - private_store(root) - token = secrets.token_urlsafe(24) - for expiry in (True, int(time.time()) - 1, int(time.time()) + 3601): - with self.assertRaises(ValueError): - publish(root, token, '#!/bin/sh\nexit', expiry) - publish(root, token, '#!/bin/sh\nexit', int(time.time()) + 30) - self.assertEqual((root / (token + '.json')).stat().st_mode & 0o777, 0o600) - root.chmod(0o755) - with self.assertRaises(ValueError): - private_store(root) - - def test_shell_wrapper_reads_consent_from_tty_and_is_valid_shell(self): - source = "INVITATION = {}\ndef main(invitation):\n pass\nif __name__ == '__main__':\n main(INVITATION)\n" - wrapper = shell_script(source) - self.assertIn("open('/dev/tty')", wrapper) - subprocess.run(['sh', '-n'], input=wrapper, text=True, check=True) - # Run the generated wrapper on a real PTY, not a mocked stdin. - import pty - source = source.replace(' pass', " print('CONSENT=' + input('yes? '), flush=True)") - with tempfile.TemporaryDirectory() as directory: - script = Path(directory) / 'invitation.sh' - script.write_text(shell_script(source)) - pid, fd = pty.fork() - if pid == 0: - os.execl('/bin/sh', 'sh', str(script)) - os.write(fd, b'yes\n') - output = b'' - try: - import select - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - if not select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]: - break - try: - chunk = os.read(fd, 4096) - except OSError: - break - if not chunk: - break - output += chunk - self.assertIn(b'CONSENT=yes', output) - finally: - os.close(fd) - try: - os.kill(pid, 15) - except ProcessLookupError: - pass - os.waitpid(pid, 0) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/session-tools.py b/tests/session-tools.py index 4ad0046..183ae48 100644 --- a/tests/session-tools.py +++ b/tests/session-tools.py @@ -12,26 +12,31 @@ class SessionTests(unittest.TestCase): - def test_expired_invitation_never_downloads_or_starts(self): + def test_standalone_program_pin_matches_committed_object(self): + import hashlib + self.assertEqual(hashlib.sha256((ROOT / 'objects/ssh-session.o').read_bytes()).hexdigest(), + client.PROGRAM_SHA256) + + def test_expired_session_never_downloads_or_starts(self): with patch.object(client.time, 'time', return_value=100), patch.object(client.subprocess, 'run') as run: with self.assertRaises(SystemExit): - client.main({'expires_at': 100}) + client.main({'expires_at': 100, 'agent_key': 'y' * 52}) run.assert_not_called() def test_refusing_consent_does_not_touch_machine(self): - invitation = dict(expires_at=150, agent_key='agent') + session = dict(expires_at=150, agent_key='y' * 52) with patch.object(client.time, 'time', return_value=100), patch('builtins.input', return_value='no'), \ patch.object(client.subprocess, 'run') as run, patch('sys.stdout', new=io.StringIO()): with self.assertRaises(SystemExit): - client.main(invitation) + client.main(session) run.assert_not_called() def test_remaining_is_bounded_by_wall_and_monotonic_time(self): - invitation = {'expires_at': 200} + session = {'expires_at': 200} with patch.object(client.time, 'time', return_value=50), patch.object(client.time, 'monotonic', return_value=150): - self.assertEqual(client.remaining(invitation, 170), 20) + self.assertEqual(client.remaining(session, 170), 20) with patch.object(client.time, 'time', return_value=201), patch.object(client.time, 'monotonic', return_value=150): - self.assertEqual(client.remaining(invitation, 170), 0) + self.assertEqual(client.remaining(session, 170), 0) def test_stop_waits_for_real_foreground_process(self): import subprocess @@ -46,32 +51,35 @@ def test_stop_waits_for_real_foreground_process(self): process.wait() def test_bootstrap_retries_recovery_and_cleans_up_on_expiry(self): - import base64 import hashlib import os import subprocess import sys import tarfile import time - import zlib # A real foreground test process and control CLI, but no network or shell # access. The first control command deliberately reports runtime recovery. fake = "#!" + sys.executable + "\n" + """ import os, pathlib, signal, sys, time -root = pathlib.Path(sys.argv[2]); args = sys.argv[3:] -if args == ['daemon', 'run']: +root = pathlib.Path(sys.argv[2]); args = [a for a in sys.argv[3:] if a != '--offline'] +if args == ['init']: + print('device key: ' + 'b' * 52) +elif args[:2] == ['trust', 'add']: + assert args[2] == 'y' * 52 +elif args == ['daemon', 'run']: (root / 'pid').write_text(str(os.getpid())) (root / 'control.sock').touch() time.sleep(60) elif args == ['daemon', 'stop']: os.kill(int((root / 'pid').read_text()), signal.SIGTERM) + (root / 'control.sock').unlink(missing_ok=True) elif args[:2] == ['source', 'add']: if not (root / 'retry').exists(): (root / 'retry').touch() print('synch: daemon starting: recovering own head', file=sys.stderr) sys.exit(1) elif args[:2] == ['socket', 'activate']: - assert 'allowed_peers=agent' in args + assert 'allowed_peers=' + 'y' * 52 in args assert any(arg.startswith('expires_at=') for arg in args) else: assert args[:2] == ['source', 'scan'] @@ -84,11 +92,7 @@ def test_bootstrap_retries_recovery_and_cleans_up_on_expiry(self): entry.size = len(content) tar.addfile(entry, io.BytesIO(content)) raw = archive.getvalue() - invitation = dict(version='v0.1.10', expires_at=time.time() + 3, - agent_key='agent', device_key='temporary', space='temporary', - database=base64.b64encode(zlib.compress(b'fake-db')).decode(), - object=base64.b64encode(b'fake-object').decode(), - checksums={name + '.tar.gz': hashlib.sha256(raw).hexdigest()}) + session = dict(expires_at=time.time() + 3, agent_key='y' * 52) real_run = subprocess.run directories = [] def run(args, **kwargs): @@ -100,11 +104,13 @@ def run(args, **kwargs): return real_run(args, **kwargs) output = io.StringIO() with patch.object(client.subprocess, 'run', side_effect=run), \ + patch.object(client.subprocess, 'check_output', return_value=hashlib.sha256(raw).hexdigest() + ' ' + name + '.tar.gz'), \ patch.object(client.platform, 'system', return_value='Darwin'), \ patch.object(client.platform, 'machine', return_value='arm64'), \ patch('builtins.input', return_value='yes'), patch('sys.stdout', new=output): - client.main(invitation) - self.assertIn('READY: temporary', output.getvalue()) + client.main(session) + self.assertIn('LOCAL READY (operator must register delegate): ' + 'b' * 52, output.getvalue()) + self.assertIn('Registration JSON:', output.getvalue()) self.assertIn('Temporary node stopped', output.getvalue()) self.assertEqual(len(directories), 1) self.assertFalse(directories[0].exists()) diff --git a/tools/invitation_store.py b/tools/invitation_store.py deleted file mode 100644 index b1b5df8..0000000 --- a/tools/invitation_store.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Private local invitation storage; HTTP only reads short-lived bearer URLs.""" -import json -import os -from pathlib import Path -import re -import shlex -import stat -import tempfile -import time -from urllib.parse import urlsplit - -TOKEN = re.compile(r'[A-Za-z0-9_-]{32}') -MAX_RECORD = 256 * 1024 - - -def short_command(public_base, token): - parts = urlsplit(public_base) - _ = parts.port # reject malformed/out-of-range ports before issuing a grant - if (parts.scheme != 'https' or not parts.hostname or parts.username is not None or parts.password is not None - or parts.query or parts.fragment or not public_base.isascii() - or any(c.isspace() for c in public_base)): - raise ValueError('public-base must be an HTTPS URL without credentials, query or fragment') - if not TOKEN.fullmatch(token): - raise ValueError('invalid invitation token') - url = public_base.rstrip('/') + '/s/' + token - command = 'curl -fsSL ' + shlex.quote(url) + ' | sh' - return url, command - - -def private_store(directory): - path = Path(directory) - path.mkdir(mode=0o700, parents=True, exist_ok=True) - info = path.lstat() - if not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077: - raise ValueError('invitation store must be a non-symlink directory with mode 0700') - return path - - -def shell_script(source): - # The downloaded stream is program text, NOT consent input. Open the TTY - # before entering main, so curl | sh cannot accidentally authorize access. - marker = " main(INVITATION)" - if source.count(marker) != 1: - raise ValueError('unexpected invitation template') - source = source.replace(marker, " import sys\n with open('/dev/tty') as terminal:\n sys.stdin = terminal\n main(INVITATION)") - return "#!/bin/sh\nexec python3 - <<'SYKIT_PYTHON'\n" + source + '\nSYKIT_PYTHON\n' - - -def publish(directory, token, script, expires_at): - root = private_store(directory) - if not TOKEN.fullmatch(token): - raise ValueError('invalid invitation token') - if type(expires_at) is not int or not time.time() < expires_at <= time.time() + 3600: - raise ValueError('invitation must expire within one hour') - body = json.dumps(dict(expires_at=expires_at, script=script)).encode() - if len(body) > MAX_RECORD: - raise ValueError('invitation record too large') - fd, temporary = tempfile.mkstemp(prefix='.pending-', dir=root) - try: - with os.fdopen(fd, 'wb') as out: - out.write(body) - out.flush() - os.fsync(out.fileno()) - # Atomic create, never overwrite another live ticket (even a symlink). - os.link(temporary, root / (token + '.json')) - finally: - Path(temporary).unlink(missing_ok=True) - - -def read_record(root, token): - if not TOKEN.fullmatch(token): - raise ValueError('invalid invitation token') - fd = os.open(Path(root) / (token + '.json'), os.O_RDONLY | os.O_NOFOLLOW) - with os.fdopen(fd, 'rb') as source: - info = os.fstat(source.fileno()) - if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_RECORD: - raise ValueError('invalid invitation record') - record = json.loads(source.read(MAX_RECORD + 1)) - if (not isinstance(record, dict) or type(record.get('expires_at')) is not int - or not isinstance(record.get('script'), str) - or not record['script'].startswith('#!/bin/sh\n')): - raise ValueError('invalid invitation record') - return record - - -def prune(root): - for path in Path(root).glob('*.json'): - if not TOKEN.fullmatch(path.stem): - continue - try: - if read_record(root, path.stem)['expires_at'] <= time.time(): - path.unlink(missing_ok=True) - except (OSError, ValueError): - # Never serve malformed records. Do not erase unrelated operator files. - continue diff --git a/tools/make-session.py b/tools/make-session.py deleted file mode 100644 index ea10da7..0000000 --- a/tools/make-session.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -"""Create a private, short-lived remote-shell invitation from a member node.""" -import argparse -import base64 -import json -import os -from pathlib import Path -import re -import secrets -import shlex -import sqlite3 -import subprocess -import tempfile -import time -import zlib -from invitation_store import private_store, publish, shell_script, short_command - -ROOT = Path(__file__).resolve().parents[1] -KEY = r'[ybndrfg8ejkmcpqxot1uwisza345h769]{52}' - - -def run(command, **kwargs): - return subprocess.check_output(command, text=True, timeout=30, **kwargs) - - -def generate(args): - if bool(args.publish_dir) != bool(args.public_base): - raise ValueError('--publish-dir and --public-base must be supplied together') - token = secrets.token_urlsafe(24) if args.publish_dir else None - command = None - if token: - _, command = short_command(args.public_base, token) # validate the URL before issuing a grant - private_store(args.publish_dir) - if not args.output and not token: - raise ValueError('provide --output or --publish-dir with --public-base') - with tempfile.TemporaryDirectory(prefix='sykit-output-') as temporary: - output = Path(args.output) if args.output else Path(temporary) / 'invitation.py' - generate_to(args, output, token, command) - - -def generate_to(args, output, token, command): - agent = [args.synch] - if args.data_dir: - agent += ['--data-dir', args.data_dir] - identity = run(agent + ['id']) - # A delegation issuer must be a named network member, not a delegate. - if not re.search(r'^origin: [^\n]+@[^\n]+$', identity, re.M): - raise RuntimeError('Run from an enrolled, named agent node.') - key = re.search(r'^ (' + KEY + r') \(active\)$', identity, re.M) - if not key: - raise RuntimeError('Could not read agent public key.') - version = 'v' + run(agent + ['--version']).strip().split()[-1] - if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version): - raise RuntimeError('Use an official release binary.') - # No unauthenticated releases/latest API: match the agent runtime version. - sums = run(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', - 'https://github.com/AFK-surf/synchronicity/releases/download/' + version + '/SHA256SUMS']) - checksums = {} - for line in sums.splitlines(): - digest, name = line.split() - if not re.fullmatch('[0-9a-f]{64}', digest): - raise RuntimeError('Malformed release checksum.') - checksums[name.lstrip('*')] = digest - program = ROOT / 'objects/ssh-session.o' - run(agent + ['socket', 'inspect', str(program)]) - # Refuse overwrite before creating any identity or delegation. - with output.open('x') as out: - os.chmod(output, 0o600) - granted = False - device = None - try: - env = {k: v for k, v in os.environ.items() if not k.startswith('SYNCH_')} - with tempfile.TemporaryDirectory(prefix='sykit-invitation-') as directory: - data = Path(directory) / 'node' - temp = [args.synch, '--data-dir', str(data)] - initialized = run(temp + ['init'], env=env) - match = re.search(r'^device key: (' + KEY + r')$', initialized, re.M) - if not match: - raise RuntimeError('Could not read temporary public key.') - device = match[1] - started = False - try: - run(temp + ['--offline', 'daemon', 'start'], env=env) - started = True - # Trust only the issuing agent; no permanent fleet membership. - run(temp + ['trust', 'add', key[1]], env=env) - finally: - if started: - run(temp + ['daemon', 'stop'], env=env) - # SQLite backup includes all WAL content without copying control tokens. - backup = Path(directory) / 'invitation.db' - with sqlite3.connect(data / 'synchronicity.db') as db, sqlite3.connect(backup) as dst: - db.backup(dst) - space = 'temporary-shell-' + secrets.token_hex(12) - expires = int(time.time()) + args.seconds - payload = dict(version=version, checksums=checksums, agent_key=key[1], device_key=device, - expires_at=expires, space=space, - database=base64.b64encode(zlib.compress(backup.read_bytes())).decode(), - object=base64.b64encode(program.read_bytes()).decode()) - run(agent + ['delegate', 'add', device, '--space', space, '--until', str(args.seconds) + 's']) - granted = True - template = (ROOT / 'tools/session-client.py').read_text() - out.write(template.replace('INVITATION = None', 'INVITATION = ' + repr(payload), 1)) - out.flush() - os.fsync(out.fileno()) - if token: - publish(args.publish_dir, token, shell_script(output.read_text()), expires) - except BaseException: - output.unlink(missing_ok=True) - if granted: - run(agent + ['delegate', 'rm', device]) - raise - proxy = shlex.join(agent + ['socket', 'connect', 'key:' + device + ':temporary-shell']) - if command: - print('PRIVATE command (' + str(len(command)) + ' characters):') - print(command) - print('The URL is an expiring bearer ticket; share only with the intended user.') - if args.output: - print('PRIVATE invitation:', output) - print('User runs: python3', shlex.quote(str(output))) - print('The invitation contains an expiring temporary device key.') - print('Expires at Unix second:', expires) - print('Agent connects after READY:') - print(shlex.join(['ssh', '-tt', '-o', 'ProxyCommand=' + proxy, 'temporary-shell-' + device])) - print('Revoke network access early:', shlex.join(agent + ['delegate', 'rm', device])) - print('The user stops the local node with Ctrl-C. Delete the invitation after use.') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--synch', default='synch') - parser.add_argument('--data-dir', help='existing agent node data directory') - parser.add_argument('--seconds', type=int, default=600, help='lifetime from generation (30–3600 seconds)') - parser.add_argument('--output', help='optional new PRIVATE invitation .py file (0600)') - parser.add_argument('--publish-dir', help='private local store read by serve-invitations.py') - parser.add_argument('--public-base', help='HTTPS public URL prefix; a short domain makes the command easier to copy') - args = parser.parse_args() - if not 30 <= args.seconds <= 3600: - parser.error('--seconds must be between 30 and 3600') - generate(args) diff --git a/tools/serve-invitations.py b/tools/serve-invitations.py deleted file mode 100644 index 8079eaa..0000000 --- a/tools/serve-invitations.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""Loopback download origin. A human-operated HTTPS proxy exposes /s/.""" -import argparse -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import re -import threading -import time -from invitation_store import private_store, prune, read_record - - -class Downloads(ThreadingHTTPServer): - daemon_threads = True - def __init__(self, address, root): - self.root = private_store(root) - self.slots = threading.BoundedSemaphore(32) - self.next_prune = 0 - super().__init__(address, Handler) - - def process_request(self, request, address): - if not self.slots.acquire(blocking=False): - self.shutdown_request(request) - return - try: - super().process_request(request, address) - except BaseException: - self.slots.release() - raise - - def process_request_thread(self, request, address): - try: - super().process_request_thread(request, address) - finally: - self.slots.release() - - def service_actions(self): - if time.monotonic() >= self.next_prune: - prune(self.root) - self.next_prune = time.monotonic() + 30 - - -class Handler(BaseHTTPRequestHandler): - server_version = 'sykit' - sys_version = '' - def setup(self): - self.request.settimeout(5) - super().setup() - - def log_message(self, _format, *args): - pass # Paths contain bearer tickets: never log them. - - def reply(self, status, body, content_type='text/plain; charset=utf-8', head=False): - self.send_response(status) - self.send_header('Content-Type', content_type) - self.send_header('Content-Length', str(len(body))) - self.send_header('Cache-Control', 'no-store, private') - self.send_header('Referrer-Policy', 'no-referrer') - self.send_header('X-Content-Type-Options', 'nosniff') - self.end_headers() - if not head: - self.wfile.write(body) - - def download(self, head=False): - match = re.fullmatch(r'/s/([A-Za-z0-9_-]{32})', self.path) - if not match: - self.reply(404, b'Not found\n', head=head) - return - try: - record = read_record(self.server.root, match[1]) - except (OSError, ValueError): - self.reply(404, b'Not found\n', head=head) - return - if record['expires_at'] <= time.time(): - self.reply(404, b'Not found\n', head=head) - return - self.reply(200, record['script'].encode(), 'text/x-shellscript; charset=utf-8', head=head) - - def do_GET(self): - self.download() - - def do_HEAD(self): - self.download(head=True) - - def do_POST(self): - self.reply(405, b'Read-only download service\n') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--directory', required=True, help='private 0700 store shared with make-session.py') - parser.add_argument('--port', type=int, default=8787) - args = parser.parse_args() - server = Downloads(('127.0.0.1', args.port), args.directory) - print('Loopback invitation origin on 127.0.0.1:' + str(server.server_port), flush=True) - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() diff --git a/tools/session-client.py b/tools/session-client.py index 5bf7d34..44825e3 100644 --- a/tools/session-client.py +++ b/tools/session-client.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -"""Template embedded in a private, expiring invitation by make-session.py.""" -import base64 +"""Start an isolated temporary node; an operator registers its public key via CP.""" +import argparse +import re import hashlib import json import os @@ -12,18 +13,20 @@ import tarfile import tempfile import time -import zlib -# Replaced by the generator. The database contains a TEMPORARY device secret. -INVITATION = None +VERSION = 'v0.1.10' +PROGRAM_REV = '531c9c068f887a6d07a717f66da7d84a6160e550' +PROGRAM_SHA256 = '6f41f4cd89f640a1a36f5625a6c85306ca3778850ec425a8575450fec8a50f72' +KEY = r'[ybndrfg8ejkmcpqxot1uwisza345h769]{52}' -def remaining(invitation, deadline): - return max(0, min(invitation['expires_at'] - time.time(), deadline - time.monotonic())) + +def remaining(session, deadline): + return max(0, min(session['expires_at'] - time.time(), deadline - time.monotonic())) def stop_node(command, process, env): - if process is None or process.poll() is not None: + if process is None or process is None or process.poll() is not None: return try: subprocess.run(command + ['daemon', 'stop'], env=env, timeout=5, @@ -38,14 +41,16 @@ def stop_node(command, process, env): process.wait() -def main(invitation): - left = invitation['expires_at'] - time.time() +def main(session): + if not re.fullmatch(KEY, session['agent_key']): + raise SystemExit('Agent key must be a device public key.') + left = session['expires_at'] - time.time() if not 0 < left <= 3600: - raise SystemExit('Invitation expired or clock incorrect; ask the agent for a new one.') + raise SystemExit('Session expired or clock incorrect; restart with a new deadline.') deadline = time.monotonic() + left print('TEMPORARY REMOTE SHELL: the named agent can run commands as your OS user.') - print('Agent key:', invitation['agent_key']) - print('Expires:', time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(invitation['expires_at']))) + print('Agent key:', session['agent_key']) + print('Expires:', time.strftime('%Y-%m-%d %H:%M:%S UTC+8', time.gmtime(session['expires_at'] + 8 * 3600))) print('Not a sandbox: commands may change files or start detached background processes.') print('Keep this terminal open. Ctrl-C stops the connection. No permanent service is installed.') if input('Allow this agent until the expiry above? Type yes: ').strip() != 'yes': @@ -57,7 +62,7 @@ def main(invitation): target = targets.get((platform.system(), platform.machine())) if not target: raise SystemExit('Unsupported platform; requires macOS or glibc Linux, Python 3 and curl.') - name = 'synchronicity-' + invitation['version'] + '-' + target + name = 'synchronicity-' + VERSION + '-' + target env = {k: v for k, v in os.environ.items() if not k.startswith('SYNCH_')} process = None command = [] @@ -69,14 +74,19 @@ def interrupted(_signum, _frame): root = Path(directory) try: archive = root / 'release.tar.gz' - left = remaining(invitation, deadline) + left = remaining(session, deadline) if not left: - raise RuntimeError('Invitation expired before download.') + raise RuntimeError('Session expired before download.') subprocess.run(['curl', '-fL', '--connect-timeout', '15', '--max-time', str(min(120, int(left))), 'https://github.com/AFK-surf/synchronicity/releases/download/' + - invitation['version'] + '/' + name + '.tar.gz', '-o', str(archive)], + VERSION + '/' + name + '.tar.gz', '-o', str(archive)], check=True, timeout=left) - expected = invitation['checksums'][name + '.tar.gz'] + sums = subprocess.check_output(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', + 'https://github.com/AFK-surf/synchronicity/releases/download/' + VERSION + '/SHA256SUMS'], + text=True, timeout=remaining(session, deadline)) + checksums = {line.split()[1].lstrip('*'): line.split()[0] + for line in sums.splitlines() if len(line.split()) == 2} + expected = checksums[name + '.tar.gz'] if hashlib.sha256(archive.read_bytes()).hexdigest() != expected: raise RuntimeError('Release checksum mismatch.') with tarfile.open(archive, 'r:gz') as tar: @@ -89,41 +99,61 @@ def interrupted(_signum, _frame): binary.chmod(0o700) data = root / 'node' data.mkdir(mode=0o700) - (data / 'synchronicity.db').write_bytes(zlib.decompress(base64.b64decode(invitation['database']))) - (data / 'synchronicity.db').chmod(0o600) source = root / 'source' source.mkdir(mode=0o700) - (source / 'ssh-session.o').write_bytes(base64.b64decode(invitation['object'])) + program = source / 'ssh-session.o' + local_program = Path(__file__).resolve().parents[1] / 'objects/ssh-session.o' + if local_program.is_file(): + shutil.copyfile(local_program, program) + else: + subprocess.run(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', + 'https://raw.githubusercontent.com/AFK-surf/sykit/' + PROGRAM_REV + '/objects/ssh-session.o', + '-o', str(program)], check=True, timeout=remaining(session, deadline)) + if hashlib.sha256(program.read_bytes()).hexdigest() != PROGRAM_SHA256: + raise RuntimeError('Session program checksum mismatch.') command = [str(binary), '--data-dir', str(data)] def call(*args): while True: - left = remaining(invitation, deadline) + left = remaining(session, deadline) if not left: - raise RuntimeError('Invitation expired.') + raise RuntimeError('Session expired.') result = subprocess.run(command + list(args), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=min(10, left)) if result.returncode == 0: return result.stdout - if 'daemon starting:' not in result.stderr or process.poll() is not None: + if 'daemon starting:' not in result.stderr or process is None or process.poll() is not None: raise RuntimeError(' '.join(args) + ': ' + result.stderr.strip()) time.sleep(min(0.1, left)) - if not remaining(invitation, deadline): - raise RuntimeError('Invitation expired during setup.') + initialized = call('init') + device_key = re.search(r'^device key: (' + KEY + r')$', initialized, re.M) + if not device_key: + raise RuntimeError('Could not read generated device public key.') + device_key = device_key[1] + space = 'temporary-shell-' + device_key[:16] + print('Register this public key through the control plane:', device_key, flush=True) + print('Registration JSON:', json.dumps(dict(spaces=[space], expires_at=session['expires_at'])), flush=True) with (root / 'daemon.log').open('w') as log: - process = subprocess.Popen(command + ['daemon', 'run'], env=env, stdout=log, stderr=log) - while not (data / 'control.sock').exists(): - if process.poll() is not None or not remaining(invitation, deadline): - raise RuntimeError('Daemon did not become ready: ' + (root / 'daemon.log').read_text()[-2000:]) - time.sleep(0.05) - call('source', 'add', invitation['space'], str(source)) - call('source', 'scan', invitation['space']) - call('socket', 'activate', 'temporary-shell', '--program', invitation['space'] + '/ssh-session.o', - '--config', 'allowed_peers=' + invitation['agent_key'], - '--config', 'expires_at=' + str(invitation['expires_at'])) - print('READY:', invitation['device_key'], flush=True) - while remaining(invitation, deadline) > 0 and process.poll() is None: - time.sleep(min(0.2, remaining(invitation, deadline))) + def start(offline=False): + nonlocal process + process = subprocess.Popen(command + (['--offline'] if offline else []) + ['daemon', 'run'], + env=env, stdout=log, stderr=log) + while not (data / 'control.sock').exists(): + if process.poll() is not None or not remaining(session, deadline): + raise RuntimeError('Daemon did not become ready: ' + (root / 'daemon.log').read_text()[-2000:]) + time.sleep(0.05) + start(offline=True) + call('trust', 'add', session['agent_key']) + stop_node(command, process, env) + start() + call('source', 'add', space, str(source)) + call('source', 'scan', space) + call('socket', 'activate', 'temporary-shell', '--program', space + '/ssh-session.o', + '--config', 'allowed_peers=' + session['agent_key'], + '--config', 'expires_at=' + str(session['expires_at'])) + print('LOCAL READY (operator must register delegate):', device_key, flush=True) + while remaining(session, deadline) > 0 and process.poll() is None: + time.sleep(min(0.2, remaining(session, deadline))) except KeyboardInterrupt: print('\nStopping temporary access…') finally: @@ -135,10 +165,14 @@ def call(*args): finally: for sig, previous in handlers.items(): signal.signal(sig, previous) - print('Temporary node stopped; local session files removed. Delete this invitation script too.') + print('Temporary node stopped; local session files removed.') if __name__ == '__main__': - if INVITATION is None: - raise SystemExit('Use tools/make-session.py to generate a private invitation first.') - main(INVITATION) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--agent-key', required=True, help='only controller admitted by the socket') + parser.add_argument('--seconds', type=int, default=600, help='total lifetime, including setup (30–3600)') + args = parser.parse_args() + if not 30 <= args.seconds <= 3600: + parser.error('--seconds must be between 30 and 3600') + main(dict(agent_key=args.agent_key, expires_at=int(time.time()) + args.seconds)) From 03b43944b256f67a4654172f88baacf9f0425d68 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Fri, 11 Sep 2026 02:57:41 +0000 Subject: [PATCH 8/8] Keep SSH host identity distinct for each temporary device Co-authored-by: 3720 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fd653cf..58e6925 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ agent no longer creates device keys, copies SQLite databases or signs grants from its local daemon. Wait for ordinary grant replication before connecting: ```sh -ssh -tt -o "ProxyCommand=synch socket connect key:$DEVICE_KEY:temporary-shell" temporary-shell +ssh -tt -o "ProxyCommand=synch socket connect key:$DEVICE_KEY:temporary-shell" "temporary-shell-$DEVICE_KEY" ``` Keep host-key verification enabled. `LOCAL READY` means the local socket is