moonshine

An SMS relay for shared MFA

SMS isn't MFA.
Some services didn't get the memo.

Some services still only offer SMS codes, even for break-glass and admin accounts. Tie that account to one person's phone and it leaves when they do. moonshine gives the account its own SIM in a Raspberry Pi, and forwards every code to your team.

Raspberry Pi Zero 2 W · SIMCom A7670E · ntfy, Teams, Slack and webhooks · Open source

Shared with the whole team in about three seconds.

Never loses a code

Written to disk before it's deleted from the SIM. A crash means a duplicate, not a loss.

Every destination, separately

One broken webhook never blocks, or duplicates, delivery to the others.

Small enough to read

Plain Python, two dependencies, a hardened systemd unit. Audit it over lunch.

Why

The fact this exists is the problem

moonshine is a workaround for vendors who still haven't shipped something better. It should never be needed. Sometimes it is.

“This solution should literally never be used, but sometimes needs must. I strongly dislike that I spent time and money on this.”

The README, being honest
  • SMS was never a strong factor. SIM swaps, SS7 interception and real-time phishing all beat it. Regulators and standards bodies have said so for years.
  • Yet it's still the only option on some consoles, portals and admin accounts. Often the most privileged ones.
  • Shared accounts make it worse. A code that only reaches one person's phone is a single point of failure for the whole team.
  • So you end up building a box that turns “something you have” into “something your team channel has”. That's a downgrade, and it's still better than the alternative.
  • Use passkeys or passwordless sign-in everywhere you can. Ask every vendor that only offers SMS when that's changing. Then switch moonshine off.

And no, this totally isn't an overreaction to a certain fruit-based company whose business management portal offers nothing but SMS or a phone call for MFA, unless you've got a federated identity provider. Totally not. Why would you think that?

Destinations

Lands where your team already looks

Every SMS goes to every destination you configure. The code is pulled out and put first, so nobody has to squint.

ntfy

Push to phones, from ntfy.sh or your own server, with a token or username and password. Self-hosted with access control is the sensible choice.

Microsoft Teams

An Adaptive Card through a Workflows webhook, with the code shown big, the full text, the sender and the time. The SMS is shown as plain text, so it can't hide a link.

Slack

An incoming webhook to a private channel. The SMS is sent as plain text, so a malicious message can't ping @channel or hide a link.

Generic webhook

JSON to any HTTPS endpoint, with optional HMAC-SHA256 signing in X-Moonshine-Signature and whatever headers you need.

Email

No. The docs explain why, at some length.

POST /hooks/sms · X-Moonshine-Signature: sha256=…
{
  "event": "sms.received",
  "device": "breakglass-sms",
  "title": "482913 — SMS from ExampleCloud",
  "from": "ExampleCloud",
  "code": "482913",
  "parts": 1,
  "incomplete": false
}

Reliability

A missed code is a locked-out admin

Break-glass accounts get used on the worst day of the year. The relay has to work then, too.

  1. Service
  2. SIM in the A7670E
  3. Pi Zero 2 W
  4. ntfy · Teams · Slack · webhook
  1. Arrives

    The modem announces each SMS. The SIM is also swept on a timer and at start-up, so nothing slips past a missed notification or a reboot.

  2. Spooled

    Decoded and written atomically to disk: temp file, fsync, rename. Only then is it deleted from the SIM.

  3. Delivered

    Each destination is tried on its own, with backoff from 5 seconds to 5 minutes. Success is recorded per destination, so working ones never get a repeat.

Security

Whoever reads the channel can sign in

So moonshine keeps codes out of everywhere else, and tells you plainly what it can't fix.

Nothing in the journal

SMS bodies aren't logged unless you turn on log_message_content. Spool files are deleted once delivered.

No sending API

Apart from scheduled keep-alives and balance checks, an SMS goes out only when someone with a shell on the Pi asks for one.

A PIN that can't lock the SIM

Set a SIM PIN so a stolen SIM is useless. If the SIM rejects it, moonshine alerts you and never enters that PIN again, even after a restart.

Strict config

Unknown keys are errors, so a typo can't quietly switch off a destination. Secrets live in a separate env file.

A hardened service

Its own system user, a read-only filesystem, no capabilities, and a 0700 state directory.

Honest about SMS

SIM swaps and SS7 attacks are out of scope because they're in SMS itself. Where a service offers FIDO2 or passkeys, use them instead.

Build

One evening, one Pi, one SIM

About the price of a round, if the round is large.

  1. Assemble

    A Pi Zero 2 W, a Waveshare A7670E Cat-1 HAT with its antenna, a SIM that receives SMS and one 5V power supply. The HAT powers on with the Pi.

  2. Free the UART

    Give the header the Pi's full UART so SMS aren't corrupted. This turns off Bluetooth, so use a Pi that doesn't need it.

  3. Install

    Run the installer, add your destinations and secrets, then send a test to every destination before you text the SIM.

$ sudo ./deploy/install.sh
$ sudo moonshine check-config
$ sudo moonshine test-notify --sms
$ sudo systemctl start moonshine
$ journalctl -u moonshine -f
moonshine starting on breakglass-sms (3 destination(s))
Modem initialised on /dev/serial0
Network: registered (home), operator ExampleNet, rssi 18/31
Received SMS from ExampleCloud (63 chars)
Delivered sms 6f1c0e5d… to ntfy
Delivered sms 6f1c0e5d… to teams
Delivered sms 6f1c0e5d… to slack

Why “moonshine”?

Moonshine is spirit made at home, out of sight, from whatever was lying around. It mostly works. Nobody's quite sure what's in it. You probably shouldn't, and you definitely shouldn't brag about it.

This project is much the same. A SIM in a shed-built Pi, distilling one-time codes and passing them round the team. Homemade, unregulated and every bit as sketchy as the drink. Handle with care, and don't serve it to anyone you like.

Know the limits

moonshine is a personal project, provided as-is under the MIT licence, with no warranty and no support. It isn't affiliated with any employer or organisation. It hasn't yet been proven on real hardware, so treat it as early.

Check your network's terms on automated messages, use a business SIM, and only relay codes for accounts whose terms allow shared access. Never use it for banking, government or anyone's personal logins. The README covers the trade-offs in full.

Homemade, and not proud of it.

Free and open source under the MIT licence. I hope it's never used.