- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| appindex | ||
| cmd | ||
| upload | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
Spitfire Repository Manager (or SRepo)
A Go library and CLI tool for managing APPINDEX based repositories.
This project currently handles:
- artifact compression (directory -> tar.xz)
- artifact uploads to S3
- APPINDEX generation/updates
- Ed25519 signing with an offline ROOT / online index-key split
And It's primary usage is to manage Spitfire Browser's repositaries.
Prerequisites
- Go version 1.25 or newer
CMD Install
go install weforge.xyz/Spitfire/Repo/cmd@latest
Commands
srepo keygen
Generates a new Ed25519 index keypair (the online key that signs APPINDEX). Store the private key at the signer as ED25519_PRIVATE_KEY. Clients never pin this key: it is valid only while listed as a k: key in the signed ROOT (see srepo root and Key distribution).
srepo keygen
# Private key (set as ED25519_PRIVATE_KEY): <base64>
# Public key (list as k: via srepo root ...): <base64>
srepo pubkey
Prints the public key derived from private ED25519_PRIVATE_KEY.
srepo pubkey
# Public key: <base64>
srepo appindex
Update a local APPINDEX entry, sign it, and write APPINDEX and APPINDEX.sig to disk. Pass --upload to also push both files to S3.
# generates files locally
srepo appindex \
--appid xyz.example.MyApp \
--name "My App" \
--version 2025.05.06 \
--arch amd64 \
--platform linux \
--compressed-file myapp.tar.xz \
--uncompressed-file myapp.tar \
--download-path amd64/2025.05.05/myapp.tar.xz \
--description "Nightly build of My App" \
--website https://example.xyz \
--license MPL-2.0
# with upload
srepo appindex --upload \
--appid xyz.example.MyApp \
...
srepo compress
Compress a directory to tar.xz locally.
srepo compress \
--dir ./build \
--output ./myapp-amd64-linux.tar.xz
srepo upload
Compress a directory, upload the artifact to S3, and update APPINDEX in one step.
# full pipeline (compress + upload + APPINDEX)
srepo upload \
--dir ./build \
--object xyz.example.MyApp/amd64/2026.05.07/myapp-amd64-linux.tar.xz \
--appid xyz.example.MyApp \
--name "My App" \
--arch amd64 \
--platform linux \
--license MPL-2.0
# pre-compressed artifact
srepo upload --no-compress \
--file ./myapp-amd64-linux.tar.xz \
--object xyz.example.MyApp/amd64/2026.05.07/myapp-amd64-linux.tar.xz \
--appid xyz.example.MyApp --name "My App" --arch amd64 --platform linux
PS: uncompressed checksum and size will be omitted from the APPINDEX entry.
srepo put
Upload one file as is, not signed, not compressed, not in APPINDEX.
srepo put --file ./spitfire-launcher.exe --object installer.exe
# https://<bucket host>/installer.exe
srepo resign
Re-sign an unchanged APPINDEX with a fresh v:/E: header, run it on a schedule (by default valid for 21 days) so the index never expires.
Refuses an index whose current signature doesn't verify, and the serial only ever increases.
# scheduled on the signer: read live index from S3, re-sign, push back
srepo resign --remote --upload # --valid-days 21 to change the E: window
# first resign after an index-key rotation
srepo resign --remote --upload --trust-pubkey "<old index pubkey b64>"
srepo root
Manage the repo's ROOT trust metadata, its reccomended to run these offline/separate machine. Each run writes ROOT, ROOT.sig and an immutable root/<version>.ROOT{,.sig} upload all of them to repositary, never overwrite a versioned copy.
srepo root keygen --out root-a # writes root-a.key (keep safe and offline!) + root-a.pub
srepo root init --key-file root-a.key \
--expiry-days 365 \
--index-keys "<index pubkey b64>" \
--root-pubs "<standby root pubkey b64>" # optional backup key
# annual re-sign (bumps version + expiry, --expiry-days to change it)
srepo root update --key-file root-a.key
# revoke a stolen index key (replaces the k: list, clients pick it up on next check)
srepo root update --key-file root-a.key --index-keys "<new index pubkey b64>"
# check and automatically publish (online machine, public files only, needs S3_* env)
srepo root upload --dir ./ceremony-output
srepo verify
Verify an APPINDEX signature against the repo's published public key.
srepo verify --pubkey "$(cat pubkey.b64)" --appindex ./APPINDEX --sig ./APPINDEX.sig
Domain ownership (y:)
appindex and upload check at build time whether the domain derived from app ID lists that ID
in https://<domain>/.well-known/srepo.txt and record a hit in the signed entry as y:1.
--no-verify-domain skips it,
--tor / --socks5 host:port route the fetch through a SOCKS5 proxy.
The domain is derived from the app ID by reversing all but the last component:
xyz.weforge.Spitfire->weforge.xyzio.github.username.App->username.github.io
To publish ownership, host a plain text file at https://<domain>/.well-known/srepo.txt listing your app IDs one per line:
xyz.weforge.Spitfire
xyz.weforge.Luncher
xyz.weforge.Installer
Key distribution
Trust is a two-level chain (a minimal TUF-style split):
- Root keys (
r:) - kept separate (srepo root keygen). The root public key is the only key built into the client - everything else is trusted only because a root key signed it. Root keys sign only theROOTfile. - Index keys (
k:) - the online keys that signAPPINDEX, not pinned by clients, the valid list comes from the signedROOT. Rotating or revoking one = publish a new ROOT, no client update.
ROOT file
Published at the repo root next to APPINDEX, signed into ROOT.sig (raw 64-byte Ed25519 by a root key).
Every version is also kept at root/<version>.ROOT{,.sig}.
v:1 monotonic version, starts at 1
E:1788115200 expiry (unix seconds)
r:<base64 pubkey> root public key (repeatable)
k:<base64 pubkey> valid APPINDEX signing key (repeatable)
Rotation
- Index key (routine, or compromise recovery):
srepo keygen, swapED25519_PRIVATE_KEYat the signer, offlinesrepo root update --key-file root-a.key --index-keys <new pubkey>, upload, thensrepo resign --remote --upload --trust-pubkey <old pubkey>. Clients revoke the old key the moment they see the new ROOT — no client release. - Root key (rare):
srepo root update ... --root-pubs <new set>signed by the current root key; clients walk the chain. Also update the root keys built into SPM/PackageStore so fresh installs start from the new key. - Root re-sign (annual):
srepo root update --key-file root-a.keybefore the ROOTE:passes, or every client starts refusing the repo. - Root key stolen or lost with no backup: last resort — ship new client binaries with new built-in root keys.
Environment variables
| Variable | Description |
|---|---|
S3_ENDPOINT |
S3-compatible endpoint URL |
S3_BUCKET |
Bucket name |
S3_ACCESS_KEY_ID |
Access key ID |
S3_SECRET_ACCESS_KEY |
Secret access key |
ED25519_PRIVATE_KEY |
Base64-encoded Ed25519 private key (for signing) |
APPINDEX format
Each entry describes one "app" for one arch+platform.
Entries are separated by a blank line, always starting with C: and ending with c:.
The whole file is signed - see APPINDEX.sig.
Header
The first two lines of APPINDEX, written by srepo at signing time and covered by APPINDEX.sig:
v:1756425600 serial - unix signing time, never decreases
E:1758240000 expiry - unix seconds, default 21 days (--valid-days)
C:... first entry
Clients refuse an expired index or a serial lower than the last accepted one, so an unchanged index must still be re-signed before E: (srepo resign). srepo derives the serial as max(now, previous + 1).
Path convention
All path fields (d:, n:, G:, i:, s:) are relative. The client reconstructs the full URL as:
{repoURL}/{AppID}/{path field value}
Example - APPINDEX at https://repo.example.com/, app xyz.weforge.Spitfire:
d:amd64/2025.05.06/xyz.weforge.Spitfire-amd64-linux.tar.xzresolves to:-
https://repo.example.com/xyz.weforge.Spitfire/amd64/2025.05.06/xyz.weforge.Spitfire-amd64-linux.tar.xz i:assets/icon.svgresolves to:-
https://repo.example.com/xyz.weforge.Spitfire/assets/icon.svg
Field spec
| Field | Description | Required |
|---|---|---|
C: |
SHA-256 hex of the compressed artifact - marks entry start | yes |
P: |
App ID - reverse-domain, e.g. xyz.weforge.Spitfire |
yes |
N: |
Display name - e.g. Spitfire Browser |
yes |
V: |
Version - YYYY.MM.DD or vX.Y.Z semver |
yes |
A: |
Architecture - amd64, arm64, … |
yes |
p: |
Platform - linux, windows, … |
yes |
k: |
Kind - see Package kinds | yes |
S: |
Compressed size in bytes | yes |
I: |
Uncompressed / installed size in bytes | yes |
d: |
Download path (relative) | yes |
t: |
Unix timestamp - publish time | yes |
c: |
SHA-256 hex of the uncompressed artifact - marks entry end | yes |
n: |
Release notes .md path (relative) |
no |
X: |
Short description - inline one-liner | no |
G: |
Long description .md path (relative) |
no |
U: |
Project website URL | no |
B: |
Bug tracker / issue reports URL | no |
x: |
Donation / support URL | no |
L: |
License - SPDX identifier | no |
m: |
Maintainer | no |
D: |
Dependencies - comma-separated app IDs, omit if none | no |
T: |
Tags - comma-separated free-form search keywords | no |
i: |
Icon path (relative) | no |
s: |
Screenshot paths - comma-separated (relative) | no |
y: |
Verified - 1 when the app ID's domain ownership at build time. Set automatically by appindex/upload |
no |
Package kinds
k: tells the client how to install a package.
| Kind | Payload | Installed as |
|---|---|---|
extension |
.xpi |
AddonManager |
statictheme |
.xpi |
AddonManager |
layout |
.tar.xz |
userChrome.css layout |
config |
.tar.xz with a user.js |
managed pref block |
bundle |
none - groups its D: members |
installs each member |
browser, launcher, installer |
.tar.xz |
full applications |
wallpaper |
.tar.xz |
new tab background, image or video |
soundpack |
.tar.xz |
UI event sounds |
keysounds |
.tar.xz |
typing sounds |
music |
.tar.xz |
looping ambient layers |
webstyle |
.tar.xz |
per-site CSS |
accent |
.tar.xz |
UI accent colors |
Example entry
C:a6af7ebb5c1382084704be0b5714ab026c819d63c2d3e4f5a6b7c8d9e0f1a2b3
P:xyz.weforge.Spitfire
N:Spitfire Browser
V:2025.05.06
A:amd64
p:linux
k:browser
S:838594187
I:3595780372
d:amd64/2025.05.06/xyz.weforge.Spitfire-amd64-linux.tar.xz
X:Fast, private Firefox fork
G:description.md
U:https://spitfirebrowser.xyz/
B:https://weforge.xyz/Spitfire/Browser/issues
x:https://spitfirebrowser.xyz/donate
L:MPL-2.0
m:Internet Addict
T:browser,experimental,testing
t:1746518400
i:assets/icon.svg
s:assets/screenshots/1.png,assets/screenshots/2.png
c:b7c3d2e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
Use as a Go module
Add the dependency:
go get weforge.xyz/Spitfire/Repo@latest
APPINDEX - generate and sign
import (
"os"
"time"
"weforge.xyz/Spitfire/Repo/appindex"
)
// Read existing index (nil = start fresh)
existing, _ := os.ReadFile("APPINDEX")
entry := appindex.Build(appindex.Entry{
AppID: "xyz.example.MyApp",
DisplayName: "My App",
Version: "2025.05.06",
Arch: "amd64",
Platform: "linux",
CompSize: 1234567,
UncompSize: 4567890,
DownloadPath: "amd64/2025.05.05/myapp.tar.xz",
CompChecksum: "sha256hex...",
UncompChecksum: "sha256hex...",
Description: "My build",
Website: "https://example.xyz",
License: "MPL-2.0",
})
updated := appindex.Finalize(
appindex.Update(existing, "xyz.example.MyApp", "amd64", "linux", entry),
21*24*time.Hour,
appindex.ParseHeader(existing).Serial)
sig, err := appindex.Sign(updated, os.Getenv("ED25519_PRIVATE_KEY"))
os.WriteFile("APPINDEX", updated, 0o644)
os.WriteFile("APPINDEX.sig", sig, 0o644)
APPINDEX - verify a signature
import "weforge.xyz/Spitfire/Repo/appindex"
data, _ := os.ReadFile("APPINDEX")
sig, _ := os.ReadFile("APPINDEX.sig")
ok, err := appindex.Verify(data, sig, publicKeyB64)
APPINDEX - checksum a file
checksum, err := appindex.SHA256File("myapp.tar.xz")
Keys - generate a keypair
privateKeyB64, publicKeyB64, err := appindex.GenerateKey()
Keys - derive public key from private key
publicKeyB64, err := appindex.PublicKey(privateKeyB64)
Upload - upload files
import (
"context"
"weforge.xyz/Spitfire/Repo/upload"
)
// reads S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY from env
client, err := upload.NewFromEnv()
ctx := context.Background()
// upload a local file
client.Upload(ctx, "xyz.example.MyApp/amd64/2025.05.05/myapp.tar.xz", "./myapp.tar.xz")
// upload bytes directly (e.g. a freshly signed APPINDEX)
client.UploadBytes(ctx, "APPINDEX", updated)
client.UploadBytes(ctx, "APPINDEX.sig", sig)
// download to memory (returns nil, nil if key not found)
data, err := client.Download(ctx, "APPINDEX")
Repository structure
example.com/
├── APPINDEX
├── APPINDEX.sig
├── ROOT
├── ROOT.sig
├── root/
│ ├── 1.ROOT
│ ├── 1.ROOT.sig
│ ├── 2.ROOT
│ └── 2.ROOT.sig
└── xyz.example.MyApp/
└── amd64/
└── 2025.05.05/
└── xyz.example.MyApp-amd64-linux.tar.xz
License