From f32e3e5100e1a51cceb681ca29789707f045aec7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 12 Jun 2026 23:40:03 -0500 Subject: [PATCH] feat: PLC identity credential support --- TODO.md | 10 - docs/tasks/03-identity-handles.md | 8 + lib/tempest/identity.ex | 14 ++ lib/tempest/lexicon/bundled.ex | 65 ++++++- lib/tempest/permissions.ex | 4 +- lib/tempest/security.ex | 62 ++++++- lib/tempest/security/plc_operation_token.ex | 27 +++ lib/tempest/xrpc/identity.ex | 20 ++ lib/tempest/xrpc/registry.ex | 18 ++ ...0613041725_create_plc_operation_tokens.exs | 18 ++ .../lexicon/com_atproto_compat_test.exs | 9 +- test/tempest_web/xrpc/plc_identity_test.exs | 171 ++++++++++++++++++ 12 files changed, 411 insertions(+), 15 deletions(-) create mode 100644 lib/tempest/security/plc_operation_token.ex create mode 100644 priv/repo/migrations/20260613041725_create_plc_operation_tokens.exs create mode 100644 test/tempest_web/xrpc/plc_identity_test.exs diff --git a/TODO.md b/TODO.md index c741717..172f887 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1 @@ # Parking Lot (TODO) - -## DID/PLC - -- stable TEMPEST_PLC_ROTATION_KEY -- optional recovery key -- derive public did:key rotation keys -- create/sign/submit PLC operations correctly -- fetch existing PLC state for updates -- never use repo signing key as the rotation key -- expose correct recommended DID credentials diff --git a/docs/tasks/03-identity-handles.md b/docs/tasks/03-identity-handles.md index 53f5dd4..096d794 100644 --- a/docs/tasks/03-identity-handles.md +++ b/docs/tasks/03-identity-handles.md @@ -34,5 +34,13 @@ remain follow-up work: that reject service-diverting or unrecoverable PLC operations. - [ ] Add `com.atproto.identity.submitPlcOperation` with fake PLC submission, failure, idempotency, and migration event-ordering tests. +- [ ] Create, sign, and submit PLC operations correctly end-to-end. +- [ ] Fetch existing PLC state before building update operations. +- [ ] Introduce a stable `TEMPEST_PLC_ROTATION_KEY` configuration path. +- [ ] Support an optional account/operator recovery key. +- [ ] Derive public `did:key` rotation keys from configured/private key material. +- [ ] Ensure repository signing keys are never used as PLC rotation keys. +- [ ] Verify `getRecommendedDidCredentials` returns the correct recommended DID + credential shape once dedicated rotation-key material exists. - [ ] Refresh bundled Lexicons and the PDS compatibility matrix when the handlers are registered. diff --git a/lib/tempest/identity.ex b/lib/tempest/identity.ex index 2f62944..6e89c7c 100644 --- a/lib/tempest/identity.ex +++ b/lib/tempest/identity.ex @@ -38,6 +38,20 @@ defmodule Tempest.Identity do def create_initial_signing_key(%Account{} = account), do: KeyStore.create_initial_key(account) + def recommended_did_credentials(%Account{} = account) do + operation = PlcOperation.for_account(account) + + %{ + "did" => account.did, + "handle" => account.handle, + "signingKey" => get_in(operation, ["verificationMethods", "atproto"]), + "rotationKeys" => operation["rotationKeys"], + "verificationMethods" => operation["verificationMethods"], + "alsoKnownAs" => operation["alsoKnownAs"], + "services" => operation["services"] + } + end + def publish_plc_operation(%Account{} = account) do with true <- String.starts_with?(account.did, "did:plc:"), :ok <- Correctness.check_local(account), diff --git a/lib/tempest/lexicon/bundled.ex b/lib/tempest/lexicon/bundled.ex index 1c731e6..d89de04 100644 --- a/lib/tempest/lexicon/bundled.ex +++ b/lib/tempest/lexicon/bundled.ex @@ -10,11 +10,13 @@ defmodule Tempest.Lexicon.Bundled do @behaviour Tempest.Lexicon.Provider @manifest %{ - "document_count" => 51, + "document_count" => 53, "document_ids" => [ "app.bsky.actor.getPreferences", "app.bsky.actor.profile", "app.bsky.actor.putPreferences", + "com.atproto.identity.getRecommendedDidCredentials", + "com.atproto.identity.requestPlcOperationSignature", "com.atproto.identity.resolveHandle", "com.atproto.identity.updateHandle", "com.atproto.label.defs", @@ -344,6 +346,67 @@ defmodule Tempest.Lexicon.Bundled do "id" => "com.atproto.identity.updateHandle", "lexicon" => 1 }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Returns recommended DID PLC credentials for the authenticated account.", + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{ + "did" => %{"format" => "did", "type" => "string"}, + "handle" => %{"format" => "handle", "type" => "string"}, + "signingKey" => %{"type" => "string"}, + "rotationKeys" => %{"items" => %{"type" => "string"}, "type" => "array"}, + "verificationMethods" => %{"type" => "unknown"}, + "alsoKnownAs" => %{"items" => %{"type" => "string"}, "type" => "array"}, + "services" => %{"type" => "unknown"} + }, + "required" => [ + "did", + "handle", + "signingKey", + "rotationKeys", + "verificationMethods", + "alsoKnownAs", + "services" + ], + "type" => "object" + } + }, + "type" => "query" + } + }, + "id" => "com.atproto.identity.getRecommendedDidCredentials", + "lexicon" => 1 + }, + %{ + "defs" => %{ + "main" => %{ + "description" => "Requests a short-lived token for signing a PLC operation after password reauthentication.", + "errors" => [%{"name" => "AuthenticationRequired"}, %{"name" => "InvalidRequest"}], + "input" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"password" => %{"type" => "string"}}, + "required" => ["password"], + "type" => "object" + } + }, + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{"token" => %{"type" => "string"}}, + "required" => ["token"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "id" => "com.atproto.identity.requestPlcOperationSignature", + "lexicon" => 1 + }, %{ "defs" => %{ "label" => %{ diff --git a/lib/tempest/permissions.ex b/lib/tempest/permissions.ex index 014d9e0..5866579 100644 --- a/lib/tempest/permissions.ex +++ b/lib/tempest/permissions.ex @@ -12,7 +12,9 @@ defmodule Tempest.Permissions do "com.atproto.server.refreshSession", "com.atproto.server.deleteSession", "com.atproto.server.getSession", - "com.atproto.identity.updateHandle" + "com.atproto.identity.updateHandle", + "com.atproto.identity.getRecommendedDidCredentials", + "com.atproto.identity.requestPlcOperationSignature" ]) def allowed?(%AuthContext{token_type: token_type}, _method_nsid, _params) diff --git a/lib/tempest/security.ex b/lib/tempest/security.ex index ca75c91..f6bfd1c 100644 --- a/lib/tempest/security.ex +++ b/lib/tempest/security.ex @@ -7,10 +7,22 @@ defmodule Tempest.Security do alias Tempest.Accounts.{Account, AppPassword, Password, Session} alias Tempest.OAuth.Token - alias Tempest.Security.{BackupCode, DelegatedAccessGrant, EmailToken, MfaCredential, RateLimiter, SecurityEvent, Totp} + + alias Tempest.Security.{ + BackupCode, + DelegatedAccessGrant, + EmailToken, + MfaCredential, + PlcOperationToken, + RateLimiter, + SecurityEvent, + Totp + } + alias Tempest.Repo @email_token_ttl_seconds 30 * 60 + @plc_operation_token_ttl_seconds 10 * 60 def log_event(%Account{} = account, event_type, metadata \\ %{}) do attrs = %{ @@ -256,6 +268,54 @@ defmodule Tempest.Security do |> Repo.all() end + def verify_account_password(%Account{} = account, password) when is_binary(password) do + if Password.verify(password, account.password_hash), do: :ok, else: {:error, :invalid_password} + end + + def verify_account_password(%Account{} = account, _password) do + Password.verify(nil, account.password_hash) + {:error, :invalid_password} + end + + def issue_plc_operation_token(%Account{} = account) do + raw = random_token(32) + + attrs = %{ + account_id: account.id, + token_hash: hash(raw), + expires_at: DateTime.add(now(), @plc_operation_token_ttl_seconds, :second) + } + + with {:ok, token} <- %PlcOperationToken{} |> PlcOperationToken.changeset(attrs) |> Repo.insert(), + {:ok, _event} <- log_event(account, "plc_operation_signature.requested", %{token_id: token.id}) do + {:ok, %{token: raw, plc_operation_token: token}} + end + end + + def consume_plc_operation_token(%Account{} = account, raw) when is_binary(raw) do + now = now() + + Repo.transaction(fn -> + token = + PlcOperationToken + |> where([t], t.account_id == ^account.id and t.token_hash == ^hash(raw)) + |> where([t], is_nil(t.used_at) and t.expires_at > ^now) + |> Repo.one() + + case token do + nil -> + Repo.rollback(:invalid_token) + + %PlcOperationToken{} = token -> + token |> Ecto.Changeset.change(%{used_at: now}) |> Repo.update!() + log_event(account, "plc_operation_signature.token_consumed", %{token_id: token.id}) + token + end + end) + end + + def consume_plc_operation_token(%Account{} = _account, _raw), do: {:error, :invalid_token} + def request_password_reset(identifier) do identifier = identifier |> to_string() |> String.trim() |> String.downcase() diff --git a/lib/tempest/security/plc_operation_token.ex b/lib/tempest/security/plc_operation_token.ex new file mode 100644 index 0000000..49bc57c --- /dev/null +++ b/lib/tempest/security/plc_operation_token.ex @@ -0,0 +1,27 @@ +defmodule Tempest.Security.PlcOperationToken do + @moduledoc """ + Short-lived, single-use authorization tokens for PLC operation signing. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Tempest.Accounts.Account + + schema "plc_operation_tokens" do + belongs_to :account, Account + field :token_hash, :string + field :expires_at, :utc_datetime + field :used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def changeset(token, attrs) do + token + |> cast(attrs, [:account_id, :token_hash, :expires_at, :used_at]) + |> validate_required([:account_id, :token_hash, :expires_at]) + |> foreign_key_constraint(:account_id) + |> unique_constraint(:token_hash) + end +end diff --git a/lib/tempest/xrpc/identity.ex b/lib/tempest/xrpc/identity.ex index 1d12309..f40cbba 100644 --- a/lib/tempest/xrpc/identity.ex +++ b/lib/tempest/xrpc/identity.ex @@ -5,6 +5,7 @@ defmodule Tempest.Xrpc.Identity do alias Tempest.Accounts alias Tempest.Identity + alias Tempest.Security def resolve_handle(_conn, params, _method) do params @@ -28,6 +29,25 @@ defmodule Tempest.Xrpc.Identity do end end + def get_recommended_did_credentials(conn, _params, _method) do + {:ok, Identity.recommended_did_credentials(conn.assigns.auth_context.account)} + end + + def request_plc_operation_signature(conn, params, _method) do + account = conn.assigns.auth_context.account + + with password when is_binary(password) and password != "" <- Map.get(params, "password"), + :ok <- Security.verify_account_password(account, password), + {:ok, %{token: token}} <- Security.issue_plc_operation_token(account) do + {:ok, %{"token" => token}} + else + nil -> {:error, 400, "InvalidRequest", "password is required"} + "" -> {:error, 400, "InvalidRequest", "password is required"} + {:error, :invalid_password} -> {:error, 401, "AuthenticationRequired", "password is invalid"} + {:error, _reason} -> {:error, 400, "InvalidRequest", "could not create PLC operation signature token"} + end + end + defp identity_error(:invalid_handle_syntax), do: {:error, 400, "InvalidRequest", "handle is invalid"} defp identity_error(:invalid_did_syntax), do: {:error, 400, "InvalidRequest", "resolved DID is invalid"} diff --git a/lib/tempest/xrpc/registry.ex b/lib/tempest/xrpc/registry.ex index 523ad07..78e772e 100644 --- a/lib/tempest/xrpc/registry.ex +++ b/lib/tempest/xrpc/registry.ex @@ -226,6 +226,24 @@ defmodule Tempest.Xrpc.Registry do handler: {Tempest.Xrpc.Identity, :update_handle}, errors: ["HandleNotFound", "InvalidRequest"] }, + %Method{ + nsid: "com.atproto.identity.getRecommendedDidCredentials", + kind: :query, + auth: :bearer, + input: nil, + output: @json, + handler: {Tempest.Xrpc.Identity, :get_recommended_did_credentials}, + errors: ["InvalidRequest"] + }, + %Method{ + nsid: "com.atproto.identity.requestPlcOperationSignature", + kind: :procedure, + auth: :bearer, + input: @json, + output: @json, + handler: {Tempest.Xrpc.Identity, :request_plc_operation_signature}, + errors: ["AuthenticationRequired", "InvalidRequest"] + }, %Method{ nsid: "com.atproto.repo.createRecord", kind: :procedure, diff --git a/priv/repo/migrations/20260613041725_create_plc_operation_tokens.exs b/priv/repo/migrations/20260613041725_create_plc_operation_tokens.exs new file mode 100644 index 0000000..259fbbd --- /dev/null +++ b/priv/repo/migrations/20260613041725_create_plc_operation_tokens.exs @@ -0,0 +1,18 @@ +defmodule Tempest.Repo.Migrations.CreatePlcOperationTokens do + use Ecto.Migration + + def change do + create table(:plc_operation_tokens) do + add :account_id, references(:accounts, on_delete: :delete_all), null: false + add :token_hash, :string, null: false + add :expires_at, :utc_datetime, null: false + add :used_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:plc_operation_tokens, [:token_hash]) + create index(:plc_operation_tokens, [:account_id]) + create index(:plc_operation_tokens, [:account_id, :used_at]) + end +end diff --git a/test/tempest/lexicon/com_atproto_compat_test.exs b/test/tempest/lexicon/com_atproto_compat_test.exs index 9013768..ea9d3d5 100644 --- a/test/tempest/lexicon/com_atproto_compat_test.exs +++ b/test/tempest/lexicon/com_atproto_compat_test.exs @@ -175,12 +175,17 @@ defmodule Tempest.Lexicon.OfficialComAtprotoCompatibilityTest do assert missing_lexicons == [] end - test "admin compatibility matrix marks unregistered PLC identity endpoints as planned" do + test "admin compatibility matrix marks registered and remaining PLC identity endpoints accurately" do endpoints = Tempest.Admin.compatibility_status().endpoints for method <- [ "com.atproto.identity.getRecommendedDidCredentials", - "com.atproto.identity.requestPlcOperationSignature", + "com.atproto.identity.requestPlcOperationSignature" + ] do + assert %{status: "implemented"} = Enum.find(endpoints, &(&1.method == method)) + end + + for method <- [ "com.atproto.identity.signPlcOperation", "com.atproto.identity.submitPlcOperation" ] do diff --git a/test/tempest_web/xrpc/plc_identity_test.exs b/test/tempest_web/xrpc/plc_identity_test.exs new file mode 100644 index 0000000..6310651 --- /dev/null +++ b/test/tempest_web/xrpc/plc_identity_test.exs @@ -0,0 +1,171 @@ +defmodule TempestWeb.Xrpc.PlcIdentityTest do + use TempestWeb.ConnCase, async: false + + import Ecto.Query + import Plug.Conn + + alias Tempest.Accounts.Account + alias Tempest.Identity.SigningKey + alias Tempest.Repo + alias Tempest.Security + alias Tempest.Security.SecurityEvent + + @password "correct horse battery staple" + + setup context do + Req.Test.set_req_test_from_context(context) + Req.Test.verify_on_exit!(context) + + old_identity_config = Application.get_env(:tempest, Tempest.Identity, []) + + on_exit(fn -> + Application.put_env(:tempest, Tempest.Identity, old_identity_config) + end) + + :ok + end + + test "getRecommendedDidCredentials requires bearer auth", %{conn: conn} do + conn = get(conn, ~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") + + assert %{"error" => "AuthenticationRequired", "message" => "Bearer token is required"} = json_response(conn, 401) + end + + test "getRecommendedDidCredentials returns local key-store and PLC operation shape", %{conn: conn} do + account = create_account!(conn, "plc-creds.test", "plc-creds@example.com") + stored_account = Repo.get_by!(Account, did: account["did"]) + signing_key = Repo.get_by!(SigningKey, account_id: stored_account.id, active: true) + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") + + response = json_response(conn, 200) + + assert response["did"] == account["did"] + assert response["handle"] == "plc-creds.test" + assert response["signingKey"] == signing_key.public_key_multibase + assert response["verificationMethods"] == %{"atproto" => signing_key.public_key_multibase} + assert response["rotationKeys"] == [signing_key.public_key_multibase] + assert response["alsoKnownAs"] == ["at://plc-creds.test"] + + assert response["services"] == %{ + "atproto_pds" => %{ + "type" => "AtprotoPersonalDataServer", + "endpoint" => "http://localhost:4002" + } + } + end + + test "getRecommendedDidCredentials is consistent with fake PLC publication boundary", %{conn: conn} do + put_identity_test_config() + + Req.Test.expect(__MODULE__, fn req_conn -> + assert req_conn.method == "POST" + assert req_conn.host == "plc.test" + assert req_conn.request_path =~ "/did:plc:" + + {:ok, body, req_conn} = Plug.Conn.read_body(req_conn) + decoded = Jason.decode!(body) + + assert decoded["rotationKeys"] == [decoded["verificationMethods"]["atproto"]] + assert decoded["services"]["atproto_pds"]["endpoint"] == "http://localhost:4002" + + send_resp(req_conn, 200, Jason.encode!(%{"ok" => true})) + end) + + account = create_account!(conn, "plc-publish.test", "plc-publish@example.com") + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> get(~p"/xrpc/com.atproto.identity.getRecommendedDidCredentials") + + response = json_response(conn, 200) + assert response["verificationMethods"]["atproto"] in response["rotationKeys"] + end + + test "requestPlcOperationSignature requires bearer auth and JSON error shape", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.identity.requestPlcOperationSignature", %{"password" => @password}) + + assert %{"error" => "AuthenticationRequired", "message" => "Bearer token is required"} = json_response(conn, 401) + end + + test "requestPlcOperationSignature enforces strong password reauth", %{conn: conn} do + account = create_account!(conn, "plc-reauth.test", "plc-reauth@example.com") + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.identity.requestPlcOperationSignature", %{"password" => "wrong password"}) + + assert %{"error" => "AuthenticationRequired", "message" => "password is invalid"} = json_response(conn, 401) + end + + test "requestPlcOperationSignature returns single-use token and writes audit log", %{conn: conn} do + account = create_account!(conn, "plc-token.test", "plc-token@example.com") + stored_account = Repo.get_by!(Account, did: account["did"]) + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.identity.requestPlcOperationSignature", %{"password" => @password}) + + assert %{"token" => token} = json_response(conn, 200) + assert is_binary(token) + assert byte_size(token) >= 40 + + assert audit_event_count(stored_account, "plc_operation_signature.requested") == 1 + assert {:ok, _token_record} = Security.consume_plc_operation_token(stored_account, token) + assert {:error, :invalid_token} = Security.consume_plc_operation_token(stored_account, token) + assert audit_event_count(stored_account, "plc_operation_signature.token_consumed") == 1 + end + + test "requestPlcOperationSignature returns InvalidRequest when password is missing", %{conn: conn} do + account = create_account!(conn, "plc-error.test", "plc-error@example.com") + + conn = + conn + |> recycle() + |> put_req_header("authorization", "Bearer #{account["accessJwt"]}") + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.identity.requestPlcOperationSignature", %{}) + + assert %{"error" => "InvalidRequest", "message" => "password is required"} = json_response(conn, 400) + end + + defp create_account!(conn, handle, email) do + conn + |> put_req_header("content-type", "application/json") + |> post(~p"/xrpc/com.atproto.server.createAccount", %{ + "handle" => handle, + "email" => email, + "password" => @password + }) + |> json_response(200) + end + + defp put_identity_test_config do + Application.put_env(:tempest, Tempest.Identity, + plc_publish_enabled: true, + plc_directory_url: "https://plc.test", + http_req_options: [plug: {Req.Test, __MODULE__}] + ) + end + + defp audit_event_count(%Account{} = account, event_type) do + SecurityEvent + |> where([event], event.account_id == ^account.id and event.event_type == ^event_type) + |> Repo.aggregate(:count) + end +end -- 2.51.2