Skip to content

Releases: Leproide/SayNoMore

SayNoMore - v6.1

15 Jun 11:29
6a8beb7

Choose a tag to compare

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, no lang.php strings 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 notified counter (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") and expired_clean ("Expired and removed") — with dedicated, properly worded Italian/English subject, body and HTML heading, plus distinct accent colors (amber for expired_open, slate for expired_clean).
  • view.php: when an already-expired secret is accessed via the unlock request, an expired_open notification is sent (deferred, after the response) before returning the standard "link invalid" error.
  • cleanup.php: sends an expired_clean notification for each expired-and-never-opened secret that carries a valid notify_email, and reports a new notified counter 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. The snm_send_notification() / snm_send_notification_deferred() signatures are unchanged; any unknown event value still falls back to destroyed for backward compatibility.
  • view.php: the notification fields (notify_email, lang, short ID) are now captured before the expiry check so the new expired_open event can use them. The existing read and destroyed flows are unchanged.

Notes / Upgrade

  • New dependency: cleanup.php now require_onces mail.php (which in turn requires lang.php). Previously it was fully standalone. In a normal installation all files are present, so nothing to do — but if you deploy cleanup.php on its own, keep mail.php, lang.php and (optionally) mailconfig.php alongside it. Including mail.phplang.php from CLI is side-effect free (lang.php only defines functions).
  • No data migration: the secret payload format is unchanged. Existing secrets keep working; the notify_email / lang fields are read as before.
  • Notifications stay opt-in: with 'enabled' => false (or no mailconfig.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.php removes expired secrets without sending the "Expired and removed" email. Only the cron cleanup.php sends 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 read and destroyed notification emails are byte-for-byte unchanged; the successful read flow (HTTP 200 + iv/ct, one-time consumption) is unaffected; cleanup.php runs warning-free in CLI with mailconfig.php absent.

⚙️ 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 Leproidehttps://github.com/Leproide
Repository: https://github.com/Leproide/SayNoMore

Full Changelog: v6...v6.1

SayNoMore - Stable

11 Jun 10:44
7021cb5

Choose a tag to compare

SayNoMore

c4b311a6-165e-437a-b2af-3d02f8bf007f

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 .onion hidden 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.sh and 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 .onion hidden services automatically use http:// instead of https://
  • 💻 No database required, just the file system

🚀 How it works

Creating a secret

  1. Enter a message, choose a password, and select how many days the link should remain valid.
  2. In your browser, JavaScript generates a random AES-256 key (fragKey) and IV and encrypts the message (AES-256-GCM). Plaintext and fragKey never leave the browser.
  3. The browser sends to the server only the IV, the ciphertext (with auth tag), the password, and the TTL.
  4. The server hashes the password (Argon2id) and stores {iv, ct, hash, expires, attempts} — it does not encrypt anything and holds no key.
  5. The browser builds the link view.php?token=...#fragKey (the server never knows fragKey).

Reading a secret

  1. The recipient opens the link; JavaScript reads fragKey from the URL fragment.
  2. The recipient enters the password; the browser sends only the token and the password (never fragKey).
  3. 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.
  4. The browser decrypts the ciphertext locally with fragKey and shows the secret; the fragment is then removed from the address bar. If fragKey is 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

https://saynomore.muninn.ovh

🛠️ 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 data folder
  • Protect the data directory 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 .onion address. 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. OpenSSL is 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.php

Open https://your-domain/argon-check.php in a browser, read the output,
then delete the file immediately:

rm argon-check.php

Leaving 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 ...
Read more