tars internals

How it works, in detail. Back to the overview.

tars v0.67.0

Two products, one relay

tars is one server process serving two things that look opposite and share almost all their machinery.

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:

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 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 (wantcdatacend), 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 (chunkreqchunklist), 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 seesThe server cannot see
ciphertext size, timing, client IPfile 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 idthe phrase, or anything derived from its secret words
that frames flowed, and how many bytesany 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.