Security model
What the host enforces around a plugin, and what it does not. Use it to decide whether to install someone else’s plugin, or what your own plugin can be trusted with. Anything declared but not enforced, or shipped but not implemented, is stated as such.
The trust model at a glance
Section titled “The trust model at a glance”| Party | Trusted for | Not trusted for, or the limit |
|---|---|---|
| Host | Everything: it owns state, credentials, secrets and install decisions | - |
| Desktop app, over the private loopback port | Admin, with a per-launch secret instead of a token | A process running as the same user can read that secret - see loopback trust |
| Plugin process | Scope plugin: the plugin protocol surface, and nothing else |
Not sandboxed: it runs with the user’s full privileges. Declared permissions are not enforced, except host:adb |
| Deck clients (web client, companion) | Scope client: the viewer-safe endpoints |
Plugin endpoints refuse them |
| LAN callers and browsers | The public listener’s API and web client | Every plugin endpoint refuses them |
| Creator Portal and Store | Signing Store plugins after checking the publishing workflow’s provenance; publishing the signed registry and its revoked keys | Revocation refuses new installs and updates, not a plugin that is already installed |
Network exposure
Section titled “Network exposure”Your plugin’s own listener binds 127.0.0.1:0 by default, and the host’s plugin endpoints answer only
from loopback. Exposing custom endpoints of your plugin to the network is a separate, explicit security
decision.
| Listener | Reachable from | Notes |
|---|---|---|
| Public listener | The LAN | Serves the API and the web client. HTTPS is optional and configurable - see ADR 0040. While its plain-HTTP port is serving, the host announces its instance name, version and that port on the LAN over mDNS (_macrodeck._tcp), unless this is turned off in Settings > Network - see ADR 0082. |
| Private loopback listener | The local machine only | Never gets TLS: the desktop shell and the plugin SDK reach it over plain HTTP on 127.0.0.1. |
| Rule | Consequence |
|---|---|
| Plugin endpoints are served on both listeners but accept only a loopback remote address | A LAN caller is refused whichever port it used. A self-registering plugin must run on the same machine as the host (ADR 0028) |
A browser-shaped request (cross-site Origin or Sec-Fetch-Site) is refused |
Plugin paths are also excluded from the app-wide CORS policy |
| Enabling HTTPS on the public listener does not widen this | The local-only gate grants no principal of its own: reaching it from another listener adds reachability, not authority |
/_macrodeck/* on the plugin’s listener is reserved (/_macrodeckery is not) |
Middleware added with Configure runs after the SDK’s and cannot answer there; a constant path there is analyzer error MDP2005. See reserved routes |
Loopback trust needs a per-launch secret
Section titled “Loopback trust needs a per-launch secret”A request on the private loopback port, from a loopback address, with a loopback Host header, is
authenticated as admin only when it also presents a secret the desktop app generates for every
launch: the app itself sends it as a header, and its window holds a session cookie derived from it. That
is how the desktop UI works without handling a token.
Reaching the port is therefore not enough: another account on the same computer, a local tool that
fetches URLs for someone else, a sandboxed process or a browser tab gets no admin rights there.
ADR 0098 states the limit that remains: a hostile process running as the same user can
read the secret, as can anything with that user’s file access, such as WSL on Windows, and is outside
what this boundary defends against. The Host header check is a
DNS-rebinding guard, and a request a browser marks as cross-site is refused even with the cookie.
Plain HTTP on the LAN leaves tokens visible to on-path attackers, so the public listener can be configured for TLS. The shipped posture is a self-signed certificate: it “exists to encrypt a link the user already trusts, not to prove identity to strangers”.
Scopes
Section titled “Scopes”Authorisation is deny-by-default: an endpoint with no authorisation metadata is admin-only.
| Scope | Grants |
|---|---|
admin |
Everything, including the plugin installation surface |
client |
The viewer-safe endpoints a deck client needs |
plugin |
The plugin protocol surface, and nothing else |
plugin:enroll |
Developer (enrollment) tokens - the only Developer-token scope defined today |
plugin is a peer scope, not a subset. A plugin session token does not grant client access to the
deck-viewing endpoints, and neither an admin token nor a client token is accepted where a plugin token is
required.
Credentials and tokens
Section titled “Credentials and tokens”A plugin holds one credential to buy a session, then sends the session token on every request. The mechanics are in the authentication guide; this is how they are stored, revoked, and must be handled.
# Right: the session token in the Authorization header, the secret only on the session exchangePOST /api/plugins/sessions HTTP/1.1X-MacroDeck-Plugin-Id: com.example.ref-probeX-MacroDeck-Plugin-Secret: <plugin-secret>
GET /plugins/ws HTTP/1.1Authorization: Bearer <session-token>
# Wrong: a token in the query string lands in logsGET /plugins/ws?access_token=<session-token> HTTP/1.1| Credential | Where it is kept | Rules |
|---|---|---|
| Launch bootstrap token (managed) | Host: memory only, keyed by hash. Minted per launch, discarded on every exit path | A process that outlives its own termination sequence can never present a token the host still recognises. The managed credential store refuses to save it |
| Developer token | Host: SQLite, as a SHA-256 hash. Plaintext shown exactly once | Never persist the plaintext; a caller that displays it should discard it |
| Per-plugin secret | Host: SQLite, as a SHA-256 hash, plaintext shown once. Plugin: credentials.json in its state directory, not encrypted at rest |
Sent only to POST /api/plugins/sessions - never on the WebSocket upgrade, in a query string or in a log line |
| Session token | Plugin memory; 15-minute JWT | Authorization header only - never a cookie or a URL |
PKCE codeVerifier |
Plugin memory | Sent only to the redemption call |
- Hashing. Verification is constant-time. A plain SHA-256 hash is used deliberately: Developer tokens and per-plugin secrets are high-entropy random values, unlike user passwords, which use PBKDF2.
- The plugin’s copy of its secret is written owner-only, to a temporary file made owner-only before anything is written to it, then moved into place. On Windows the per-user profile ACL is the protection.
- Revocation cascades. Revoking a Developer token revokes every registration derived from it and terminates those sessions immediately. Revoking one registration terminates that plugin’s session. The host re-checks the live session registry on every request, so a terminated session stops working at once even though its 15-minute JWT is still cryptographically valid.
- Identity is never taken from a claim. The host checks the credential against its own record.
MACRO_DECK_PLUGIN_LAUNCH_IDis yours to log and nothing more. - Client sessions (web client, companion) hold an access token - 15 minutes for the admin surface, 60 days for a deck device - and a refresh token that rotates on every use and lives 365 days. Signing a device out in the devices list, removing it, or changing the account password or username is checked on every request from then on, so a long device token ends the moment you end the session rather than when it expires - every device at once for a credential change. A client session that is not tied to a device keeps the 15 minute token, because there is nothing to revoke it by. A rotated refresh token presented again within 30 days of its rotation revokes that token’s rotation chain - the session it belongs to - and leaves every other session of the account signed in. One exception: the token rotated out most recently is accepted once more, from the same device and scope, so a client whose rotation response was lost in transit can retry - within a minute of the rotation, or, when the host restarted in between, within ten minutes of it starting up - and then only for the rotation that restart interrupted, because a restart is exactly when a rotation response goes missing. The companion pairs with a six-digit, single-use code from the desktop app’s network panel: one code at a time, minted only on the loopback listener, cleared after five failed guesses from any caller, and throttled globally. See ADR 0083.
Interactive pairing
Section titled “Interactive pairing”By default a self-registering plugin gets its per-plugin secret by pairing, not from a Developer token: it creates a request, the user approves it in the desktop app, and the plugin redeems it. Mechanics: the authentication guide.
| Guarantee | Detail |
|---|---|
| Loopback only | Pairing endpoints are ordinary plugin endpoints: served on both listeners, reachable only from a loopback remote address |
| Gated by Developer Mode, continuously | Off by default. Turning it off invalidates redemption of an already-approved request, so no stale approval survives it |
| Approved only in the trusted desktop app | The prompt is rendered over the private loopback transport (ADR 0003). An admin bearer token over the network listener is not sufficient to approve: approval is a human act in a specific UI, not an API call |
| Proof of possession | The plugin presents a PKCE-style verifier at redemption; the host stores only the SHA-256 challenge, never the verifier. requestId is unguessable but authorises nothing on its own, so it is safe in a URL |
| One-time, short-lived, one per plugin id, capped, rate-limited | Expires on a host-advertised timer; consumed permanently on successful redemption; a second create for a plugin id with a live request is refused with 429 instead of replacing it; a global cap on pending requests; separate rate limits on creation and redemption |
| Nothing persists past a host restart | Requests are memory-only in every state - pending, approved, rejected |
| Self-reported fields shown as unverified; transport origin verified | Executable path, process id, SDK version and display name are shown as reported, labelled unverified, since deriving them from the loopback socket would authenticate nothing. Whether the request arrived on the public listener is verified and shown as verified |
| Replacement is atomic at redemption | If the host holds a registration for the plugin id but the local credential is gone, the prompt offers, with a separate confirmation, to replace it. Redemption rotates the secret in place and terminates live sessions in the same step, so old and new never both authenticate. The secret is minted at redemption, never at approval: an unredeemed approval leaves an existing working credential untouched |
Accepted residual exposure
Section titled “Accepted residual exposure”Pairing uses the same loopback check as the rest of the plugin protocol, which has no port clause: a device
reaching the loopback address through an adb reverse tunnel is indistinguishable at the socket level from
a local process (ADR 0030).
Pairing does not widen that existing exposure. A connection from a USB link without debugging is different:
the host dials it itself and marks it, so it is never taken for a local process and cannot reach pairing
(ADR 0095).
What pairing changes is what a hostile local process needs. Before, it could mint a Developer token itself through the then credential-less loopback admin. Now it needs a human to approve a specific prompt while Developer Mode is on, unless it runs as the same user and reads the desktop app’s loopback secret. That is bounded by Developer Mode being off by default, approval only over the trusted transport, a prompt that names what is approved and labels unverified fields, the one-request-per-plugin-id and rate-limit rules, and nothing being minted before proof of the verifier - a bound, not a cryptographic guarantee, as recorded in ADR 0028.
Secrets in configuration
Section titled “Secrets in configuration”Declare a secret field in a setup flow, and read it back only when you need it:
ActionParameter.Secret("api_key", label: Strings.Setup.ApiKey(), required: true)
// Values the user never typed, such as OAuth tokens:["access_token"] = ConfigFlowValue.Secret(token.AccessToken),
// At runtime - decrypted only on request:var apiKey = await context.Config.GetSecretAsync(entry.Id, "api_key");| Rule | Detail |
|---|---|
| Stored encrypted | Secret and Password fields and ConfigFlowValue.Secret values go into SQLite through ASP.NET Core Data Protection. Plain values are stored as they are |
| Referenced, never inlined | Configuration refers to a secret as { "$secret": "<id>" }, so no flow or action document carries plaintext |
| Key ring protected by the OS | The Data Protection key ring on disk under the host’s data root is encrypted under a key in the OS credential store - Windows Credential Manager, macOS keychain, or Secret Service on Linux - so a copy of the data directory does not carry the key that opens it |
| Honest fallback | With no credential store, and in a portable installation, the key ring stays readable on disk and the host reports that state rather than implying protection |
| Never log or show | Secrets never belong in logs or user-visible errors: log the failure type, not the value |
See setup flows and ADR 0047.
Signing and install trust
Section titled “Signing and install trust”Check an artifact yourself, or in CI, before it reaches a host:
macrodeck-plugin verify ./artifacts/com.example.hello-deck-1.0.0-linux-x64.macroDeckPluginArtifacts
Section titled “Artifacts”A .macroDeckPlugin artifact is a ZIP whose root is the version directory. Before a byte reaches its
destination, the installer enforces:
| Check | Rejects |
|---|---|
| Path safety (zip-slip) | Absolute paths, Windows drive letters, .. segments, any resolved destination outside the target |
| Filesystem traps | Illegal filename characters, reserved Windows device names (CON, PRN, COM1, …), any symlink, fifo, socket or device node |
| Zip-bomb caps | Entry count, per-entry uncompressed size, total uncompressed size, compressed size, compression ratio - measured live through a bounded stream, never taken from the archive’s central directory |
| Payload identity | When the manifest declares files[]: a file whose SHA-256 or size does not match, and any extracted file not declared |
| Identity | id or version that does not match the directory names they install under |
Nothing from an artifact is ever executed during install, activate or uninstall. The manifest has no hook field; the only process a version directory ever produces is the supervisor’s own health-gated launch after activation. The execute bit is set only on the entrypoints for the current runtime identifier, and a self-contained entrypoint may not be a shell script.
Activation is atomic. current.json is written to a sibling temporary file and renamed over the real
one - a single filesystem operation on every supported platform - so it always names the previous version
or the new one. If the new version does not reach a healthy running state in time, the rename is undone,
the failed version is removed, and the previous version is restarted if it was running. See
ADR 0029.
Signing: the Creator Portal signs, and the host verifies before install and before every load
Section titled “Signing: the Creator Portal signs, and the host verifies before install and before every load”Verification lives in
MacroDeck.Signing,
used by both the host and the macrodeck-plugin CLI’s verify and
keygen/sign commands.
When it is checked: in a staging directory before anything is written into the plugin directory, and again on every launch of an installed plugin. The plugin directory is user-writable on every platform, so editing a plugin’s files after installation, or stripping the certificate from one installed as trusted, stops it from loading.
The host resolves a package to exactly one verdict:
| Outcome | Verdict | Blocks install? |
|---|---|---|
| Signature valid, chained to the Macro Deck root directly or through an issuer certificate | Trusted |
No |
No signature declared |
Unsigned |
Only without consent |
| Signature block malformed | Malformed |
Yes |
| Signature present but does not verify | SignatureInvalid |
Yes |
| Contents do not match the signature | ContentMismatch |
Yes |
| Certificate does not chain to the pinned root | UntrustedRoot |
Yes |
| Certificate issued for another purpose | WrongCertificatePurpose |
Yes |
Certificate not valid at signedAt |
CertificateNotValidAtSignature |
Yes |
| Certificate, or the issuer that signed it, revoked by the Store registry | Revoked |
Yes, at install and update only |
| Package unreadable, or algorithm unknown to this host | VerificationUnavailable |
Yes |
Unsigned is the only verdict a confirmation can admit, because nothing published today is signed yet:
| Source of an unsigned plugin | Installs? |
|---|---|
A .macroDeckPlugin you select yourself, open through the file association, or upload |
After a deliberate confirmation, and stays marked unverified |
| The Store or registry, Developer Mode on | Refused first; installs only if you then confirm the same warning. Developer Mode is read by the host from its own settings, so asking for the exception without it changes nothing |
| The Store or registry, Developer Mode off | No |
An update to a plugin installed as Trusted |
No, regardless of consent |
| An update Macro Deck found for you | Only ever reported to you, never installed unsigned on your behalf |
Icon packs and profile templates have no signature of their own and are unaffected. A signature that is present but does not verify is a failure, not a weaker kind of unsigned: no confirmation installs it. An unknown signature algorithm fails closed rather than being treated more leniently than no signature.
Signature format. Every signable format - .macroDeckPlugin, .macroDeckIconPack, and the portable
.macroDeckProfile, .macroDeckFolder and .macroDeckWidget - carries its signature in its own manifest
and its certificate as certificate.json and certificate.sig at the archive root, plus issuer.json and
issuer.sig when an issuer certificate signed that certificate. There is no detached signature file: a
signed artifact verifies on its own. The signature covers a format-specific canonical
digest - the package identity and the declared file list, never the manifest’s own JSON encoding - so
reformatting a manifest does not invalidate a signature, while adding a file or repointing an entrypoint
does. See the manifest’s signature field and the
certificate (v1,
v2) and
package signature schemas.
Trust anchor. MacroDeck.Signing.MacroDeckRootKey (formerly
MacroDeckHost.Domain.Security.MacroDeckRootKey, which no longer exists) holds the public half of an
offline Ed25519 key pair, verification-only. The private half never exists on a build machine or in CI, and
is not any of the release-signing keys. --root-public on sign and verify points at a different root
for testing; both commands then warn non-production-root and report a result not anchored to the Macro
Deck root.
Certificate chain. A certificate is signed either by the root itself or by exactly one issuer certificate the root signed, so the root can stay offline while the Creator Portal issues certificates with the issuer’s key. The rules are strict:
- An issuer certificate carries exactly the
issuerkey usage and theissuersubject kind, usesschemaVersion2, is signed by the root, and names no issuer of its own. There is never a second intermediate level. - A certificate it signs uses
schemaVersion2, names the issuer’scertificateIdinissuerand the samerootKeyId, carries exactlypackageorregistry, and has a validity window inside the issuer’s. - An issuer certificate never signs a package or a registry manifest, and a
packageorregistrycertificate never signs another certificate. - Both validity windows are evaluated at the signature’s
signedAt. - A
schemaVersion1 certificate is always signed by the root, exactly as before.
The registry publishes an issuer certificate under certificates/ beside the certificates it signed. Macro
Deck versions from before this chain refuse an issuer-signed certificate. See
ADR 0096.
Store artifacts are signed by the Creator Portal, not by their author. Publishing runs as Trusted Publishing: the plugin’s CI workflow authenticates to the Creator Portal with its own workload identity, the Portal verifies that the workflow is a trusted publisher and checks the run’s provenance, then signs the artifact server-side. Plugin developers and their CI workflows do not generate, receive, manage or hold signing keys, certificates or signing credentials - no signing secret in a repository or CI secret store, and no manual artifact upload in the publishing path. Certificate issuance and revocation also live in the Creator Portal. See Publishing to the Store and ADR 0042.
The CLI signs and verifies; it never issues trust. The public CLI has no root key generation, no
certificate issuance and no registry signing, and keygen/sign is not the Store path - it is for
artifacts distributed outside the Store and for Macro Deck’s own infrastructure. keygen produces a
creator Ed25519 key pair and nothing else; sign requires a certificate only the Creator Portal can issue,
validates the whole artifact against it, and embeds the signature. verify checks the certificate chain,
the certificate’s validity at the signature’s own signedAt rather than at verify time, the canonical
digest, and every declared file’s hash and size. See sign and
verify.
| Limit | What it means |
|---|---|
verify never consults revocation |
It says so on every run, in both output formats. A valid verdict is a fact about the signature and chain at signing time, not a live trust decision |
| Revocation stops new installs, not installed plugins | The revoked keys come from the security.json of the signed Store registry the host has loaded. A package whose certificate, or whose certificate’s issuer, is listed there is refused at install and update. A version that is already installed keeps activating and launching, and the Store marks it Certificate revoked. See ADR 0096 |
| Revocation needs a loaded registry | A host that has not loaded a registry snapshot yet, or never can because it is offline, answers Unavailable, and that deliberately does not block: failing closed would refuse every signed plugin. See ADR 0044 |
| A leaked signing key must be contained by other means | Treat everything signed with it as untrusted. For the Store that is a Creator Portal concern, since the keys are the Portal’s; outside the Store see private-key handling |
signedAt is not authenticated |
No canonical digest covers it, so the key holder can set any value. Checking validity at signedAt is advisory against that key holder: it only protects a package from its certificate’s later expiry. Revocation is the control that stops new installs signed with a compromised or misused key |
publisher is a claim |
It becomes an attribution only behind a Trusted verdict. Verified publisher identity comes from the Creator Portal having checked the workflow’s provenance before signing, never from the manifest |
| Only plugins carry a publisher signature | Store icon packs and profile templates are authenticated by the signed registry: the pinned root signs the registry’s certificate, directly or through an issuer certificate, that certificate signs the registry manifest, and the manifest carries the digest and size of every file, including the release manifest that declares each artifact’s digest. That proves the bytes are the ones the Macro Deck registry published, nothing more: they are never presented as publisher-verified, and a publisher’s revoked certificate does not apply to them |
Permissions: declared, and enforced only for ADB
Section titled “Permissions: declared, and enforced only for ADB”{ "permissions": ["host:variables", "host:adb"] }A manifest may declare permissions from a fixed vocabulary (host:variables through device:usb),
mirroring the host callback surface one for one. They are parsed, shape-validated, persisted and surfaced
through the installed-plugins API so a consent surface has something real to render.
The host gates exactly one host API on a declared permission: adb, on host:adb. Every other host
callback is served whether or not the calling plugin declared the permission that covers it. Plugins that
predate the vocabulary declare nothing, so a default-deny posture for the rest would break every plugin
already running, and a default-allow posture would not be a boundary worth the name.
host:adb is enforced because it is new: no existing plugin relied on it. A plugin reaches Macro Deck’s
ADB connection only when ADB is enabled, the user has left Allow plugins to use ADB on, and the active
version of the installed plugin declares host:adb. Installing a plugin that declares it while ADB or
that setting is off asks the user whether to turn both on. A self-registered session, admitted through a
developer token or interactive pairing, is exempt from the declaration because the user admitted it by
hand. See Android devices and
ADR 0092.
This is user control, not isolation. A plugin process is not sandboxed and could run its own adb
without declaring anything. host:adb gives the user a visible, switchable decision about plugins using
Macro Deck’s connection; it does not stop a hostile plugin from reaching a device. Local paths an adb
call names are read and written with Macro Deck’s identity, which is the same user the plugin already runs
as.
Treat the other declared permissions as documentation of intent, not as a constraint on what a plugin can reach.
Messages between plugins
Section titled “Messages between plugins”Plugins and integrations can exchange messages through Macro Deck’s
message channel. Macro Deck stamps every message with the sender’s integration
id, so Sender is reliable, but it authorizes nothing: any plugin can publish on any topic, send commands
and requests to any handled topic, and handle any topic nobody has claimed yet. Treat a message like any
other input from another process: validate its payload, and check Sender before acting on a command
where it matters who asked. host:messaging is declared, not enforced.
Video streams
Section titled “Video streams”A video stream provider gives Macro Deck the URL of a stream, and Macro Deck
relays the media to every client signed in to Macro Deck, the desktop app or any paired device. The
provider’s URL never reaches a client, so credentials in it stay on the computer; a provider still must
not serve anything it would not show every signed-in client, and never puts the source’s own password or
API key in a URL. Macro Deck never logs a description. host:video-streams is declared, not enforced.
The relay is a deliberate, bounded fetch on behalf of a plugin:
- The relay URL is a capability.
/api/video-streams/relay/<token>/...carries an unguessable token that is bound to one session, sent only over the authenticated UI WebSocket to the connection that owns the session, and dead once the session ends. The route is anonymous by design, because an image or video element cannot send an authorization header. Anyone who holds the URL can read that one stream until it ends, which is no more than the client it was sent to could. Macro Deck redacts the token from its logs. - It pins the origin of the provider’s URL and nothing else. Redirects and HLS playlist URIs stay on that
origin (apart from
data:andskd:URIs, which are passed on untouched), and onlyGETandHEADare sent. The provider chooses the origin, so a plugin can point the relay at any service on the computer or the local network, or at Macro Deck itself. A plugin could make those requests without the relay, so this adds no capability, but the relay returns the response to a client, which is why only responses that look like the session’s media are passed on. - Plugin bytes cannot run as a page. Content types are checked against the session’s transport,
responses carry
X-Content-Type-Options: nosniffand a sandboxing Content-Security-Policy, and a response that does not fit is refused, so a plugin cannot serve a script or an HTML page from Macro Deck’s own origin. - It is bounded. Eight concurrent relayed requests per session, 64 in all, a header timeout and an idle timeout on media.
A tree can name any provider’s stream in a macrodeck.video-stream, so
any plugin’s view can make the clients that draw it open sessions on another plugin’s provider. That is no
wider than what a signed-in client can already open, but it means a stream’s audience is everyone who can
see any Macro Deck view, not only the views its own plugin draws.
Logging and redaction
Section titled “Logging and redaction”_logger.Information("Authenticated as {User}", account.DisplayName); // not the tokenThe host redacts every event once, before any sink, to ***: values of sensitive keys (password,
token, secret, api_key, authorization, client_secret and similar) in key=value or
"key": value form, credentials in URLs (https://user:pass@), Bearer/Basic/Digest credentials,
JWTs and PEM private keys. That is a safety net, not a licence: a secret in an unrecognised shape is written
as-is into a file users attach to bug reports. Plugin identity on a log line comes from the authenticated
session, so a plugin cannot log under another integration’s name. See
logging.
Threat-model limitations
Section titled “Threat-model limitations”Read this before deciding what a plugin should be trusted with.
| Limitation | Detail |
|---|---|
| A plugin runs with the user’s full privileges | A managed plugin is an ordinary child process: no sandbox, container, separate account or privilege reduction. It can do anything the user can - read and write their files, open network connections, start processes. Installing one is equivalent to running any other downloaded program |
| Permissions are not a boundary | A plugin that declares nothing can still reach every host API except adb, and even host:adb gates only Macro Deck’s own ADB connection, not a plugin’s own - see above |
| An unsigned plugin is still admitted on your say-so | See signing. For an unsigned install the declared-digest check is corruption detection, not a boundary: whoever can rewrite the binary can rewrite the unsigned manifest. Trust rests on where you got the file |
| Revocation stops new installs only | A plugin installed before its certificate or issuer was revoked keeps running; the Store marks it, and removing it is your decision |
| A process running as the same user can become admin | It can read the desktop app’s loopback secret. The model defends against LAN callers, browsers and other local accounts, not a hostile process running as the same user |
| The plugin secret and session token cross plain HTTP on loopback | The local-only rule confines that to processes already on the machine, but it is not encryption |
| A self-signed public certificate proves nothing about identity | It encrypts the link; it does not authenticate the host to a stranger, and a DHCP change means regenerating it |
| Supervision contains failure, not intent | Isolation is not a goal. The child’s environment is scrubbed of inherited MACRO_DECK_PLUGIN_* and ASPNETCORE_URLS; the listener port is bound by the host and handed to the child; a fresh credential is minted per launch and discarded on exit; a restart budget stops a crash loop. See ADR 0029 |
| A hard kill is a hard kill | A plugin that does not exit within its graceful timeout has its process tree killed. An SDK that does not recognise close code 4004 gets no notice and no chance to flush (“kill-only” in ADR 0029) |
| macOS arm64 and unsigned binaries | An unsigned, freshly extracted Mach-O needs at least an ad-hoc signature on Apple silicon before the kernel runs it, and the installer does not add one. This is a known, unsolved gap. |
Reporting a vulnerability
Section titled “Reporting a vulnerability”Report privately through GitHub’s private vulnerability reporting, never a public issue, discussion or Discord. What to include, supported versions and scope are in SECURITY.md.
| The flaw is in | Report to |
|---|---|
| Macro Deck: host, first-party clients, built-in integrations, SDK, plugin infrastructure | This repository, privately |
| A third-party plugin that exploits Macro Deck or bypasses a boundary Macro Deck should enforce | This repository, privately |
| Only a third-party plugin | Its author, or its repository’s security process. Store plugin with no suitable contact: report it through the Store |
See also
Section titled “See also”- Authentication - credentials, session exchange, and what a plugin must never do with either.
- Plugin hosting - the artifact format, the installer’s rejection rules, and what the supervisor injects.
- Compatibility policy - what is frozen and what may change.