Please do not open a public issue for security vulnerabilities. Instead, use GitHub's private vulnerability reporting (the "Report a vulnerability" button under the repository's Security tab), or open a minimal private channel with the maintainers.
Because SkipperKit holds an accessibility service (broad read access to on-screen content for its scoped apps) and can dispatch taps, the areas of most interest are:
- anything that could widen the service's package scope without explicit user action,
- the remote-config fetch path (HTTPS-only, bounded, falls back to bundled),
- any path that could cause an unintended tap (the engine clicks; discovery and Teach Mode only propose and require user confirmation),
- the one-tap contribution path — both the on-device side (
ContributionPortbuilds a payload of skip plus taught custom buttons;ContributionSenderPOSTs over HTTPS only; nothing about what the user watches leaves the device) and the ingestion side (validation, risky-word filter, custom buttons land in PRs disabled-by-default, human merge required to publish).
The remote config is only applied when it passes the signing gate
(config/RemoteConfigGate.kt): a detached signature must be present, verify
against the maintainer public key bundled in the app (config/ConfigSignature.kt
— ECDSA P-256 over the exact published bytes, fetched from <configUrl>.sig),
and carry a version no older than the last applied config. The gate runs on
both the freshly fetched config and the on-disk cache at every startup, so
the invariant holds continuously — a cache poisoned before signing shipped (or
by a local attacker) is never trusted and self-heals on the next verified fetch,
and an attacker who controls the host cannot roll the app back to an older
validly-signed config (the version lives inside the signed bytes). A missing,
invalid, or stale-version signature is logged and ignored; the app falls back to
its cached (if still valid), then bundled identifiers.
Note the rollout consequence: builds with verification enabled ignore remote
updates entirely until a .sig is published alongside config.json — bundled
identifiers keep working, so this degrades gracefully rather than breaking.
Publish the .sig to the config repo before releasing a verifying build.
The private key is handled like the release keystore: a local gitignored file
(config-signing-key.pem) or a CI secret, never committed. Sign a payload with
tools/sign-config.sh. Forks must generate their own keypair and replace
PUBLIC_KEY_B64.
SkipperKit reads only the accessibility node tree of its scoped apps (the built-ins plus any app the user explicitly adds) and never captures the screen, runs OCR, or analyzes media. Reports that depend on disabling these guarantees are out of scope.
We aim to acknowledge reports within a few days.
The config signing private key MUST NOT sit unencrypted alongside the
repository. It is stored at ~/.config/skipperkit/signing/config-signing-key.pem
encrypted with a strong passphrase held in the macOS Keychain.
mkdir -p ~/.config/skipperkit/signing
mv config-signing-key.pem ~/.config/skipperkit/signing/config-signing-key.pem
# Encrypt in place with AES-256:
openssl ec -aes256 -in ~/.config/skipperkit/signing/config-signing-key.pem \
-out ~/.config/skipperkit/signing/config-signing-key.pem.enc
mv ~/.config/skipperkit/signing/config-signing-key.pem.enc \
~/.config/skipperkit/signing/config-signing-key.pem
# Store the passphrase in Keychain:
security add-generic-password -a "$USER" -s "skipperkit-signing-passphrase" -wSKIPPERKIT_SIGNING_PASSPHRASE=$(security find-generic-password \
-a "$USER" -s "skipperkit-signing-passphrase" -w) \
tools/sign-config.sh <path/to/config.json>If the key is ever suspected to be exposed (backup taken to an
unencrypted disk, laptop stolen, malware discovered), rotate the key by
generating a new pair, updating PUBLIC_KEY_B64 in
app/src/main/java/com/skipperkit/config/ConfigSignature.kt, cutting a
new signed release, and publishing a new signed config.json.sig from
the new key.
Symptoms that require rotation: password observed in shell history, CI log, clipboard capture, or a screenshot; laptop backup to an unencrypted disk; loss of exclusive access to the machine holding the JKS.
mkdir -p ~/.config/skipperkit/release
STORE_PASS=$(openssl rand -hex 24)
KEY_PASS=$(openssl rand -hex 24) # independent from STORE_PASS
keytool -genkey -v \
-keystore ~/.config/skipperkit/release/skipperkit-release.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias skipperkit \
-storepass "$STORE_PASS" -keypass "$KEY_PASS"
# Store both passwords in Keychain:
security add-generic-password -a "$USER" -s "skipperkit-release-store" -w "$STORE_PASS"
security add-generic-password -a "$USER" -s "skipperkit-release-key" -w "$KEY_PASS"Create keystore.properties in the repo root (still gitignored) with:
storeFile=/Users/YOU/.config/skipperkit/release/skipperkit-release.jks
storePassword=<from Keychain: skipperkit-release-store>
keyPassword=<from Keychain: skipperkit-release-key>
keyAlias=skipperkitThe keystore.properties file itself must never contain the plaintext
passwords when copied off-machine. Prefer a helper script that reads
Keychain and writes the file just before gradle assembleRelease, then
deletes it (mirrors the CI cleanup pattern).
For RELEASE_KEYSTORE_B64, RELEASE_KEYSTORE_PASSWORD,
RELEASE_KEY_PASSWORD, run in the SkipperKit repo settings:
gh secret set RELEASE_KEYSTORE_B64 -b "$(base64 -i ~/.config/skipperkit/release/skipperkit-release.jks)"
gh secret set RELEASE_KEYSTORE_PASSWORD -b "$STORE_PASS"
gh secret set RELEASE_KEY_PASSWORD -b "$KEY_PASS"Note: any released APK signed with the old key remains valid on installed devices; upgrades to APKs signed with the new key will be rejected by Android's package installer. Communicate this in release notes if rotation is done post-launch.