Todos los proyectos
Open Source · v0.1.0Proyecto 05
CLIZero-knowledgeOpen Source

kip

kip push .env devuelve un link que expira en una hora y se borra tras una lectura. Sin cuenta para enviar ni para recibir, self-hosted con un compose, y un binario estático de 8,7 MB para Linux, macOS y Windows.

GoCobraAES-256-GCMRedisSQLiteAstro + VueWeb CryptoMIT

El servidor que guarda tu .env no puede leerlo. Ni aunque quiera.

La clave de descifrado viaja detrás del # de la URL. Los navegadores no envían el fragment en la petición HTTP, y el CLI tampoco. Una base de datos robada entrega ciphertext y nada más.

https://kip.example.com/s/h4l1yddyftd8#8tvB2j3oq38kATqid2fjQkakgECPbmcTTuAyGW3yXg3W
Viaja en la petición HTTP
GET /api/v1/secret/h4l1yddyftd8
El servidor sabe qué blob entregar. Nada más.
Se queda en el navegador
#8tvB2j3oq38kATqid2…
El fragment no forma parte de la petición. Ningún navegador lo envía.
1 lecturay el link se borra, por defecto. Y una hora de vida aunque nadie lo abra.
8,7 MBel binario del CLI: estático, sin dependencias en runtime
6 binariosLinux, macOS y Windows en amd64 y arm64, en la release v0.1.0
01 · Cómo funciona

Un push, un link, una lectura

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza de la que habla.

Tu .env
en tu máquina
kip push
cifra en local
Servidor
solo ciphertext
Link
clave tras el #
Navegador
descifra en local
Paso 1 de 5 · avanza solo
El CLI cifra antes de que nada salga de tu máquina
Una clave aleatoria de 32 bytes y un nonce de 12. AES-256-GCM con la stdlib de Go: el archivo sale cifrado y con su etiqueta de autenticación. La clave es única por envío y no se reutiliza nunca.

Lo que ve quien envía. La línea del link se construye en el CLI, después de que el servidor haya devuelto el 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 en segundos, --reads n, --password para una capa extra
02 · Arquitectura

Dos almacenes, un dominio, cero frameworks

Lo que hay detrás del link cuando se despliega de verdad. El servidor Go no lleva framework web; los secretos viven en Redis y los teams en SQLite.

Topología · docker-compose.prod.yml
Clientes
CLI
Go + Cobra · un binario
push · pull · revoke
Navegador
Astro + Vue · SubtleCrypto
descifra en local
HTTPS · ciphertext y nonce, nunca la clave
Borde
Reverse proxy
Traefik · un solo dominio
sin CORS
Web estática
nginx · 64 MB de tope
0 scripts de terceros
/api y /health al servidor · el resto a la web
API
Servidor Go
net/http de la stdlib
30 req/min por IP · 5 MB máx
sin puerto publicado en el host
Datos
Redis
secretos · TTL nativo
de 1 min a 30 días
SQLite
teams · volumen /data
embebido

Sin Redis, el servidor cae a un almacén en memoria para desarrollo. Sin proxy, docker-compose.yml publica el puerto 8080 en el host para probarlo en local.

03 · Un ejemplo de punta a punta

El .env de staging cruza Slack sin quedarse en Slack

Un caso inventado con tiempos pequeños, para ver qué pasa en cada tramo. Los nombres son de ejemplo.

1
10:02 · máquina de Luis
Luis empuja el archivo
kip push .env.staging. El CLI genera la clave, cifra, sube el blob y recibe el id. Imprime el link con la clave detrás del #. El archivo en claro no ha salido de su disco.
2
10:03 · Slack
Pega el link, no el secreto
Lo que queda en el historial del canal es una URL. En una hora deja de servir, y después de una lectura, también. El texto STRIPE_SECRET_KEY=sk_live… nunca pasó por Slack.
3
10:05 · navegador de María
María lo abre sin instalar nada
La página pide /api/v1/secret/h4l1…. El servidor entrega el ciphertext, descuenta la lectura y, al llegar a cero, borra el registro. El navegador descifra con la clave del fragment y muestra el archivo con un botón para descargarlo.
4
10:20 · alguien más
El link ya no existe
Un tercero encuentra la URL en el canal y la abre: 404. No hay blob que entregar. Y aunque quedara, quien solo tuviera acceso al servidor no tendría la clave.
5
Alternativa · 10:04
Luis se arrepiente antes de que nadie lo lea
kip revoke <link> elimina el secreto en el servidor. El link muere antes de la primera lectura.
04 · Los retos

