chore: prepare the repository for public release

Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
This commit is contained in:
jwinterm
2026-07-27 06:40:38 -04:00
parent 1cbbae73b2
commit ed24d43b56
207 changed files with 5023 additions and 8332 deletions
+15 -3
View File
@@ -6,15 +6,27 @@ Run with Blender, not plain Python:
blender --background --python scripts/build_kenney_3d_characters.py
The source FBX files remain in the local Kenney bundle. Outputs are written under
html/assets/generated/ (gitignored) for CDN/local delivery.
The source FBX files remain in your local Kenney bundle. Outputs are written under
html/assets/generated/ (gitignored) for local delivery.
Point KENNEY_ASSETS_DIR at the "Animated Characters 1" directory of your own copy of the
Kenney Game Assets bundle, for example:
KENNEY_ASSETS_DIR="$HOME/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1" \
blender --background --python scripts/build_kenney_3d_characters.py
"""
import os
from pathlib import Path
import bpy
ROOT = Path(__file__).resolve().parents[1]
KENNEY = Path("/home/jw/Drawings/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1")
_kenney_dir = os.environ.get("KENNEY_ASSETS_DIR")
if not _kenney_dir:
raise SystemExit(
"Set KENNEY_ASSETS_DIR to the 'Animated Characters 1' directory of your Kenney bundle."
)
KENNEY = Path(_kenney_dir).expanduser()
OUT = ROOT / "html/assets/generated/3d/kenney-animated-characters"
MODEL = KENNEY / "Model/characterMedium.fbx"
@@ -1,241 +0,0 @@
# Parallel Monero stagenet wallet candidate
> **STATUS: HISTORICAL NON-MUTATING DESIGN RECORD.** It is not an installation procedure. Such
> Software production wallet preparation/promotion is owned by the separately reviewed,
> default-closed roles in `~/src/such-fleet`; this document and its sample unit authorize nothing.
>
> The read-only preflight and disposable fixture test in this change are executable. The candidate
> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy,
> credential-bundle, installer, activation, or cutover program yet. Do not improvise those steps
> from shell commands. No in-place destructive migration is shipped with this design; deleting an
> abandoned draft does not make the parallel candidate production-deployable.
This is the smaller alternative to replacing the current wallet in place. It is fixed to the
Monero **stagenet** wallet used by `monerogue.app`. It has no mainnet or Wownero mode.
The safety property is simple: the current wallet, unit, and application stay exactly as they are.
A stopped, consistent copy is opened under a new dedicated identity on a different loopback port.
Nothing can use that copy until one complete directory containing the wallet, protected
credentials, configuration, and readiness marker has been validated and atomically published.
## What is safe to run now
The local fixture uses only synthetic bytes below a temporary directory:
```bash
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
```
The fixed-path preflight is read-only. It deliberately does not read unit text, process argv,
wallet bytes, keys, passwords, or mnemonics. After independent review, it can check the current
legacy RPC:
```bash
sudo python3 scripts/deploy/monero-stagenet-parallel-preflight.py legacy
```
Its `candidate-prereqs` scope additionally requires the new system identity, free candidate ports,
an unused candidate path, the root-owned candidate binary, and enough local space. Provisioning
that identity is a future approved change; the preflight does not create it.
Neither command proves that secret capture, bundle creation, activation, backup recovery, or an
application cutover is safe. The service template must not be installed yet.
## Fixed boundary
| Purpose | Fixed value |
| --- | --- |
| Legacy unit | `monero-wallet-rpc.service` |
| Legacy wallet | `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test` + `.keys` |
| Legacy RPC | `127.0.0.1:38083` |
| Network/daemon | Monero stagenet / `127.0.0.1:38081` |
| Candidate identity | `monerogue-wallet-candidate:monerogue-wallet-candidate` |
| Staging generation | `/var/lib/.monerogue-stagenet-wallet-candidate.staging` |
| Published generation | `/var/lib/monerogue-stagenet-wallet-candidate` |
| Candidate RPC | `127.0.0.1:38084`, Digest authentication required |
| Offline verification RPC | `127.0.0.1:39084`, temporary only |
| Candidate unit | `monerogue-stagenet-wallet-candidate.service`, manual only |
The final bundle must be one directory on the same filesystem as its staging generation:
```text
/var/lib/monerogue-stagenet-wallet-candidate/ root:root 0711
├── READY root:root 0600, written last
├── STATE.json root:root 0600, non-secret phase/address metadata
├── expected-address root:root 0600, public address only
├── wallet/ candidate:candidate 0700
│ ├── stagenet candidate:candidate 0600
│ └── stagenet.keys candidate:candidate 0600
├── config/ root:candidate 0710
│ └── wallet-rpc.conf root:candidate 0640
├── secrets/ root:root 0711
│ ├── wallet-password root:candidate 0640
│ └── rpc-auth.netrc root:root 0600
└── integration/ root:root 0711
└── candidate-app.env root:monerogue 0640
```
Execute-only directory traversal permits each identity to open its one reviewed fixed path without
listing the directory. No file may be a symlink or have a link count other than one. The `READY`
marker contains only version, stagenet, `127.0.0.1:38084`, the public address, and a
`manual-only` flag—never a password, hash of a seed, private key, or wallet bytes.
## Required staged procedure
These are acceptance requirements for small future helpers, not commands to run today.
### 1. Read-only inventory
The preflight must pass immediately before a maintenance window. Separately record the public
wallet address without recording credentials. Confirm the current application and wallet remain
healthy and that `38084` and `39084` have no listener.
The preflight intentionally does not inspect `/proc/<pid>/cmdline`, because the current process
contains a password. That work belongs only in the bounded capture helper described next.
### 2. Capture the already-exposed wallet password
A future fixed-path helper may read the password only from the validated live process. It must:
- run as root with `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0`, `umask 077`, and no tracing/logging;
- obtain only `MainPID` from systemd, then validate PID start time, `/proc/<pid>/exe`, UID, cgroup,
and the sole PID-owned `127.0.0.1:38083` listener before and after capture;
- read NUL-delimited `/proc/<pid>/cmdline` directly, never systemd unit text or `ExecStart` output;
- accept exactly one bounded `--password VALUE` or `--password=VALUE`, reject duplicates,
`--password-file`, NUL/newline data, or a process change;
- write only a new `root:root 0600` file below the unpublished staging generation using
`openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, `fsync`, and retained directory descriptors;
- print only success/refusal, never arguments, password length, content, hash, or exception data;
- zero mutable buffers before exit; and
- leave an incomplete stage inert and refuse automatic overwrite or cleanup after any failure.
The helper needs executable fixture coverage for both argv forms, duplicates, process replacement,
short writes, fsync failures, interruption, and restart with every possible partial file/state. A
static source test is not sufficient.
### 3. One bounded stopped-wallet copy window
Only a narrow wrapper may stop the legacy unit. It must acquire a fixed root-only lock and install
an `EXIT`, signal, and error trap that always attempts to restart the exact legacy unit. It must not
read or alter the unit.
After stop, require `MainPID=0`, no listener on `38083`, and no other process descriptor open on
either source inode. Open every source/path component with retained directory descriptors and
`O_NOFOLLOW`; require `jw:jw 0600`, a single link, and stable inode/size/timestamps. Copy wallet and
keys with `O_EXCL` temporary files, `fsync`, byte-compare through retained descriptors, publish each
inside the unpublished stage with `renameat2(RENAME_NOREPLACE)`, and fsync its directory.
The wrapper must restart the legacy unit whether the copy succeeds, fails, or is interrupted. A
successful window is not complete until the restarted RPC has the same PID ownership/listener
properties and returns the exact public address captured before stop. At no point may a helper
move, rename, chmod, chown, truncate, erase, or unlink the legacy wallet, keys, directory, or unit.
### 4. Build and verify one unused bundle
Generate a new RPC credential in a non-dumpable process. The wallet password and RPC credential
must be distinct. Keep all copies in the one unpublished generation; no `/etc` credential file or
application drop-in is needed.
The protected wallet config must contain exactly these non-secret semantics plus one Digest login:
```text
stagenet=1
wallet-file=/var/lib/monerogue-stagenet-wallet-candidate/wallet/stagenet
password-file=/var/lib/monerogue-stagenet-wallet-candidate/secrets/wallet-password
rpc-bind-port=38084
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:38081
rpc-login=monerogue-candidate:<generated-secret>
non-interactive=1
```
Before publication, open the copied wallet offline on temporary port `39084` as the candidate UID,
with supplementary groups and all capabilities cleared, `no_new_privs`, core dumps disabled, and
logs directed to `/dev/null`. Prove all of the following without printing response data:
- the verifier PID owns the sole exact loopback listener;
- unauthenticated JSON-RPC returns HTTP 401;
- Digest-authenticated `get_address` returns HTTP 200 and the captured stagenet address; and
- termination removes the verifier and listener cleanly.
Then verify every directory/file owner, mode, type, and link count. Write and fsync `STATE.json`,
write `READY` **last**, fsync every file and directory, atomically rename the whole staging
directory to the unused final name with `RENAME_NOREPLACE`, and fsync `/var/lib`. A crash before the
single directory rename leaves no final candidate. An existing final candidate is never replaced.
### 5. Manual candidate validation
Only after an independent reviewer accepts the helper and crash evidence may the inert service
template be installed as `/etc/systemd/system/monerogue-stagenet-wallet-candidate.service`. It has
no `[Install]` section, no boot target, no restart loop, no relationship to `monerogue.service`, and
requires the exact readiness marker.
Start it manually, then prove systemd MainPID/executable/UID/cgroup agreement, the sole
`127.0.0.1:38084` listener owned by that PID, HTTP 401 without auth, HTTP 200 with the protected
netrc, and the sealed public address. The legacy RPC and live app must remain healthy throughout.
### 6. Candidate application canary
The bundle may contain one `root:monerogue 0640` environment override with candidate endpoint and
Digest credentials. It must also force:
```text
HOST=127.0.0.1
PORT=3103
PAYMENTS_ENABLED=false
PAYOUTS_ENABLED=false
DIRECT_PAYMENT_ENABLED=false
DIRECT_PAYOUTS_ENABLED=false
CREDITS_PAYOUTS_ENABLED=false
MATCH_ENABLED=false
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
```
Run the exact release only against a fresh, isolated canary database. Do not attach port 3103 to
the NPM/router, public ingress, production database, payment intake, payout dispatchers, or the
live service. Readiness and a non-money wallet query are the only allowed checks. Stop both canary
app and candidate wallet afterward.
Promotion of the production app from `38083` to `38084` is explicitly out of scope. It requires a
separate reviewed maintenance change and rollback plan.
## Rollback and partial failures
There is no destructive wallet rollback because this path never alters the legacy artifacts.
- During the copy window, always restart and validate the legacy unit from the trap.
- Before candidate activation, leave any partial staging generation root-only and inert. Do not
delete, merge, resume, or overwrite it automatically.
- After candidate activation, stop the candidate unit and prove port `38084` is closed. Restart and
validate the legacy unit if needed. The live application still points to `38083`.
- Preserve the unused candidate bundle for incident review; cleanup is a later exact-target,
separately approved operation.
If the legacy unit cannot restart or its address differs, keep the application stopped/ingress
held under the existing incident runbook. Do not attempt an app cutover to rescue the ceremony.
## GO criteria
All are mandatory before any host mutation beyond provisioning the unused candidate identity:
1. Small fixed-path capture, copy-window, bundle, and validation helpers exist; no general source,
destination, network, unit, port, user, or overwrite arguments exist.
2. Disposable tests inject failure before/after every create, write, chmod/chown, file fsync,
directory fsync, state write, readiness write, stop, start, address check, and final rename.
3. Kill/restart tests prove every partial stage remains inert and every stopped-wallet failure
makes a bounded restart attempt without touching legacy bytes or metadata.
4. Symlink, hardlink, inode replacement, open-writer, wrong UID/cgroup/PID, wildcard/IPv6 listener,
port collision, duplicate password option, and existing-final fixtures all fail closed.
5. A reviewer verifies secrets never enter argv, environment, stdout/stderr, journald, unit text,
tracebacks, Git, ordinary docs, or the non-secret readiness marker.
6. The restored wallet backup plus its password has been opened offline and address-matched, and
mnemonic/off-host recovery custody is resolved separately. A `.keys` file alone is not mnemonic
custody evidence.
7. The complete process succeeds, rolls back, and succeeds again on an isolated stagenet host with
synthetic/unfunded material before touching the funded stagenet wallet.
8. An independent reviewer records GO against an immutable release hash.
Until every item passes, the production-deployable answer is **no**. Only the read-only preflight
and disposable fixture test are approved by this document; even the candidate service template is
not approved for installation.
@@ -1,306 +0,0 @@
#!/usr/bin/env python3
"""Disposable fixture proof for the parallel-wallet filesystem contract.
This test uses only synthetic bytes below a fresh TemporaryDirectory. It never imports or invokes
the host preflight, systemd, a wallet binary, a wallet file, or a credential. Run directly with:
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
"""
from __future__ import annotations
import ctypes
import errno
import os
import pathlib
import secrets
import stat
import tempfile
import unittest
from collections.abc import Callable
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
RENAME_NOREPLACE = 1
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def rename_noreplace(source_fd: int, source: str, destination_fd: int, destination: str) -> None:
result = LIBC.renameat2(
ctypes.c_int(source_fd),
ctypes.c_char_p(os.fsencode(source)),
ctypes.c_int(destination_fd),
ctypes.c_char_p(os.fsencode(destination)),
ctypes.c_uint(RENAME_NOREPLACE),
)
if result != 0:
problem = ctypes.get_errno()
raise OSError(problem, os.strerror(problem), destination)
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
raise Refusal("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
return descriptor
except Exception:
os.close(descriptor)
raise
def file_identity(details: os.stat_result) -> tuple[int, ...]:
return (
details.st_dev,
details.st_ino,
details.st_uid,
details.st_gid,
stat.S_IMODE(details.st_mode),
details.st_nlink,
details.st_size,
details.st_mtime_ns,
details.st_ctime_ns,
)
def open_source(directory_fd: int, name: str) -> tuple[int, tuple[int, ...]]:
descriptor = os.open(name, os.O_RDONLY | O_NOFOLLOW, dir_fd=directory_fd)
details = os.fstat(descriptor)
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
os.close(descriptor)
raise Refusal("source is not a single-linked regular file")
if stat.S_IMODE(details.st_mode) != 0o600:
os.close(descriptor)
raise Refusal("source mode differs from the contract")
return descriptor, file_identity(details)
def copy_one(
source_fd: int,
expected: tuple[int, ...],
destination_fd: int,
name: str,
after_copy: Callable[[], None] | None = None,
) -> None:
temporary = f".{name}.pending.{secrets.token_hex(8)}"
output = os.open(
temporary,
os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW,
0o600,
dir_fd=destination_fd,
)
published = False
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
block = os.read(source_fd, 1024 * 1024)
if not block:
break
view = memoryview(block)
while view:
written = os.write(output, view)
if written <= 0:
raise OSError("short synthetic copy write")
view = view[written:]
os.fsync(output)
if after_copy is not None:
after_copy()
if file_identity(os.fstat(source_fd)) != expected:
raise Refusal("source metadata changed during copy")
os.lseek(source_fd, 0, os.SEEK_SET)
os.lseek(output, 0, os.SEEK_SET)
while True:
source_block = os.read(source_fd, 1024 * 1024)
output_block = os.read(output, 1024 * 1024)
if source_block != output_block:
raise Refusal("copy differs from retained source")
if not source_block:
break
os.close(output)
output = -1
rename_noreplace(destination_fd, temporary, destination_fd, name)
published = True
os.fsync(destination_fd)
finally:
if output >= 0:
os.close(output)
if not published:
try:
os.unlink(temporary, dir_fd=destination_fd)
except FileNotFoundError:
pass
def copy_pair(source: pathlib.Path, wallet_destination: pathlib.Path) -> None:
source_fd = open_directory_nofollow(source)
destination_fd = open_directory_nofollow(wallet_destination)
try:
opened: list[tuple[int, tuple[int, ...], str]] = []
try:
for source_name, destination_name in (
("test", "stagenet"),
("test.keys", "stagenet.keys"),
):
descriptor, expected = open_source(source_fd, source_name)
opened.append((descriptor, expected, destination_name))
for descriptor, expected, destination_name in opened:
copy_one(descriptor, expected, destination_fd, destination_name)
finally:
for descriptor, _, _ in opened:
os.close(descriptor)
finally:
os.close(source_fd)
os.close(destination_fd)
def require_ready(stage: pathlib.Path) -> None:
descriptor = open_directory_nofollow(stage)
try:
marker = os.open("READY", os.O_RDONLY | O_NOFOLLOW, dir_fd=descriptor)
try:
details = os.fstat(marker)
if (
not stat.S_ISREG(details.st_mode)
or details.st_nlink != 1
or stat.S_IMODE(details.st_mode) != 0o600
):
raise Refusal("readiness marker metadata is invalid")
finally:
os.close(marker)
except FileNotFoundError:
raise Refusal("readiness marker is absent")
finally:
os.close(descriptor)
def publish(stage: pathlib.Path, final: pathlib.Path) -> None:
require_ready(stage)
if stage.parent != final.parent:
raise Refusal("publication must be one same-filesystem directory rename")
parent = open_directory_nofollow(stage.parent)
try:
rename_noreplace(parent, stage.name, parent, final.name)
os.fsync(parent)
finally:
os.close(parent)
class BundleFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory(prefix="monero-parallel-fixture-")
self.root = pathlib.Path(self.temporary.name)
self.source = self.root / "legacy"
self.stage = self.root / ".candidate.staging"
self.final = self.root / "candidate"
self.source.mkdir(mode=0o700)
self.stage.mkdir(mode=0o700)
(self.stage / "wallet").mkdir(mode=0o700)
for name, content in (("test", b"synthetic-wallet\0bytes"), ("test.keys", b"synthetic-key\0bytes")):
path = self.source / name
path.write_bytes(content)
path.chmod(0o600)
def tearDown(self) -> None:
self.temporary.cleanup()
def ready(self) -> None:
marker = self.stage / "READY"
descriptor = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, 0o600)
try:
os.write(descriptor, b"network=stagenet\nrpc_bind=127.0.0.1:38084\n")
os.fchmod(descriptor, 0o600)
os.fsync(descriptor)
finally:
os.close(descriptor)
stage_fd = open_directory_nofollow(self.stage)
try:
os.fsync(stage_fd)
finally:
os.close(stage_fd)
def test_partial_stage_is_not_a_candidate(self) -> None:
copy_pair(self.source, self.stage / "wallet")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
self.assertTrue(self.stage.exists())
def test_ready_bundle_is_one_atomic_nonreplacing_publish(self) -> None:
copy_pair(self.source, self.stage / "wallet")
self.ready()
publish(self.stage, self.final)
self.assertFalse(self.stage.exists())
self.assertEqual((self.final / "wallet" / "stagenet").read_bytes(), b"synthetic-wallet\0bytes")
self.assertEqual((self.final / "wallet" / "stagenet.keys").read_bytes(), b"synthetic-key\0bytes")
def test_existing_final_is_never_replaced(self) -> None:
self.ready()
self.final.mkdir(mode=0o700)
sentinel = self.final / "sentinel"
sentinel.write_text("older generation", encoding="ascii")
with self.assertRaises(OSError) as problem:
publish(self.stage, self.final)
self.assertEqual(problem.exception.errno, errno.EEXIST)
self.assertEqual(sentinel.read_text(encoding="ascii"), "older generation")
self.assertTrue(self.stage.exists())
def test_symlink_component_is_refused(self) -> None:
linked = self.root / "linked-source"
linked.symlink_to(self.source, target_is_directory=True)
with self.assertRaises(OSError):
copy_pair(linked, self.stage / "wallet")
def test_hard_linked_key_is_refused(self) -> None:
os.link(self.source / "test.keys", self.root / "second-link")
with self.assertRaises(Refusal):
copy_pair(self.source, self.stage / "wallet")
self.assertFalse((self.stage / "wallet" / "stagenet.keys").exists())
def test_source_change_is_detected_before_publication(self) -> None:
source_fd = open_directory_nofollow(self.source)
destination_fd = open_directory_nofollow(self.stage / "wallet")
descriptor, expected = open_source(source_fd, "test")
try:
def mutate() -> None:
with open(self.source / "test", "ab") as output:
output.write(b"changed")
with self.assertRaises(Refusal):
copy_one(descriptor, expected, destination_fd, "stagenet", mutate)
self.assertFalse((self.stage / "wallet" / "stagenet").exists())
finally:
os.close(descriptor)
os.close(source_fd)
os.close(destination_fd)
def test_marker_contains_no_synthetic_secret(self) -> None:
self.ready()
self.assertNotIn(b"synthetic-key", (self.stage / "READY").read_bytes())
self.assertNotIn(b"synthetic-wallet", (self.stage / "READY").read_bytes())
def test_linked_readiness_marker_is_refused(self) -> None:
external = self.root / "external-marker"
external.write_text("network=stagenet\n", encoding="ascii")
external.chmod(0o600)
os.link(external, self.stage / "READY")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
if __name__ == "__main__":
os.umask(0o077)
unittest.main(verbosity=2)
@@ -1,374 +0,0 @@
#!/usr/bin/env python3
"""Read-only preflight for a parallel Monero stagenet wallet-RPC candidate.
This script does not read systemd unit text, process arguments, wallet bytes, keys, passwords, or
mnemonics. It does not create, modify, move, or delete anything. It validates only public RPC
identity, process/listener identity, path metadata, capacity, and inert candidate prerequisites.
"""
from __future__ import annotations
import argparse
import ctypes
import grp
import http.client
import json
import os
import pathlib
import pwd
import resource
import socket
import stat
import subprocess
import sys
import urllib.error
import urllib.request
LEGACY_UNIT = "monero-wallet-rpc.service"
LEGACY_USER = "jw"
LEGACY_GROUP = "jw"
LEGACY_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
LEGACY_DIR = pathlib.Path("/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4")
LEGACY_WALLET = LEGACY_DIR / "test"
LEGACY_KEYS = LEGACY_DIR / "test.keys"
LEGACY_RPC_PORT = 38083
LEGACY_RPC_URL = "http://127.0.0.1:38083/json_rpc"
CANDIDATE_UNIT = "monerogue-stagenet-wallet-candidate.service"
CANDIDATE_USER = "monerogue-wallet-candidate"
CANDIDATE_GROUP = "monerogue-wallet-candidate"
CANDIDATE_HOME = pathlib.Path("/var/lib/monerogue-stagenet-wallet-candidate")
CANDIDATE_STAGE = pathlib.Path("/var/lib/.monerogue-stagenet-wallet-candidate.staging")
CANDIDATE_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
CANDIDATE_RPC_PORT = 38084
VERIFY_RPC_PORT = 39084
ADDRESS_REQUEST = {
"jsonrpc": "2.0",
"id": "parallel-preflight",
"method": "get_address",
"params": {"account_index": 0},
}
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def refuse(message: str) -> None:
raise Refusal(message)
def harden_process() -> None:
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
try:
if LIBC.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE
refuse("could not disable process dumps")
except AttributeError:
refuse("could not disable process dumps")
def identity(user: str, group: str) -> tuple[int, int]:
try:
return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid
except KeyError:
refuse(f"required identity is missing: {user}:{group}")
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
refuse("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
refuse("opened path is not a directory")
return descriptor
except Exception:
os.close(descriptor)
raise
def require_directory(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
try:
descriptor = open_directory_nofollow(path)
except (FileNotFoundError, NotADirectoryError, OSError):
refuse(f"required real directory is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if actual != expected:
refuse(f"directory metadata mismatch: {path}")
return details
finally:
os.close(descriptor)
def require_regular(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
parent = open_directory_nofollow(path.parent)
try:
try:
descriptor = os.open(path.name, os.O_RDONLY | O_NOFOLLOW, dir_fd=parent)
except OSError:
refuse(f"required real file is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or actual != expected:
refuse(f"file metadata mismatch or hard link refused: {path}")
if details.st_size <= 0:
refuse(f"required file is empty: {path}")
return details
finally:
os.close(descriptor)
finally:
os.close(parent)
def systemctl(*arguments: str) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
["/usr/bin/systemctl", *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
refuse("systemd state check failed")
def main_pid(unit: str) -> int:
result = systemctl("show", unit, "--property=MainPID", "--value")
if result.returncode != 0:
refuse("systemd MainPID check failed")
try:
return int(result.stdout.strip())
except ValueError:
refuse("systemd returned an invalid MainPID")
def process_uid(pid: int) -> int:
try:
with open(f"/proc/{pid}/status", "r", encoding="ascii") as source:
for line in source:
if line.startswith("Uid:"):
values = line.split()[1:]
if len(values) == 4 and len(set(values)) == 1:
return int(values[0])
except (OSError, UnicodeDecodeError, ValueError):
pass
refuse("legacy process UID is unavailable")
def process_start_time(pid: int) -> str:
try:
raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
fields = raw[raw.rfind(")") + 2 :].split()
return fields[19]
except (OSError, UnicodeDecodeError, IndexError):
refuse("legacy process start time is unavailable")
def process_has_unit_cgroup(pid: int, unit: str) -> bool:
try:
lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines()
except (OSError, UnicodeDecodeError):
refuse("legacy process cgroup is unavailable")
return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines)
def parse_listener_table(path: str, port: int, ipv6: bool) -> list[tuple[str, int]]:
found: list[tuple[str, int]] = []
try:
with open(path, "r", encoding="ascii") as source:
next(source, None)
for line in source:
fields = line.split()
if len(fields) < 10 or fields[3] != "0A":
continue
address_hex, port_hex = fields[1].split(":")
if int(port_hex, 16) != port:
continue
if ipv6:
address = socket.inet_ntop(socket.AF_INET6, bytes.fromhex(address_hex))
else:
address = socket.inet_ntoa(bytes.fromhex(address_hex)[::-1])
found.append((address, int(fields[9])))
except (OSError, UnicodeDecodeError, ValueError, IndexError):
refuse("kernel listener state is unavailable")
return found
def listeners(port: int) -> list[tuple[str, int]]:
return parse_listener_table("/proc/net/tcp", port, False) + parse_listener_table(
"/proc/net/tcp6", port, True
)
def process_owns_socket(pid: int, inode: int) -> bool:
expected = f"socket:[{inode}]"
try:
for entry in os.scandir(f"/proc/{pid}/fd"):
try:
if os.readlink(entry.path) == expected:
return True
except OSError:
continue
except OSError:
refuse("legacy process descriptors are unavailable")
return False
def require_exact_listener(pid: int, port: int) -> None:
active = listeners(port)
if len(active) != 1 or active[0][0] != "127.0.0.1":
refuse(f"port {port} is not one exact IPv4 loopback listener")
if not process_owns_socket(pid, active[0][1]):
refuse(f"port {port} is not owned by the expected process")
def require_unused_port(port: int) -> None:
if listeners(port):
refuse(f"candidate port {port} is already in use")
def validate_public_address() -> None:
body = json.dumps(ADDRESS_REQUEST, separators=(",", ":")).encode("ascii")
request = urllib.request.Request(
LEGACY_RPC_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# Never honor ambient proxy settings for a loopback identity check.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=5) as response:
if response.status != 200:
refuse("legacy wallet RPC returned an unexpected status")
raw = response.read(1024 * 1024 + 1)
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
refuse("legacy wallet RPC public address check failed")
if len(raw) > 1024 * 1024:
refuse("legacy wallet RPC response is oversized")
try:
payload = json.loads(raw.decode("utf-8"))
address = payload["result"]["address"]
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError):
refuse("legacy wallet RPC returned an invalid public address response")
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
if (
not isinstance(address, str)
or len(address) != 95
or not address.startswith("5")
or any(character not in alphabet for character in address)
):
refuse("legacy wallet RPC address is not a primary stagenet address")
def validate_legacy() -> tuple[os.stat_result, os.stat_result]:
if os.geteuid() != 0:
refuse("run as root so process ownership checks cannot be hidden")
if systemctl("is-active", "--quiet", LEGACY_UNIT).returncode != 0:
refuse("legacy wallet service must be active")
pid = main_pid(LEGACY_UNIT)
if pid <= 1:
refuse("legacy wallet service has no stable MainPID")
started = process_start_time(pid)
expected_uid, _ = identity(LEGACY_USER, LEGACY_GROUP)
if process_uid(pid) != expected_uid:
refuse("legacy wallet process UID is unexpected")
try:
executable = pathlib.Path(os.readlink(f"/proc/{pid}/exe"))
except OSError:
refuse("legacy wallet process executable is unavailable")
if executable != LEGACY_BINARY:
refuse("legacy wallet process executable is unexpected")
if not process_has_unit_cgroup(pid, LEGACY_UNIT):
refuse("legacy wallet process cgroup is unexpected")
require_exact_listener(pid, LEGACY_RPC_PORT)
require_directory(LEGACY_DIR, LEGACY_USER, LEGACY_GROUP, 0o775)
wallet = require_regular(LEGACY_WALLET, LEGACY_USER, LEGACY_GROUP, 0o600)
keys = require_regular(LEGACY_KEYS, LEGACY_USER, LEGACY_GROUP, 0o600)
validate_public_address()
if main_pid(LEGACY_UNIT) != pid or process_start_time(pid) != started:
refuse("legacy wallet process changed during preflight")
require_exact_listener(pid, LEGACY_RPC_PORT)
return wallet, keys
def validate_candidate_prerequisites(wallet: os.stat_result, keys: os.stat_result) -> None:
try:
record = pwd.getpwnam(CANDIDATE_USER)
group = grp.getgrnam(CANDIDATE_GROUP)
except KeyError:
refuse("parallel candidate service identity is not provisioned")
if (
record.pw_uid >= 1000
or record.pw_gid != group.gr_gid
or os.getgrouplist(CANDIDATE_USER, record.pw_gid) != [record.pw_gid]
or record.pw_dir != os.fspath(CANDIDATE_HOME)
or record.pw_shell not in ("/usr/sbin/nologin", "/bin/false")
):
refuse("parallel candidate identity is not a dedicated system account")
require_regular(CANDIDATE_BINARY, "root", "root", 0o755)
require_directory(CANDIDATE_HOME.parent, "root", "root", 0o755)
if os.path.lexists(CANDIDATE_STAGE) or os.path.lexists(CANDIDATE_HOME):
refuse("candidate staging/final path already exists; do not overwrite it")
if systemctl("is-active", "--quiet", CANDIDATE_UNIT).returncode == 0:
refuse("candidate unit must be inactive")
require_unused_port(CANDIDATE_RPC_PORT)
require_unused_port(VERIFY_RPC_PORT)
capacity = os.statvfs(CANDIDATE_HOME.parent)
available = capacity.f_bavail * capacity.f_frsize
required = wallet.st_size + keys.st_size + 64 * 1024 * 1024
if available < required:
refuse("insufficient space for a non-replacing candidate wallet copy")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read-only Monero stagenet parallel preflight")
parser.add_argument(
"scope",
choices=("legacy", "candidate-prereqs"),
help="legacy checks the current RPC; candidate-prereqs also checks inert new-path gates",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
options = parse_args(sys.argv[1:] if argv is None else argv)
harden_process()
wallet, keys = validate_legacy()
if options.scope == "candidate-prereqs":
validate_candidate_prerequisites(wallet, keys)
print(f"PASSED: read-only {options.scope} preflight")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Refusal as problem:
print(f"REFUSED: {problem}", file=sys.stderr)
raise SystemExit(1)
except Exception:
# Do not render process or RPC data in an unexpected exception.
print("REFUSED: unexpected read-only preflight failure", file=sys.stderr)
raise SystemExit(1)
+31 -20
View File
@@ -1,35 +1,46 @@
# Monero STAGENET wallet RPC for the monerogue.app instance.
# Mirrors wownero-wallet-rpc.service. Install to /etc/systemd/system/monero-wallet-rpc.service
# Monero STAGENET wallet RPC for the monerogue instance.
# Install to /etc/systemd/system/monero-wallet-rpc.service.
#
# Wallet: /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test (the stagenet wallet with coins)
# Daemon: monerod --stagenet RPC on 127.0.0.1:38081 (already running)
# RPC: binds :38083 (free)
#
# IMPORTANT: replace REPLACE_WITH_WALLET_PASSWORD below with the wallet's real password
# (do NOT commit the real password). Consider --wallet-dir + --rpc-login for production,
# but this mirrors the existing wownero setup for parity.
# Wallet credentials belong in /etc/monerogue/wallet-rpc.conf (root:monerogue-wallet 0640), never in
# this unit: anything passed on the command line is readable from /proc by any local user. See
# monero-wallet-rpc.conf.example.
[Unit]
Description=Monero Wallet RPC (Stagenet) - monerogue
After=network-online.target monerod.service
Wants=network-online.target
Wants=monerod.service
[Service]
User=jw
Group=jw
User=monerogue-wallet
Group=monerogue-wallet
Type=simple
ExecStart=/usr/bin/monero-wallet-rpc \
--stagenet \
--wallet-file /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test \
--password "REPLACE_WITH_WALLET_PASSWORD" \
--rpc-bind-port 38083 \
--daemon-address 127.0.0.1:38081 \
--non-interactive \
--rpc-bind-ip 127.0.0.1 \
--disable-rpc-login
ExecStart=/usr/bin/monero-wallet-rpc --config-file /etc/monerogue/wallet-rpc.conf
Restart=always
RestartSec=10
TimeoutStopSec=60
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/monerogue-wallet
[Install]
WantedBy=multi-user.target
@@ -1,54 +0,0 @@
# Manual-only, parallel Monero STAGENET wallet-RPC candidate.
#
# REVIEW STATUS: INERT TEMPLATE. Do not install until the bundle producer and its crash-injection
# evidence satisfy README-monero-stagenet-parallel-candidate.md. This unit intentionally has no
# installation stanza, so it cannot be enabled into a boot target.
[Unit]
Description=Monerogue parallel wallet RPC candidate (stagenet, manual only)
After=network-online.target monerod.service
Wants=network-online.target monerod.service
ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY
[Service]
Type=simple
User=monerogue-wallet-candidate
Group=monerogue-wallet-candidate
SupplementaryGroups=
WorkingDirectory=/var/lib/monerogue-stagenet-wallet-candidate/wallet
# The protected config fixes stagenet, wallet/password paths, daemon 127.0.0.1:38081,
# RPC 127.0.0.1:38084, and Digest rpc-login. No secret belongs in this unit or argv.
ExecStart=/usr/bin/monero-wallet-rpc --config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf --log-file /dev/null
# A candidate must be restarted deliberately and revalidated; it is not a production dependency.
Restart=no
TimeoutStopSec=60
UMask=0077
LimitCORE=0
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateUsers=no
RemoveIPC=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
RestrictRealtime=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
MemoryDenyWriteExecute=yes
KeyringMode=private
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/monerogue-stagenet-wallet-candidate/wallet
@@ -1,372 +0,0 @@
#!/usr/bin/env python3
"""Synthetic-only tests for the fixed wallet keyfile recovery proof primitives."""
from __future__ import annotations
import dataclasses
import importlib.util
import json
import os
import pathlib
import sys
import tempfile
import unittest
from unittest import mock
SCRIPT = pathlib.Path(__file__).with_name("wallet-keyfile-recovery-proof.py")
SPEC = importlib.util.spec_from_file_location("wallet_keyfile_recovery_proof", SCRIPT)
if SPEC is None or SPEC.loader is None: # pragma: no cover
raise RuntimeError("fixture import unavailable")
proof = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = proof
SPEC.loader.exec_module(proof)
def synthetic_profile(stagenet: bool = True):
return proof.WalletProfile(
name="synthetic",
unit="synthetic.service",
user="synthetic",
group="synthetic",
binary=pathlib.Path("/fixed/bin/wallet-rpc"),
live_wallet=pathlib.Path("/fixed/live/wallet"),
live_rpc_port=38083,
live_bind_address="127.0.0.1",
snapshot_wallet=pathlib.Path("/fixed/snapshot.wallet"),
snapshot_keys=pathlib.Path("/fixed/snapshot.keys"),
proof_port=39084,
stagenet=stagenet,
address_kind="xmr-stagenet" if stagenet else "wow-mainnet",
proof_directory=pathlib.Path("/fixed/proof"),
)
def cmdline(profile, *, wallet=None, passwords=(b"synthetic-wallet-password",), extra=()):
arguments = [os.fsencode(profile.binary)]
if profile.stagenet:
arguments.append(b"--stagenet")
arguments.extend((b"--wallet-file", os.fsencode(wallet or profile.live_wallet)))
for password in passwords:
arguments.extend((b"--password", password))
arguments.extend((b"--rpc-bind-port", str(profile.live_rpc_port).encode("ascii")))
arguments.extend(extra)
raw = bytearray(b"\0".join(arguments) + b"\0")
return raw, len(raw)
class CmdlineContractFixture(unittest.TestCase):
def test_exact_fixed_argv_extracts_only_the_inline_password_and_wipes_input(self):
profile = synthetic_profile()
raw, length = cmdline(profile)
secret = proof.extract_inline_wallet_password(raw, length, profile)
try:
self.assertEqual(secret, b"synthetic-wallet-password")
self.assertFalse(any(raw))
finally:
proof.wipe_buffer(secret)
def test_wrong_wallet_path_is_refused_and_input_is_wiped(self):
profile = synthetic_profile()
raw, length = cmdline(profile, wallet=pathlib.Path("/wrong/wallet"))
with self.assertRaises(proof.Refusal):
proof.extract_inline_wallet_password(raw, length, profile)
self.assertFalse(any(raw))
def test_duplicate_password_is_refused(self):
profile = synthetic_profile()
raw, length = cmdline(profile, passwords=(b"one", b"two"))
with self.assertRaises(proof.Refusal):
proof.extract_inline_wallet_password(raw, length, profile)
self.assertFalse(any(raw))
def test_password_file_or_wrong_network_is_refused(self):
profile = synthetic_profile()
for indirect in (
(b"--password-file", b"/not-accepted"),
(b"--password-file=/not-accepted",),
(b"--wallet-dir=/not-accepted",),
):
raw, length = cmdline(profile, extra=indirect)
with self.assertRaises(proof.Refusal):
proof.extract_inline_wallet_password(raw, length, profile)
self.assertFalse(any(raw))
mainnet = synthetic_profile(stagenet=False)
for wrong_network in ((b"--stagenet",), (b"--stagenet=1",), (b"--testnet=1",)):
raw, length = cmdline(mainnet, extra=wrong_network)
with self.assertRaises(proof.Refusal):
proof.extract_inline_wallet_password(raw, length, mainnet)
self.assertFalse(any(raw))
class SnapshotCopyFixture(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="wallet-recovery-fixture-")
self.root = pathlib.Path(self.temporary.name)
self.source = self.root / "source"
self.destination = self.root / "destination"
self.source.mkdir(mode=0o700)
self.destination.mkdir(mode=0o700)
self.wallet = self.source / "snapshot.wallet"
self.keys = self.source / "snapshot.keys"
self.wallet.write_bytes(b"synthetic encrypted wallet cache\0bytes")
self.keys.write_bytes(b"synthetic encrypted keyfile\0bytes")
self.wallet.chmod(0o600)
self.keys.chmod(0o600)
def tearDown(self):
self.temporary.cleanup()
def copy(self, after_first_copy=None):
proof.copy_snapshot_pair(
self.source,
self.wallet.name,
self.keys.name,
self.destination,
os.getuid(),
os.getgid(),
after_first_copy,
)
def test_copy_is_byte_equal_private_and_source_is_unchanged(self):
wallet_before = self.wallet.read_bytes()
keys_before = self.keys.read_bytes()
self.copy()
self.assertEqual((self.destination / "wallet").read_bytes(), wallet_before)
self.assertEqual((self.destination / "wallet.keys").read_bytes(), keys_before)
self.assertEqual(self.wallet.read_bytes(), wallet_before)
self.assertEqual(self.keys.read_bytes(), keys_before)
self.assertEqual((self.destination / "wallet").stat().st_mode & 0o777, 0o600)
self.assertEqual((self.destination / "wallet.keys").stat().st_mode & 0o777, 0o600)
def test_hard_linked_source_is_refused(self):
os.link(self.keys, self.root / "second-key-link")
with self.assertRaises(proof.Refusal):
self.copy()
self.assertFalse((self.destination / "wallet").exists())
def test_symlink_source_component_is_refused(self):
linked = self.root / "linked-source"
linked.symlink_to(self.source, target_is_directory=True)
with self.assertRaises((OSError, proof.Refusal)):
proof.copy_snapshot_pair(
linked,
self.wallet.name,
self.keys.name,
self.destination,
os.getuid(),
os.getgid(),
)
def test_source_change_during_copy_is_refused_and_partial_copy_removed(self):
def mutate():
with self.wallet.open("ab") as output:
output.write(b"changed")
with self.assertRaises(proof.Refusal):
self.copy(mutate)
self.assertFalse((self.destination / "wallet").exists())
self.assertFalse((self.destination / "wallet.keys").exists())
class CleanupAndProtocolFixture(unittest.TestCase):
def test_protection_failure_wipes_the_synthetic_secret(self):
value = bytearray(b"synthetic-secret")
with mock.patch.object(proof.LIBC, "mlock", return_value=-1):
with self.assertRaises(proof.Refusal):
proof.protect_buffer(value)
self.assertFalse(any(value))
def test_cleanup_never_follows_a_link_outside_its_exact_directory(self):
with tempfile.TemporaryDirectory(prefix="wallet-cleanup-fixture-") as temporary:
root = pathlib.Path(temporary)
proof_dir = root / "proof"
proof_dir.mkdir(mode=0o700)
secret = proof_dir / "wallet-password"
secret.write_bytes(b"synthetic secret")
secret.chmod(0o600)
external = root / "external"
external.write_bytes(b"must survive")
(proof_dir / "linked").symlink_to(external)
details = proof_dir.stat()
self.assertFalse(
proof.secure_remove_proof_directory(
proof_dir, (details.st_dev, details.st_ino)
)
)
self.assertFalse(proof_dir.exists())
self.assertEqual(external.read_bytes(), b"must survive")
def test_cleanup_never_overwrites_an_external_hard_link(self):
with tempfile.TemporaryDirectory(prefix="wallet-hardlink-fixture-") as temporary:
root = pathlib.Path(temporary)
proof_dir = root / "proof"
proof_dir.mkdir(mode=0o700)
external = root / "external"
external.write_bytes(b"must survive")
os.link(external, proof_dir / "linked")
details = proof_dir.stat()
self.assertFalse(
proof.secure_remove_proof_directory(
proof_dir, (details.st_dev, details.st_ino)
)
)
self.assertFalse(proof_dir.exists())
self.assertEqual(external.read_bytes(), b"must survive")
def test_fixed_address_shapes_reject_cross_network_values(self):
xmr = synthetic_profile()
wow = synthetic_profile(stagenet=False)
xmr_address = "5" + "1" * 94
wow_address = "Wo" + "1" * 95
self.assertTrue(proof.network_shaped_address(xmr_address, xmr))
self.assertTrue(proof.network_shaped_address(wow_address, wow))
self.assertFalse(proof.network_shaped_address(wow_address, xmr))
self.assertFalse(proof.network_shaped_address(xmr_address, wow))
def test_digest_header_does_not_embed_the_ephemeral_rpc_password(self):
challenge = 'Digest realm="wallet-rpc", nonce="abcdef", qop="auth", algorithm=MD5'
rpc_secret = bytearray(b"a" * 64)
authorization = proof.digest_authorization(challenge, rpc_secret)
self.assertTrue(authorization.startswith("Digest "))
self.assertNotIn(rpc_secret.decode("ascii"), authorization)
def test_digest_requires_auth_qop_but_accepts_a_spaced_offer(self):
parsed = proof.parse_digest_challenge(
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int, auth"'
)
self.assertEqual(parsed["nonce"], "abcdef")
with self.assertRaises(proof.Refusal):
proof.parse_digest_challenge(
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int"'
)
def test_candidate_files_use_password_file_loopback_offline_contract(self):
with tempfile.TemporaryDirectory(prefix="wallet-config-fixture-") as temporary:
profile = dataclasses.replace(
synthetic_profile(), proof_directory=pathlib.Path(temporary)
)
profile.candidate_directory.mkdir(mode=0o700)
written = {}
def capture(_directory_fd, name, value):
written[name] = bytes(value)
with mock.patch.object(proof, "write_new_file", side_effect=capture):
rpc_secret = proof.prepare_candidate_files(
profile, bytearray(b"synthetic-wallet-password")
)
try:
config = written["wallet-rpc.conf"]
self.assertEqual(
written["wallet-password"], b"synthetic-wallet-password\n"
)
self.assertIn(
b"wallet-file="
+ os.fsencode(profile.candidate_directory / "wallet")
+ b"\n",
config,
)
self.assertIn(
b"password-file="
+ os.fsencode(profile.candidate_directory / "wallet-password")
+ b"\n",
config,
)
self.assertIn(b"rpc-bind-ip=127.0.0.1\n", config)
self.assertIn(b"rpc-bind-port=39084\n", config)
self.assertIn(b"non-interactive=1\n", config)
self.assertIn(b"rpc-login=recovery-proof:" + bytes(rpc_secret), config)
self.assertIn(b"stagenet=1\n", config)
self.assertNotIn(b"daemon-address", config)
finally:
proof.wipe_buffer(rpc_secret, True)
def test_fixed_message_signature_must_verify_as_spend_capability(self):
profile = synthetic_profile()
requests = []
signature = "SigV1" + "1" * 80
def answer(_profile, _challenge, _rpc_secret, request_body, nonce_count):
request = json.loads(bytes(request_body).decode("ascii"))
requests.append((request, nonce_count))
if request["method"] == "sign":
return json.dumps({"result": {"signature": signature}}).encode()
return json.dumps(
{"result": {"good": True, "signature_type": "spend"}}
).encode()
with mock.patch.object(proof, "authenticated_candidate_rpc", side_effect=answer):
proof.prove_spend_capability(
profile,
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
bytearray(b"synthetic-rpc-secret"),
"5" + "1" * 94,
)
self.assertEqual([request["method"] for request, _ in requests], ["sign", "verify"])
self.assertEqual([nonce_count for _, nonce_count in requests], [2, 3])
self.assertEqual(requests[0][0]["params"], {"data": proof.SIGN_MESSAGE})
self.assertEqual(requests[1][0]["params"]["signature"], signature)
def test_missing_spend_signature_is_refused(self):
with mock.patch.object(
proof,
"authenticated_candidate_rpc",
return_value=b'{"error":{"message":"watch-only"}}',
):
with self.assertRaises(proof.Refusal):
proof.prove_spend_capability(
synthetic_profile(),
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
bytearray(b"synthetic-rpc-secret"),
"5" + "1" * 94,
)
def test_verified_view_signature_is_not_spend_capability(self):
signature = "SigV1" + "1" * 80
responses = [
json.dumps({"result": {"signature": signature}}).encode(),
b'{"result":{"good":true,"signature_type":"view"}}',
]
with mock.patch.object(
proof, "authenticated_candidate_rpc", side_effect=responses
):
with self.assertRaises(proof.Refusal):
proof.prove_spend_capability(
synthetic_profile(),
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
bytearray(b"synthetic-rpc-secret"),
"5" + "1" * 94,
)
def test_child_drops_to_exact_ids_with_no_supplementary_groups(self):
with (
mock.patch.object(proof.os, "setgroups") as setgroups,
mock.patch.object(proof.os, "setgid") as setgid,
mock.patch.object(proof.os, "setuid") as setuid,
mock.patch.object(proof.os, "getresuid", return_value=(123, 123, 123)),
mock.patch.object(proof.os, "getresgid", return_value=(456, 456, 456)),
mock.patch.object(proof.os, "getgroups", return_value=[]),
mock.patch.object(proof.os, "umask"),
mock.patch.object(proof.resource, "setrlimit"),
mock.patch.object(proof.LIBC, "prctl", return_value=0) as prctl,
):
proof.child_hardening(123, 456)
setgroups.assert_called_once_with([])
setgid.assert_called_once_with(456)
setuid.assert_called_once_with(123)
self.assertEqual(prctl.call_count, 3)
def test_listener_collision_is_refused(self):
with mock.patch.object(proof, "listeners", return_value=[("127.0.0.1", 42)]):
with self.assertRaises(proof.Refusal):
proof.require_unused_port(39084)
if __name__ == "__main__":
os.umask(0o077)
unittest.main(verbosity=2)
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* splitOutputs.js — Split wallet outputs for concurrent payout capacity.
* splitOutputs.js: split wallet outputs for concurrent payout capacity.
*
* Wownero/Monero lock change outputs for several blocks after spending.
* Pre-splitting one large output into many smaller ones allows the wallet
@@ -20,7 +20,7 @@ const path = require('path');
const fs = require('fs');
const http = require('http');
// Parse .env manually — no external dependencies needed
// Parse .env manually so the script needs no external dependencies.
function loadEnv() {
const envPath = path.resolve(__dirname, '..', 'src', '.env');
try {
@@ -35,7 +35,7 @@ function loadEnv() {
if (!process.env[key]) process.env[key] = val;
}
} catch (e) {
// .env not found — that's fine, user can pass --endpoint
// .env not found. The caller can pass --endpoint instead.
}
}
loadEnv();
@@ -76,7 +76,7 @@ function parseArgs() {
case '--help':
case '-h':
console.log(`
splitOutputs.js — Split wallet outputs for concurrent payout capacity.
splitOutputs.js: split wallet outputs for concurrent payout capacity.
Usage: node scripts/splitOutputs.js [options]