Replies: 1 comment
-
|
I've moved this to a PR: #1870 |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Summary
RFC 0001 defines how sandboxed plugins are published as FAIR records over an atproto transport, and how a site installing a plugin verifies them. For non-interactive (CI) publishing it suggests app passwords — full-account atproto credentials stored in a CI secrets store — while noting that app passwords are a deprecated stopgap kept only because atproto's headless-client story is unfinished.
This RFC supersedes RFC 0001's app-password suggestion and defines the CI publishing path properly. It specifies:
repo:pm.fair.package.release, create-only) for authors who delegate to it, which creates release records on their behalf. Anyone can run one. CI authenticates to it with a short-lived workload token (e.g. GitHub Actions OIDC), never a stored account credential.requireProvenance,confirmation) that constrain how releases may be published, enforced by the signing service and verifiable by the installing site.The attested flow this RFC describes is the way to publish from CI, and the only way to produce provenance. App-password publishing is not implemented in the EmDash CLI and is not part of this design; it remains possible only insofar as an author's PDS independently chooses to permit it, which is outside EmDash's tooling and outside this RFC.
Everything new is defined under
com.emdashcms.*, reusing RFC 0001's extension mechanism. Build provenance is generic enough that we also propose it as a FAIR artifact field; see Provenance and FAIR.Background & Motivation
The attack this is built against
Since September 2025 the npm ecosystem has been under sustained attack by the self-replicating Shai-Hulud worm and its successors (the "Mini Shai-Hulud" waves attributed to the TeamPCP group, ongoing through 2026). Security researchers describe Shai-Hulud as a prototype for a new class of automated supply-chain attack that weaponises developer identity and the implicit trust in CI/CD pipelines. The attack chain has distinct stages, and they are worth setting out because each maps to a specific defence in this RFC:
The common thread is a long-lived publishing credential that, once stolen, grants the attacker the victim's full publishing rights, and a publish step that attests nothing about the build, so a backdoored artifact published under a genuine identity is indistinguishable from a legitimate release.
Where RFC 0001 is exposed
RFC 0001's CI path stores an app password — a full-account atproto credential — in the CI secrets store. This reproduces both weaknesses the worm exploits:
checksumproves the bytes are what the publisher blessed, but says nothing about where those bytes came from. An attacker who reaches the build pipeline (stage 2/3) can cause a valid, correctly-signed release to point at a poisoned artifact, and nothing in RFC 0001 detects it.The mapping of stages to defences lives in the Threat model.
Goals
declaredAccessrequires a passkey approval from an approver the author authorised. A stolen workload token cannot escalate on its own.Non-Goals
The signing service
What it is
A service that creates
pm.fair.package.releaserecords on behalf of authors who delegate to it. It holds, per delegating author, an atproto session authorised with a granular OAuth scope limited to the release record:repo:pm.fair.package.releasewith the action restricted tocreate(releases are version-immutable in RFC 0001, so update and delete are not requested). This is the release-only scope RFC 0001 already defines for interactive publishing, used here for the automated path. The scope syntax and semantics are specified in the atproto Permissions specification. The key property is that a session bearing this scope can create release records and do nothing else: it cannot read or write the package profile, the author's identity, their other collections, or account settings.It authenticates to atproto as a confidential OAuth client (
private_key_jwt), so its sessions are long-lived but revocable: removing the service's key from its published JWKS invalidates every session it holds.It also holds staged release intents pending human approval and runs the passkey ceremonies for approvers the author has authorised; see Staged releases.
Like the aggregator, the mirror, and the artifact host in RFC 0001, it is trusted for availability and convenience, never for integrity: every release it produces is verified by the installing site against the author's PDS, the artifact checksum, and
declaredAccessbefore install.Anyone can run one
The signing service follows RFC 0001's run-anywhere pattern. EmDash operates a default instance, with no special authority. Hosting platforms and plugin directories can run one for their users; individual authors can self-host. The software is the same in every case, the scope bound is identical, and the choice of signing service is the author's. See What you trust a signing service for.
The automated release flow
sequenceDiagram participant CI as CI (e.g. GitHub Actions) participant Host as Artifact host participant Signer as Signing service participant Approver as Approver (human, passkey) participant PDS as Author's PDS participant Agg as Aggregator CI->>CI: Build bundle, generate build attestation CI->>Host: Upload bundle + attestation CI->>Signer: Submit release intent + workload token (OIDC) Signer->>Signer: Verify token against registered policy Signer->>Host: Fetch bundle, verify checksum, validate declaredAccess, verify attestation Signer->>Signer: Stage intent, evaluate confirmation policy alt Auto-approvable (non-escalating, confirmation: escalation-only) Signer->>Signer: Auto-approve else Approval required Signer-->>CI: Staging ID + approval URL Signer->>Approver: Notify (email / DM / webhook) Approver->>Signer: Passkey assertion Signer->>Host: Re-verify checksum, declaredAccess, attestation end Signer->>PDS: Create pm.fair.package.release (release scope only) PDS-->>Agg: Record propagates via firehose Signer-->>CI: Release AT URIStep by step:
actions/attest-build-provenance, which attests an arbitrary tarball and emits an SLSA provenance statement carrying the source repository, ref, commit, and workflow). CI uploads both the bundle and the attestation document to the artifact host — the attestation must be fetchable at a URL the release record can reference, not left only in the build platform's own attestation store.checksum, validates that the bundle manifest'sdeclaredAccessis deep-equal to what the release will declare (RFC 0001's existing consistency check), and verifies the attestation (the same four checks the installing site performs — see Verification).checksum, thedeclaredAccess, and the provenance reference. From there it is an ordinary RFC 0001 release: it propagates via the firehose and is verified by the installing site at install time.The signing service never holds a profile-scoped session, so it cannot create or alter the package profile, including the release policy that governs it. That separation is the basis of the downgrade defence (Release policy).
The held session
atproto has no grant for stateless headless writes. A confidential-client session, minted once interactively, is the closest available mechanism, and the signing service holds one. Compared with app passwords: the credential is release-scoped instead of full-account, revocable, and held in one place the author chose instead of duplicated into every CI runner. PDS-side MFA, if any, applies at the initial authorisation.
Recent Shai-Hulud variants steal workload tokens from CI memory. A stolen token lets an attacker call the signing service as that workflow, but the layers behind it (provenance,
requireProvenance, the escalation gate) catch what the token alone cannot bypass. See the Threat model. No single stolen credential is sufficient.Staged releases
Every automated release passes through staging. CI submits a release intent; the PDS record is written only after the intent is approved, either automatically (when policy allows) or by a human approver presenting a passkey.
What is staged
On submission, the signing service runs the same four-layer check the installing site runs (Verification) and rejects early if any layer fails. The staged intent records:
checksum, anddeclaredAccess;checksum;At approval time the signing service re-runs the four-layer check before signing. This catches an artifact host that has changed bytes between submit and approve, an attestation that has been substituted, or profile records that have moved on (e.g.
repositorychanged). Verifying once at submit is for fast rejection; verifying again at approve is what the signature actually rests on.Auto-approval versus human approval
The signing service evaluates the package's
confirmationpolicy (Release policy) against the staged intent:escalation-only(default): the intent auto-approves unless it widensdeclaredAccessrelative to the package's previous release (an escalation). Escalating intents require human approval. A first-ever release whosedeclaredAccessis non-empty is an escalation from the implicit empty floor.always: every intent requires human approval, escalation or not.The escalation gate is not configurable. Profile policy can tighten it (require approval for every release) but cannot loosen it. An author with no
confirmationfield and no enrolled approver still has the gate: an escalating intent in that state is held, will not auto-approve, and expires unapproved. The fix is to enrol an approver, not to bypass the gate.From CI's perspective, the signing service's response is either a release AT URI (auto-approved, written) or a staging ID plus approval URL (held).
Approver identities
The author signs an
approverslist into the profile policy (Release policy) declaring whose passkeys the signing service may accept. Each entry is an atproto DID (did:plc:…ordid:web:…). Non-atproto identity bindings (email, OIDC, and so on) are deliberately not part of this RFC; see Future work.The signing service MUST refuse approvals from DIDs not on the signed
approverslist. The set of authorised humans is on the author's signed profile (where the signing service cannot rewrite it); the passkey material is at the signing service (which the protocol cannot carry).Enrolment
An approver listed in
approverscannot approve until they have enrolled a passkey at the signing service. The signing service MUST refuse approval attempts from DIDs for which no credential is registered. "Listed but not enrolled" is a no-op; it does not silently authorise a JIT enrol-and-approve flow.The enrolment ceremony has two parts, in order:
The signing service MUST persist enrolment outcomes so revocation, re-enrolment, and listing-versus-enrolled status are queryable by the author. Enrolment does not produce an approval; the approver returns to the approval surface separately. Approval is a deliberate decision on top of an established identity, never a side effect of identity setup.
Invitations. The invitation channel is operator policy, not protocol. When the author adds an entry via the CLI or admin UI, they typically pass the new approver's contact channel (e.g. email) to the signing service privately, not on the signed profile. The signing service then issues an enrolment URL and delivers it. Authors who skip this can direct approvers to the enrolment endpoint manually. The enrolment URL authorises nothing on its own; the OAuth ceremony is what proves DID control.
Re-enrolment. An approver who loses their authenticator MAY re-enrol by repeating the ceremony. The signing service MUST require the OAuth proof again, MUST replace the previous credential (multiple credentials per DID MAY be supported as an explicit "add another authenticator" action), and MUST log the re-enrolment and notify the author. An unexpected re-enrolment is a signal the author needs to see.
Revocation. Removing a DID from the signed
approverslist revokes that approver for that package as soon as the signing service observes the profile update on the firehose. The signing service MAY retain the credential record if the same DID is still listed on other packages it serves.Cross-package and cross-signer. A passkey enrolment is per (DID, signing service) pair, bound to the signing service's origin. The same DID can be enrolled at multiple signing services, with independent credentials. Within one signing service, an already-enrolled DID listed on a new package does not need to re-enrol; the signing service SHOULD inform the approver of the new association.
Approval surface
The signing service hosts the approval surface itself: a web view (the approval URL returned to CI) and a CLI command (
emdash plugin approve <stage-id> --signer <url>), both speaking the same JSON API. Other clients — an EmDash site's admin UI, third-party dashboards — MAY consume that API; the signing service MUST implement it. The contract is the API; the UI is conventional. This is the run-anywhere shape applied to approval: anyone can run the signing service, anyone can build a client against the API.The approver's view, before they touch the passkey, shows the submitting workflow's OIDC claims, the artifact
checksum, thedeclaredAccess(with an explicit diff against the previous release when escalating), and the provenance reference. The WebAuthn assertion is bound to the signing service's origin and to a server-issued challenge tied to the staging ID, so an assertion captured from one signing service cannot be replayed at another.Lifecycle
What this gates and what it does not
Staging gates the publication of a release intent into the author's PDS. It does not gate the bundle's existence on the artifact host (already uploaded by CI), nor anything else outside the signing service's reach. An attacker with a stolen workload token can submit intents and waste storage; they cannot publish, and they cannot escalate. Staging does not protect against a signing service that ignores its own policy. That risk is the same as before; staging extends what the signing service is trusted to enforce, not what it is trusted for.
Build provenance
The provenance reference
A release gains an optional reference to a build attestation, modelled on RFC 0001's existing
sbomartifact-sibling: an externally-hosted document, referenced by URL, integrity-bound by a multibase-multihashchecksum, and therefore transitively signed by the author's repo MST like everything else in the record.predicateTypehttps://slsa.dev/provenance/v1. A type the verifier does not understand is treated as present-but-unverifiable.urlchecksumsourceRepositoryrepositoryat verification time.builderIdThe artifact digest is not duplicated here. The release already carries the artifact's
checksum; verification compares the attestation's subject digest against that, so there is one source of truth for the digest.Provenance and FAIR
Build provenance is not EmDash-specific — every FAIR ecosystem with author-hosted artifacts has the same build-pipeline gap, and the natural home is a FAIR artifact field beside
checksum,signature, andsbom. We will propose it upstream. Until or unless FAIR adopts it, the reference is carried undercom.emdashcms.package.releaseExtension(alongsidedeclaredAccess), so this RFC is not blocked on a FAIR decision — the same dual-track approach RFC 0001 takes on thepm.fair.*versuscom.emdashcms.package.*namespace question.Author-generated, not registry-generated
The registry never witnesses the build. The attestation is generated by the author's CI and is self-contained; the signing service, aggregator, and mirror pass it through and vouch for nothing. This is weaker than a registry-witnessed build (it trusts the build platform and Sigstore to have attested correctly) and stronger than identity-signing alone (forging it requires compromising the source-and-build chain, not merely a publishing credential or the artifact host).
Verification
"The installing site" below means the EmDash site that is installing the plugin — the admin server performing the install, per RFC 0001's install flow.
When a release carries provenance, the installing site verifies four claims, all of which must hold. This is additive to RFC 0001's existing install checks (PDS-direct record fetch, MST signature, artifact
checksum,declaredAccessdeep-equal) and runs only when provenance is present (subject to policy, below). The signing service performs the same four checks before signing; the installing site repeats them because the signing service is not trusted for integrity.checksum(both decoded to raw bytes and compared).checksumfor it in the signed release record.builderIdandsourceRepository, andsourceRepositoryis consistent with the package profile'srepositoryfield.Without layer 1 the attestation is an unsigned document anyone could write. Without layer 2 a genuine attestation from an unrelated build can be attached to a malicious artifact. Without layer 3 a mirror can substitute a different Sigstore-valid attestation describing an unrelated source. Without layer 4 an attacker's own pipeline can produce a valid attestation of their malicious artifact (layers 1–3 pass because they genuinely built it); only layer 4 catches that the source is not the one the package claims.
Layer 4 must accommodate legitimate build repositories that differ from source repositories (monorepos, build-farm repositories). Because both
builderIdand the profile'srepositoryare author-signed, the check is whether this is the builder the author's own signed records point at, not whether it is the same organisation as the source. An installing site may apply stricter operator-configured policy.Absent versus failed. Absent provenance falls back to RFC 0001 trust and is not a failure — unless the package's signed policy requires provenance (Release policy), in which case absent is a hard reject. Failed provenance (present but a layer fails) is tampering or misconfiguration: the installing site warns clearly and blocks per operator policy, and does not silently downgrade it to absent.
What it does not establish. A verified chain proves origin, not safety. A compromised author building malware from their own real repository through their own real workflow passes all four layers. Provenance answers "where did this come from"; the sandbox answers "is it safe to run". The installing site surfaces provenance as origin information, not as a safety endorsement.
Release policy
The fields
An author can constrain how releases of their package are published, by signing policy into the package profile through
com.emdashcms.package.profileExtension. (RFC 0001'scom.emdashcms.package.releaseExtensioncarries per-release fields likedeclaredAccessand the provenance reference; the profile extension introduced here carries package-level policy that applies to every release.) Because it lives on the profile, it is governed by therepo:pm.fair.package.profilescope, which the signing service does not hold.requireProvenancefalseconfirmationescalation-onlyescalation-only(default) oralways. Both values gate escalating releases (intents wideningdeclaredAccessversus the prior release) on a passkey approval;alwaysextends the gate to every release. The escalation gate is not configurable; see Staged releases. Enforced by the signing service.approvers[]always, or any escalating release underescalation-only) means the release cannot be approved and will expire. Authors who never want to escalate may keep the list empty; everyone else must populate it.Absent means default. A profile with no policy is
requireProvenance: false,confirmation: escalation-only,approvers: []. This must not be inverted: treating absence as strict would retroactively invalidate every existing package. The escalation gate still applies: an existing package whose first signed-policy release escalates is held until anapproversentry exists and approves it.requireProvenanceis the load-bearing control because it is verifiable by the installing site and by aggregators: anyone can check whether a release has valid provenance and whether the profile requires it.confirmationis enforced by the signing service and is not verifiable after the fact by the installing site (a past release cannot be checked for whether it was human-confirmed, only the policy that asked for it). Client-verifiable confirmation via signed receipts is Future work.Defeating the downgrade attack
With
requireProvenance: truesigned into the profile, an attacker who has gained a release-publishing capability still cannot publish a malicious release that a client will install:The remaining move is to weaken the policy first (
requireProvenance: truetofalse), then publish freely. Two mechanisms block this:repo:pm.fair.package.profile. The signing service holds onlyrepo:pm.fair.package.releaseand cannot touch the policy. A compromised signing service or a stolen workload token has no reach to the profile. Scope separation, not the signing service's tenancy, is what bounds the attack.security:yanked. The downgrade cannot be silent or immediately effective; a legitimate author downgrading waits out the cooldown, which is the right cost for loosening one's own posture.The strongest further step, refusing to lower a once-asserted floor without an out-of-band signal tied to the account's atproto rotation key, is Future work.
Aggregator handling of policy-violating releases
For a release that violates its package's
requireProvenancepolicy (absent or failed provenance), a conforming aggregator:This is scoped to the policy in force at the release's publication time, determined by firehose ordering (more reliable here than the author-supplied creation timestamp): releases that predate a later-added policy are not retroactively violations. The installing site remains the authoritative gate; aggregator handling surfaces the problem earlier. Determining provenance validity requires the aggregator to run the four-layer check at index time, a capability beyond RFC 0001's structural validation, noted as a cost in Drawbacks.
Delegated publishing without protocol changes
RFC 0001 leaves multi-author and team publishing to a shared organisational atproto account. The signing-service model gives a cleaner pattern for delegating release approval without any protocol support, expressed entirely as signing-service policy:
approverslist (see Release policy), signed by the publisher account.confirmation: always) are granted with each approver's own passkey against their own identity.Because the publisher account does nothing else, the blast radius of its compromise is bounded to that package's releases, and because approval delegation lives in signing-service policy rather than the protocol, teams can change who may approve without touching any record or any lexicon. This needs no protocol support beyond the release-scoped delegation already described; it is a deployment pattern the signing service enables, and the recommended one for teams.
What you trust a signing service for
The signing service is bounded by its scope.
A signing service can:
pm.fair.package.releaserecords for authors who delegated the release scope to it.confirmationpolicy andapproverslist.A signing service cannot:
declaredAccess-governing metadata, security contacts, the release policy (requireProvenance/confirmation), or theapproverslist. The downgrade defence holds whichever signing service is used.Residual risk. A compromised signing service that ignores its own staging and approval policy can publish unwanted but still-verified releases for its delegating authors; a multi-tenant one can do so for all of them. The damage is bounded by the create-only release scope: no account takeover, no profile change, no escalation past the profile's signed
declaredAccess.requireProvenance(out of the signing service's reach) and install-time verification constrain even that. Staging raises the cost of compromise (policy enforcement must also be subverted) without eliminating it. Authors who reject the multi-tenant blast radius run their own signing service.Threat model additions
Extends RFC 0001's threat model. Stages refer to the Shai-Hulud chain in Background.
requireProvenance, a provenance-less release is invalid; and any escalating release requires a passkey approval the attacker does not hold.declaredAccesschecksum, anddeclaredAccessdiff before the passkey is touched.repository.approvers, identity, ordeclaredAccess, and cannot forge passing provenance. A compromised service that ignores its own staging policy can still publish releases its release-scoped session allows; staging raises the cost of compromise (policy enforcement must also be subverted) but does not lower the existing floor. Damage is limited to unwanted-but-verified releases for delegating authors.approvers, then self-enrols and self-approvesapproverschange (observable on the firehose) and the enrolment event (observable at the signing service, with author notification recommended) are visible signals. A firehose-driven cooldown onapproversadditions is Future work.requireProvenance, then publish provenance-lessWhat this means if you want to publish
Interactively — unchanged from RFC 0001:
emdash plugin login,init, build, host the bundle,publish --url.Automatically — the path this RFC defines:
approverslist (atproto DIDs) into your profile and have each approver enrol a passkey at the signing service. Enrolment is a one-time OAuth-against-PDS ceremony followed by WebAuthn registration; the signing service refuses approvals from listed-but-not-enrolled DIDs. This is required before any release that escalatesdeclaredAccesscan be approved, and before any release at all if you setconfirmation: always. Packages that never escalate can technically skip this, but enrolling at least one approver up front is recommended: escalations tend to appear later, and an unenrolled approver means a held-and-expiring intent at the worst moment.requireProvenance: trueto require an attestation on every release;confirmation: alwaysto require human approval on every release, not just escalating ones. Policy lives on the profile and is changed only through the profile path, not by your CI and not by the signing service.always) return a staging ID and approval URL.emdash plugin approve <stage-id> --signer <url>, reviews the submission, and presents their passkey. The signing service re-verifies and writes the release. New approvers added to a package go throughemdash plugin enrol --signer <url>(or the equivalent web URL) once before they can approve.Steady state is an ordinary CI release. The credential in the runner is ephemeral; the signing capability lives at the service you chose; your policy and approvers live where neither CI nor the signing service can rewrite them; and any release that widens
declaredAccesswaits for an approver you authorised.Reference architecture
Two third-party sandboxed plugins, illustrating the trust layout (hosting specifics are illustrative, not prescriptive):
gallery-pluginby@example.dev: bundle and attestation hosted on GitHub Releases; published through the EmDash-operated signing service; profile setsrequireProvenance: true,confirmation: always, andapprovers: ["did:plc:example-dev"]with a passkey registered against that DID. Every CI release is held for the author's own passkey approval.forms-pluginby@studio.example: bundle and attestation on the studio's own R2 bucket; published through a signing service the studio's hosting platform runs;confirmation: escalation-only(default) withapproverslisting three studio engineers bydid:plc:…, each enrolled with a passkey at the studio's signing service. Routine releases auto-approve; any release wideningdeclaredAccessis held until one of the named engineers approves with a passkey.approvers, or identity changes; integrity (re-verified at install)The pattern is RFC 0001's throughout: independent, run-anywhere components, none trusted by the installing site for integrity. The signing service is one more such component, scope-bounded, that any party can host.
Drawbacks
requireProvenanceand flagging downgrades requires an aggregator to run four-layer attestation verification at index time, beyond RFC 0001's structural validation.confirmationis not verifiable at install. It constrains the signing service, not the installing site. An operator expecting "always confirm" to be checkable at install will not find it so without future signed receipts.Future work
confirmation: always— making human approval on every release the protocol default rather than an opt-in. The current default (escalation-only) is calibrated for the long tail of small/solo authors; the npm trusted-publishing direction suggests the ecosystem may converge on always-confirm. Promoting it is the next ratchet point.did:path. This RFC deliberately restrictsapproversto atproto DIDs because thedid:route reuses the trust model the rest of the protocol already commits to; admitting a weaker identity here would smuggle "the signing service decides who an approver is" back into the design. A future RFC could add other identity types if the binding can be made non-asymmetric.approverschange ratchet — extending the firehose-driven cooldown that already applies torequireProvenancedowngrades so it also applies toapproversadditions. This would close the path where an attacker who compromises the profile credential adds their own DID and self-enrols. Not specified here because the appropriate cooldown duration is unclear and the existing scope-separation and notification defences already raise the cost.requireProvenancefloor without a signature from the account's atproto rotation key, closing the compromised-profile-credential downgrade.declaredAccesscategories are present.Unresolved Questions
predicateType: closed or open set? Closed (SLSA v1 only) is simpler to verify; open requires the explicit "unknown predicate is unverifiable, treated as failed not absent" handling specified above.approverslist and staged-release model give teams a clean shape: any one approver on the list can approve. Whether the protocol should support quorum approvals (e.g. two-of-three for sensitive packages) is open. The current design admits this as a future per-package field on the profile; no protocol-level support is needed today.Beta Was this translation helpful? Give feedback.
All reactions