Cuatro veces en que la solución obvia estaba mal

Cada reto sigue el mismo patrón: lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo demuestra. El detalle técnico queda plegado.

01

Zero-knowledge de verdad, no HTTPS y una promesa

Lo obvio
Subir el archivo por HTTPS, cifrarlo en el servidor y prometer que nadie lo mira.
Por qué falla
El servidor ve el texto en claro aunque sea un instante, y la clave vive junto al dato. Una brecha de base de datos, un backup mal guardado o un operador curioso tienen todo lo que necesitan. La promesa no se puede verificar desde fuera.
Quien envía
CLI · Go
.env en claro · nunca sale
Clave de 32 bytes · la genera
Ciphertext + nonce · lo sube
Id del secreto · lo recibe
Servidor
Redis · TTL
.env en claro
Clave · no llega
Ciphertext + nonce · con TTL
Id del secreto
Quien recibe
Navegador · Web Crypto
.env en claro · tras descifrar
Clave · del fragment
Ciphertext + nonce · lo pide
Id del secreto · del path
Lo que se hizo
El cifrado ocurre en la máquina de quien envía, con crypto/aes y crypto/cipher de la stdlib. Al servidor viaja el ciphertext y el nonce. La clave se codifica en base58 y se coloca detrás del # en el cliente, después de recibir el id. Ni el navegador ni el CLI envían jamás el fragment.
32 bytesde clave, única por envío, que nunca salen de la máquina de origen. GCM detecta cualquier manipulación del blob.
Ver detalle técnico

Modelo de amenazas, tal como lo publica SECURITY.md: brecha de base de datos, solo blobs cifrados; man-in-the-middle, HTTPS obligatorio y el fragment nunca cruza la red; fuerza bruta sobre ids, nanoid de 12 caracteres más rate limiting por IP; operador malicioso, puede borrar pero no descifrar.

Riesgo residual declarado: el fragment sí aterriza en el historial del navegador. La mitigación es que la página de descifrado no carga scripts de terceros y no guarda el secreto en ningún objeto global de la página.

--password añade una segunda capa: el paquete nonce + ciphertext se cifra otra vez con una clave derivada por Argon2id (64 MB, 4 hilos). Quien tenga la URL sigue necesitando la frase.

02

Autodestrucción sin un proceso que borre

Lo obvio
Un cron que cada minuto busca secretos expirados y los elimina.
Por qué falla
Es un proceso más que operar y que puede caerse. Deja una ventana entre la expiración y el borrado en la que el blob sigue ahí. Y no resuelve el límite de lecturas, que es una carrera: dos lecturas simultáneas podrían pasar las dos.
Alguien lo abre a los 12 minutos
push · 0 minlectura → borrado · 12 minTTL · 60 min
Nadie lo abre
push · 0 minRedis lo borra · 60 min
Lo que se hizo
Dos mecanismos, ninguno con scheduler. El TTL nativo de Redis borra el registro a la hora aunque nadie lo lea. El contador de lecturas se decrementa en la misma operación que entrega el ciphertext, conservando el TTL restante, y en cero el registro se elimina. kip revoke lo borra antes.
0 cronsPor defecto: 3 600 s y una lectura. El servidor acota el TTL entre un minuto y 30 días, y las lecturas a 100.
Ver detalle técnico

Los archivos que descarga kip pull, la configuración del CLI y el fichero .kip de un directorio se escriben con permisos 0600. Los únicos 0644 restantes son .gitignore, .git/info/exclude y .env.example, que existen para ser leídos.

03

Quien recibe no instala nada

