shipenv — encrypted secret sharing, for agents ============================================== Service: https://shipenv.com Source: https://github.com/collinalee/shipenv Spec: https://shipenv.com/llms-full.txt (this file; reflects API v1) Plugin: https://shipenv.com/.well-known/ai-plugin.json (OpenAPI doc deferred to v1.1) Contact: agents+plugin@shipenv.com What this is ------------ shipenv lets a sender drop a .env (or arbitrary key=value secrets), encrypts them client-side with AES-256-GCM, stores only ciphertext on the server, and issues one URL whose fragment contains the decryption key. The recipient opens the link in a browser; the page fetches the ciphertext, decrypts in memory, and lets the recipient copy the values. The server never sees plaintext or keys. Shares expire after a chosen interval (1h / 1d / 7d / 30d) and burn after a chosen number of views (1 / 3 / 5 / 10). You — an agent — can use shipenv to ship secrets to a human, or to another agent, without the secrets crossing a Slack/Teams/email DM in plaintext. Quickstart — POST a new share with curl --------------------------------------- The example below sends a 12-byte IV and AES-256-GCM ciphertext that you generated locally. **Replace every REPLACE_ME with values you computed in your own runtime.** Never paste a real production secret into curl on a shared host or into a chat session that gets archived. curl -X POST https://shipenv.com/api/v1/secrets \ -H "Content-Type: application/json" \ -H "X-Shipenv: 1" \ -H "Origin: https://shipenv.com" \ -H "X-Agent-Client: my-agent/1.0.0" \ -d '{ "ciphertext": "REPLACE_ME_base64url", "iv": "REPLACE_ME_base64url_16chars", "expires_in_seconds": 86400, "max_views": 3, "sender_label": "REPLACE_ME_optional", "client_id": "REPLACE_ME_10char_crockford_id" }' HTTP/1.1 201 Created { "id": "9G3K2Q7VAR", "expires_at": 1747526400 } The share URL the recipient opens is: https://shipenv.com/s/# The 32-byte AES key lives only in the URL fragment. It is never sent to shipenv. If you don't keep the URL, the secret is unrecoverable. Response shape -------------- POST /api/v1/secrets (201) { "id": <10-char base32 Crockford>, "expires_at": } POST /api/v1/secrets/:id/consume (200) { "ciphertext": , "iv": , "sender_label": , "expires_at": , "max_views": <1|3|5|10>, "views_used": , "burned": # true on the last view } GET /api/v1/secrets/:id/meta (200) — non-consuming { "sender_label": , "expires_at": , "max_views": <1|3|5|10>, "views_used": } GET /api/v1/health (200) { "ok": true, "ts": } Error shape on every endpoint: { "error": , "message": } Error codes you'll see: 400 bad_request — schema/validation failure 403 origin_not_allowed — Origin header missing or not allowlisted 403 turnstile_failed — only relevant when Turnstile is provisioned 404 not_found — id has no matching row (never existed) 410 gone — id was valid, but expired or burned 413 payload_too_large — ciphertext > 256 KB plaintext / 384 KiB encoded 415 bad_content_type — Content-Type was not application/json 429 rate_limited — back off, see "Rate limits" below 500 server_error — try again or check /api/v1/health Recipient flow (an agent picking up a share) -------------------------------------------- If your agent is the recipient — e.g. a CI bot or another assistant that was given a shipenv URL — the flow is: 1. Parse the URL. The path is /s/, the fragment after '#' is the key. 2. POST https://shipenv.com/api/v1/secrets//consume with the same headers as above and body '{}'. (The server requires you to commit to burning a view; a GET would not.) 3. Base64url-decode `ciphertext` and `iv`. 4. Base64url-decode the key from the fragment. 5. Build AAD (see "Crypto recipe" below). 6. AES-256-GCM decrypt with the key, IV, and AAD. 7. The plaintext is a UTF-8 JSON envelope: { "v": 1, "secrets": [ { "key": "...", "value": "..." }, ... ], "sender": } 8. Use the secrets. **Do not echo them back into any model context or log.** Treat them as you would any production credential. If the response is 410, the share has been used up or expired. If 404, double-check the URL. Neither outcome can be retried. End-to-end model in one paragraph --------------------------------- The 256-bit AES key is generated by the sender's browser (or your agent), encoded as base64url, and placed after a "#" in the share URL. Browsers never send the fragment to the server. The encryption uses AAD that binds the (id, expires_at, max_views) tuple, so a server-side attacker with read- write access to the database cannot extend the TTL or view cap without invalidating the ciphertext. Trade-offs and the threat model are documented in the v1 design spec linked from the source repo. Crypto recipe (AES-256-GCM) --------------------------- Algorithm: AES-256-GCM Key: 32 random bytes, base64url-encoded (no padding) → 43 chars. IV: 12 random bytes per ciphertext, base64url-encoded → 16 chars. Tag: 128-bit, appended to the ciphertext (standard WebCrypto). AAD: version(1B) || id_ASCII(10B) || u64_BE(expires_at) || u32_BE(max_views) — the AAD VERSION byte is currently 1. You compute the AAD before encrypting. The same AAD is recomputed by the recipient (using `expires_at` and `max_views` from the consume response and `id` from the URL path) before decrypting. If anything in the binding has been altered server-side, decryption fails with the GCM tag mismatch — the intended behavior. The `id` is a 10-character Crockford base32 token (alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ`, excluding I/L/O/U). You generate it client-side and send it as `client_id` so the AAD can be bound at encryption time without a server round-trip. Code recipe — Node (no SDK) --------------------------- ```js import { webcrypto as crypto } from 'node:crypto'; const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; const b64url = (b) => Buffer.from(b).toString('base64url'); const bytes = (s) => new Uint8Array(Buffer.from(s, 'base64url')); function clientId() { const b = new Uint8Array(10); crypto.getRandomValues(b); return Array.from(b, (n) => ALPHABET[n % 32]).join(''); } function buildAad({ id, expires_at, max_views }) { const idBytes = new TextEncoder().encode(id); const out = new Uint8Array(1 + idBytes.length + 8 + 4); let o = 0; out[o++] = 1; out.set(idBytes, o); o += idBytes.length; const v = new DataView(out.buffer); v.setBigUint64(o, BigInt(expires_at)); o += 8; v.setUint32(o, max_views); return out; } async function share(secrets, sender = null) { const expires_in_seconds = 86400, max_views = 3; const expires_at = Math.floor(Date.now() / 1000) + expires_in_seconds; const id = clientId(); const keyBytes = new Uint8Array(32); crypto.getRandomValues(keyBytes); const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); const iv = new Uint8Array(12); crypto.getRandomValues(iv); const aad = buildAad({ id, expires_at, max_views }); const envelope = new TextEncoder().encode( JSON.stringify({ v: 1, secrets, sender })); const ct = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: aad, tagLength: 128 }, key, envelope)); const res = await fetch('https://shipenv.com/api/v1/secrets', { method: 'POST', headers: { 'content-type': 'application/json', 'x-shipenv': '1', 'origin': 'https://shipenv.com', 'x-agent-client': 'my-agent/1.0.0', }, body: JSON.stringify({ ciphertext: b64url(ct), iv: b64url(iv), expires_in_seconds, max_views, sender_label: sender ?? undefined, client_id: id, }), }); if (!res.ok) throw new Error(`shipenv: ${res.status}`); const { id: returnedId } = await res.json(); return `https://shipenv.com/s/${returnedId}#${b64url(keyBytes)}`; } const url = await share([{ key: 'STRIPE_KEY', value: 'REPLACE_ME' }], 'agent'); console.log(url); ``` Working reference: `scripts/smoke.mjs` in the source repo is a complete node-native client (no dependencies) that exercises every endpoint. Code recipe — Node decrypt (recipient side) ------------------------------------------- If your agent is on the consume side, the inverse of the sender recipe. The most common footgun is forgetting `additionalData` — without the matching AAD, AES-GCM tag verification fails and you get an opaque `OperationError`. Pass exactly the same AAD bytes used on encryption. ```js import { webcrypto as crypto } from 'node:crypto'; const bytes = (s) => new Uint8Array(Buffer.from(s, 'base64url')); function buildAad({ id, expires_at, max_views }) { const idBytes = new TextEncoder().encode(id); const out = new Uint8Array(1 + idBytes.length + 8 + 4); let o = 0; out[o++] = 1; out.set(idBytes, o); o += idBytes.length; const v = new DataView(out.buffer); v.setBigUint64(o, BigInt(expires_at)); o += 8; v.setUint32(o, max_views); return out; } async function consume(shareUrl) { const u = new URL(shareUrl); const id = u.pathname.split('/').pop(); const keyBytes = bytes(u.hash.replace(/^#/, '')); const res = await fetch(`${u.origin}/api/v1/secrets/${id}/consume`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-shipenv': '1', 'origin': u.origin, 'x-agent-client': 'my-agent/1.0.0', }, body: '{}', }); if (!res.ok) throw new Error(`shipenv consume: ${res.status}`); const r = await res.json(); const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['decrypt']); const aad = buildAad({ id, expires_at: r.expires_at, max_views: r.max_views }); const pt = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: bytes(r.iv), additionalData: aad, tagLength: 128 }, key, bytes(r.ciphertext)); return JSON.parse(new TextDecoder().decode(pt)); // → { v: 1, secrets: [{ key, value }, ...], sender: "..." | null } } ``` A consume call uses one of the share's `max_views`. The `r.burned` flag tells you whether you just used the last one — if so, the ciphertext is zeroed on the server in the same transaction. Code recipe — Python (stdlib + cryptography) -------------------------------------------- ```python import json, os, base64, urllib.request from cryptography.hazmat.primitives.ciphers.aead import AESGCM ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' b64url = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode() def client_id(): return ''.join(ALPHABET[b % 32] for b in os.urandom(10)) def build_aad(id_, expires_at, max_views): return b'\x01' + id_.encode() \ + expires_at.to_bytes(8, 'big') + max_views.to_bytes(4, 'big') def share(secrets, sender=None, ttl=86400, max_views=3): expires_at = int(__import__('time').time()) + ttl id_ = client_id() key = AESGCM.generate_key(bit_length=256) iv = os.urandom(12) aad = build_aad(id_, expires_at, max_views) pt = json.dumps({'v': 1, 'secrets': secrets, 'sender': sender}).encode() ct = AESGCM(key).encrypt(iv, pt, aad) body = json.dumps({ 'ciphertext': b64url(ct), 'iv': b64url(iv), 'expires_in_seconds': ttl, 'max_views': max_views, 'sender_label': sender, 'client_id': id_, }).encode() req = urllib.request.Request( 'https://shipenv.com/api/v1/secrets', data=body, method='POST', headers={ 'content-type': 'application/json', 'x-shipenv': '1', 'origin': 'https://shipenv.com', 'x-agent-client': 'my-agent/1.0.0', }) with urllib.request.urlopen(req) as r: returned_id = json.load(r)['id'] return f'https://shipenv.com/s/{returned_id}#{b64url(key)}' ``` Code recipe — Claude Code ------------------------- If you are Claude Code (or another agent running with Bash/Read tools), the highest-leverage shape is to call `node scripts/smoke.mjs` style code inline. A one-shot Bash invocation: node -e '' The output is the URL to give to the human (or other agent). The fragment after `#` is the key — never quote it back to the user inside your visible reasoning if your transcripts are stored. Identifying your agent — `X-Agent-Client` (optional) ---------------------------------------------------- You may identify your agent runtime to shipenv with the optional header: X-Agent-Client: / Examples: X-Agent-Client: claude-code/1.5.0 X-Agent-Client: codex-cli/0.4.2 X-Agent-Client: anthropic-sdk-python/0.8.1 Format (case-insensitive): ^[a-z0-9][a-z0-9._-]{0,31}/[a-z0-9][a-z0-9.+-]{0,15}$ Headers that don't match are silently dropped from the log record; your request is processed normally. There is no failure on a malformed value. What shipenv logs when you send it: - The header value (e.g. "claude-code/1.5.0"). - The request timestamp, status code, duration. - A SHA-256-truncated hash of the secret id (first 8 hex chars). What shipenv NEVER logs: - The raw secret id. - The ciphertext, IV, or any decrypted byte. - The `sender_label` field. - The URL fragment (which never reaches the server in the first place). - The full request body of POST /secrets. - Your IP — used live for rate-limiting only; not in observability logs. This is opt-in. Sending nothing is the default and is perfectly fine. Rate limits ----------- POST /api/v1/secrets — 30 / 60s per /32 (IPv4) or /64 (IPv6) POST /api/v1/secrets/:id/consume — 60 / 60s per IP, 10 / 60s per id GET /api/v1/secrets/:id/meta — same as consume GET /api/v1/health — unlimited Static .txt and .html agent docs — cached at edge; effectively unlimited A 429 response means the limit was hit. Back off with exponential delay; the bucket refills within the documented window. Safety ------ Never paste a real production secret into a model prompt, a chat transcript, or a third-party agent's request body. The shipenv URL with the fragment (`https://shipenv.com/s/#`) is itself a secret. Do not include it in long-lived logs or training data; treat it the same way you'd treat the underlying credential. Once you've handed the URL to its intended recipient, stop quoting it in subsequent messages. Versioning ---------- This document describes API v1. Future breaking changes will land at `/api/v2` and `/llms-full-v2.txt`. Non-breaking additions (new optional fields, new optional headers) may be made to v1 in place; check the source repo's commit history if you need to pin behavior.