Two products, one relay
tars is one server process serving two things that look opposite and share almost all their machinery.
- burn — asynchronous. You upload a file (a tz), leave, and someone downloads it later, exactly once.
- link — synchronous. Two machines are online together and mirror a directory live.
Everything added since is a recombination of those two poles rather than a third system: chat is link minus the files, inbox is link minus the reads, a vault is a tz that never travels, and the time-lock and dead man's switch are a tz whose key or delivery is withheld. Each is documented below at the point where it diverges from the machinery it reuses.
burn — asynchronous, exactly one download
─────────────────────────────────────────
sender server recipient
│ encrypt locally │ │
├── upload ciphertext ─────▶│ stores sha256(token) only │
│◀── URL#key ───────────────┤ │
│ │ │
│ · · · share the URL · · · · · · · · · · · · · · · · · · ▶│
│ │◀────────── download ─────────┤
│ ├── stream + destroy ─────────▶│ decrypt (#key)
│ │ (any later request: 410) │
link — synchronous, live two-way mirror
───────────────────────────────────────
host blind relay guest
│ both ends type the same phrase; the key is derived │
│ from it (SPAKE2) and never travels │
├── sealed frames ────────▶├── forwards ciphertext ──────▶│ apply
│◀── sealed frames ────────┤◀──── (can read nothing) ─────┤ edit
│ after first sync only changed chunks travel; an │
│ interrupted transfer resumes where it stopped │
That difference in liveness is the reason almost every design decision below splits the way it does — most visibly in how the two carry their encryption key. A dead drop has to leave the key with the message; a live call can negotiate one.
The burn invariant
“Exactly once” is the product, so it is worth being precise about what
enforces it. Not a lock, not a transaction, not a queue — one
conditional UPDATE:
UPDATE tzs SET burned_at = ? WHERE id = ? AND burned_at IS NULL
RETURNING filename, size
SQLite (via bun:sqlite) runs in-process and synchronously,
and there is deliberately no await between request
dispatch and that statement. So when two downloads race, both
reach the same statement, and the database decides: one gets a row back,
the other gets nothing and is answered 410. There is no
window in which both see “not burned yet”.
two downloads race for the same tz
──────────────────────────────────
client A SQLite (in-process) client B
│ │ │
├─ GET, X-Onetime ────────▶│ │
│ │◀──────── GET, X-Onetime ────┤
│ ┌─────┴─────┐ │
│ │ UPDATE … WHERE burned_at IS NULL │
│ │ ← serialized, no await in between│
│ └─────┬─────┘ │
│◀── row: filename, size ──┤ │
│ ├── (no row) ────────────────▶│
│ │ │
├─ open(blob); unlink(blob) │
│ ← name gone, fd still readable │
│◀═ streaming bytes ═══════╡ 410 Gone ▼
│
▼ a crash here cannot resurrect the tz: the name is already unlinked
This is why the burn path contains no rate-limiting, quota or IP logic
of any kind — those live at the routing layer. Anything that could
introduce an await into this path would silently break the
guarantee, which is also why the concurrency test cannot prove it
correct: synchronous handlers serialize on the event loop regardless, so
a violation would still pass. It is enforced by review.
Then the bytes
The winner open()s the blob and immediately
unlink()s it, before streaming. The open file
descriptor keeps the data readable for that one response while the name
is already gone from the filesystem, so a crash halfway through cannot
resurrect the tz. That is a deliberate POSIX dependency.
Uploads land as <hash>.part and become burnable only
after an atomic rename plus the row insert, so a half-written upload is
never reachable.
Revoke is the same transition
tars revoke is not a separate mechanism: it is the same
pending → gone move, keyed on a second credential, without
delivery. Same conditional-UPDATE shape, so revoke racing a burn still
produces exactly one winner.
What the server stores
A tzs row holds an id, filename, size, timestamps, an
optional expiry, and an optional revoke credential. Two things about it
matter more than the schema:
-
The id is
sha256(token)and the revoke column issha256(revoke_token)— never the credentials themselves. A stolen database yields no working URLs and no revocations, because the hash cannot be turned back into the token that appears in a link. -
For an encrypted send the
filenamecolumn holds the placeholder"file". The real name travels inside the ciphertext, so the server never learns it.
Lifetime counters live in a separate counters table
precisely because the sweeper deletes tzs rows — the public
“N transfers processed” number must not shrink when history is pruned.
The sweeper deletes blob-first-then-row (never the reverse, which would orphan bytes) and never touches burned rows.
410 tells you nothing
Burned, expired, revoked and never-existed are deliberately
indistinguishable from outside — all 410. For the same
reason /list answers 404, not 401,
when the operator token is wrong: a 401 would confirm the
endpoint exists.
An armed dead man's switch answers 410 too. A
distinct “not yet” would hand anyone holding the URL the
existence and schedule of an insurance file, which is the one audience
it must tell nothing. Only the sender sees the truth, via
GET /status/:rtoken — authenticated by the revoke
credential, which doubles as the watch and check-in credential.
Withholding is enforced in the route as a synchronous, read-only check
before the burn, so the single-UPDATE serialization below is
untouched by it.
tz1 — the encryption framing
Sends are encrypted on your machine by default, with AES-256-GCM, in a STREAM-style framing so a multi-gigabyte file never has to be held in memory to be authenticated:
stream = "tz1" | prefix(8 random bytes) | frame*
frame = len(u32be) | AES-256-GCM(key, iv, aad, data)
iv = prefix ‖ counter(u32be)
aad = counter(u32be) ‖ isLast(u8)
plaintext = nameLen(u16be) | name(utf8) | file bytes
on the wire
───────────
┌───────┬────────────┬───────────────┬───────────────┬─────┐
│ "tz1" │ prefix (8) │ frame 0 │ frame 1 │ ... │
└───────┴────────────┴───────────────┴───────────────┴─────┘
│
└─▶ ┌────────────┬──────────────────────────┐
│ len(u32be) │ AES-256-GCM ciphertext │
└────────────┴──────────────────────────┘
iv = prefix ‖ counter
aad = counter ‖ isLast
frame 0 plaintext ─▶ ┌──────────────┬──────────┬─────────────────┐
│ nameLen(u16) │ name │ file bytes … │
└──────────────┴──────────┴─────────────────┘
the filename lives INSIDE the ciphertext
Each element is load-bearing:
- the counter in the nonce and the AAD stops frames being reordered or replayed within a stream;
- the
isLastflag in the AAD stops truncation — you cannot cut a stream short and have it still authenticate; - GCM stops modification;
- a fresh key per tz is what makes counter-based nonces safe: they can never collide across files.
The filename is inside the plaintext, which is why an encrypted send omits the filename header entirely. The decoder caps the frame length it will buffer, so a hostile length prefix cannot force an out-of-memory.
The key rides in the URL fragment (#k=). Fragments
are never sent to servers by any HTTP client, so the origin stores
ciphertext it genuinely cannot read. Anyone holding the whole link can
decrypt — that is the intended sharing model.
The fragment is a grammar
#k=<key> the common case: key alone
#k=<key>&p=<salt> --pass: key AND a passphrase
#x=KofN --split: NO key — only a marker
#t=<round‖U‖V‖W>&k=… --unlock: the key, time-locked
Two parsers read this grammar — the CLI and the inline browser
decryptor — and both obey one rule: a fragment that is present but
unparseable must refuse before the burn. Treating it
as “no key” once made get burn a tz and write
undecryptable bytes; the download is the destructive step, so every
interpretation question is settled first.
--pass makes the passphrase a second
factor rather than the only one:
contentKey = SHA-256(urlKey ‖ PBKDF2-SHA256(passphrase, salt, 600 000)).
A leaked link cannot be opened without the passphrase, and an offline
attack on the passphrase still needs the 32 random bytes from the
fragment. Products that bolt a password onto a link make the password
the only protection once the link escapes; this does not.
--split cuts the key into Shamir shares
over GF(256): any k of n reconstruct it, fewer learn
nothing — information-theoretically, not computationally. There is
deliberately no checksum on a share: a checksum that rejects a wrong
share is an oracle that helps guess a right one, so wrong shares are
rejected downstream by AES-GCM's authentication tag instead.
--unlock encrypts the key to a
future drand round (Boneh–Franklin identity-based encryption over
BLS12-381, against drand's quicknet chain: a threshold signature every
three seconds from independent organisations). The round's signature
is the decryption key, and it does not exist yet — not for
the sender, the relay, or a subpoena. This is the trustless
counterpart to the dead man's switch: that one needs a witness to
notice your silence and the witness is the relay; this needs no
witness at all. Honest limits: it depends on drand continuing to
publish, get must reach drand, and the hash domain
separators are tars' own — a locked tz opens with
tars get, not with the tlock CLI, because a
format that almost interoperates is worse than one that obviously
does not.
Vaults — the tarslock1 container
tars lock is the one deliberately durable thing here: a
directory or file becomes a single encrypted .tars file
and the original is deleted. It reuses the tz1 stream unchanged over a
ustar archive, behind a 30-byte self-describing header:
offset size field
0 9 magic "tarslock1"
9 1 version (1)
10 16 salt
26 4 PBKDF2 iterations, u32 big-endian
30 … tz1 stream over a ustar archive
The key is
PBKDF2-SHA256(passphrase, salt, iterations)
used directly — unlike --pass there is no URL key to mix
in, so this is a single factor and is named as one. The iteration
count is recorded, not assumed: raising the default later
must not lock anyone out of vaults written today. The magic is checked
before anything else, so opening a JPEG says “not a tars
vault” rather than “wrong passphrase”.
The ordering is the safety story: refuse an existing output before
prompting, write to .part, decrypt the written file in
full and compare it against a hash taken from the archive as it
streamed past, rename, and only then ask before deleting the original.
Deletion is the last step and every earlier failure throws past it.
Extraction parses the archive with a reader whose every path goes
through the same jail as link's (safeJoin): an archive
could have been written by anyone, so
../../.ssh/authorized_keys fails loudly instead of
landing outside the destination. verify is the same work
minus the extraction, and its reader takes no destination — a command
whose job is to check a file is structurally unable to modify one.
There is no recovery path. No shares, no printed backup — the passphrase is the only key, which is the promise the rest of tars already makes, kept by the one feature that stores something.
The blind relay
Live sharing runs over a WebSocket (wss:// in production).
The relay pairs one host with its guests and forwards frames. It is
blind in a specific, checkable sense: it routes on the frame
kind and a guest id, and never inspects a payload.
[0x00][json] control frame, relay-authored
[0x01][sealed] guest ↔ relay leg
[0x02][u32be guestId][sealed] host ↔ relay leg (multiplexed)
one socket per party; the host's leg is multiplexed
──────────────────────────────────────────────────
host relay guests
│ │ ┌───▶ guest 1
├─[0x02][id=1][sealed]───────▶├── strips id ───────┘
├─[0x02][id=2][sealed]───────▶├── strips id ────────────▶ guest 2
│ │
│◀──────[0x01][sealed]────────┤◀── tags with id ─────────┤
│ + guest id │ │
│ │
│ the relay reads the FIRST BYTE and the id.
│ it never opens a payload — it cannot.
Sessions are keyed by sha256(token) and live in RAM; only
session history (for the operator page) is written to SQLite.
The relay enforces per-session limits — a guest cap, a whole-server
session cap, a byte quota counted in both directions, an idle sweep and
a hard session lifetime — and its close codes are a public contract:
4001 host already connected 4008 quota exceeded
4002 no host for this link 4009 session expired
4003 link is full 4010 terminated by operator
4005 host left 4400 malformed frame
Clients auto-reconnect on an ordinary drop, but 4001,
4003 and 4010 mean “the relay ended this
deliberately” and must never be retried — retrying a deliberate refusal
is how you build an accidental denial-of-service against yourself.
The stream relay
tars stream (ADR-0008) is the one place the relay keeps
payload bytes, and the one relay that parses frames — it has to, to
number them. What it keeps is still ciphertext. An event is
{ producer, counter, tag, ts, body }
body = [iv 12][AES-256-GCM(line)] AAD = producer ‖ counter ‖ ts ‖ tag
Every plaintext field the relay stores or filters on is bound into the
body's AAD, so the relay can drop or withhold an event but cannot
alter, reorder, relabel or replay one without the consumer's decrypt
failing. The per-stream seq is assigned by one synchronous
SQLite statement — the same no-await discipline as the
burn invariant — so it is gap-free in arrival order; the producer's own
counter, assigned when the line is sealed, makes per-producer order
checkable end to end. Dedup is UNIQUE(room, producer,
counter): a retry is acked with its existing seq.
A producer keeps at most 64 events un-acked and resends from its lowest
un-acked counter after a drop. If an insert fails the relay closes
the socket and acks nothing — skipping one event and carrying on
is exactly what would reorder the next. Consumers replay from a cursor
and, with a tag filter, still see every sequence number: events they
did not ask for arrive as skip frames, so the contiguity
check never weakens. Events expire after 72 hours; the stream row that
carries head outlives them, so seq never
restarts. Close codes, protocol v1:
4100 protocol version mismatch 4011 relay could not persist
4400 malformed frame 4003 relay at capacity
4008 stream quota exceeded
Per-message sealing
Every link message is individually sealed with AES-256-GCM. The authenticated data binds each message to its place in the conversation:
aad = sender role ‖ u32be senderId ‖ u64be counter
That single line defeats a hostile relay reordering messages, reflecting a message back at its sender, or cross-routing one guest's traffic to another — each would change the AAD and fail authentication.
Why a per-session subkey
Counters reset to zero on reconnect, so a URL key alone would let a
relay replay a captured frame into a later session. On connect
each side generates a fresh random salt, exchanges it in the clear
(salts are not secret), and both derive
HKDF(key, hostSalt ‖ guestSalt). Because each side
contributes fresh entropy, the relay cannot steer the derived key back
to an old session's value, and a replayed frame simply fails to open.
Ordering discipline on the client is part of the security property, not
just tidiness: the receive counter is claimed synchronously
before any await, and sends go through a per-peer promise
queue so wire order equals seal order. An innocent-looking reordering
breaks decryption outright.
The envelope, padding and jitter
Sealed or not, a payload's size and timing are the two things a blind relay still sees. Since protocol v12 the envelope carries an explicit payload length, so anything after the payload is padding by construction:
[u32be jsonLen][u32be payloadLen][json][payload][padding…]
Small control messages are padded up to fixed buckets —
256, 1024, 4096, 16384, 65536 bytes — so a keepalive, a
chat line and a small manifest are indistinguishable by length, and
sends are delayed by a random 0–8 ms so message timing does not
echo keystroke timing. Honest limits, stated rather than implied: bulk
file frames are not padded (padding a 64 KiB stream
would double the bandwidth for nothing — the transfer's total size is
visible in aggregate regardless), and a global observer correlating
traffic at both ends defeats any padding. This narrows what the
relay can infer; it is not Tor.
Pairing by phrase (SPAKE2)
A live share pairs on something like
8823-cyan-falcon-jasmine. A short secret is safe here only
because of how it is used, so it is worth spelling out the mechanism.
Both ends run SPAKE2 (RFC 9382, the
P-256/SHA-256/HKDF/HMAC ciphersuite). Each derives a password scalar
w from the phrase through a slow KDF, then sends a public
value with its ephemeral key blinded by w:
host → pA = w·M + x·G
guest → pB = w·N + y·G
both → K, then Ke‖Ka = SHA-256(transcript), then HMAC confirmations
Only two parties who started from the same phrase arrive at the same key. The relay forwards the exchange and learns nothing usable from it, and — the property that actually matters — there is no offline attack. Recovering a phrase from a captured session is not a slow computation; it is not a computation at all. Every guess must be made online, against a live host.
the pairing handshake
─────────────────────
host relay (blind) guest
│ w = KDF(phrase) │ w = KDF(phrase) │
│ │ │
├─ pakeHello: salt, pA ──────▶├────────────────────────────▶│
│◀───────────────────────────┤◀────── pakeHello: salt, pB ──┤
│ │ │
│ K = x·(pB − w·N) │ K = y·(pA − w·M) │
│ Ke‖Ka = SHA-256(transcript)│ … same on both sides │
│ │ │
├─ confirm: HMAC(KcA) ───────▶├────────────────────────────▶│ verify
│◀───────────────────────────┤◀────── confirm: HMAC(KcB) ───┤
│ verify │ │
│ │
│ ✗ mismatch → "wrong phrase", session dropped, NOTHING served
│ ✓ match → subkey = HKDF(Ke, hostSalt‖guestSalt), manifest sent
The room id is public by construction
The phrase splits: the leading four digits are a public room
selector, and the room id is sha256 of that selector
alone. The words are never hashed into anything the
relay sees.
This is the subtlest decision in the system. The relay must be told which room to pair, so anything the room id derives from is disclosed to it. An earlier design hashed the whole phrase — and a fast hash over a phrase space that size inverts on one commodity GPU in about a second, handing a malicious relay the phrase and the session. Deriving the room from public input makes that leak impossible by construction rather than merely expensive.
Guessing is bounded by the host
With the selector public, the secret is the three words. The host counts failed confirmations itself and stops after five. That counter lives on the host, not the relay, for a reason worth stating plainly: a malicious relay can pose as a guest and is obviously not restrained by its own throttle. The honest claim is therefore “a few online attempts against a 24-bit secret”, not “an unbreakable short phrase”.
Nothing is served until the phrase is proven — a wrong guess sees no filename and no bytes, only an immediate error.
The same split decides where a mirror lands
Once you separate a phrase into a public selector and secret words, the
distinction has to hold everywhere the phrase is used — not
just on the wire. So tars link 8823-cyan-falcon-jasmine
with no destination mirrors into ./tars-8823, named from
the selector alone.
Naming it after the whole phrase would have been friendlier and would have written the live session credential into a filesystem path — one that outlives the session, appears in any directory listing, and rides into backups and screen shares. A secret that is safe on the wire is not automatically safe at rest, and a folder name is very much at rest.
$ tars link 8823-cyan-falcon-jasmine # → ./tars-8823
$ tars link 8823-cyan-falcon-jasmine work # → ./work
$ tars link 8823-cyan-falcon-jasmine ~/notes
error: /…/notes is not empty (14 entries). Mirroring overwrites same-named
files with the host's version. Pass a different directory, or --force to
mirror into it anyway.
The refusal is the other half. Mirroring writes the host's version over
same-named local files, so an existing folder is declined unless
--force says otherwise. The documentation used to tell
readers to pick a folder with nothing in it, which is a check the tool
had declined to perform — and one that only held while the reader was
paying attention.
Ephemeral chat
tars chat is the link machinery with the files removed:
same relay, same phrase pairing, same per-message sealing, so the
relay reads nothing and there is no new trust story to audit. Two
message shapes were added (protocol v8–v11): chat — a
line of text with a timestamp and a display name — and
presence, one shape covering join, leave and rename; a
newcomer's roster is the same message replayed once per person
present, so there is no second format to keep in step.
Nicknames are cosmetic and self-asserted — they authenticate nothing, because the PAKE already proved the peer knew the phrase. Leaves are best effort (a crash sends nothing), so the host also announces a departure it notices at the transport level, and receivers tolerate a leave they have already seen. No message touches disk at either end and no history is kept, so there is nothing to seize afterwards — that absence is the feature, not a missing one.
Inbox — a drop, by subtraction
tars link --inbox is link with the reads removed, and the
removal is enforced at the host rather than requested of the guest:
guests receive an empty manifest, every read request is
refused, and the host's own changes are never announced. One source
therefore cannot see another's submission, or the host's files — not
because the client declines to ask, but because there is nothing to
answer.
Each guest's submissions land under from-guest-N/, so two
sources can never overwrite each other, and the host can tell
which connection delivered what without the relay learning either.
The first join declares the mode in the manifest — an inbox that
looked like an empty mirror once meant the first join uploaded
nothing, which is backwards for a drop.
Content-defined chunking and delta sync
Files are split with a gear-hash rolling checksum (FastCDC-style): a boundary falls wherever the hash masks to zero, so boundaries follow content. Insert a line at the top of a file and only the chunks you touched change — every other chunk keeps its hash. Fixed-size blocks would lose every boundary after the insertion point, which is exactly the common case for editing text.
min 16 KiB · target ≈ 64 KiB · max 256 KiB
gear table = sha256("tars-cdc-gear-v1" ‖ blockIndex)
fetching a changed file
───────────────────────
guest host
│◀─ manifest: path, hash, chunks[h,len] ───────────────────┤
│ │
│ diff against chunks I already hold (any local file) │
│ │
├─ want: [h3, h7] ────────────────────────────────────────▶│
│◀─ cdata h3 ══════════════════════════════════════════════╡
│◀─ cdata h7 ══════════════════════════════════════════════╡
│◀─ cend: whole-file sha256 ───────────────────────────────┤
│
│ assemble in MANIFEST order: local chunks from my disk,
│ fetched ones from cdata → temp file → verify hash → rename
│
│ any mismatch → discard, stream the whole file instead
The gear table is derived from a fixed tag so it is identical on every platform. These parameters are protocol state: both ends must cut the same boundaries forever, so changing any of them requires a protocol version bump.
A manifest entry carries a chunk list; the receiver diffs it against
chunks it already holds and asks only for what is missing
(want → cdata → cend), then
assembles into a temp file, verifies the whole-file hash, and renames
atomically. Write-back works the same way with the direction inverted.
Chunks are gzipped before sealing, since ciphertext cannot be
compressed afterwards.
Resume
An interrupted transfer used to restart from zero. It now continues, and the mechanism is pleasingly small because the delta machinery already existed.
On disconnect the guest keeps the partial file instead
of deleting it. On reconnect it indexes that partial with the same CDC
chunker and offers those chunks as an additional source alongside its
current copy of the file. The ordinary want exchange then
asks only for the remainder. There is no new message for the common
case at all.
The reason a prefix is usable: CDC boundaries are content-defined, so chunking the first 60% of a file produces exactly the same boundaries as chunking the whole thing — the chunks match by hash. A torn final chunk simply fails to match anything and is refetched.
Everything else that a disconnect resets — flow gates, in-flight sets, delta state — is still cleared, because those are what stop a path wedging permanently. Only the received bytes survive.
an interrupted transfer, resumed
────────────────────────────────
guest host
├─ get big.bin ───────────────────────────────────────────▶│
│◀═ fchunk ═══ fchunk ═══ fchunk ═══ ✗ connection lost ════╡
│
│ KEEP the partial (7 MiB of 24). Reset everything else:
│ flow gates, in-flight sets, delta state — those wedge if kept.
│
├─ reconnect ─────────────────────────────────────────────▶│
│◀─ manifest ──────────────────────────────────────────────┤
│
│ chunk the partial with the SAME CDC → its chunks match
│ the file's, because boundaries follow content, not offsets
│
├─ want: [only the missing hashes] ───────────────────────▶│
│◀═ 17 MiB, not 24 ════════════════════════════════════════╡
│
│ torn tail / edited during outage / changed manifest
│ → no hash match → refetched. never a stitched-wrong file.
Files too large for a manifest chunk list
Above 64 MB a file carries no chunk list in the manifest, because doing
so would bloat every manifest for a case that rarely applies — yet that
is precisely the size where resuming matters most. So the receiver can
ask for one (chunkreq → chunklist),
and only ever does so when it actually holds a partial worth reusing.
The rule that keeps it safe
A partial is only ever consumed through hash-matched chunks. A torn tail, a file edited during the outage, a changed manifest, a declined request, a timeout — every one of these degrades to a plain whole-file stream. Resume may save bandwidth; it can never assemble a wrong file. That rule is why the feature is safe to have at all.
Flow control
Every streamed transfer is paced by a 4 MB stop-and-wait window: the sender blocks once that many unacknowledged bytes are outstanding, and the receiver's acknowledgements release it.
a 4 MiB stop-and-wait window
────────────────────────────
sender receiver
├─ chunk ─────────▶│ │
├─ chunk ─────────▶│ in flight: 4 MiB ──────────────────▶│ drains
├─ chunk ─────────▶│ ██████████ full │ to disk
│ ⏸ blocked │ │
│ │◀───────── fack: bytes drained ────────┤
├─ chunk ─────────▶│ ████░░░░░░ credit released │
│
│ without this the relay's socket buffer overruns and it
│ SILENTLY DROPS frames — the file stalls forever at 90%
│ with both sockets looking perfectly healthy.
This is not an optimisation. Without it a fast sender overruns the relay's socket backpressure limit and the relay silently drops frames — a file that stalls forever at 90% with both sockets looking perfectly healthy. It is invisible on a single machine, where fill rate and drain rate are effectively equal, and only appears over a real network. Credit is always counted in plaintext bytes, before compression and sealing.
The HTTP API
The CLI has no privileged channel — it speaks ordinary HTTP, so a pipeline can too. Useful for rotating a credential out of CI without leaving it in an artifact store. Every response below is real output from a running server.
One warning first, because it is the whole difference:
encryption happens in the CLI, not the server. A plain
POST /up uploads exactly the bytes you send, and
the server can read them. The one-time delivery still works; the
end-to-end secrecy does not, because nothing encrypted them. Encrypt
before posting, or use the CLI.
Create a tz
$ curl -X POST https://tz.tarbase.com/up \
-H 'X-Filename: deploy.key' \
-H 'X-TTL: 3600' \
--data-binary @deploy.key
{"url":"https://tz.tarbase.com/d/IYDRT4kgSSk","expires_at":1786052865615,"revoke":"_EfGUlI_zk8"}
X-TTL is seconds and may only shorten the 72-hour
ceiling. X-Deadman: <seconds>:<release|destroy>
arms a dead man's switch. revoke is shown once and is
unrecoverable — it is also the status and check-in credential, so keep
it if you want either.
Burn it
$ curl -X POST https://tz.tarbase.com/d/IYDRT4kgSSk
s3cret-value
$ curl -o /dev/null -w '%{http_code}\n' -X POST https://tz.tarbase.com/d/IYDRT4kgSSk
410
POST burns; a plain GET returns the landing
page and never does (which is why link previewers cannot destroy a tz,
and why a tripwire only fires on a real read). A client that prefers
GET can send X-Onetime: 1 to burn instead.
Ask, revoke, check in
$ curl https://tz.tarbase.com/status/_EfGUlI_zk8
{"state":"armed","created_at":1786049265615,"size":12}
# after someone downloads it
{"state":"tripped","created_at":1786049265615,"at":1786049265792}
$ curl -X POST https://tz.tarbase.com/revoke/_EfGUlI_zk8
revoked
$ curl -X POST https://tz.tarbase.com/checkin/_EfGUlI_zk8
{"state":"armed","fires_at":1786135665984,"mode":"release"}
An unknown credential answers 404, never 401,
so these routes cannot be used to confirm that a token exists. Status
and check-in share a rate-limit bucket separate from burning, because
polling is expected and must never spend the delivery budget.
Proxies
The standard variables are honored by every command, including the
WebSocket. ALL_PROXY may name a SOCKS5 proxy, which covers
Tor and an improvised ssh -D tunnel.
The runtime speaks HTTP CONNECT but not SOCKS5, so rather
than reimplement proxying per transport the CLI starts a
loopback-only bridge: it accepts CONNECT
and forwards over SOCKS5. It binds 127.0.0.1 and refuses
anything else, so it can never become an open proxy for the local
network, and it dies with the process. Hostnames are sent to the proxy
unresolved, which Tor requires and which is also correct under
split-horizon DNS.
the loopback CONNECT → SOCKS5 bridge
────────────────────────────────────
tars (fetch / WebSocket) bridge SOCKS5 proxy
│ speaks CONNECT only │ 127.0.0.1 only │
├─ CONNECT host:443 ────────────▶│ │
│ ├─ greet, auth ─────────▶│
│ ├─ CONNECT host:443 ────▶│ resolves
│ │◀── reply ──────────────┤ the name
│◀─ 200 Connection established ──┤ │
│ │ │
├══ TLS, then WebSocket ════════▶├═══════════════════════▶│═══▶ relay
│ the bridge never inspects; it only pipes bytes
One honest limitation: through any proxy an upload is buffered in memory
rather than streamed, because the runtime cannot stream a request body
through a CONNECT tunnel. Proxied sends are therefore
capped and say so, rather than failing halfway. Direct sends still
stream.
What the server can and cannot know
| The server sees | The server cannot see |
|---|---|
| ciphertext size, timing, client IP | file contents |
sha256(token) | the token in your URL |
sha256(revoke_token) | the revoke credential |
the placeholder name "file" | the real filename (it is inside the ciphertext) |
| a link room id | the phrase, or anything derived from its secret words |
| that frames flowed, and how many bytes | any message content, path or filename in a live share |
The threat model assumes the relay is untrusted, not merely curious. Most of the decisions above — sealed frames with binding AAD, per-session subkeys, the public-selector room id, host-side guess counting — exist because “the operator is honest” is not an assumption worth resting on.
What it does not defend against: anyone holding the full link or the phrase can read the data, by design. Share them accordingly.
Roads not taken
The shape above is easier to trust knowing what it was chosen against. Each of these was seriously considered and rejected for a stated reason.
Live sharing: why not a filesystem?
A FUSE mount is the obvious answer to “make a remote directory look local”, and it kills the single-static-binary install — you are shipping a kernel extension. A local WebDAV gateway avoids that but pays WAN latency on every file operation, so an editor that stats a thousand files feels broken. CRDT collaboration is a different product altogether, one where two people type in the same file simultaneously.
Watch-and-stream keeps a real local replica: edits are local-speed because they are local, and propagation is a background detail. The cost is a conflict window when both sides change one file at once, handled bluntly rather than cleverly — the host wins and your version is kept beside it. Blunt and predictable beats subtle and surprising when the subject is someone's unsaved work.
Chunking: why not fixed-size blocks?
Fixed blocks are far simpler and they fail at exactly the common case. Insert one line at the top of a file and every subsequent block boundary shifts, so every block hash changes and the whole file re-sends. That is precisely the edit people make most. Content-defined boundaries move with the content, so an insertion dirties only the chunks it touches.
A fixed-size implementation would have looked finished, passed its tests, and quietly transferred far more than it needed to — the worst kind of wrong, because nothing ever reports it.
Pairing: why P-256 rather than a nicer curve?
The first design used ristretto255, which has better ergonomics and fewer footguns. It was abandoned for a blunt reason: RFC 9382 publishes SPAKE2 test vectors only for the P-256 ciphersuite. Choosing the curve with no published vectors would have meant hand-rolled cryptography validated by nothing but its author's confidence.
That gate paid for itself immediately. The specification's key-schedule notation is ambiguous about one argument order, and the wrong reading produces correct-looking intermediate values that only diverge at the final confirmation step. Reading alone would have shipped it; the vectors caught it.
Revoke: why not a separate mechanism?
Revoking looks like a new operation, and implementing it as one means a second path that can race the first — with a window where a tz is both being delivered and being destroyed. Instead, revoke is the same pending → gone transition initiated by a different credential, using the identical conditional-UPDATE shape. Burn and revoke racing on one tz still produce exactly one winner, because there is only ever one transition.
Interrupted downloads: why no grace window?
A tz burns even if the download is cut off halfway, which is occasionally annoying and permanently honest. Any grace period — “only count it if they got 90%” — turns “exactly once” into “usually about once”, and an attacker who can sever a connection at will can then replay a download indefinitely. The single promise this tool makes is worth more than the convenience of retrying.
Accounts: why none?
No accounts means no password database to leak, no session management, no recovery flow, and nothing to subpoena. The credential is the capability. The cost is real and stated plainly elsewhere on this page: lose the link and it is gone; share the link and you have shared the file.