All projects
Open source · v0.1.0Project 05
CLIZero-knowledgeOpen source

kip

kip push .env returns a link that expires in an hour and is deleted after one read. No account to send or receive, self-hosted with one compose file, and an 8.7 MB static binary for Linux, macOS and Windows.

GoCobraAES-256-GCMRedisSQLiteAstro + VueWeb CryptoMIT

The server that stores your .env cannot read it. Even if it wanted to.

The decryption key travels after the # in the URL. Browsers never send the fragment in the HTTP request, and neither does the CLI. A stolen database yields ciphertext and nothing else.

https://kip.example.com/s/h4l1yddyftd8#8tvB2j3oq38kATqid2fjQkakgECPbmcTTuAyGW3yXg3W
Travels in the HTTP request
GET /api/v1/secret/h4l1yddyftd8
The server knows which blob to serve. Nothing else.
Stays in the browser
#8tvB2j3oq38kATqid2…
The fragment is not part of the request. No browser sends it.
1 readand the link is deleted, by default. Plus a one-hour lifetime even if nobody opens it.
8.7 MBthe CLI binary: static, no runtime dependencies
6 binariesLinux, macOS and Windows on amd64 and arm64, in the v0.1.0 release
01 · How it works

One push, one link, one read

Click through the steps or let them play. Each step lights up the piece it describes.

Your .env
on your machine
kip push
encrypts locally
Server
ciphertext only
Link
key after the #
Browser
decrypts locally
Step 1 of 5 · plays on its own
The CLI encrypts before anything leaves your machine
A random 32-byte key and a 12-byte nonce. AES-256-GCM from the Go stdlib: the file goes out encrypted, with its authentication tag. The key is unique per push and never reused.

What the sender sees. The link line is built in the CLI, after the server has returned the id.

$ kip push .env.staging
Secret shared successfully!
Link: https://kip.example.com/s/h4l1yddyftd8#8tvB2j3oq38kATqid2fjQkakgECPbmcTTuAyGW3yXg3W
Expires: 2026-08-02 16:32 (in 1h) | Reads: 1
# --ttl in seconds, --reads n, --password for an extra layer
02 · Architecture

Two stores, one domain, zero frameworks

What sits behind the link in a real deployment. The Go server carries no web framework; secrets live in Redis and teams in SQLite.

Topology · docker-compose.prod.yml
Clients
CLI
Go + Cobra · one binary
push · pull · revoke
Browser
Astro + Vue · SubtleCrypto
decrypts locally
HTTPS · ciphertext and nonce, never the key
Edge
Reverse proxy
Traefik · one domain
no CORS
Static web
nginx · 64 MB cap
0 third-party scripts
/api and /health to the server · everything else to the web
API
Go server
stdlib net/http
30 req/min per IP · 5 MB max
no port published on the host
Data
Redis
secrets · native TTL
1 min to 30 days
SQLite
teams · /data volume
embedded

Without Redis, the server falls back to an in-memory store for development. Without a proxy, docker-compose.yml publishes port 8080 on the host for local testing.

03 · An end-to-end example

The staging .env crosses Slack without staying in Slack

A made-up case with short timings, to see what happens at each stage. The names are examples.

1
10:02 · Luis's machine
Luis pushes the file
kip push .env.staging. The CLI generates the key, encrypts, uploads the blob and receives the id. It prints the link with the key after the #. The plaintext file has not left his disk.
2
10:03 · Slack
He pastes the link, not the secret
What stays in the channel history is a URL. In one hour it stops working, and after one read, too. The text STRIPE_SECRET_KEY=sk_live… never went through Slack.
3
10:05 · María's browser
María opens it without installing anything
The page requests /api/v1/secret/h4l1…. The server hands over the ciphertext, decrements the read count and, at zero, deletes the record. The browser decrypts with the key from the fragment and shows the file with a download button.
4
10:20 · someone else
The link no longer exists
A third party finds the URL in the channel and opens it: 404. There is no blob to serve. And even if there were, someone with only server access would not have the key.
5
Alternative · 10:04
Luis changes his mind before anyone reads it
kip revoke <link> deletes the secret on the server. The link dies before the first read.
04 · The challenges

Four times the obvious solution was wrong

Each challenge follows the same pattern: what anyone would do first, why it does not work, what was done, and the number that proves it. The technical detail stays folded.

01

Real zero-knowledge, not HTTPS and a promise

