diff --git a/README.md b/README.md index 8610d33..02504bd 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,47 @@ tail -f /tmp/local.wyoming-parakeet.stderr Each request logs audio duration, inference time and the transcript. +## Security + +**The Wyoming protocol has no authentication or transport encryption.** Any +client that can reach the port can submit audio, and Home Assistant trusts +whatever this service returns — a transcript becomes an intent, and an intent +unlocks doors. Treat the port as a trust boundary and keep it on a network you +control. + +Two things this server does about that: + +- **Buffered audio is capped** (`--max-audio-seconds`, default 120). Audio + accumulates until `AudioStop`, and a client that never sends one — malicious + or just stuck — otherwise grows the buffer indefinitely. Measured at ~11 MB/s + over loopback, enough to exhaust 32 GB in under an hour from one connection. + Over the cap, further audio is dropped with a single warning and whatever was + captured is still transcribed. +- **Transcripts are not logged at INFO.** The log records duration, latency and + character count; the text itself is behind `--debug`. Log files are + long-lived, and on macOS `/tmp` they are world-readable by default — meaning + every voice command would otherwise sit in plaintext readable by any local + account. + +Worth doing yourself, depending on your threat model: + +- **Bind to one interface.** The daemon listens on `0.0.0.0`, so it is exposed + on every network the host is attached to — including VPN interfaces like + Tailscale, which is easy to overlook. Set the URI to a specific address + (`--uri tcp://192.168.1.10:7892`) or firewall the port to your Home + Assistant host. +- **Do not run it as an admin account.** The installer defaults `--user` to + whoever runs it. On a typical macOS setup that account is in `admin`, and if + `%admin` has a `NOPASSWD` sudo rule then a compromise of this service is a + direct path to root. A dedicated non-admin service account costs nothing. +- **Pin your dependencies** if you care about supply chain. `requirements.txt` + is deliberately loose so `install.sh` picks up fixes; pin exact versions (and + ideally hashes) if you would rather audit upgrades. Model weights are + `safetensors`, so loading them does not execute code, but the initial + download from HuggingFace is trust-on-first-use — pin a revision if that + matters to you. `HF_HUB_OFFLINE=1` in the daemon means it never re-fetches + after that point. + ## License MIT diff --git a/test/test_handler.py b/test/test_handler.py index 56c8e1a..4307b93 100644 --- a/test/test_handler.py +++ b/test/test_handler.py @@ -1,4 +1,5 @@ """Tests for the Wyoming event handling.""" +from argparse import Namespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -7,7 +8,12 @@ from wyoming.audio import AudioChunk, AudioStop from wyoming.info import AsrModel, AsrProgram, Attribution, Describe, Info from wyoming_parakeet.engine import SAMPLE_RATE -from wyoming_parakeet.handler import ParakeetEventHandler +from wyoming_parakeet.handler import ( + DEFAULT_MAX_AUDIO_SECONDS, + ParakeetEventHandler, +) + +ARGS = Namespace(max_audio_seconds=DEFAULT_MAX_AUDIO_SECONDS) INFO = Info( asr=[ @@ -36,7 +42,7 @@ INFO = Info( def handler(): engine = MagicMock() engine.transcribe = AsyncMock(return_value="turn off the kitchen lights") - h = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock()) + h = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock()) h.write_event = AsyncMock() return h @@ -172,8 +178,8 @@ async def test_concurrent_handlers_do_not_share_audio(): seen = [] engine.transcribe.side_effect = lambda pcm: seen.append(pcm) or "" - a = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock()) - b = ParakeetEventHandler(INFO, MagicMock(), engine, MagicMock(), MagicMock()) + a = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock()) + b = ParakeetEventHandler(INFO, ARGS, engine, MagicMock(), MagicMock()) a.write_event = AsyncMock() b.write_event = AsyncMock() @@ -224,3 +230,81 @@ async def test_8bit_input_is_widened(handler): await handler.handle_event(AudioStop().event()) assert len(handler.engine.transcribe.await_args[0][0]) == SAMPLE_RATE * 2 + + +# --- resource limits -------------------------------------------------------- +# Wyoming is unauthenticated, so buffered audio is attacker-controlled. + + +def capped_handler(seconds): + engine = MagicMock() + engine.transcribe = AsyncMock(return_value="ok") + h = ParakeetEventHandler( + INFO, Namespace(max_audio_seconds=seconds), engine, MagicMock(), MagicMock() + ) + h.write_event = AsyncMock() + return h + + +async def test_audio_buffer_is_capped(): + handler = capped_handler(1.0) + for _ in range(10): + await handler.handle_event(chunk(SAMPLE_RATE)) # 10s into a 1s cap + assert len(handler.audio) == SAMPLE_RATE * 2 + + +async def test_truncated_audio_is_still_transcribed(): + """Cap the memory, don't drop the user's command on the floor.""" + handler = capped_handler(1.0) + for _ in range(5): + await handler.handle_event(chunk(SAMPLE_RATE)) + await handler.handle_event(AudioStop().event()) + + handler.engine.transcribe.assert_awaited_once() + assert len(handler.engine.transcribe.await_args[0][0]) == SAMPLE_RATE * 2 + assert Transcript.from_event(handler.write_event.await_args[0][0]).text == "ok" + + +async def test_truncation_warns_once(caplog): + handler = capped_handler(1.0) + with caplog.at_level("WARNING"): + for _ in range(6): + await handler.handle_event(chunk(SAMPLE_RATE)) + assert caplog.text.count("Audio exceeded") == 1 + + +async def test_cap_resets_between_utterances(): + handler = capped_handler(1.0) + for _ in range(3): + await handler.handle_event(chunk(SAMPLE_RATE)) + await handler.handle_event(AudioStop().event()) + assert handler.truncated is False + + await handler.handle_event(chunk(SAMPLE_RATE // 2)) + assert len(handler.audio) == SAMPLE_RATE # accepted again, not still capped + + +async def test_audio_under_the_cap_is_untouched(): + handler = capped_handler(DEFAULT_MAX_AUDIO_SECONDS) + await handler.handle_event(chunk(SAMPLE_RATE * 3)) + assert len(handler.audio) == SAMPLE_RATE * 3 * 2 + + +# --- transcript privacy ----------------------------------------------------- +# Logs are long-lived and readable by other local accounts. + + +async def test_transcript_text_is_not_logged_at_info(handler, caplog): + handler.engine.transcribe = AsyncMock(return_value="unlock the front door") + with caplog.at_level("INFO"): + await handler.handle_event(chunk(SAMPLE_RATE)) + await handler.handle_event(AudioStop().event()) + assert "unlock the front door" not in caplog.text + + +async def test_transcript_text_is_available_at_debug(handler, caplog): + handler.engine.transcribe = AsyncMock(return_value="unlock the front door") + with caplog.at_level("DEBUG"): + await handler.handle_event(chunk(SAMPLE_RATE)) + await handler.handle_event(AudioStop().event()) + assert "unlock the front door" in caplog.text diff --git a/wyoming_parakeet/__main__.py b/wyoming_parakeet/__main__.py index 114f91b..b160909 100644 --- a/wyoming_parakeet/__main__.py +++ b/wyoming_parakeet/__main__.py @@ -8,7 +8,7 @@ from wyoming.info import AsrModel, AsrProgram, Attribution, Info from wyoming.server import AsyncServer from .engine import ParakeetEngine -from .handler import ParakeetEventHandler +from .handler import DEFAULT_MAX_AUDIO_SECONDS, ParakeetEventHandler _LOGGER = logging.getLogger(__name__) __version__ = "1.0.0" @@ -25,7 +25,17 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--uri", required=True, help="unix:// or tcp://") parser.add_argument("--model", default=DEFAULT_MODEL, help="HuggingFace model id") parser.add_argument("--language", default="en", help="Language code reported to HA") - parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") + parser.add_argument( + "--max-audio-seconds", + type=float, + default=DEFAULT_MAX_AUDIO_SECONDS, + help="Cap on buffered audio per utterance (default: %(default)s)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Log DEBUG messages, including transcript text", + ) parser.add_argument("--log-format", default=logging.BASIC_FORMAT) parser.add_argument("--version", action="version", version=__version__) return parser diff --git a/wyoming_parakeet/handler.py b/wyoming_parakeet/handler.py index 7d5e445..dc14311 100644 --- a/wyoming_parakeet/handler.py +++ b/wyoming_parakeet/handler.py @@ -12,6 +12,13 @@ from .engine import SAMPLE_RATE _LOGGER = logging.getLogger(__name__) +# Wyoming has no authentication, so any client that can reach the port can +# stream audio. Without a cap, self.audio grows until AudioStop -- measured at +# ~11 MB/s over loopback, which exhausts 32 GB in under an hour from a single +# connection. A stuck satellite that never sends AudioStop does the same thing +# by accident. No real voice command approaches this bound. +DEFAULT_MAX_AUDIO_SECONDS = 120 + class ParakeetEventHandler(AsyncEventHandler): def __init__(self, wyoming_info: Info, cli_args, engine, *args, **kwargs): @@ -20,13 +27,27 @@ class ParakeetEventHandler(AsyncEventHandler): self.wyoming_info_event = wyoming_info.event() self.engine = engine self.audio = bytes() + self.truncated = False self.converter = AudioChunkConverter(rate=SAMPLE_RATE, width=2, channels=1) + max_seconds = getattr(cli_args, "max_audio_seconds", DEFAULT_MAX_AUDIO_SECONDS) + self.max_bytes = int(max_seconds * SAMPLE_RATE * 2) + + def _append(self, chunk: bytes) -> None: + room = self.max_bytes - len(self.audio) + if room > 0: + self.audio += chunk[:room] + if len(self.audio) >= self.max_bytes and not self.truncated: + self.truncated = True + _LOGGER.warning( + "Audio exceeded %.0fs; ignoring the rest of this utterance", + self.max_bytes / (SAMPLE_RATE * 2), + ) async def handle_event(self, event: Event) -> bool: if AudioChunk.is_type(event.type): if not self.audio: _LOGGER.debug("Receiving audio") - self.audio += self.converter.convert(AudioChunk.from_event(event)).audio + self._append(self.converter.convert(AudioChunk.from_event(event)).audio) return True if AudioStop.is_type(event.type): @@ -34,12 +55,16 @@ class ParakeetEventHandler(AsyncEventHandler): started = time.monotonic() try: text = await self.engine.transcribe(self.audio) + # The transcript is everything the user said, and the log file + # is long-lived and readable by other local accounts. Keep the + # operational signal at INFO and the content behind --debug. _LOGGER.info( - "%.2fs audio -> %.0fms :: %r", + "%.2fs audio -> %.0fms, %d chars", duration, (time.monotonic() - started) * 1000, - text, + len(text), ) + _LOGGER.debug("Transcript: %r", text) except Exception: # wyoming's run loop has no except clause, so letting this # propagate closes the connection without ever sending a @@ -49,6 +74,7 @@ class ParakeetEventHandler(AsyncEventHandler): text = "" finally: self.audio = bytes() + self.truncated = False await self.write_event(Transcript(text=text).event()) return False