This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
What I Built
Kecal is a 37.5 M parameter open-weight language model that does exactly one thing: it writes casual Czech chat messages, a few lines in a row, the way people actually type on Messenger or WhatsApp.
I built it to power the Chat cipher on encrypt.click. You type a message and a passphrase, the tool encrypts it, and then it disguises the ciphertext as an ordinary chat conversation. Paste the conversation back in with the same passphrase and you get your message back.
The lazy part: nobody has to invent a believable conversation by hand at 1 a.m. The model writes the cover text, in your browser, offline.
- Model: huggingface.co/danielsebesta/kecal (open weights, CC BY-NC-SA 4.0, non-commercial)
- Tool repo (MIT): github.com/danielsebesta/encrypt
- Live demo: encrypt.click/tools/chat-cipher
What is new for this challenge: encrypt.click (file sharing, encrypted chat, whiteboard) existed before. Everything in this post — the Kecal model itself, its training run, the neural steganography engine in
src/lib/chatCipher/, the Czech language pack and the ONNX export — was built and shipped during the challenge window, on 24–25 October 2026 (the model was uploaded to Hugging Face on 25 October 2026; the Chat cipher replaced the site's older "AES words" style in the commitfeat(tools): replace AES Words with Chat cipher using Kecal and Falcon packs).
Demo
Pick Czech, download the language pack once (37.6 MB, stored only in your browser), type a message and a passphrase, hit Encrypt to chat.
I tested it with a 30-character secret, koupil jsem to vcera v obchode. First lines of the resulting cover:
ty uz si tam nebyl tak dva roky co sme se nevideli
tak sem ti chtel napsat ze se stehuju
uz nejsem v tom meste ani kde je centrum v novym centru no je takovej novej barak kde sem to nasel nazivo
je uplne v centru hned vedle stary benzinky vedle ty restaurace jak ty vzdycky jis a varis
maj tam nejlepsi parky na trhu jeste od patku
ja sem je vcera dal vsechny na chodbu aby bylo jidlo lepsi kdyz je vic casu pro malou na hriste
The full cover is 243 words / 1,133 characters — yes, that ciphertext is really in there — and writing it took about 90 seconds on my laptop, on single-threaded WASM.
Paste the whole cover text back in with the same passphrase and the original message comes out:
A wrong passphrase or an edited cover text fails closed instead of returning garbage.
The English mode uses Falcon-H1-Tiny (93.5 MB), Czech uses Kecal (37.6 MB). Both download once and then encrypt and decrypt without touching the network.
How It Works
Two independent halves.
1. Encryption. The message is sealed with AES-256-GCM under a key derived from your passphrase with Argon2id (the ECP3 envelope in src/lib/passwordEnvelope.ts). The output is not just the ciphertext: it carries a 72-byte header with the salt and nonce, a length field and a 16-byte authentication tag. My 30-character test message came out at about 120 bytes of sealed data — which is why the cover text is long.
2. Disguise. This is the part the project is actually about — deterministic top-k linguistic steganography:
- At each step the model scores every word it could write next.
- We keep the top 16 candidates (special tokens and line-break tokens are banned so the cover never looks indented in a chat UI).
- 16 candidates = 4 bits. We take the next 4 bits of the sealed ciphertext and use them as an index: bit pattern
0110means "take the 6th most likely word".
So every word in the cover carries exactly 4 bits — two words per byte of sealed data — and the choice is fully deterministic: 120 bytes of sealed data → 240 words, which matches the 243 words measured above (the length header and the last partial group account for the extra three).
Decoding runs the same model over the same cover text, looks up where each word ranked in its own top-16, reads the 4 bits back, rebuilds the bytes and decrypts them.
That is also why sender and receiver need the same model and the same tokenizer: decoding depends on reproducing the exact logits. Swap in different weights and the rankings shift, a word no longer appears in the expected top-16, and the tool reports "Cover text does not match this language pack" rather than decrypting something wrong.
The Model
| Parameters | 37.5 M |
| Architecture | decoder-only transformer (GPT-2), 8 layers, 8 heads, width 512 |
| Tokens | 24,000 words + <pad>, <eol>, <unk>, <turn>
|
| Context | 128 words |
| Perplexity | ~17 on held-out one-writer chat |
| Web pack | ONNX, int8 quantized → 37.6 MB |
| Licence | CC BY-NC-SA 4.0 — open weights, non-commercial |
| Published | Hugging Face, 25 October 2026 |
Why a word-level tokenizer? At this size a subword model spends most of its capacity on spelling. With a 24k vocabulary of whole words, 37.5 M parameters is enough for plausible Czech chat — and a word is a natural unit to hide 4 bits in.
Training data (publicly available datasets):
- Base run: OpenSubtitles, ~800 M words of Czech
- Fine-tuning: CZLC/CNC_oral_ortofon (spoken Czech), CZLC/fb_sentiment_balanced (Facebook comments), lang-uk/Reddit-MultiGEC (Czech posts from r/czech, MIT)
- A final pass on synthetic chat messages generated for this project, biased toward one-writer bursts (a person sending several messages with no reply)
Licensing follows the training data: attribution, non-commercial, share-alike. I did not want a permissive licence on weights built from corpora that are not permissively licensed — "publicly available" and "open source" are not the same claim, and I would rather be precise.
Inference in the browser runs through @huggingface/transformers (transformers.js) on ONNX Runtime Web. The 37.5 M model quantized to int8 fits in 37.6 MB, which is small enough to ship as a one-time download from encrypt.click's own origin, cache in the browser and run from local_files_only — after the first download there is no model host in the loop at all. WebGPU is tried first with a 20-second timeout, then it falls back to single-threaded WASM, which is the reliable path.
Why Open Innovation Matters Here
A privacy tool that calls a closed API is a contradiction. If the cover text came from someone else's server, you would be sending the context around your encrypted message to a third party — defeating the point of encrypting it.
Open weights make the local path possible at all:
- Nothing leaves your device. The model runs in the tab, offline, after one download.
- Nobody can revoke or change it. No API deprecations, no terms-of-service changes, no price.
- It's auditable. A steganographic tool asks you to trust it. With an open repo, open weights and a documented training set, you can check what the output looks like from the outside — including the exact top-k logic that hides the bits.
- It's extensible. Fine-tune Kecal for another language, or swap in a different small open model.
Limitations
I would rather say this up front:
- It is slow. About 90 seconds for a 30-character secret on single-threaded WASM. WebGPU is faster when the browser supports it, but the fallback path is what most people will hit on first use.
- No diacritics, no punctuation. The vocabulary stores words folded (lowercase, without diacritics), so the tool's output looks like fast informal typing. The Python helper in the model repo restores the most common written form of each word; the web tool does not.
-
A 37.5 M model sometimes repeats itself. The full cover of my test run contains a mid-sentence
mas jeste jeste, and the model drifts between topics. It is plausible, not literary. - The cover is statistically detectable. Anyone who knows this tool can tell the text is generated, and a classifier could too. This is camouflage that reads naturally to a human, not a guarantee of invisibility.
- The cover is long. Salt, nonce, header and tag always travel with the message: a 30-character secret became 243 words. For very short secrets the cover is dominated by the envelope, not by your message.
- Czech and English only, and the model's licence is non-commercial.
- The real protection is AES-256-GCM. The cover text hides that a secret exists; the encryption protects the content.
The Lazy Payoff
Writing a believable chat that hides the fact that it hides something is slow, fiddly work — and when people are tired they skip it and send the ciphertext raw. Kecal does the boring part: you write your message, click once, and a plausible dialogue appears.
Lazier output, safer output.



Top comments (0)