The obvious approach
Upload the file over HTTPS, encrypt it on the server and promise nobody looks at it.
Why it fails
The server sees the plaintext, even if only for an instant, and the key lives next to the data. A database breach, a misplaced backup or a curious operator has everything they need. The promise cannot be verified from outside.
Sender
CLI · Go
Plaintext .env · never leaves
32-byte key · generates it
Ciphertext + nonce · uploads it
Secret id · receives it
Server
Redis · TTL
Plaintext .env
Key · never arrives
Ciphertext + nonce · with TTL
Secret id
Receiver
Browser · Web Crypto
Plaintext .env · after decrypting
Key · from the fragment
Ciphertext + nonce · fetches it
Secret id · from the path
What was done
Encryption happens on the sender's machine, with crypto/aes and crypto/cipher from the stdlib. The ciphertext and the nonce travel to the server. The key is encoded in base58 and placed after the # on the client, after the id comes back. Neither the browser nor the CLI ever sends the fragment.
32 bytesof key, unique per push, that never leave the source machine. GCM detects any tampering with the blob.
Technical detail

Threat model, as published in SECURITY.md: database breach, encrypted blobs only; man-in-the-middle, HTTPS required and the fragment never crosses the network; brute force on ids, 12-character nanoid plus per-IP rate limiting; malicious operator, can delete but cannot decrypt.

Declared residual risk: the fragment does land in the browser history. The mitigation is that the decryption page loads no third-party scripts and does not keep the secret in any global page object.

--password adds a second layer: the nonce + ciphertext package is encrypted again with a key derived by Argon2id (64 MB, 4 threads). Whoever has the URL still needs the passphrase.

02

Self-destruction without a process that deletes

The obvious approach
A cron that looks for expired secrets every minute and removes them.
Why it fails
It is one more process to operate, and one that can go down. It leaves a window between expiry and deletion where the blob is still there. And it does not solve the read limit, which is a race: two simultaneous reads could both get through.
Someone opens it at 12 minutes
push · 0 minread → deleted · 12 minTTL · 60 min
Nobody opens it
push · 0 minRedis deletes it · 60 min
What was done
Two mechanisms, neither with a scheduler. The native Redis TTL deletes the record after an hour even if nobody reads it. The read counter is decremented in the same operation that hands over the ciphertext, keeping the remaining TTL, and at zero the record is removed. kip revoke deletes it earlier.
0 cronsDefaults: 3,600 s and one read. The server bounds the TTL between one minute and 30 days, and reads at 100.
Technical detail

Files downloaded by kip pull, the CLI configuration and a directory's .kip file are written with 0600 permissions. The only remaining 0644 files are .gitignore, .git/info/exclude and .env.example, which exist to be read.

03

The receiver installs nothing

