Core concepts
Identity
DID and handle resolution, PLC submission and journals, and identity change flows.
Public server DID document#
GET /.well-known/did.json publishes the server service identity when
ATOLL_PDS_DID=did:web:<hostname> matches the configured HTTPS endpoint hostname
and the request host. Set ATOLL_PDS_SIGNING_KEY to a base64-encoded 32-byte
secp256k1 private key. Generate it once, store it in your deployment secret store,
and reuse it across restarts. For example, generate a key locally with:
mix run --no-start -e 'key = Atoll.SigningKey.generate(); IO.puts(Base.encode64(key.private))'
This service key is separate from session JWT signing, account repository keys,
and the key-vault encryption key. The document exposes only its Multikey public
key, the configured DID, and the #atproto_pds service URL. It uses
application/did+ld+json, allows public cross-origin reads, and caches for five
minutes. No request header can override the configured endpoint or key.
Missing key configuration returns an uncached 503. A hostname mismatch, non-HTTPS endpoint, path-based DID, localhost DID, or non-web DID returns 404 here, except for the explicitly enabled localhost development mode described below. Other identities are not automatically provisioned by this endpoint; externally managed DIDs still need their own publication mechanism. DNS, TLS certificates, deployment, and service-key rotation coordination remain operator responsibilities.
PLC operation primitives#
Atoll.Identity.PLC.Operation constructs signed ATProto genesis operations using
separate repository signing and PLC rotation keys. It derives the DID and operation
CID from canonical signed DAG-CBOR, verifies modern and legacy genesis operations,
and checks update/tombstone signatures against an already-trusted predecessor.
Both secp256k1 and P-256 rotation keys are supported. Signature encodings must be
canonical unpadded base64url with low-S compact ECDSA values. Signed operations
are limited to 7500 bytes; current regular operations require 1–5 distinct rotation
keys. Legacy operations can be verified but are not generated.
Tests use the PLC project's pinned interoperability fixtures (with provenance and
license in test/fixtures/plc) to check exact DIDs, CIDs, signatures, and malformed
signature rejection. These primitives do not validate recovery windows or audit-log
nullification. Fresh signup uses the genesis primitives.
Persist a signed genesis operation before attempting registration: signing it again
can produce different bytes and therefore a different DID. Full audit validation
and automatic background registration retries remain pending.
PLC directory submission#
Atoll.Identity.PLC.Client.submit_genesis/3 validates a supplied signed genesis,
submits those exact operation fields, then fetches /log/last to confirm the
operation CID and genesis signature. A timeout or duplicate-submission error can
still succeed if the directory confirms the exact genesis. A different latest
operation fails closed; successful submission alone never authorizes activation.
Set ATOLL_PLC_DIRECTORY_URL to a trusted HTTPS directory origin (default
https://plc.directory), also available as config :atoll, :plc_directory_url.
This setting currently affects submission only; DID resolution still uses the
public directory. Redirects and automatic retries are disabled. Each request has
a 10-second overall deadline and a 64 KiB response limit; compressed log responses
are rejected. No directory requests run at startup. Tests use a mock transport.
The caller must persist and reuse the signed genesis before calling this client.
It does not store operations, reserve handles, schedule retries, or create accounts.
Atoll.Identity.PLC.Registrations supplies the internal durable journal described
below. Fresh signup uses this journal; automatic background retries remain pending.
Durable PLC registration journal#
Registrations.stage/3 stores the exact signed genesis and a retained rotation key
inside the caller's account-provisioning transaction. It requires a deactivated
repository, a matching profile handle and repository public key, and a recoverable
repository signing key. Profile uniqueness reserves the handle and email. The
operation is insert-only; repeated staging of the same operation and rotation key
preserves the existing encryption envelope.
Rotation keys use AES-256-GCM with ATOLL_KEY_ENCRYPTION_KEY, a distinct purpose
label, and authenticated DID, operation CID, curve and public key. Private keys
are never stored in plaintext. Database backups require the master key to recover
both repository and PLC rotation keys. Rotation/master-key migration is pending.
After committing, Registrations.submit/2 loads the stored operation, checks both
keys remain recoverable, and uses the directory client. It rejects calls inside a
repository transaction. Failed requests leave the journal available for an exact
retry; confirmed submissions retain the first confirmation timestamp. Confirmation
never activates an account or issues sessions, and is historical acceptance evidence,
not a substitute for checking current identity state. Account deletion cascades to
the local journal and encrypted rotation key; it does not tombstone the public DID.
These APIs are internal and do not authorize callers. Fresh signup invokes them after validating account input and authenticating retries. No registration scheduler runs. No live PLC registrations are performed by tests, migrations, or startup.
DID resolution cache#
Routine DID resolution caches successful, identity-matched documents on each node
for 60 seconds. Set ATOLL_DID_CACHE_TTL_SECONDS between 0 and 300; zero disables
storage. The supervised cache holds at most 256 entries, including in-flight
placeholders, and 8 MiB of serialized document payloads. Older entries are evicted
when either budget is exceeded. These payload limits exclude normal process/map
metadata. Expiry uses monotonic time and is checked on access; errors are not
cached and expired documents are never served as a fallback.
Service-JWT verification (including migration authorization), account activation, account status checks, and identity refreshes force an authoritative network lookup. Forced refresh removes the old cached result even if the request fails. Per-fetch tokens prevent an earlier request from overwriting a later refresh. Network work runs in the calling process outside the cache server. Cache restarts or failures fall back to the resolver; simultaneous misses are not coalesced.
Trusted internal callers can pass force_refresh: true or cache: false. Custom
transport/DNS options bypass shared caching unless an explicit cache is supplied,
keeping test and alternate resolver data isolated. Each account-resolution call
still parses the document's signing key, PDS endpoint and handle; handle forward
lookups remain uncached. Multi-node cache invalidation and PLC log verification
remain pending. Existing SSRF checks, pinned addresses and response limits apply
on every network lookup.
IPv6 identity resolution#
DID and HTTPS handle resolution accept public IPv6 destinations as well as IPv4. DNS selection prefers the first permitted A answer, then checks AAAA if there is no permitted IPv4 result. Both lookups share a three-second DNS budget. The selected address is pinned into the HTTPS URL; IPv6 uses a bracketed literal and an IPv6 socket. HTTP Host and TLS verification/SNI retain each requested domain. DID redirects remain disabled; HTTPS handle redirects repeat these checks at every hop. This does not add connection racing or retry another address when the selected address cannot connect.
The IPv6 policy permits 2000::/3 global unicast, excluding 2001::/23 IETF
assignments, 2001:db8::/32 and 3fff::/20 documentation ranges, and 2002::/16
6to4. It also excludes all mapped/translated IPv4, loopback, unspecified, private,
link-local, multicast and other space outside that global-unicast range. This is
conservative: it excludes some globally reachable special-purpose assignments.
The policy follows the ranges in the IANA IPv6 special-purpose registry.
Tests cover address boundaries, DNS fallback, pinned URLs and transport options;
they do not depend on the test machine having public IPv6 connectivity.
Owner-requested identity refresh#
POST com.atproto.identity.refreshIdentity takes a full password-session access
token or a DPoP-bound OAuth token with base atproto scope, and JSON identifier
containing the account's DID or a handle resolving to that DID.
Atoll restricts this endpoint to the requesting account; app passwords and
taken-down export tokens cannot use it. OAuth requires an active account; password
sessions also support deactivated accounts. Refreshing another DID returns Forbidden.
The response contains did, the bidirectionally verified handle (or
handle.invalid), and the complete didDoc. DID lookup bypasses and refreshes the
node-local cache. DNS/HTTPS resolution retains the existing public-address checks,
timeouts and response limits. DID redirects are rejected; handle redirects follow
the bounded HTTPS policy described below. Missing identities return
DidNotFound or HandleNotFound; confirmed PLC tombstones return DidDeactivated.
Resolution failures and tombstones preserve the previous
observation and return an error. PLC-log verification follows the configured
resolution policy.
Resolution runs outside database locks. Before storing the observation, Atoll rechecks the live session and account availability under the repository/event lock order. OAuth also rechecks the current access token and grant, expiry and base scope; proof admission precedes JSON parsing and stays consumed after a failed refresh. Revocation during resolution prevents publication. Observation changes and identity events commit together; unchanged observations emit no duplicate event. Refreshing does not change the account's stored handle, signing key, hosting status, or DID document at its authority.
Requests have a 4 KiB JSON limit, no-store responses, and share the per-node login/recovery budget of 20 requests per five minutes per direct client IP. This endpoint does not provide distributed request coalescing or replace the optional periodic refresh worker.
Public identity resolution#
GET com.atproto.identity.resolveDid?did=... returns { "didDoc": ... } for a
resolved DID, without requiring an ATProto signing key or PDS service and without
verifying a handle. GET com.atproto.identity.resolveIdentity?identifier=...
accepts a DID or handle and returns did, handle, and didDoc. It requires valid
ATProto identity fields and verifies the document's claimed handle back to the DID;
an absent or unverified claim is returned as handle.invalid. Handle input is
case-insensitive. Neither endpoint requires a local account or authentication.
Queries use the existing bounded positive DID cache, public-address-pinned HTTPS
resolver, DNS handle resolution, and response/time limits. DID lookups reject
redirects; HTTPS handle lookups follow the bounded policy below. Query parameters
cannot override resolver options. Resolution performs
no account mutation and emits no identity event. Owner refreshIdentity remains
the explicit fresh-resolution and observation-update path.
A missing DID or handle returns DidNotFound or HandleNotFound. PLC directory
HTTP 410 responses and surviving signed tombstones in audit mode return
DidDeactivated, as specified by the identity Lexicons.
This distinction applies to resolveDid, resolveIdentity, and owner-authenticated
refreshIdentity. An HTTP 410 from did:web, an audit-log fetch, a handle lookup or
an OAuth/Lexicon endpoint does not prove a PLC tombstone and remains a resolution
failure. Responses do not include upstream response bodies. Invalid documents
and upstream failures return InvalidRequest without upstream response bodies.
Audit mode independently verifies the operation log before deriving the DID
document or reporting a tombstone.
All three public identity queries (resolveDid, resolveIdentity, resolveHandle)
share a per-node limit of 60 requests per five minutes per direct client IP, return
no-store responses, and reject request bodies. Query parsing retains the existing
32 KiB bound and Lexicon parameter validation.
HTTPS handle redirects#
The HTTPS fallback for handle resolution follows up to three redirects (four
requests total), as allowed by the handle specification.
Supported statuses are 301, 302, 303, 307, and 308. Relative and cross-host locations
are accepted, provided every destination uses HTTPS on port 443 with a valid,
non-reserved DNS hostname. IP-literal destinations, embedded credentials, fragments,
and control/space characters are rejected. A response must provide exactly one
Location value of at most 2048 bytes.
Every hop performs fresh address resolution, checks the public-address policy, and pins the chosen IP while preserving that hop's Host and TLS hostname. This also applies to redirects back to the same hostname. Automatic client redirects and retries stay disabled. Each response, including redirect bodies, retains the 4 KiB handle-response limit; each hop retains the three-second DNS and five-second HTTP budgets. A loop fails when the hop allowance runs out. The periodic identity worker's existing overall task deadline still applies.
DID-document resolution and PLC-directory submission continue to reject redirects. DNS TXT still takes precedence over HTTPS, and a successful redirect only proves the forward handle claim; bidirectional verification still checks the DID document.
Handle-resolution cache#
Successful normalized handle-to-DID claims are cached separately from DID
documents. ATOLL_HANDLE_CACHE_TTL_SECONDS controls the TTL (default 60 seconds,
range 0–300; zero disables caching). The node-local cache holds at most 256 entries
and 1 MiB of serialized payload. It caches only successful syntactically valid DID
claims, never lookup failures or ambiguous DNS results. Reserved/invalid handles
are rejected before cache lookup.
Routine handle resolution and repository reads reuse claims within this TTL, then re-resolve DNS or HTTPS. Bidirectional checks still require the DID document to claim the requested handle. Cache failures fall back to resolution. A forced refresh replaces the previous claim; failure removes that claim rather than returning stale data. Tokens identifying in-flight lookups prevent an older result from overwriting a newer forced refresh. There is no distributed cache or concurrent-request coalescing.
Handle login, handle-based repository writes, migration account provisioning, and
owner/periodic identity refreshes force fresh handle resolution. Login and writes
also force fresh DID resolution. Public queries cannot set trusted resolver options
to bypass these policies. Internal calls use force_refresh: true; test/custom
transports disable the shared handle cache unless explicitly supplied with
handle_cache: cache_pid.
Offline PLC audit verification#
Verify a saved PLC /log/audit JSON response against an explicitly supplied DID:
mix atoll.plc.verify did:plc:ewvi7nxzyoun6zhxrhs64oiz audit-log.json
The command performs no network requests or database changes. It reads at most
8 MiB and accepts 1–1000 entries, using Atoll.Identity.PLC.AuditLog.verify/2.
Output contains the DID, verified last-operation CID, tombstone status, and counts
of active and nullified operations. Invalid, mismatched, oversized, or unreadable
input fails without printing its contents.
Verification derives the DID from the signed genesis, recomputes every operation
CID, verifies signatures with the predecessor's authorized rotation keys, and
reconstructs the active chain in submission order. It rejects duplicate operations,
unknown or already-nullified predecessors, updates descending from a tombstone,
backwards timestamps, and forged nullified flags. A recovery must use a strictly
higher-priority key at the fork point than the key that signed the first displaced
operation, arrive after the latest submission, and fall within 72 hours of that
first displaced operation. Exactly 72 hours is accepted. Valid recovery can displace
a tombstone; a surviving final tombstone is reported as deactivated history.
Historical verification accepts the empty or repeated rotation-key lists present
in the upstream compatibility fixtures, within the five-key bound. Empty keys
cannot authorize a subsequent operation. Atoll's signing API still requires one
to five distinct rotation keys when creating modern operations. Existing canonical
signature, curve, encoding, operation-size, and structural checks remain in force.
The new recovery fixtures use the same pinned upstream revision and MIT provenance
recorded in test/fixtures/plc/README.md.
This validates the supplied history, not proof that it is complete or current. Directory timestamps are unsigned metadata; cryptographic verification cannot independently establish when an operation was submitted or detect an omitted newer suffix. Live resolution can use the verified audit policy described below. Operator did:web and ordinary PLC repository signing-key rotation are available below; recovery remains pending.
Verified PLC resolution#
Set ATOLL_PLC_RESOLUTION_MODE=audit to derive did:plc documents from independently
verified operation history. The application setting is
config :atoll, :plc_resolution_mode, :audit. The default directory mode preserves
the existing HTTPS trust policy; only directory and audit are accepted at startup.
The setting applies to shared resolver callers, including public identity queries,
service-token verification, login identity checks, activation, and identity refresh.
Hostname-level did:web resolution is unchanged.
Audit mode fetches https://plc.directory/<did>/log/audit with the resolver's existing
public-address checks, DNS address pinning, TLS hostname verification, five-second
request timeout, and no redirects or automatic retries. It accepts at most 8 MiB
and 1000 operations. Verification checks the genesis, signatures, CID chain,
recovery authority/window, timestamps, and declared nullifications as described
above. No rendered DID document is requested and no directory-trust fallback is
attempted on invalid, unavailable, or oversized history.
The surviving operation supplies the aliases, Multikey verification methods, and
services. Legacy genesis operations are normalized to those document fields.
A surviving tombstone resolves as did_deactivated; a properly recovered tombstone
can resolve normally. Invalid logs become invalid_did_document. The ordinary
ATProto key/PDS extraction and bidirectional handle checks still apply afterward.
Ordinary lookups may use an unexpired
positive cache entry; forced refresh bypasses it and evicts it when a tombstone
is observed. Deactivation errors are not cached, allowing subsequent recovery to
resolve without a negative-cache delay. Directory mode distinguishes the PLC
document endpoint's HTTP 410 response
from HTTP 404; audit mode requires the surviving tombstone's verified signature
and history, with no fallback to the directory's rendered document.
Verified and directory-trusted documents have separate keys in the bounded positive
cache. Switching to audit mode cannot reuse a document cached under the directory
policy. Forced refresh replaces or invalidates the relevant verified entry, using
the existing TTL, byte/count bounds, and protection against stale in-flight loads.
The setting is trusted server configuration, never a public request parameter.
ATOLL_PLC_DIRECTORY_URL still controls registration submission only; the shared
resolver's public PLC source remains plc.directory.
Verification authenticates operation contents and their permitted transitions. It still relies on the supplied directory timestamps and cannot prove the response is complete or detect an omitted newer suffix. No durable last-seen checkpoint or independent witness service is implemented. Oversized histories fail closed in audit mode rather than being partially verified. Tests mock network traffic and exercise pinned upstream logs, invalid recovery, tombstones, response/address bounds, and cache isolation; they do not mutate any external DID.
PLC update submission#
Atoll.Identity.PLC.Client.submit_update/4 submits an already signed and persisted
ordinary update to the configured ATOLL_PLC_DIRECTORY_URL. It verifies the update
signature and predecessor CID locally, reads the directory's latest operation, and
posts only when that operation matches the trusted predecessor. If the update is
already latest, retry succeeds without posting again. After POST, only an exact
latest-operation CID match counts as success, even after a timeout or HTTP error.
A different latest operation reports conflict; callers must not silently re-sign
or overwrite it. Reads use the same bounded HTTPS transport as genesis submission.
This is an internal transport primitive. Callers must authenticate the predecessor's chain to the requested DID, authorize the user action, and durably retain the exact signed update before submission. The initial read does not lock the remote directory; the directory arbitrates concurrent operations under its PLC rules. This path does not submit recovery forks against older predecessors. It does not itself change local profiles, repository signing keys, or session state. Authenticated handle changes and operator key rotation use this transport. Recovery has a separate journal and submission path described below; operator recovery reconciliation remains pending. Behavior follows the PLC update specification.
Durable PLC update journal#
Migration 20260926154349 adds plc_updates. Internal
Atoll.Identity.PLC.Updates.stage/3 verifies a supplied audit log back to the DID's
genesis, then verifies the proposed ordinary update against its surviving latest
operation. It stores the exact signed operation and trusted predecessor under the
repository lock. Only one locally incomplete update per DID is allowed; identical
staging retries return the same journal entry. Tombstones and recovery forks are
not staged by this workflow.
submit/3 must run after the staging transaction commits. It uses the persisted
operation and records the first exact directory confirmation. Transport failures
leave the entry pending. Once confirmed, further calls return the stored historical
confirmation without posting again. Confirmation is not proof that the operation
is still latest after subsequent directory changes or recovery.
The future authorized workflow must commit its local profile/key changes together
with complete!/2 inside one transaction. Completion requires confirmation and
releases the pending slot; both completion and local changes roll back together.
The journal itself changes no account status, profile, key, or session. Its callers
must authorize the action and verify any current identity conditions needed for
local activation. Supplied audit evidence proves signatures and chain consistency,
not freshness or completeness of the directory's history.
Include the journal in database backups. Account deletion cascades local entries; it does not undo a public directory operation, including a submission already in flight. Never discard an ambiguously submitted pending operation or generate a replacement signature merely to retry. No automatic submission worker or public identity-mutation endpoint is enabled by this internal foundation.
Directory evidence for identity changes#
Atoll.Identity.PLC.Client.fetch_audit/2 reads /log/audit from the configured
trusted PLC directory, verifies its signatures, genesis binding, recovery rules,
and nullification flags, then compares the verified surviving head with a separate
fresh /log/last read. It returns both the entries and verified state; mismatched
heads fail with plc_conflict. There is no cache, redirect following, automatic
retry, or fallback to an unverified document. Audit responses are limited to 8 MiB
and 1000 operations; the latest-operation response retains the 64 KiB limit.
Encoded responses are rejected. Tombstone state is returned explicitly for callers
to handle; update staging refuses it.
Updates.stage_from_directory/3 connects this lookup to durable staging. It refuses
an open local transaction so network waits cannot hold local mutation locks.
Workflows needing atomic local reservations can instead fetch evidence first and
call stage/3 in their authorized reservation transaction. User authorization is
still the workflow's responsibility. The final submission checks the predecessor
again because the directory can change after evidence is read.
These two reads detect inconsistent snapshots, including a truncated audit that omits the separately reported latest operation. They cannot prove completeness against a directory withholding both views, authenticate its unsigned timestamps, or prevent changes after either response. Public DID resolution retains its own fixed-directory policy; this configured-directory lookup is for internal identity mutation workflows.
Handle-change reservations and completion#
Migration 20260926155031 adds durable handle reservations linked to their exact PLC
journal operation. Atoll.Identity.HandleChanges.stage/5 authorizes an active
account's full access session, validates a signed handle-only update against verified
audit evidence and the locally hosted signing key/service, and reserves the target
name in the same transaction as the journal entry. Other identity fields must remain
unchanged. The new operation uses a single at:// alias. This currently accepts
modern and legacy PLC predecessors; legacy keys can be installed with the operator command below. did:web accounts use
the separate document-reconciliation path below.
Hosted names use the configured server domains. Custom names must freshly resolve to the owner's DID. Authorization is checked again under the account lock after network resolution. App passwords and inactive accounts cannot stage changes. Signup and migration check both current profile handles and reservations under the shared event lock, so another account cannot claim a pending target. The old profile and hosted handle response remain in place while submission is pending.
After directory submission is confirmed, complete/3 fetches a fresh verified audit
and requires the exact reserved operation to be latest. It rechecks custom-handle
ownership, session authorization, and the local key/service before atomically updating
the profile and identity observation, emitting one identity event, completing the
journal, and releasing the reservation. Repeating a completed operation emits no
second event. Conflicts retain the reservation and previous local handle for explicit
reconciliation. Account deletion cascades reservations; it does not undo public PLC
history. Existing resolver caches can retain old observations until their configured
TTL; authorization lookups force refresh.
The public updateHandle procedure now orchestrates these functions and signs with
the retained rotation key. Conflict recovery and other DID mutation workflows remain
pending. No real directory updates were submitted during tests.
Public handle updates#
POST /xrpc/com.atproto.identity.updateHandle accepts a full active-account access
token and JSON {"handle":"new.example.com"}. Success returns HTTP 200 with an
empty body, following the endpoint Lexicon.
Names are normalized to lowercase. Requests have a 4 KiB JSON limit and share the
20-per-five-minute login/identity-mutation IP budget. App-password sessions are denied.
New changes obtain fresh verified PLC history, verify local hosting/key continuity, sign with the encrypted retained rotation key, reserve the name and signed operation, submit, then perform the fresh completion checks described above. The retained key must still be authorized by the directory's current rotation-key list. Custom handles must resolve forward to this DID both before staging and before completion. Hosted handles use the advertised server domains. The old hosted name stops resolving when the new profile is committed.
After a timeout or HTTP 503, retry the same handle: the reservation selects the exact persisted signed operation instead of creating a new signature. A different requested handle while one is pending returns HTTP 409. Repeating an already completed current handle checks fresh directory state and is a no-op, with no extra directory POST or identity event. A directory conflict leaves local state pending for reconciliation.
This supports modern PLC accounts whose rotation key is retained by Atoll. Legacy predecessors can now be converted into modern updates, with operator-installed rotation keys. Operator retained-key replacement and authority rotation are available below; operator recovery remains pending. did:web owners use the reconciliation path below. Current configured directory/key availability is required; local confirmation history never substitutes for fresh completion checks. No live PLC writes are exercised by the test suite.
did:web handle reconciliation#
For a hosted did:web account, updateHandle reconciles a DID document the owner has
already updated. Publish the new at:// handle in the document's first recognized
handle alias, retaining the account's current repository signing key and this PDS's
service URL, then call the same authenticated procedure. The existing resolver
supports hostname-level did:web identities, not path-based identifiers.
Atoll forces a fresh DID-document lookup through its existing bounded, address-pinned HTTPS resolver. Custom handles must freshly resolve forward to that DID; hosted names must be available under the configured domains. It checks the signing key, PDS URL, active status, and full session again under the repository lock before changing the profile and identity observation and emitting one identity event. Current names and pending PLC handle reservations are both protected against takeover. A concurrent local handle/observation change invalidates stale completion. Repeating the current verified handle is a no-op.
This path does not write the externally hosted DID document and sends no PLC requests. A mismatched document or failed lookup leaves the local account unchanged. As with other identity resolution, the document can change after the lookup; later identity refresh detects subsequent remote changes. App passwords cannot authorize this action.
Legacy PLC successors#
Operation.successor/1 prepares a modern unsigned update from an already trusted
modern or legacy predecessor. For legacy create operations it maps signingKey
to verificationMethods.atproto, normalizes the handle alias and service endpoint,
and preserves rotation authority as recovery key first, signing key second (with
duplicates removed). Its prev is the CID of the original signed legacy operation;
it does not replace that genesis or derive a new DID. This follows the
PLC legacy operation format.
Handle-only staging and completion now accept this conversion while still checking the local signing key, service, exact predecessor, owner session, and signed update. The builder itself does not authenticate a chain; callers must verify audit evidence before trusting a predecessor. Tombstones cannot be extended.
Tests cover upstream legacy fixtures and a signed legacy-to-modern handle change through the journal and atomic local completion. Fresh signup still creates modern operations. Retained keys for imported legacy accounts can now be installed through the operator command below; no private key is inferred from public history.
PLC signing authorization email#
POST /xrpc/com.atproto.identity.requestPlcOperationSignature takes a full password-session
access token or an OAuth token with identity:*, and no body. It requires a PLC account with a confirmed email, and sends the
code exclusively through the configured Cloudflare email Worker. Active accounts
and user-deactivated accounts using password sessions can request it; app passwords, suspended/taken-down
accounts, and unsupported DID methods cannot. The endpoint returns empty HTTP 200
only after Worker acceptance. A persistent one-minute cooldown limits issuance.
Migration 20260926160604 adds a redacted purpose/DID/address-bound SHA-256 digest,
expiry, and request timestamp to the account profile. Codes contain 192 bits of
randomness and expire after 15 minutes. A new request replaces the previous code.
Worker failure leaves the cooldown and pending challenge intact; there is no
automatic retry or SMTP fallback. Email changes, password resets, and operator
password/email changes invalidate these challenges alongside existing account codes.
SignatureChallenges.consume!/2 is the internal signing-transaction boundary: it
rechecks current session or OAuth identity authorization and confirmed email, verifies expiry and the
digest, and clears the token atomically. Signing failure must roll back the same
transaction, preserving the authorization for retry. The public signPlcOperation endpoint now consumes these codes;
submitPlcOperation is available for operations matching this local account. Requesting a code does not itself sign or
submit a PLC operation.
Email-authorized PLC signing#
POST /xrpc/com.atproto.identity.signPlcOperation requires a full active or
user-deactivated password session, or an active OAuth session with identity:*,
and the current email code in token. Optional
rotationKeys, alsoKnownAs, verificationMethods, and services replace the
corresponding fields of the fresh verified predecessor; omitted fields are preserved.
Callers cannot supply prev, sig, type, or another DID. It returns
{"operation": {...}}, following the
pinned endpoint Lexicon.
Atoll verifies the email authorization before directory lookup, checks the audit
head against /log/last, and then consumes the code and signs in one local transaction.
The current DID signing key and PDS service must still match this hosted repository.
Its retained rotation key must be authorized by the predecessor. A pending journaled
identity update blocks another signature. Authorization, email state, and session
revocation are rechecked under locks after network lookup. Invalid operations or
signing failures roll back code consumption.
The JSON envelope is limited to 16 KiB; canonical signed operations remain bounded to 7500 bytes and new rotation-key lists to one through five distinct supported keys. The endpoint shares the 20-per-five-minute identity/login IP budget. It returns the signature without posting to the directory, changing local keys/profiles, or emitting an event. Owners can use the operation for migration or rotation through a separate submission workflow. Directory changes after lookup can make its predecessor stale.
A successful response consumes the code once. If the response is lost after commit, request a new code after the cooldown; there is no signed-response replay cache. Keep the returned signed operation unchanged when submitting or retrying it. Public submission is available for operations matching the local account; operator signing-key transition workflows are described below; local recovery reconciliation remains pending.
Authenticated PLC submission#
POST /xrpc/com.atproto.identity.submitPlcOperation accepts a full active or
user-deactivated account session and {"operation": {...}}. Its 16 KiB JSON envelope
and 20-per-five-minute identity/login IP budget match signing. It returns empty
HTTP 200 after directory confirmation and local reconciliation, following the
pinned submission Lexicon.
The signed modern operation must name this PDS, the local repository signing key,
and the profile's single at:// handle alias. The corresponding private repository
key must be available locally. Custom handles require fresh forward ownership;
hosted names use the configured domains. New operations require one through five
distinct supported rotation keys. Tombstones, arbitrary handle changes, and operations
pointing away from this local account are rejected. This endpoint can receive an
externally signed migration operation; it does not require the destination PDS to
possess the identity's rotation key or another email code.
Atoll verifies fresh audit evidence and predecessor authorization, durably stages the exact operation under the owner lock, rechecks the session before sending, confirms directory acceptance, and verifies fresh directory state again before completing the journal and emitting one identity event. Local profile, repository key, and active/deactivated status remain unchanged. A migrated account still needs explicit activation after its other migration requirements are satisfied.
Retry the identical operation after ambiguous errors. A matching accepted latest operation is confirmed without reposting, including acceptance before local staging. That case derives its predecessor from the fully verified surviving audit chain; nullified historical forks are not accepted as ordinary updates. Pending handle changes must finish through their own workflow. Directory conflicts preserve the pending journal for reconciliation. Completed retries emit no additional event. No real PLC submissions were made in tests.