Lo obvio
Que el receptor instale el CLI y haga kip pull.
Por qué falla
El receptor muchas veces no es quien tiene terminal: un diseñador, alguien de producto, un freelance de una semana. Si compartir exige que el otro instale algo, la gente vuelve a pegarlo en Slack. Y una SPA que descifra necesita hablar con una API en otro dominio: CORS, una URL compilada en el front, dos certificados.
Un solo dominio · kip.example.com
/api/*→servidor Go · :8080
/health→servidor Go · :8080
/*→web estática · nginx
El link que imprime kip push abre la página, y la página habla con la API en la misma origin. Sin CORS, sin URL de API que compilar en el front.
Lo que se hizo
La página /s/:id (Astro + Vue) lee el fragment, pide el ciphertext y corre AES-256-GCM con SubtleCrypto. El compose de producción usa un layout de un solo dominio: /api y /health van al servidor Go; todo lo demás, a la web estática servida por nginx. Misma origin, sin CORS.
0 scriptsde terceros en la página de descifrado. Por el mismo motivo del modelo de amenazas: nada ajeno corre junto al secreto.
Ver detalle técnico

La landing es HTML estático generado por Astro. Vue entra solo en la página que descifra, porque es la única que necesita JavaScript. Las fuentes se sirven desde el propio dominio.

Deuda declarada en el código: la capa de contraseña se deriva con Argon2id en el CLI, pero Web Crypto no implementa Argon2id y la página usa PBKDF2. Hoy un link con --password se abre con kip pull; en el navegador esa segunda capa no cuadra. La solución pasa por una librería Argon2 en JS o por acordar el KDF con el servidor.
04

El CI estaba verde y el linter nunca había corrido

Lo obvio
Badge verde en el README, tests en verde: el proyecto está sano.
Por qué falla
El job de lint usaba golangci-lint-action con version: latest. El action saltó a una major que rechaza configuraciones antiguas y el job dejó de ejecutar el linter. Y el Makefile apuntaba a un directorio que ya no existía tras un rename. Verde por omisión, no por mérito.
LeaveTeam · sqlite.go
Un COUNT(*) sin comprobar el error: 0 era indistinguible de "no quedan miembros" y borraba un team que aún los tenía.
http.Server
Valor cero: sin timeouts, expuesto a Slowloris. Ahora ReadHeader, Read, Write e Idle.
os.Exit en main
Se comía los defer que cerraban Redis y SQLite. Patrón run() error.
Sentinels con ==
En vez de errors.Is. El tope max-same-issues escondía tres; se encontraron a mano.
Cliente HTTP del CLI
Sin contexto. Los 7 métodos reciben ctx y Ctrl+C aborta una subida en vuelo con exit 130.
Cobra
Ante un connection refused imprimía el error dos veces y el bloque de ayuda entero. SilenceUsage + SilenceErrors.
Lo que se hizo
Configuración migrada y pineada a la major del linter. La primera pasada real reveló 102 issues, ajustados con criterio: fieldalignment apagado por irrelevante en un CLI, y los tests exentos de gosec G101 porque los fixtures del scanner de secretos son, por definición, secretos. Entre lo que sobró había bugs reales.
102 → 0issues de lint. Seis bugs corregidos que ningún test unitario había cazado.
Ver detalle técnico

Verificado a mano más allá del linter: ciclo push → pull contra un servidor real con contenido intacto y permisos 600, segundo pull rechazado por autodestrucción, contraseña correcta e incorrecta, y teams con inspección directa del SQLite.

Cobertura actual de go test -cover: crypto 82,8 %, envfile 84 %, scanner 100 %, handlers 60,1 %, store 62,1 %. El cliente HTTP y la configuración del CLI no tienen tests todavía.

"El envoltorio contradecía al código."
El núcleo aguantaba una revisión seria: crypto correcta, arquitectura limpia, tests reales. El README, no: documentaba un tier de "AI Security Scan", comandos como diff e inbox y una página de precios con planes que no existían. Todo se recortó a lo real y el resto se movió a un Roadmap explícito antes de hacer público el repositorio.
05 · Decisiones

Un solo filtro: ¿esto le pide al receptor una cuenta, una instalación o un paso más?

Si la respuesta era sí, sobraba. kip vive entre "pegarlo en Slack" y "desplegar Vault", y cada pieza del stack se midió contra esa posición.

WHY
¿Por qué net/http de la stdlib y no un framework?
El servidor tiene un puñado de rutas: subir un blob, leerlo, revocarlo y el CRUD de teams. Un framework añade dependencias a un proyecto cuya promesa es un binario sin nada en runtime. El CLI son Go y Cobra, compilado con GoReleaser para tres sistemas y dos arquitecturas.
Profundizar

Se instala con install.sh, que detecta SO y arquitectura y descarga el archivo de la release, o con go install github.com/kipenv/kip/cmd/kip@latest. El middleware es propio: logger, CORS y un rate limiter por IP de token bucket, 30 peticiones por minuto por defecto.

WHY
¿Por qué Redis para secretos y SQLite para teams?
Porque son dos problemas. Los secretos son efímeros y el TTL nativo de Redis hace que la expiración sea gratis. Los teams son relacionales y durables: SQLite embebido, sin servicio extra, persistido en un volumen.
Profundizar

Los teams funcionan como lobbies: uno crea, comparte el invite code, los demás se unen. Sin registro, sin roles, sin panel. La API ya implementa compartir a nivel de team, inbox, audit log y un env "oficial" pineado por proyecto, con tests. El CLI todavía no expone esos endpoints; es el primer punto del roadmap.

SQLite entra por cgo, así que el servidor se distribuye solo como imagen Docker. La primera configuración de GoReleaser lo compilaba con CGO_ENABLED=0 sobre mattn/go-sqlite3: compilaba y crasheaba al abrir la base de datos. Hoy GoReleaser publica solo el CLI.
WHY
¿Por qué Astro + Vue y no una SPA?
La landing es estática. La única página que necesita JavaScript es la de descifrado, y necesita exactamente SubtleCrypto y nada más. Astro deja la landing como HTML y Vue entra solo donde hay que descifrar.
WHY
¿Por qué no hay instancia pública?
Porque cambia el modelo de confianza: el operador no puede descifrar, pero sí borrar, y eso ya es una relación con un tercero. Añade además costo operativo a un proyecto MIT sin monetización. El README lo dice en la primera pantalla en vez de prometerlo.
Profundizar

Se apunta el CLI al servidor propio con kip config set --server o se corre en local con el compose de desarrollo. Cuando exista una instancia pública será una opción, no el default.

Dónde se sitúa
Slack DM
sin cifrado · sin expiración
El secreto queda en el historial del workspace, buscable por cualquiera con acceso.
Yopass
zero-knowledge · sin CLI
Autodestructivo y self-hostable, pero sin CLI nativo ni foco en .env.
1Password · Doppler
cuenta obligatoria
Con CLI, pero sin zero-knowledge, sin autodestrucción y con cuenta para recibir.
Vault
self-hostable · infraestructura
Sin autodestrucción ni zero-knowledge, y una pila entera que operar para compartir un .env.
kip: CLI nativo, zero-knowledge, autodestrucción, sin cuenta para enviar ni para recibir, self-hosted, enfocado en .env, MIT. Para equipos que quieren que el secreto desaparezca sin levantar infraestructura.
06 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
README escrito por delante del código
Hoy haría
Documentar solo lo que existe y mover el resto a un Roadmap. Había un tier de "AI Security Scan", comandos que no existían y una página de precios con planes inventados.
Hice
version: latest en golangci-lint-action
Hoy haría
Pinear la major del action junto a la del linter. El salto de major rechazó la configuración y el lint no corrió hasta que alguien miró el log.
Hice
kip-server en GoReleaser con CGO_ENABLED=0
Hoy haría
modernc.org/sqlite o sacar el servidor de GoReleaser desde el inicio. Compilaba y crasheaba al arrancar.
Hice
Compartir en equipo implementado primero en la API
Hoy haría
Cerrar cada feature en CLI y API a la vez. El servidor tiene inbox, audit log y env pineado con tests; el CLI solo tiene membresía.
Hice
Argon2id en el CLI, PBKDF2 en el navegador
Hoy haría
Elegir el KDF pensando en los dos clientes desde el primer día. Hoy la capa de contraseña solo cuadra entre CLIs.
07 · Si te llevas tres cosas

Recap

1
La clave va detrás del # y el protocolo HTTP no la envía. El servidor guarda ciphertext; una base de datos robada no sirve de nada.
2
Los secretos mueren solos: una lectura y una hora por defecto, sin cron, con el TTL nativo de Redis y un contador que se descuenta al entregar.
3
Quien recibe no instala nada: abre el link y el navegador descifra con Web Crypto en la misma origin. Quien envía, un binario estático de 8,7 MB.
Enlaces