Every bundle you deploy carries an RSA signature that proves it came from your signing key. The server verifies it on upload, and each client verifies it again before applying the bundle — mandatorily, with no configuration that lets an unverified bundle through.
Algorithm
| Property | Value |
|---|---|
| Key type | RSA, 4096-bit modulus |
| Signature scheme | RSASSA-PKCS1-v1_5 (not PSS) |
| Digest | SHA-256 |
| Signature encoding | Base64 |
| Public key format on the wire | PEM, X.509 SubjectPublicKeyInfo (-----BEGIN PUBLIC KEY-----) |
| Private key format on disk | PEM, PKCS#8 (-----BEGIN PRIVATE KEY-----) |
The signature is computed over the exact bytes that are uploaded — the bundle ZIP itself, with the per-file manifest already inside it. Bundles are not encrypted; see Encryption for why.
Generating keys
Run this from anywhere inside your project:
dya keys generate
It creates four files in .dya/ at your project's web root. Only the signing pair matters:
| File | Purpose | Where it belongs |
|---|---|---|
.dya/dya-signing-private.pem |
Signs bundles during dya deploy |
Your machine or a CI secret. Never commit it |
.dya/dya-signing-public.pem |
Verifies signatures | Uploaded to the server, and set as the plugin's publicKey |
.dya/dya-encryption-public.pem |
None. Uploaded to the server, read by nothing | Left where it is |
.dya/dya-encryption-private.pem |
None. Bundles are never encrypted | Left where it is — do not embed it in your app |
Both private key files are written with mode 0600.
The encryption pair is a leftover from the removed bundle-encryption feature and currently has
no consumer: dya deploy does not encrypt, and no client decrypts. See
Encryption.
The command also writes a .gitignore block that excludes everything in .dya/ except the
project config — as do dya setup and dya deploy, before moving a stray private key into
.dya/:
# DeployYourApp — keys and local state. Never commit these.
.dya/*
!.dya/config.json
Keys written by an older CLI to your project root — or into src-capacitor/ by a command run in
the wrong directory — are still found and used. The lookup order is .dya/, then the web root,
then the Capacitor root, then the legacy .deployyourapp-private.pem filename in each. New keys
are only ever written to .dya/, and moving the old ones there is optional. See
where keys are looked up.
If you are logged in and have an active organization and an app ID in your project config,
dya keys generate uploads both public keys to the server automatically. If that upload is
skipped or fails, run it explicitly:
dya keys upload --app-id com.example.app
Either path calls POST /api/orgs/:orgId/apps/:appId/keys with publicKey and, when the file
exists, encryptionPublicKey. The server stores them on the app record and never receives a
private key. Only publicKey is used for anything; encryptionPublicKey is stored and ignored.
One keypair per app
A project gets exactly one signing keypair. dya keys generate refuses to run when a
signing private key already exists anywhere in the project — in .dya/, at the web root, at the
Capacitor root, or under its legacy filename — and prints where it found it. Delete the existing
key files only if you genuinely intend to rotate, and read Key rotation first.
If two distinct signing keypairs are found, the CLI refuses and exits 1 without writing
anything. It lists each one's path and public key fingerprint — the first 16 hex characters of
the SHA-256 of its public half — and marks the one whose fingerprint matches the publicKey
embedded in capacitor.config.json:
✗ This project contains more than one signing keypair:
.dya/dya-signing-private.pem e2bbb0fc4a91d3e7
src-capacitor/dya-signing-private.pem 59c5420cb8d7f102 ← the key your apps verify against
⚠ Keep the one marked above and delete the other. Signing with the wrong key makes
⚠ every installed app reject that update and every update after it.
There is no prompt to answer. Delete the keypair you do not want and re-run dya keys generate
(or, more likely, just dya keys upload, since the surviving key needs no regeneration).
dya setup applies the same check, and applies it on every run rather than only when it is about
to generate keys — a project that already holds two keypairs never reaches the generator's guard,
and it is the project that most needs stopping. The check runs before the project config is
written, before the plugin is installed, and before npx cap sync is offered, so a refusal cannot
leave a half-configured project behind:
✗ Setup stopped before writing anything. Re-run `dya setup` once one keypair is left.
The closing advice changes with what the config says:
- No
publicKeyembedded yet — nothing has shipped against either key, so the CLI says so and tells you to keep one, delete the other, and re-run. - An embedded key matching neither — the CLI names that third fingerprint and tells you to find its private half, because it is the only key your installed apps accept.
It never picks for you, and the reason is that picking wrong is unrecoverable.
Why this is unrecoverable
An installed app verifies every bundle against the public key that was compiled into it when you shipped that native release. That key is in the binary on the device. It is not something the server, the dashboard, or a later OTA deploy can change.
So if a project holds two keypairs and you sign a release with the one that is not embedded:
- Every installed app rejects that update, with
SIGNATURE_INVALID. - Every subsequent update signed with the same key is rejected too.
- Devices stay on their last good bundle and stop receiving updates entirely.
- There is no server-side fix. Re-uploading, re-deploying, rolling back, or rotating the registered public key changes nothing, because the apps in the field are not consulting the server about which key to trust.
The only way out is shipping a new native release through the app stores with the correct public key in it, and waiting for users to install it. That is a store review cycle per platform, plus however long adoption takes — during which the affected devices cannot be reached over the air at all.
The failure is quiet on the way in. Nothing warns you when a second keypair is created; you find out when a deploy that reported success reaches no one. That is why the guard is a hard refusal rather than a warning.
How a project ends up with two
Running dya keys generate from the wrong directory in a split layout. The old duplicate-key
check looked only in the current working directory, so running it once at the web root and once
inside src-capacitor/ produced two unrelated keypairs, each invisible to the other check. The
current check searches every location the CLI would ever read a key from, which is what makes
the refusal reliable.
If you find yourself in this state, decide which key is correct by fingerprint — the one matching
the publicKey in your capacitor.config.json is the one your released apps trust. If no
release has shipped yet, neither key is embedded anywhere and you can safely delete both and
generate a fresh one.
Configuring the client
The server sends the signature to the device in the update-check response. The client only verifies it if you give the client the public key.
Capacitor — capacitor.config.json:
{
"plugins": {
"DeployYourApp": {
"appId": "com.example.app",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIICIjANBg...\n-----END PUBLIC KEY-----"
}
}
}
Electron — pass it to the constructor:
import { ElectronUpdateManager } from '@deploy-your-app/electron-update-manager';
const updater = new ElectronUpdateManager({
appId: 'com.example.app',
publicKey: process.env.DYA_PUBLIC_KEY,
});
Use the X.509 SPKI form — the contents of .dya/dya-signing-public.pem as generated. The Android
client parses the public key with X509EncodedKeySpec and will not accept a PKCS#1
(-----BEGIN RSA PUBLIC KEY-----) key.
Setup will not embed a key it cannot sign with
When dya setup fills in publicKey for a capacitor.config.json, it does not simply copy
whatever .pem it finds. It derives the public half of the dya-signing-private.pem that
dya deploy would sign with, and uses that. A dya-signing-public.pem on disk is only used when
its fingerprint matches; when it does not, setup writes nothing at all:
✗ The signing public key in this project is NOT the public half of the signing private key.
.dya/dya-signing-private.pem e2bbb0fc4a91d3e7 ← what `dya deploy` signs with
.dya/dya-signing-public.pem 59c5420cb8d7f102 ← what would have been embedded
⚠ Nothing was written to the Capacitor config. Embedding that public key would make
⚠ every installed app verify updates against a key nothing here can sign with, so
⚠ every update would be rejected — and no server-side change could fix it.
Delete the stray public key — a public key can always be re-derived from its private half — or
restore the private key the other fingerprint belongs to, then run dya setup again.
When they agree, setup announces the key it is about to embed and where it came from, for example
ℹ Signing public key to embed: e2bbb0fc4a91d3e7 (derived from .dya/dya-signing-private.pem).
If the private key is missing or unreadable, setup warns and embeds no publicKey rather
than embedding one nothing in the project can sign for. The rest of the configuration is still
written; re-run setup once the signing key is back.
Setup will not replace a key that already shipped
Pairing the key to embed with the key dya deploy signs with says nothing about the key
installed apps are already verifying against — the one in capacitor.config.json. Setup merges
its plugin config over that file, so before it does, it fingerprints both and stops on a
difference.
The path into this is a clone, not carelessness. capacitor.config.json is committed and
.dya/* is gitignored, so a second developer has the shipped public key and none of the private
half. A keypair generated on that machine is internally consistent — it passes every check above
— and is not the key any installed app trusts. Setup says so in consequences:
✗ This project already ships a DIFFERENT signing public key.
src-capacitor/capacitor.config.json e2bbb0fc4a91d3e7 ← what installed apps verify against
.dya/dya-signing-private.pem 59c5420cb8d7f102 ← what would be written, and what `dya deploy` signs with
⚠ Every app already installed — from a store build or an earlier OTA update — checks
⚠ updates against e2bbb0fc4a91d3e7. Changing the key here does not reach those apps.
⚠ They go on trusting e2bbb0fc4a91d3e7, every bundle you sign from now on is signed
⚠ with 59c5420cb8d7f102, and every one of those updates is rejected. Permanently: no
⚠ server-side change, no re-deploy and no later `dya setup` can undo it — only a new
⚠ store release built with the new key can.
⚠ The Capacitor config is committed and `.dya/*` is not, so a fresh clone of a project
⚠ that has already shipped lands here. If that is what happened, get the private key for
⚠ e2bbb0fc4a91d3e7 from whoever made the first release — do not replace it.
⚠ Go ahead only if no build carrying e2bbb0fc4a91d3e7 was ever installed anywhere.
? Type "replace the signing key" to replace it, or press Enter to keep it:
There is no y/N here on purpose: y is the answer given without reading, and this one is not
reversible. Anything but that sentence — a bare Enter included — keeps the embedded key and ends
the run with Kept <fingerprint>. Nothing was written to <config>. and Setup stopped: the plugin was not configured and `npx cap sync` was not run. Typing it writes the new key and warns
that every app carrying the old one is now unreachable by OTA update until a new store build
ships.
Every one of these refusals exits non-zero, so dya setup && npx cap sync stops rather than
syncing a project the wizard deliberately left unconfigured.
Generating a keypair over one that already shipped
The same reasoning applies one step earlier, when setup offers to generate keys — and to
dya keys generate run on its own. Both guards above look only at private keys on disk, and the
clone case has none: the Capacitor config is committed with the shipped public key while .dya/*
is not, so nothing fires and the keypair that gets minted is one every installed app rejects.
So when the Capacitor config pins a different publicKey and no local key matches it, generating
demands the same typed phrase. The consequences are printed first, then:
? Type "replace the signing key" to generate a DIFFERENT keypair anyway, or press Enter to stop:
A bare Enter or anything but the phrase writes nothing —
No keys were generated. The app stays pinned to <fingerprint>. — and exits 1. Typing it
generates the keypair and warns that dya setup must embed the new key and a new store build must
ship before you deploy. Without a terminal there is nobody to ask, so the command refuses and
exits 1 rather than defaulting either way, naming the file the correct private key belongs in.
Run from inside dya setup, the generator is told which fingerprint you have already consented to
replace, so the phrase is asked once per run rather than twice.
Reading the pinned key out of a .ts or .js config
The pinned key is read from capacitor.config.json, capacitor.config.ts, or
capacitor.config.js — whichever Capacitor itself resolves first. A .ts or .js config is
code, and the CLI does not execute your config to read it: it looks for a plain publicKey string
literal and accepts it only if it parses as a public key. A key assembled from an import, an
environment variable, or a concatenation cannot be read that way.
Where the answer cannot be established — a code config no literal could be read from, a
capacitor.config.json that is not valid JSON, a publicKey that is not a string, an unreadable
file — the result is reported as unknown, never as "no key pinned". Treating the two the same
is precisely how a guard that exists to stop a second keypair silently stops applying, so
dya keys generate and dya deploy say so instead:
⚠ Could not read the signing key pinned in src-capacitor/capacitor.config.ts:
⚠ it is code, not data, and no `publicKey` string literal holding a public key could be read from it.
⚠ So this is not a clean bill of health. If that config pins a `publicKey`, a different
⚠ key here is rejected by every installed app, permanently and unfixably from the server.
⚠ Check `plugins.DeployYourApp.publicKey` in it yourself before shipping a key change.
That is a warning rather than a refusal — a project whose config is a .ts file has to be able to
make its first keypair like any other — so check the file yourself before shipping a key
change. Setup rewrites only a .json config; for .ts and .js it prints a snippet to paste,
so the publicKey in such a config only ever changes when you change it.
Signing on deploy
dya deploy signs automatically when the project has a dya-signing-private.pem. The order is
fixed:
Build web assets
|
v
Hash every file, and check it against the bundle asset policy
|
v
Bundle and compress (zip), with .dya-manifest.json written into the archive root
|
v
SHA-256 checksum of the bytes to be uploaded
|
v
RSA-SHA256 signature over the same bytes
|
v
Upload bundle + checksum + signature
Nothing is encrypted at any step. The asset-policy check runs first so a bundle that every client would refuse fails at your terminal instead of after an upload; see Bundle asset policy.
If no signing key is found, the CLI prints a warning and uploads the bundle with no signature
field. Every client refuses such a bundle, so this is a deploy that reaches no device. It is
still not an error at upload time when the app has no registered public key — see
Server-side verification.
Deploy will not sign with a key your apps reject
Before the bundle is signed, dya deploy fingerprints the signing key it would use
and compares it with the publicKey pinned in the Capacitor config. A mismatch stops the deploy
and exits 1 with nothing uploaded:
✗ Refusing to deploy: this bundle would be signed with a key your apps reject.
src-capacitor/capacitor.config.json e2bbb0fc4a91d3e7 ← what installed apps verify against
.dya/dya-signing-private.pem 59c5420cb8d7f102 ← what this deploy would sign with
⚠ Every installed app checks updates against e2bbb0fc4a91d3e7. A bundle signed with
⚠ 59c5420cb8d7f102 fails that check, so this release and every release after it would
⚠ be rejected — while the upload succeeds and nothing on the server looks wrong.
ℹ Two ways out, and only one of them is usually right:
ℹ • Get the private key for e2bbb0fc4a91d3e7 — whoever made the first release has it —
ℹ and put it in .dya/dya-signing-private.pem. The Capacitor config is committed;
ℹ `.dya/*` is not, which is why a fresh clone lands exactly here.
ℹ • Or, only if no build carrying e2bbb0fc4a91d3e7 was ever installed anywhere, re-pin
ℹ the app with `dya setup` — it asks for that in words — and rebuild for the stores.
⚠ There is no flag for this. A signed bundle your apps reject cannot be undone.
There is no --force, deliberately. An override on the publishing command would be pasted
into a CI script once and would then disable the guard for every deploy afterwards, with nobody
reading the warning again. A real key rotation does not need one: dya setup takes the typed
consent and re-pins the config to the new key, after which the two fingerprints match and this
check passes honestly. That is the difference between a flag that silences a check and a step that
makes the check true.
The guard is silent in two cases. A deploy with no signing key at all is not refused — there is nothing to compare — though the resulting bundle is refused by every client, so it reaches no device. And a config the pinned key could not be read from is reported as unreadable rather than treated as clean; the comparison is skipped there because nothing was established either way.
Server-side verification
On POST /api/orgs/:orgId/apps/:appId/bundles the server:
- Checks whether the app has a registered signing public key. If it does and the upload has no signature, the upload is rejected with a message naming the missing key file.
- Verifies the signature over the uploaded bytes with
RSA-SHA256against the registered public key. An invalid signature is rejected. - Verifies the SHA-256 checksum using a timing-safe comparison. A mismatch is rejected.
An app with no registered public key accepts unsigned uploads. Register a key to close that path — though such a bundle is refused by every client anyway, so it installs nowhere.
Client-side verification
All three clients verify in the same order, over the downloaded bytes, before the archive is opened:
- SHA-256 checksum against the
checksumfield from the update-check response. - RSA signature against the configured
publicKey.
| Client | Verification API | Digest handling |
|---|---|---|
| iOS | SecKeyVerifySignature with .rsaSignatureDigestPKCS1v15SHA256 |
Pre-hashes with CC_SHA256 and passes the digest |
| Android | Signature.getInstance("SHA256withRSA") |
Passes the full data; the provider hashes |
| Electron | createVerify('SHA256') |
Passes the full data; Node hashes |
A failed signature check aborts the update, emits an update_verify_fail analytics event with
reason: "signature_invalid", and leaves the currently active bundle in place.
Verification cannot be skipped
Signature verification is mandatory on every client. There is no configuration in which a bundle is installed without it:
- No
publicKeyconfigured — the update is refused withSIGNATURE_REQUIRED, and on Electron the download is refused before a single byte is fetched. The message names the configuration key that is missing. - No
signaturein the update-check response — alsoSIGNATURE_REQUIRED. A server cannot open a fail-open path by omitting the field.
That is a change from earlier versions, where verification only ran when a publicKey happened
to be set and iOS and Android applied an unsigned bundle. Both fail closed now.
The practical consequence for deploying: a bundle uploaded without a signature installs nowhere. Keep a signing key in your project and in CI.
Per-file manifest
The signature covers the archive as a whole. Inside it, every bundle also carries a
.dya-manifest.json at its root, written by dya deploy, mapping each file's bundle-relative
path to its SHA-256:
{
"manifestVersion": 1,
"bundleVersion": "1.4.0",
"createdAt": "2026-08-04T00:00:00.000Z",
"files": {
"index.html": "e3b0c442...",
"assets/app.js": "9f86d081..."
}
}
Each file's hash is recomputed as it is written to disk and compared with its manifest entry. The
comparison runs in both directions: every manifest entry must exist on disk with the declared
hash, and every extracted file must appear in the manifest. A one-way check would let a file be
added (forward only) or removed (reverse only). .dya-manifest.json is the single entry exempt
from listing itself.
A bundle with no readable manifest is refused with MANIFEST_MISSING; a hash mismatch, an
undeclared file, or a missing declared file is refused with MANIFEST_MISMATCH. There is no
fallback path and no legacy branch.
Alongside the manifest, the extractor enforces a web-assets-only policy — an extension allowlist
plus executable magic-byte rejection — so the updater cannot install native code whatever a
bundle claims to contain. The normative lists live in
packages/shared/src/constants/bundlePolicy.js and are mirrored in the Swift and Kotlin clients.
Key rotation
Rotate when a signing private key may have been exposed, or on whatever schedule your own policy sets.
A rotation is indistinguishable, from the CLI's side, from the clone accident described above — in both cases a key that is not the pinned one is about to take over. So each step below that could be either one asks you to say which it is, in words.
- Delete
dya-signing-private.pemanddya-signing-public.pemfrom.dya/— and from any other location they resolve from, or the generate guard will refuse — and from any CI secret store. - Run
dya keys generate. Because the Capacitor config still pins the oldpublicKeyand nothing on disk matches it any more, it asks you to typereplace the signing keybefore it mints anything — see Generating a keypair over one that already shipped. It then creates a new pair in.dya/and uploads the new public key, replacing the one on the app record. - Update the
publicKeyvalue in your Capacitor config or Electron updater config, and ship a native app release containing it. If you letdya setupwrite that value it asks for the same phrase again — see Setup will not replace a key that already shipped. Doing it through setup is what makes step 4 possible: it re-pins the config, so the deploy-time fingerprint check passes on the new key instead of being overridden. - Redeploy your bundles with
dya deployso they carry signatures from the new key. Until the config is re-pinned in step 3,dya deployrefuses — see Deploy will not sign with a key your apps reject. There is no flag to skip it.
Sequencing matters. The server verifies against a single registered public key, so bundles signed with the old key stop passing upload verification as soon as the new key is registered. Devices still running the old native binary hold the old public key and will reject bundles signed with the new one — so a rotation requires a native release, not only an OTA deploy.
Bundles already downloaded and applied on a device are not recalled by a rotation. If you are rotating because a key leaked, also publish a clean bundle and mark it mandatory.
Related
- Encryption — what is and is not encrypted, and why bundles are not
- Security overview
- CLI commands