Releases: Leproide/SayNoMore
SayNoMore - v6.1
Release date: 2026-06-15
Release 6.1 extends the optional email-notification system to secret expiry. Until now the creator could only be notified when a secret was read or destroyed (too many failed attempts). Starting with 6.1, two new events cover the full lifecycle of a secret that is never successfully opened.
✨ What's new
Expiry notifications
When email notifications are enabled (mailconfig.php → 'enabled' => true) and the creator provided a notification address, SayNoMore now also sends an email for:
| Event | When it fires | Source |
|---|---|---|
| Opened after expiry | Someone opens the link of a secret that has already expired. The content is not shown and the file is removed. | view.php (on the unlock request) |
| Expired and removed | The cron cleanup deletes a secret that expired without ever being opened. | cleanup.php |
Together with the existing Secret read and Secret destroyed events, the creator can now be informed of every terminal state of a secret.
- Emails are localized in the language chosen at creation time (Italian / English) — handled inline in
mail.php, nolang.phpstrings required. - Each email shows the short secret ID (first 8 hex characters of the token), plus date and time.
- The cleanup CLI prints a new
notifiedcounter (number of "Expired and removed" emails sent during the run).
📝 Changelog
[6.1] — 2026-06-15
Added
mail.php: two new notification events —expired_open("Opened after expiry") andexpired_clean("Expired and removed") — with dedicated, properly worded Italian/English subject, body and HTML heading, plus distinct accent colors (amber forexpired_open, slate forexpired_clean).view.php: when an already-expired secret is accessed via the unlock request, anexpired_opennotification is sent (deferred, after the response) before returning the standard "link invalid" error.cleanup.php: sends anexpired_cleannotification for each expired-and-never-opened secret that carries a validnotify_email, and reports a newnotifiedcounter in its output.README.md: documented the expiry notifications (Email notifications + Expired secret cleanup sections).
Changed
mail.php: the notification builder moved from a two-state (read/destroyed) layout to an explicit four-event layout. Thesnm_send_notification()/snm_send_notification_deferred()signatures are unchanged; any unknown event value still falls back todestroyedfor backward compatibility.view.php: the notification fields (notify_email,lang, short ID) are now captured before the expiry check so the newexpired_openevent can use them. The existingreadanddestroyedflows are unchanged.
Notes / Upgrade
- New dependency:
cleanup.phpnowrequire_oncesmail.php(which in turn requireslang.php). Previously it was fully standalone. In a normal installation all files are present, so nothing to do — but if you deploycleanup.phpon its own, keepmail.php,lang.phpand (optionally)mailconfig.phpalongside it. Includingmail.php→lang.phpfrom CLI is side-effect free (lang.phponly defines functions). - No data migration: the secret payload format is unchanged. Existing secrets keep working; the
notify_email/langfields are read as before. - Notifications stay opt-in: with
'enabled' => false(or nomailconfig.php) the behavior is identical to 6.0 — secrets are simply removed, no email is sent. - In-request cleanup is silent: the probabilistic in-request cleanup in
index.php/view.phpremoves expired secrets without sending the "Expired and removed" email. Only the croncleanup.phpsends it. If you depend on expiry notifications, run the cron — and optionally disable the in-request cleanup (const CLEANUP_ENABLED = false;).
Compatibility
- No breaking changes. Verified: the
readanddestroyednotification emails are byte-for-byte unchanged; the successful read flow (HTTP 200 +iv/ct, one-time consumption) is unaffected;cleanup.phpruns warning-free in CLI withmailconfig.phpabsent.
⚙️ Configuration recap
Email notifications are configured entirely in mailconfig.php:
return [
'enabled' => true,
'host' => 'smtp.example.com',
'port' => 587,
'secure' => 'tls', // 'ssl' | 'tls' | ''
'username' => 'noreply@example.com',
'password' => 'your-smtp-password',
'from' => 'noreply@example.com',
'from_name' => 'SayNoMore',
'site_url' => 'https://your-site.example',
'max_retries' => 3,
'debug' => false,
'timeout' => 10,
];For the full configuration reference, SMTP profiles and debug logging, see the main README.md.
Files changed in 6.1
mail.php # +2 events (expired_open, expired_clean)
view.php # expired_open notification on expired access
cleanup.php # expired_clean notification + notified counter
README.md # documentation
License
This project is distributed under the GNU General Public License, version 2, consistent with the GPL v2 headers in every source file of the repository. No warranty is provided.
Author
Created by Leproide — https://github.com/Leproide
Repository: https://github.com/Leproide/SayNoMore
Full Changelog: v6...v6.1
SayNoMore - Stable
SayNoMore
SayNoMore is a simple One Time Secret service for sharing passwords or sensitive information that can only be viewed once.
⚠ BREAKING UPDATE (end-to-end encryption)
Since v6, encryption and decryption happen entirely in the browser (Web Crypto, AES-256-GCM). The server never sees the plaintext nor the AES key, in any phase. Read before upgrading:
- Secrets created with previous versions become unreadable (the on-disk format and the key scheme changed). Since secrets are ephemeral (max 30 days), do a clean cutover: empty the
data/folder on deploy, or wait for old secrets to expire.- Creating and reading now require JavaScript and a secure context. On clearnet you need HTTPS; on a
.onionhidden service it works. On plain-HTTP clearnet, encryption is disabled with an explicit on-screen message — never a silent downgrade.- OpenSSL is no longer required on the server for secret encryption (it moved to the browser); it is only used by the optional email notifications over SSL/STARTTLS.
cleanup.php,ExpireCheck.shand the email notifications are unchanged and fully compatible with the new format.
🔐 Features
- ✉️ Secrets readable only once, protected by a password (Argon2id hashing with automatic salt)
- 🔒 End-to-end AES-256-GCM: encryption and decryption happen in the browser (Web Crypto). The server only stores and relays ciphertext and can never decrypt it. The GCM authentication tag detects any ciphertext tampering.
- 🧠 Zero-knowledge on the content: the AES key is generated in the browser, lives only in the URL fragment (
#), and is never sent to the server — not in the link, not in any request. A correct password alone cannot decrypt without it. - 🔑 Password as a server-side access gate: the view-password is verified server-side (Argon2id) to enforce the one-time read and the 5-attempt limit; it does not decrypt the content, so a stolen password alone is useless.
- ⏳ User-configurable expiration: from 1 to 30 days (default 7)
- 🧹 Automatic cleanup with non-blocking locking: expired secrets are removed in the background without interfering with active unlock attempts
- 🧼 Destruction after read (with best-effort overwrite, see notes below)
- 🛡 Anti-abuse mitigations: 64 KB secret size limit, max 5 password attempts, uniform timing against token enumeration, input type validation against malformed requests
- 🌍 Multilingual: Italian for Italian browsers, English everywhere else (based on
Accept-Language) - 📬 Optional email notifications (off by default): when enabled in
mailconfig.php, the creator can tick a checkbox to receive an email when the secret is opened or destroyed after too many failed attempts - 🧅 Tor support: links generated on
.onionhidden services automatically usehttp://instead ofhttps:// - 💻 No database required, just the file system
🚀 How it works
Creating a secret
- Enter a message, choose a password, and select how many days the link should remain valid.
- In your browser, JavaScript generates a random AES-256 key (
fragKey) and IV and encrypts the message (AES-256-GCM). Plaintext andfragKeynever leave the browser. - The browser sends to the server only the IV, the ciphertext (with auth tag), the password, and the TTL.
- The server hashes the password (Argon2id) and stores
{iv, ct, hash, expires, attempts}— it does not encrypt anything and holds no key. - The browser builds the link
view.php?token=...#fragKey(the server never knowsfragKey).
Reading a secret
- The recipient opens the link; JavaScript reads
fragKeyfrom the URL fragment. - The recipient enters the password; the browser sends only the
tokenand thepassword(neverfragKey). - The server verifies the password. On success it returns the stored IV + ciphertext and destroys the file (one-time). On wrong password it increments the counter; after 5 failures the secret is destroyed.
- The browser decrypts the ciphertext locally with
fragKeyand shows the secret; the fragment is then removed from the address bar. IffragKeyis missing/corrupted, decryption fails client-side (the secret is already consumed).
The password is mandatory: trying to generate a link without one shows a localized popup ("La password è obbligatoria." / "Password is required.") attached to the field. Validation messages are shown in the page language (not the browser language) by overriding the native message via
setCustomValidity. The secret and (when notifications are on) the email field use the same mechanism. The empty-password rule is also enforced server-side, so no secret is ever created without a password.
🔗 Demo
🛠️ Requirements
- PHP 7.4+ (8.x recommended)
- Argon2id available (PHP built with libargon2, default on modern distros)
random_bytes/random_int(CSPRNG)- OpenSSL is not required for secret encryption anymore (it now runs in the browser); it is only used by the optional email notifications over SSL/STARTTLS
- Web server with write permissions, the script will create a
datafolder - Protect the
datadirectory from unauthorized read access (recommended, see the Security section). - HTTPS configured at the web server level (required on clearnet — Web Crypto needs a secure context; see security section)
- JavaScript enabled on the client (required both to encrypt on creation and to read the key on viewing)
- Modern browser with Web Crypto (
crypto.subtle) in a secure context: HTTPS,localhost, or a.onionaddress. On plain-HTTP clearnet the app refuses to encrypt/decrypt and shows a message. - Local filesystem (ext4, xfs, btrfs, ntfs). On NFS/SMB file locking is not guaranteed.
✅ Verify PHP dependencies
Before deploying, make sure the PHP runtime serving SayNoMore has the
required crypto primitives. Missing Argon2id would break password hashing
and verification.
v6 note: secret encryption is now done in the browser, so server-side you
only need Argon2id and random_bytes.OpenSSLis optional — it is
used only by the email notifications over SSL/STARTTLS (and AES-256-GCM is no
longer used server-side at all, since it runs in the browser).
One-line CLI check
Run this from the same environment that serves the app:
php -r "echo 'Argon2id (required): ', (defined('PASSWORD_ARGON2ID') ? 'OK' : 'MISSING'), PHP_EOL, 'random_bytes (required): ', (function_exists('random_bytes') ? 'OK' : 'MISSING'), PHP_EOL, 'OpenSSL (email TLS only): ', (extension_loaded('openssl') ? 'OK' : 'absent (fine if you do not use email)'), PHP_EOL;"Expected output:
Argon2id (required): OK
random_bytes (required): OK
OpenSSL (email TLS only): OK
If Argon2id or random_bytes says MISSING, do not deploy: rebuild
PHP with the missing support, or switch to a modern distro package (php:8.x),
where they are present by default. OpenSSL showing absent is fine unless you
enable the email notifications.
Browser-side check (recommended)
The CLI php binary may differ from the one serving HTTP requests.
To test the exact PHP that will run SayNoMore, the repo ships a
ready-made probe file. Rename it to activate it:
mv argon-check.php.lock argon-check.phpOpen https://your-domain/argon-check.php in a browser, read the output,
then delete the file immediately:
rm argon-check.phpLeaving it online would expose your PHP version and capabilities, useful
information for an attacker.
⚙️ Configuration
The main parameters are constants at the top of index.php, view.php, and cleanup.php:
| Constant | File | Default | Description |
|---|---|---|---|
DEFAULT_TTL_DAYS |
index.php | 7 | Default validity in days for new secrets |
MIN_TTL_DAYS |
index.php | 1 | Minimum TTL selectable by the user |
MAX_TTL_DAYS |
index.php | 30 | Maximum TTL selectable by the user |
MAX_SECRET_BYTES |
index.php | 65536 (64 KB) | Plaintext size limit (enforced client-side, re-checked server-side as ciphertext − 16 GCM tag) |
MAX_CT_B64_BYTES |
index.php | 98304 (96 KB) | Hard cap on the base64 ciphertext accepted by the server (bounds memory before decoding) |
GCM_IV_LEN |
index.php | 12 | Expected GCM IV length in bytes (validated server-side) |
MAX_ATTEMPTS |
view.php | 5 | Maximum number of password attempts before destruction |
CLEANUP_ENABLED |
index.php / view.php | true | Master switch for in-request cleanup. Set to false to disable it entirely (useful when you run cleanup.php via cron) |
CLEANUP_PROB_PCT |
index.php / view.php | 50 | Probability (%) of running a global cleanup on each request (ignored when CLEANUP_ENABLED is ... |
