The encrypted format behind Secretli: how a share secret becomes keys and tokens, how the metadata envelope and the bundle (one stream sealed in 64 KiB chunks) are laid out, what a link looks like, and how a link is handed to another device with a short code. Two implementations, one specification, and tests that hold them to each other.
- spec/FORMAT.md is the specification. Everything else follows it.
- Go: the module
github.com/secretli/format, whose packages live ingo/:keys,bundle,link, andtransferwithcpacefor the short code. - TypeScript: the package
@secretli/formatints/, attached to every release as an archive. - Vectors: each implementation encrypts fixtures that the other one's tests must decrypt. The small ones are committed under
spec/vectors; CI regenerates large, multi-chunk ones on both sides at every run.
The library is pure. It talks to no server, keeps no state, and has no dependencies beyond the crypto primitives (golang.org/x/crypto and github.com/gtank/ristretto255; @noble/ciphers, @noble/curves and @noble/hashes). What goes over the wire is the business of the clients that use it: the web app and the command-line client.
The repository is split by what a change means for a release (see Changes and releases):
spec/ the format
FORMAT.md the specification
vectors/ the committed vectors both implementations must read and reproduce
go/ the Go library (module github.com/secretli/format)
keys/ bundle/ link/ transfer/ cpace/ internal/padme/
vectors/ the Go side of the interop tests
ts/ the TypeScript library (@secretli/format)
src/ what the archive ships, compiled to dist/
test/ its tests, including the TypeScript side of the interop tests
go.mod go.sum the Go module; its require lines are the library's runtime dependencies
.github/ .golangci.yml .nvmrc renovate.json tooling
go get github.com/secretli/formatimport (
"github.com/secretli/format/go/bundle"
"github.com/secretli/format/go/keys"
"github.com/secretli/format/go/link"
)
// Making a secret: fresh keys, optionally with a password, then a bundle.
ks, _ := keys.Generate()
blobKeys, _ := ks.WithPassword("optional")
sources := []bundle.Source{{Name: "notes.txt", Type: "text/plain", Size: 5, Reader: strings.NewReader("notes")}}
plan, _ := bundle.NewStreamPlan(sources) // plan.TotalSize is exact before anything is read
envelope, _ := ks.EncryptMeta(keys.Meta{Type: "bundle", PasswordProtected: true})
enc, _ := bundle.NewEncrypter(plan, sources, blobKeys) // an io.Reader: cut it into upload parts
data, _ := io.ReadAll(enc)
l := link.Link{Origin: "https://secretli.app", Secret: ks.Encoded().ShareSecret, DeletionToken: ks.Encoded().DeletionToken}
// Opening one: the link's secret, the password if any, and byte ranges of the bundle.
parsed, _ := link.Parse(l.String())
blobKeys, _ = keys.FromShareSecret(parsed.Secret, "optional")
fetch := func(_ context.Context, start, end int64) ([]byte, error) { return data[start : end+1], nil }
b, _ := bundle.Open(ctx, fetch, blobKeys, int64(len(data))) // b.Files: name, type and size of each
_ = b.DecryptFile(ctx, 0, os.Stdout, nil) // b.Decrypt reads several in one passHanding a link over with a code runs over a relay you implement against the server's transfer API; transfer does the cryptography and the order of the legs:
import "github.com/secretli/format/go/transfer"
// Sending: draw the words and the session id, open the transfer with the offer.
words, _ := transfer.RandomWords()
sid, _ := transfer.NewSID()
party := transfer.Party{Words: words, SID: sid, Origin: "https://secretli.app"}
offer, _ := transfer.NewOffer(party)
// … POST the transfer with base64url(sid) and base64url(offer.Share), get a nameplate …
fmt.Println(transfer.Code{Nameplate: nameplate, Words: words}) // 7-acid-rocket
err := transfer.Send(ctx, senderRelay, party, offer, l.String())
// Receiving: parse what was typed, claim the nameplate, get the sid and the offer.
code, _ := transfer.ParseCode("7 acid rocket")
link, err := transfer.Receive(ctx, receiverRelay, transfer.Party{Words: code.Words, SID: sid, Origin: origin}, offerShare)
// errors.Is(err, transfer.ErrCodeMismatch) when the codes differedThe package is not on any registry. Every release carries it as an archive, and a project depends on that archive's URL:
pnpm add https://github.com/secretli/format/releases/download/v0.7.1/secretli-format-0.7.1.tgzFrom then on it is an ordinary dependency named @secretli/format: the lockfile pins the archive by its hash, the code imports it by name, and installs need no token. To move to a newer version, add the newer release's archive the same way. It ships as ES modules with type declarations, for browsers and Node 20 or later.
import { KeySet, cutIntoParts, encryptStream, openBundle, planStream } from "@secretli/format";
const keys = await KeySet.generateRandom();
const blobKeys = await KeySet.fromShareSecret(keys.getEncoded().shareSecret, "optional");
const envelope = await keys.encryptMeta({ type: "bundle", password_protected: true });
const files = [new File(["notes"], "notes.txt", { type: "text/plain" })];
const plan = planStream(files); // plan.totalSize is exact before anything is read
for await (const part of cutIntoParts(encryptStream(plan, files, blobKeys), 32 * 1024 * 1024)) {
// … upload the part …
}
const opened = await openBundle(fetchRange, blobKeys, size); // opened.files: name, type and size
const [{ blob }] = await opened.decryptFiles([0]);The transfer has the same shape: randomWords, createOffer, sendLink and formatCode on the sending side, parseCode and receiveLink on the receiving one, each role over a SenderRelay or ReceiverRelay you implement.
The snippets leave out error handling. The upload protocol (sessions, parts), the retrieval sessions and the transfer relay belong to the server's API, not to this library.
Every change is one of three kinds, and the kind decides the release:
-
The format (
spec/): rare and deliberate. One pull request changes the specification, both implementations and both committed vector files; CI's interop job fails otherwise. A committed vector that changes without the specification means an implementation's output changed, which is a break. Readers keep accepting the previous form for as long as old secrets can exist, and the clients ship the reader before anything writes the new form. Released as a minor version while below 1.0, as a major one from then on. A change toFORMAT.mdalone that changes no byte and no rule, such as a clearer sentence or a corrected statement about what the server learns, is editorial: no release. -
The libraries (the code in
go/andts/src/, and the runtime dependencies: therequirelines ingo.mod,dependenciesints/package.json):- a fix in our own code is released right away, as a patch version;
- new or changed API is released as a minor version;
- a change nobody can observe, such as a refactoring, goes out with the next release.
Dependency updates need no release of their own. The command-line client's
go.modand the web app's lockfile choose the versions they install, so they take a fixedgolang.org/x/cryptoor@noble/*themselves. Release only to raise the lowest version this library accepts. -
Tooling (everything else: tests, CI, lint and Renovate configuration, development dependencies): no release. The TypeScript compiler is tooling too, but it builds the
dist/the archive ships, so a new compiler reaches the clients with the next release; CI checks that the packed archive works in plain Node.
The TypeScript side builds with Node 24 and pnpm 12, the version ts/package.json pins. Regenerate the committed vectors with:
cd ts && WRITE_VECTORS=../spec/vectors pnpm vitest run test/vectors.test.ts
go test ./go/vectors -run TestWritesGoVectors -args -write-vectors=../../spec/vectorsA tag such as v0.7.1 releases both implementations at that version. For Go the tag is the release: the Go module proxy serves it from this repository. For TypeScript the release workflow checks that ts/package.json carries the same version, packs ts/, signs the archive with a build attestation, and attaches it to the GitHub release. To check a downloaded archive:
gh attestation verify secretli-format-0.7.1.tgz --repo secretli/formatBump the version in ts/package.json in one commit, tag it, push the tag. Tags are permanent once the Go proxy has seen them: never move or delete one, release a new version instead.
MIT. The code word list is the EFF short word list 2.0 by the Electronic Frontier Foundation, licensed CC BY 3.0 US.