The obvious approach
Have the receiver install the CLI and run kip pull.
Why it fails
The receiver is often not the one with a terminal: a designer, someone from product, a freelancer for a week. If sharing requires the other person to install something, people go back to pasting it in Slack. And a SPA that decrypts needs to talk to an API on another domain: CORS, a URL baked into the front end, two certificates.
One domain · kip.example.com
/api/*→Go server · :8080
/health→Go server · :8080
/*→static web · nginx
The link printed by kip push opens the page, and the page talks to the API on the same origin. No CORS, no API URL baked into the front end.
What was done
The /s/:id page (Astro + Vue) reads the fragment, fetches the ciphertext and runs AES-256-GCM with SubtleCrypto. The production compose uses a single-domain layout: /api and /health go to the Go server; everything else goes to the static web served by nginx. Same origin, no CORS.
0 scriptsfrom third parties on the decryption page. For the same reason as the threat model: nothing foreign runs next to the secret.
Technical detail

The landing is static HTML generated by Astro. Vue only enters on the page that decrypts, because it is the only one that needs JavaScript. Fonts are served from the same domain.

Debt declared in the code: the password layer is derived with Argon2id in the CLI, but Web Crypto does not implement Argon2id and the page uses PBKDF2. Today a link with --password opens with kip pull; in the browser that second layer does not match. The fix is either an Argon2 library in JS or agreeing on the KDF with the server.
04

CI was green and the linter had never run

The obvious approach
Green badge in the README, tests passing: the project is healthy.
Why it fails
The lint job used golangci-lint-action with version: latest. The action jumped to a major that rejects old configurations and the job stopped running the linter. And the Makefile pointed at a directory that no longer existed after a rename. Green by omission, not by merit.
LeaveTeam · sqlite.go
A COUNT(*) with an unchecked error: 0 was indistinguishable from "no members left" and deleted a team that still had them.
http.Server
Zero value: no timeouts, exposed to Slowloris. Now ReadHeader, Read, Write and Idle.
os.Exit in main
Swallowed the defers that closed Redis and SQLite. Replaced with the run() error pattern.
Sentinels with ==
Instead of errors.Is. The max-same-issues cap hid three; they were found by hand.
CLI HTTP client
No context. All 7 methods now take ctx, and Ctrl+C aborts an in-flight upload with exit 130.
Cobra
On a connection refused it printed the error twice plus the whole help block. SilenceUsage + SilenceErrors.
What was done
Configuration migrated and pinned to the linter's major. The first real pass surfaced 102 issues, tuned with judgment: fieldalignment turned off as irrelevant in a CLI, and tests exempted from gosec G101 because the secret scanner's fixtures are, by definition, secrets. What remained included real bugs.
102 → 0lint issues. Six bugs fixed that no unit test had caught.
Technical detail

Verified by hand beyond the linter: push → pull cycle against a real server with intact content and 600 permissions, second pull rejected by self-destruction, correct and wrong password, and teams with direct inspection of the SQLite file.

Current go test -cover coverage: crypto 82.8%, envfile 84%, scanner 100%, handlers 60.1%, store 62.1%. The HTTP client and the CLI configuration have no tests yet.

"The wrapper contradicted the code."
The core held up under serious review: correct crypto, clean architecture, real tests. The README did not: it documented an "AI Security Scan" tier, commands like diff and inbox, and a pricing page with plans that did not exist. Everything was cut down to what was real and the rest moved to an explicit Roadmap before the repository went public.
05 · Decisions

One filter: does this ask the receiver for an account, an install or one more step?

If the answer was yes, it was out. kip lives between "paste it in Slack" and "deploy Vault", and every piece of the stack was measured against that position.

WHY
Why stdlib net/http and not a framework?
The server has a handful of routes: upload a blob, read it, revoke it, and the teams CRUD. A framework adds dependencies to a project whose promise is a binary with nothing at runtime. The CLI is Go and Cobra, built with GoReleaser for three operating systems and two architectures.
Go deeper

It installs with install.sh, which detects OS and architecture and downloads the release archive, or with go install github.com/kipenv/kip/cmd/kip@latest. The middleware is in-house: logger, CORS and a per-IP token-bucket rate limiter, 30 requests per minute by default.

WHY
Why Redis for secrets and SQLite for teams?
Because they are two problems. Secrets are ephemeral and the native Redis TTL makes expiry free. Teams are relational and durable: embedded SQLite, no extra service, persisted on a volume.
Go deeper

Teams work like lobbies: one person creates, shares the invite code, the others join. No sign-up, no roles, no dashboard. The API already implements team-level sharing, inbox, audit log and a pinned "official" env per project, with tests. The CLI does not expose those endpoints yet; it is the first item on the roadmap.

SQLite comes in through cgo, so the server ships only as a Docker image. The first GoReleaser configuration built it with CGO_ENABLED=0 on mattn/go-sqlite3: it compiled and crashed on opening the database. Today GoReleaser publishes only the CLI.
WHY
Why Astro + Vue and not a SPA?
The landing is static. The only page that needs JavaScript is the decryption page, and it needs exactly SubtleCrypto and nothing else. Astro keeps the landing as HTML and Vue enters only where decryption happens.
WHY
Why is there no public instance?
Because it changes the trust model: the operator cannot decrypt, but can delete, and that is already a relationship with a third party. It also adds operating cost to an MIT project with no monetization. The README says so on the first screen instead of promising it.
Go deeper

Point the CLI at your own server with kip config set --server, or run it locally with the development compose. When a public instance exists it will be an option, not the default.

Where it sits
Slack DM
no encryption · no expiry
The secret stays in the workspace history, searchable by anyone with access.
Yopass
zero-knowledge · no CLI
Self-destructing and self-hostable, but no native CLI and no focus on .env.
1Password · Doppler
account required
Has a CLI, but no zero-knowledge, no self-destruction, and the receiver needs an account.
Vault
self-hostable · infrastructure
No self-destruction or zero-knowledge, and a whole stack to operate just to share a .env.
kip: native CLI, zero-knowledge, self-destruction, no account to send or to receive, self-hosted, focused on .env, MIT. For teams that want the secret to disappear without standing up infrastructure.
06 · What I would do differently

Five decisions I would make the other way today

I did
README written ahead of the code
Today I would
Document only what exists and move the rest to a Roadmap. There was an "AI Security Scan" tier, commands that did not exist and a pricing page with invented plans.
I did
version: latest in golangci-lint-action
Today I would
Pin the action's major together with the linter's. The major bump rejected the configuration and lint did not run until someone looked at the log.
I did
kip-server in GoReleaser with CGO_ENABLED=0
Today I would
modernc.org/sqlite, or take the server out of GoReleaser from the start. It compiled and crashed on startup.
I did
Team sharing implemented first in the API
Today I would
Close each feature in CLI and API at the same time. The server has inbox, audit log and pinned env with tests; the CLI only has membership.
I did
Argon2id in the CLI, PBKDF2 in the browser
Today I would
Choose the KDF with both clients in mind from day one. Today the password layer only matches between CLIs.
07 · If you take three things away

Recap

1
The key goes after the # and the HTTP protocol never sends it. The server stores ciphertext; a stolen database is worthless.
2
Secrets die on their own: one read and one hour by default, no cron, with the native Redis TTL and a counter decremented on delivery.
3
The receiver installs nothing: open the link and the browser decrypts with Web Crypto on the same origin. The sender, a static 8.7 MB binary.
Links