Updates & signing
A .nepget bundle carries its own signature. bundle.sig sits at the root of the bundle
next to manifest.json, and it covers the SHA-256 hash of every other file inside. NepTunes
verifies it before a widget is installed or loaded — not just for gallery downloads, but
on every install path: double-click, drag-and-drop, deep link, and update. Every widget in
the gallery is signed, and you should sign yours.
Gallery downloads add a second, independent layer on top: the update feed itself is signed
with a key compiled into the app, and the downloaded .zip is checked against the hash and
signature the feed carries. The two layers are deliberately separate — the feed proves this
download is the one we published, bundle.sig proves these files on disk are the ones the
author signed, and it keeps proving that long after the download is gone.
bundle.sig
A JSON file at the root of the bundle:
{
"version": 1,
"algorithm": "ed25519",
"authorPublicKey": "9LnkF5kMetsyjG6MEOtZ/Lo1jUzVyevNC7ubQ12A214=",
"files": [
{ "path": "index.html", "sha256": "1cf7ea9612478e4c19c153e8223ab5c308a9fb790dddc825712a68bd439a32ca" },
{ "path": "manifest.json", "sha256": "b5a68e1b09b5bac979034102ef06f7957c73296f765cd6c2a1249a62949c207b" },
{ "path": "styles.css", "sha256": "47853e0b4811b9570848acec5939021f68dbbc08f231942d10e0776373d6ecae" }
],
"signature": "w2fo0nYd8zoxrAaEm9KFvTNB3juOgB2UQ2/q6Drffq8qjzFDPFwzHTqleNycplbGNH/NhxkggqLkCiSj3Bm1Bg=="
}
Field rules, all enforced — a bundle that breaks any of them is rejected, never repaired:
| Field | Rule |
|---|---|
version |
The integer 1. Anything else is an unsupported wire format and is refused. |
algorithm |
The string ed25519. There is no negotiation and no second algorithm. |
authorPublicKey |
Base64 of the 32 raw bytes of your Ed25519 public key — no ed25519: prefix, no PEM. It must equal authorPublicKey in manifest.json. |
files |
One entry per covered file: path plus lower-case hex sha256. Paths are POSIX-relative to the bundle root (assets/logo.svg), Unicode-normalized NFC, and must be sorted by raw UTF-8 bytes, strictly ascending — which also makes duplicates impossible. |
path |
No .. anywhere, no leading /, no backslash, no control characters, never empty. Symlinks are never signed — the signer walks past them, and the verifier refuses any listed path that turns out to be one. |
signature |
Base64 of the 64 raw bytes of the Ed25519 signature over the bytes described below. |
manifest.json is itself listed in files, so the manifest — including the
authorPublicKey it declares and the permissions it requests — is covered by the same
signature as your code.
What the signature actually covers
The signature is not over the JSON text of bundle.sig. It is over a flat, line-based
signature manifest built from the fields above, so that two independent implementations
(the Node tooling and the app’s Swift verifier) can reproduce the exact same bytes without
agreeing on a JSON serializer:
neptunes-bundle-sig
1
ed25519
9LnkF5kMetsyjG6MEOtZ/Lo1jUzVyevNC7ubQ12A214=
1cf7ea9612478e4c19c153e8223ab5c308a9fb790dddc825712a68bd439a32ca index.html
b5a68e1b09b5bac979034102ef06f7957c73296f765cd6c2a1249a62949c207b manifest.json
47853e0b4811b9570848acec5939021f68dbbc08f231942d10e0776373d6ecae styles.css
Line by line: the fixed domain tag neptunes-bundle-sig, then version as a decimal
string, then algorithm, then authorPublicKey, then one line per files entry —
sha256, two spaces, path. Lines are joined with \n and the last line also ends
with \n. Those UTF-8 bytes are signed raw: nothing re-serializes them as JSON, and
nothing re-orders or re-normalizes them at verification time. That is precisely what keeps
the signer and the verifier in agreement.
Which files are covered
Everything in the bundle, recursively, except four leaf file names:
bundle.sigitself (it cannot contain its own hash),.DS_Storeand.localized,- anything starting with
._(macOS AppleDouble sidecars).
That list is a file-name rule only. Directories are always descended into — a folder
named ._cache is walked like any other, and its contents are signed. This is deliberate:
pruning a directory by name would let a bundle smuggle in unsigned, script src-able
JavaScript under a metadata-looking folder.
Anything else you ship is signed, so a stray, unlisted file is a verification failure, not a harmless extra.
What NepTunes verifies, in order
Any failure aborts; a bundle never installs on a partial pass.
bundle.sigreads and decodes, and itsalgorithm/versionare the supported pair.bundle.sig.authorPublicKeyequalsmanifest.authorPublicKey. A signature that claims a different identity than the manifest is refused outright.filesis sorted by raw UTF-8 bytes and unique.- The Ed25519 signature verifies over the signature-manifest bytes — before any file
is read. Nothing downstream trusts
filesuntil this passes. - Every listed file’s SHA-256 matches, after its path passes the safety rules and is confirmed not to be a symlink or an escape from the bundle.
- Completeness: the set of files actually on disk (ignore-list dropped, NFC-normalized)
must equal the set listed in
files— no unlisted extras, no listed-but-missing entries.
Signature first, then hashes, then completeness. The order matters: the file list is only worth walking once it has been proven authentic.
Three refusals come out of the install gate, and they’re worth knowing by name:
- Signature could not be verified — a
bundle.sigis present but fails any step above. - Declares an author key but is not signed — the manifest carries
authorPublicKeywith nobundle.sig. Claiming an identity without proving it is worse than claiming none. - A signed version is already installed — see the ratchet below.
Signing your bundle
Signing is done with widget-tools.mjs — one Node script,
built-ins only, no npm install and nothing to configure. Download that link, or:
curl -O https://neptunesmac.app/widget-tools.mjs
node widget-tools.mjs # prints the available commands
It needs Node 18 or newer, and it is the same file the NepTunes source tree uses to sign the first-party gallery widgets — published by copying, with a test asserting the two stay byte-identical, so it can never sign in a format the app has stopped accepting.
Put it wherever you like and run it from there. The commands below assume it sits next to your bundle; adjust the paths if not.
# 1. Once per author: an Ed25519 keypair. The private half is written with mode
# 0600 to .keys/my-widget-author.pem — back it up, and never share it. The
# public half is printed and written to public-keys/my-widget-author.pub.
node widget-tools.mjs keygen my-widget-author
# 2. Structural check: manifest against the JSON Schema, referenced files present.
# If a bundle.sig exists, validate also verifies it.
node widget-tools.mjs validate MyWidget.nepget
# 3. Sign the bundle in place: hashes every covered file, writes bundle.sig, and
# fills in manifest.authorPublicKey if it isn't there yet.
node widget-tools.mjs embed-sign MyWidget.nepget --key .keys/my-widget-author.pem
# 4. Verify exactly the way the app will.
node widget-tools.mjs embed-verify MyWidget.nepget
Each command prints what it did and exits non-zero on failure, so it drops straight into a
build script. Run embed-sign last — it hashes the bundle as it stands, so any edit
afterwards invalidates the signature and the app will refuse to load the widget.
embed-sign writes bundle.sig and keeps manifest.authorPublicKey in sync: it injects
the key if the manifest has none, and fails if the manifest already declares a
different key than the one you are signing with. It also refuses to sign an unsafe path
rather than emitting a bundle its own verifier would reject.
Re-run embed-sign after every content change. Editing one byte of script.js — or
bumping version in the manifest — invalidates the old bundle.sig, and the app will
refuse the bundle rather than ignore the mismatch.
Widget ids and the registry
A widget id is a namespace claim. We keep a widget id registry as part of the packaging
tooling — not something you edit or need a copy of — mapping each known id to the author
public key allowed to publish it. The packager enforces it over every bundle before
anything is packaged:
- id not in the registry → allowed, and reported as a new registration.
- id in the registry, same key → fine, that’s the author re-publishing.
- id in the registry, different key → the whole run aborts. An id cannot change author.
If you’re submitting a widget, that means the flow is:
- Generate your own keypair with
keygen. - Put its public half in
manifest.jsonasauthorPublicKey. embed-signthe bundle with your private half andembed-verifyit.- Submit the bundle and send us the public key for registration against your id.
Never send the private half — to us or to anyone. It never needs to leave your machine: the whole point of the design is that we only need the public key to check your work.
One caveat, and it’s on our side. The gallery packager can only sign with private keys it holds, so it cannot yet pass an already-signed third-party bundle through untouched: today an accepted submission is published re-signed with the first-party NepTunes key, and its id is registered against that key. Your own signature still does real work — it proves the bundle we reviewed is the bundle you sent, byte for byte — but it is not (yet) the key your users see pinned. Pass-through publishing of author-signed bundles is pipeline work we still owe you; until it lands, sign anyway, and expect the gallery copy to carry our key.
Once signed, always signed
The first time a signed bundle for an id installs successfully, NepTunes records that id as signed. From then on, an unsigned bundle claiming the same id is refused — even though unsigned bundles are still accepted for ids that were never signed.
That ratchet closes the obvious downgrade: without it, an attacker could strip bundle.sig
off a bundle, keep the id, and install whatever they liked as a “legacy unsigned widget”.
Practically, it means signing is a one-way door per id. Once you ship a signed version, every later version of that widget must be signed too.
Key rotation
On a successful install NepTunes pins the verified authorPublicKey against the widget’s
id. Every later version for that id has to be signed by the same key.
If a later bundle is signed by a different key, it is neither silently accepted nor silently refused: the install becomes a re-consent prompt. The sheet explains that this version is signed with a different developer key than the one pinned at first install, and only if the user explicitly continues does the new key replace the old pin. That is how you rotate a key — by publishing a version signed with the new key and accepting that every user sees the prompt.
So: do not lose your author private key. A replacement key turns every one of your users’ installs into a re-consent decision.
Gallery updates
Widgets from the gallery get updates through a signed feed on top of all of the above.
NepTunes checks for updates in the background (roughly daily) and whenever you press Check for Updates in Settings › Widgets. The feed and its detached signature are downloaded only from NepTunes’ own domain, and the feed’s signature is checked before the JSON is even decoded — a tampered or unsigned feed is discarded unread. Download URLs are rebuilt from the widget’s slug, never taken from the feed, so nothing in a feed can point your Mac somewhere else.
When a newer version exists for a widget you have installed, its row shows an Update
available badge with the release notes. You press Update (or Update All) and confirm
a sheet naming the widget, the version it moves to, and any new permissions the update
asks for. Only then is the new version downloaded, checked against the feed’s sha256 and
author signature, verified again through its own bundle.sig, and swapped in. Your settings
carry over — an update never resets how you configured the widget.
As an author, the two things that keep updates flowing are unchanged: keep your id
stable, and bump version (numeric major.minor.patch) for every change. An update is
only offered when the version increases; downgrades are refused.
Ready to submit? Head to the publishing guidelines.