I recently wrote about account recovery and password reset using rotation keys, and now I’d like to propose another method to strengthen identity document security.
It started with the question “what can a did-method-plc identity do moments after its holder realizes something is wrong?” Today the answer is “undo one thing, then hope.” This is a proposal for two new operation types, plc_seal and plc_unseal, that let an account holder use a high-authority rotation key to switch off the keys below it temporarily.
Problem
Rotation keys in did-method-plc identities are an ordered list, and that order is critical to the security model. A key earlier in the list can nullify an operation signed by a key later in the list, as long as it does so within 72 hours by forking from the operation before the bad one. This is a good mechanism, and it’s why the standard advice for anyone who cares about their identity is to put a key they control ahead of the key their PDS holds.
What the 72-hour window gives you is the ability to react and undo (not negate) PLC operations. It does not give you the ability to invoke any immediate preventative measures.
This is the scenario that worries me the most: An account has rotation keys [A, B], where A is a key the holder keeps offline, and B is the PDS operator’s key. An attacker gets into the holder’s email, and from there into the account, and uses the PDS’s own com.atproto.identity.requestPlcOperationSignature and signPlcOperation flow. The PDS does exactly what it is supposed to do: it emails a token, the attacker pastes it back, and the PDS signs a PLC operation with B that points the identity at a different host. The holder notices, pulls out A, forks past the attacker’s operation, and restores the correct state. Good. Then the attacker requests another signature. The PDS signs again with B. PLC has nothing that lets the holder say, “B is not allowed to sign right now,” so this repeats until the holder wins the race to reset the email and password, and every round gives the holder a fresh 72-hour window to be awake.
The one lever the holder does have is removing B from rotationKeys. That stops the attacker and also stops the PDS from doing its job: no handle changes, no signing key rotation, no migration assistance, and no bulk DID document updates like the ones the Spaces work will need (#atproto_space and #atproto_space_host). It is a permanent fix for a temporary problem, and it is reversible only if the holder adds the key back later and remembers to.
Design
I landed on “seal” and “unseal” actions. A seal names a specific rotation key in the rotation key list. While the seal is in effect, the directory accepts operations signed by that key or the keys listed before it, and rejects operations signed by any keys listed after it. For example, given the keys [A, B, C, D], sealing at B would allow operations signed by A and B, but not C and D.
A seal restricts who may sign, not what they may write. Authorized keys can still change fields, including removing an excluded key that turns out to be compromised. Put differently, the seal is not a freeze on the DID document, but it is a constraint on who can sign PLC operations.
The seal key is named by its did:key value, not by its index.
Every seal has a duration, and the duration is bounded. This is measured from the directory’s own createdAt timestamp for the seal PLC operation. Expiry is a safety net for critical failure: sealing a key and then losing it. The cost of the bound is that the expiry time is public, so an attacker who still holds the operator key knows exactly when to try again. I have mixed feelings about this, but kept the duration since a permanent lockout from a lost key seemed worse than a known retry date.
Seal state is derived from the operation log. There is no directory-side flag. The sealed check involves walking the valid operations and computing the last plc_seal not followed by a plc_unseal is the active seal where createdAt + sealDuration is still in the future. Mirrors recompute it the same way, and anyone auditing the log would catch a directory that accepted an operation in violation of a seal, which fits the existing trust model.
Seal and unseal operations carry the full identity state, like plc_operation does. This means a single plc_seal with prev set to the fork point before the attacker’s operation both nullifies that operation and installs the seal in one acceptance decision. There is no window between recovery and lockdown for the attacker to slip another operation into. It is the strongest protection the account holder has against the scenario described above.
A seal never removes authority from a key above its signer. A key can seal at its own level or at the level of a key below it, and it always stays authorized under the seal it creates. Keys above the seal’s signer can nullify the seal within 72 hours, unseal at any time, or re-seal at a different key. So an attacker who holds B can at most lock out C and D, which B could already do damage with, and A is unaffected.
Solution
PLC Operations
A plc_seal is a plc_operation with two additional fields and a different type. sealKey is the did:key of the key to seal at, and sealDuration is an integer number of seconds. Both are signed over like everything else, and sealDuration is a plain unsigned integer in DAG-CBOR.
{
"type": "plc_seal",
"rotationKeys": [
"did:key:zQ3shUserRecoveryKeyA",
"did:key:zQ3shOperatorKeyB",
"did:key:zQ3shOperatorKeyC"
],
"verificationMethods": {
"atproto": "did:key:zQ3shAtprotoSigningKey"
},
"alsoKnownAs": ["at://alice.example.com"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.com"
}
},
"sealKey": "did:key:zQ3shUserRecoveryKeyA",
"sealDuration": 604800,
"prev": "bafyreigq3zpkj5n2ax3m7ubyqdyfcc2lhw4vbhe4b3l4qkq5ssdvmrlbka",
"sig": "<base64url signature by key A>"
}
A plc_unseal is the same shape without the two seal fields. It must be signed by the seal key or a key above it, and it is only valid while a seal is in effect.
{
"type": "plc_unseal",
"rotationKeys": ["<same as above>"],
"verificationMethods": { "atproto": "<same>" },
"alsoKnownAs": ["at://alice.example.com"],
"services": { "atproto_pds": "<same>" },
"prev": "bafyreih6v4m2ykc5o7s2nq3ugxw2l3pbz7rj5kzq7c4wyf2v3xg6ad3xqe",
"sig": "<base64url signature by key A>"
}
Constraints On Seal
A plc_seal is valid when
- The prev value points to an existing valid operation (a seal is never a genesis op)
- The sealKey value appears in the operation’s own rotationKeys
- At least one rotation key is listed after sealKey, so the DID has at least two keys and the seal excludes at least one of them.
- The sealDuration value is an integer between 3600 and 2592000, inclusive.
- The key that signed the operation, if it appears in the operation’s rotationKeys, is listed at or before sealKey
The last rule keeps a key from locking itself out. A seal excludes every key listed after sealKey, so a seal whose signer sits after sealKey would exclude the very key that placed it. With keys [A, B, C, D], a seal signed by C can name C as the seal key but cannot name A or B; a seal signed by A can name any of A, B, or C. The check runs against the operation’s own rotationKeys rather than the previous state’s, because a seal carries full state and can reorder keys in the same step. If B signs a seal with rotationKeys: [A, C, B] and sealKey: C, then B has moved itself below the seal key and the operation is rejected. A signer that removes itself from rotationKeys entirely is not subject to this rule, since there is nothing left to exclude.
If the DID is already sealed when the seal operation arrives, the operation is also subject to the rules in the next section: its signer has to be authorized under the existing seal. On acceptance, it replaces the active seal, and the new seal key and duration apply from the new operation’s createdAt.
Constraints On Unseal
A plc_unseal is valid when
- The prev field value points to an existing valid operation (an unseal is never a genesis op)
- The DID is sealed at prev as of the time of submission, meaning a seal operation is active in the chain ending at prev,and its sealDuration has not elapsed.
- The key that signed the operation is authorized under that seal: it appears in prev’s rotationKeys at or before the seal key.
An unseal is subject to the same seal rule as every other operation on a sealed DID, and nothing more. Its signer has to be one of the keys the seal left standing; there is no separate unseal authority. With keys [A, B, C, D] sealed at B, either A or Bcan unseal, and C or D cannot. The system rejects an unseal submitted when no seal is active, since there is nothing to lift. An unseal submitted after the seal has expired is rejected for the same reason, so a client that wants to lift a seal should check /data first rather than assuming one is still in effect.
Because an unseal carries full state, it may change rotationKeys, verificationMethods, alsoKnownAs, and services in the same step. An unseal may remove the seal key, since the seal no longer applies once the operation is accepted. An unseal is nullifiable under the ordinary recovery rules: a key of higher authority than the unseal’s signer can fork past it within 72 hours, which puts the DID back under the seal that was in effect before the unseal.
Operation Evaluation
When an operation of any type arrives, the directory works out whether the DID is sealed at the operation’s prev, as of the timestamp it is about to assign. Evaluating at prev rather than at the head of the log is deliberate, and I’ll explain why in a moment. If it is sealed, three rules apply on top of signature checking and the existing recovery rules.
The signing key has to be authorized: it appears in prev’s rotationKeys at an index less than or equal to the seal key’s index there. This applies to regular ops, seals, unseals, and tombstones.
A plc_operation keeps the seal key in its rotationKeys. It can add, remove, and reorder other keys freely. Changing the seal key is what plc_seal is for; lifting it is what plc_unseal is for.
A plc_operation does not demote its own signer below the seal key. That protects against an authorized key accidentally locking itself out.
Seal state is evaluated at prev because a recovery operation has to be judged by the seal that was in effect at its fork point. If we judged it against the head of the log, then B could seal at B, remove A with an ordinary operation, and A’s 72-hour fork would be rejected because A is no longer in the head’s rotationKeys. Evaluating at the fork point, where A is still listed, keeps the existing recovery guarantee intact.
That alone opens a different hole. Suppose D signs an operation, B seals at B on top of it, and C then forks from before D’s op. Under the current recovery rule, C only has to outrank the key that started the branch, which is D, so C would nullify B’s seal along with everything else and escape it. The fix is one extra condition on recovery: an operation that would nullify a plc_seal has to be signed by a key that outranks the seal’s signer at the fork point. With that in place, A can still fork behind the seal and C cannot.
I checked this pair of rules against the cases I could think of: remove-then-reseal, unseal-remove-reseal, a seal that itself removes a higher key, and a seal whose signer was added after the fork point. It held up in each, but this is the part of the proposal most worth an adversarial read.
API Changes
GET /:did/data gains a seal object while sealed, and nothing when not. It carries only what cannot be derived from the rest of the response: the seal key, the CID of the seal operation (whose createdAt is in the audit log), and the expiry. The DID document itself is unchanged, since rotation keys are already an internal detail and seal state means nothing without them.
{
"did": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"rotationKeys": ["...A", "...B", "...C"],
"verificationMethods": { "atproto": "..." },
"alsoKnownAs": ["at://alice.example.com"],
"services": { "atproto_pds": { "...": "..." } },
"seal": {
"key": "did:key:zQ3shUserRecoveryKeyA",
"cid": "bafyreih6v4m2ykc5o7s2nq3ugxw2l3pbz7rj5kzq7c4wyf2v3xg6ad3xqe",
"expiresAt": "2026-10-26T14:02:11.512Z"
}
}
The audit log needs no schema change. Seal and unseal ops show up as ordinary entries with cid, createdAt, and nullified, and auditors recompute the seal state from them.
The Attack, But With Seals
Back to [A, B] plus the email flow. The attacker’s operation lands, signed by B. The holder signs one plc_seal with A: prev is the last good operation, the state fields restore the right PDS and signing key, sealKey is A, and sealDuration is a week. The directory accepts it, nullifies the attacker’s op, and seals the DID. The attacker requests another signature. The PDS can still sign with B, but the directory rejects the result because B is excluded. The holder resets the email and password at their own pace and either unseals with A or lets the week run out.
A rejected submission looks like any other invalid operation today: a 400 with a message. I’d propose adding a stable errorcode alongside it so PDS software can tell a seal rejection apart from a bad signature:
{
"error": "SealedOperationRejected",
"message": "DID is sealed with rotation key X until 2026-10-26T14:02:11.512Z"
}
PDS Operator Cost
While sealed at a holder-controlled key, the PDS’s key is excluded, so the PDS cannot rotate the signing key, change a service endpoint, update the handle, help with a migration, or apply protocol-wide DID document changes. That is the point of the feature, and it is also why most accounts shouldn’t use it regularly. A PDS that hits SealedOperationRejectedshould surface an IdentitySealed error to the user with the expiry, remind them that the account is sealed, and retry after it. requestPlcOperationSignature should check /data first and refuse to send an email token at all when the PDS’s key is excluded, which during an attack is the behavior you want anyway. Migration is still possible while sealed; the holder signs the migration op with the seal key, keeps the seal key in rotationKeys, and the new operator’s key sits below it until the seal lifts.
Closing
This is not a backward-compatible change in the quiet sense. The reference implementation and production PLC Directory reject unspecified fields, the TypeScript export schemas look like a closed union on type, and go-didplc and my Rust crates parse only the shapes they know about. Submitting a seal today fails safely-ish. Libraries that read, parse, and validate the audit logs will need updates to support these new data structures, plus the behavior and constraints they introduce.
I’m not entirely sure what the next steps are. I want to incorporate feedback into a proposal to the PLC organization. Attached are the patch to the specification document and a rendered version of the specification document: