perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add PLC identity support and upstream interop coverage

alice 3134af17 547d57ea

+958 -93
+7
README.md
··· 14 14 Reference differential validation: 15 15 16 16 - Run `script/differential-validate` to compare `perlds` against the official published `@atproto/pds` on a focused set of account, repo, sync, and firehose behaviors. 17 + - Run `PERLDS_DIFF_ACCOUNT_DID_METHOD=did:plc script/differential-validate` to exercise the same harness in PLC-account mode, including recommended DID credentials, PLC signature requests, PLC handle updates, and token-gated PLC signing behavior. 17 18 - The helper installs the reference runtime into `.tools/reference-runtime` with Node 20 via `fnm`. 18 19 - Run `PERLDS_RUN_REFERENCE_DIFF=1 prove -lv t/reference-differential.t` to exercise the same harness from the test suite. 20 + - Run `PERLDS_RUN_REFERENCE_DIFF=1 prove -lv t/reference-differential-plc.t` to run the PLC-specific reference comparison from the test suite. 21 + 22 + Interop fixtures: 23 + 24 + - `t/crypto-interop.t` loads the official Bluesky `tools/reference/atproto/interop-test-files/crypto/w3c_didkey_K256.json` vectors so secp256k1 `did:key` encoding stays pinned to the same public fixtures as the upstream stack. 25 + - `t/plc-identity.t` drives `perlds` against the local PLC mock built on the official `@did-plc/lib`, covering account creation, recommended DID credentials, PLC handle updates, token-gated PLC signing, and validated PLC submission semantics.
+7 -2
lib/ATProto/PDS/API/Admin.pm
··· 11 11 use ATProto::PDS::API::Helpers qw(account_view find_account invite_code_view require_admin subject_key); 12 12 use ATProto::PDS::API::Util qw(xrpc_error); 13 13 use ATProto::PDS::Auth::Password qw(hash_password); 14 + use ATProto::PDS::Crypto::Secp256k1 qw(signing_did_to_public_key_multibase); 14 15 use ATProto::PDS::Identity qw(account_did_doc normalize_handle); 15 16 16 17 our @EXPORT_OK = qw(register_admin_handlers); ··· 216 217 my $body = $c->req->json || {}; 217 218 my $account = $c->store->get_account_by_did($body->{did} // q()); 218 219 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 219 - my $multibase = $body->{signingKey} // q(); 220 - $multibase =~ s/\Adid:key://; 220 + my $signing_key = $body->{signingKey} // q(); 221 + xrpc_error(400, 'InvalidRequest', 'signingKey must be a did:key') 222 + unless $signing_key =~ /\Adid:key:/; 223 + my $multibase = signing_did_to_public_key_multibase($signing_key); 221 224 my $updated = { 222 225 %$account, 223 226 public_key_multibase => $multibase, 227 + signing_key_did => $signing_key, 224 228 }; 225 229 $c->store->update_account( 226 230 $account->{did}, 227 231 public_key_multibase => $multibase, 232 + signing_key_did => $signing_key, 228 233 did_doc => account_did_doc($c->app->settings, $updated), 229 234 ); 230 235 return {};
+63 -43
lib/ATProto/PDS/API/Misc.pm
··· 13 13 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 14 14 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 15 15 use ATProto::PDS::Identity qw(account_did_doc normalize_handle service_did service_did_doc); 16 + use ATProto::PDS::PLC qw(create_signed_plc_operation is_plc_did plc_rotation_did plc_update_handle recommended_did_credentials refresh_plc_did_doc submit_plc_operation); 16 17 use ATProto::PDS::Repo::CID; 17 18 use ATProto::PDS::Repo::DagCbor qw(encode_dag_cbor); 18 19 ··· 20 21 21 22 sub register_misc_handlers ($registry, $app) { 22 23 $registry->register('com.atproto.identity.getRecommendedDidCredentials', sub ($c, $endpoint) { 23 - return { 24 - rotationKeys => [], 25 - alsoKnownAs => [], 26 - verificationMethods => {}, 27 - services => { 28 - atproto_pds => service_did_doc($c->app->settings)->{service}[0], 29 - }, 30 - }; 24 + my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 25 + return recommended_did_credentials($c->app->settings, $account); 31 26 }); 32 27 33 28 $registry->register('com.atproto.identity.refreshIdentity', sub ($c, $endpoint) { ··· 60 55 61 56 $registry->register('com.atproto.identity.requestPlcOperationSignature', sub ($c, $endpoint) { 62 57 my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 58 + xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 59 + unless defined($account->{email}) && length($account->{email}); 63 60 my $token = $c->store->create_action_token( 64 61 did => $account->{did}, 65 62 email => $account->{email}, 66 - purpose => 'plc_signature', 63 + purpose => 'plc_operation', 67 64 expires_at => time + 3600, 68 65 ); 69 66 $c->store->log_outbound_email( 70 67 recipient_did => $account->{did}, 71 68 recipient_email => $account->{email}, 72 - subject => 'perlds PLC operation signature', 73 - content => "Use token $token->{token} to sign your PLC operation.", 74 - ) if $account->{email}; 69 + subject => 'PLC update requested', 70 + content => "Use token $token->{token} to authorize your PLC operation.", 71 + ); 75 72 return {}; 76 73 }); 77 74 78 75 $registry->register('com.atproto.identity.signPlcOperation', sub ($c, $endpoint) { 79 76 my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 77 + xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 78 + unless is_plc_did($account->{did}); 80 79 my $body = $c->req->json || {}; 81 - if (defined($body->{token}) && length($body->{token})) { 82 - my $token = $c->store->get_action_token($body->{token}); 83 - xrpc_error(400, 'InvalidToken', 'Token was not found') unless $token; 84 - xrpc_error(400, 'InvalidToken', 'Token purpose did not match') unless ($token->{purpose} // q()) eq 'plc_signature'; 85 - xrpc_error(400, 'ExpiredToken', 'Token has expired') 86 - if defined($token->{expires_at}) && $token->{expires_at} < time; 87 - xrpc_error(400, 'InvalidToken', 'Token was not issued for this account') 88 - unless ($token->{did} // q()) eq $account->{did}; 89 - $c->store->consume_action_token($token->{token}); 90 - } 80 + my $token_value = $body->{token} // q(); 81 + xrpc_error(400, 'InvalidRequest', 'email confirmation token required to sign PLC operations') 82 + unless length $token_value; 83 + my $token = $c->store->get_action_token($token_value); 84 + xrpc_error(400, 'InvalidToken', 'Token is invalid') unless $token; 85 + xrpc_error(400, 'InvalidToken', 'Token purpose did not match') unless ($token->{purpose} // q()) eq 'plc_operation'; 86 + xrpc_error(400, 'ExpiredToken', 'Token has expired') 87 + if defined($token->{expires_at}) && $token->{expires_at} < time; 88 + xrpc_error(400, 'InvalidToken', 'Token was not issued for this account') 89 + unless ($token->{did} // q()) eq $account->{did}; 90 + $c->store->consume_action_token($token->{token}); 91 + my $current = recommended_did_credentials($c->app->settings, $account); 92 + my $last_op = ATProto::PDS::PLC::get_last_plc_operation($c->app->settings, $account->{did}); 91 93 return { 92 - operation => { 93 - type => 'com.atproto.identity.plcOperation', 94 - did => $account->{did}, 95 - alsoKnownAs => $body->{alsoKnownAs} // $account->{did_doc}{alsoKnownAs} // [], 96 - verificationMethods => $body->{verificationMethods} // {}, 97 - services => $body->{services} // {}, 98 - rotationKeys => $body->{rotationKeys} // [], 99 - signedAt => iso8601(), 100 - }, 94 + operation => create_signed_plc_operation($c->app->settings, { 95 + type => 'plc_operation', 96 + rotationKeys => $body->{rotationKeys} // $current->{rotationKeys}, 97 + alsoKnownAs => $body->{alsoKnownAs} // $current->{alsoKnownAs}, 98 + verificationMethods => $body->{verificationMethods} // $current->{verificationMethods}, 99 + services => $body->{services} // $current->{services}, 100 + prev => ATProto::PDS::Repo::CID->for_dag_cbor(encode_dag_cbor($last_op))->to_string, 101 + }), 101 102 }; 102 103 }); 103 104 104 105 $registry->register('com.atproto.identity.submitPlcOperation', sub ($c, $endpoint) { 106 + my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 107 + xrpc_error(400, 'InvalidRequest', 'PLC operations are only supported for did:plc accounts') 108 + unless is_plc_did($account->{did}); 105 109 my $body = $c->req->json || {}; 106 110 my $operation = $body->{operation} || {}; 107 - my $account = $c->store->get_account_by_did($operation->{did} // q()); 108 - if (!$account) { 109 - my (undef, $authed) = require_auth($c, audience => 'access', allow_refresh => 1); 110 - $account = $authed; 111 - } 112 - xrpc_error(404, 'DidNotFound', 'Account was not found') unless $account; 113 - 114 - my $did_doc = $account->{did_doc} || account_did_doc($c->app->settings, $account); 115 - $did_doc->{alsoKnownAs} = $operation->{alsoKnownAs} if exists $operation->{alsoKnownAs}; 116 - $did_doc->{verificationMethod} = $operation->{verificationMethods} if exists $operation->{verificationMethods}; 117 - $did_doc->{service} = $operation->{services} if exists $operation->{services}; 111 + my $rotation_did = ATProto::PDS::PLC::plc_rotation_did($c->app->settings); 112 + xrpc_error(400, 'InvalidRequest', q{Rotation keys do not include server's rotation key}) 113 + unless grep { ($_ // q()) eq $rotation_did } @{ $operation->{rotationKeys} || [] }; 114 + xrpc_error(400, 'InvalidRequest', 'Incorrect type on atproto_pds service') 115 + unless (($operation->{services}{atproto_pds}{type} // q()) eq 'AtprotoPersonalDataServer'); 116 + xrpc_error(400, 'InvalidRequest', 'Incorrect endpoint on atproto_pds service') 117 + unless (($operation->{services}{atproto_pds}{endpoint} // q()) eq $c->app->settings->{base_url}); 118 + xrpc_error(400, 'InvalidRequest', 'Incorrect signing key') 119 + unless (($operation->{verificationMethods}{atproto} // q()) eq ($account->{signing_key_did} // q())); 120 + my $primary_aka = (($operation->{alsoKnownAs} || [])->[0]) // q(); 121 + xrpc_error(400, 'InvalidRequest', 'Incorrect handle in alsoKnownAs') 122 + if ($account->{handle} // q()) && ($primary_aka ne 'at://' . $account->{handle}); 123 + submit_plc_operation($c->app->settings, $account->{did}, $operation); 124 + my $did_doc = refresh_plc_did_doc($c->app->settings, $account->{did}); 118 125 $c->store->update_account($account->{did}, did_doc => $did_doc); 126 + $account = $c->store->update_account($account->{did}, did_doc => $did_doc); 127 + $c->store->append_event( 128 + did => $account->{did}, 129 + type => 'identity', 130 + rev => $account->{repo_rev}, 131 + payload => { 132 + did => $account->{did}, 133 + handle => $account->{handle}, 134 + }, 135 + ); 119 136 return {}; 120 137 }); 121 138 ··· 130 147 if $existing && ($existing->{did} // q()) ne $account->{did}; 131 148 xrpc_error(400, 'HandleNotAvailable', 'That handle is reserved') 132 149 if $c->store->get_reserved_handle($handle); 150 + my $did_doc = is_plc_did($account->{did}) 151 + ? plc_update_handle($c->app->settings, $account, $handle) 152 + : account_did_doc($c->app->settings, { %$account, handle => $handle }); 133 153 my $updated = $c->store->update_account( 134 154 $account->{did}, 135 155 handle => $handle, 136 - did_doc => account_did_doc($c->app->settings, { %$account, handle => $handle }), 156 + did_doc => $did_doc, 137 157 ); 138 158 $c->store->append_event( 139 159 did => $updated->{did},
+24 -4
lib/ATProto/PDS/API/Server.pm
··· 13 13 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt); 14 14 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 15 15 use ATProto::PDS::Identity qw(account_did account_did_doc normalize_handle service_did); 16 + use ATProto::PDS::PLC qw(account_did_method create_plc_account is_plc_did refresh_plc_did_doc); 16 17 use ATProto::PDS::Repo::CAR qw(read_car); 17 18 18 19 our @EXPORT_OK = qw(register_server_handlers require_auth session_view); ··· 42 43 } 43 44 44 45 my $account_id = random_hex(8); 45 - my $did = $body->{did} || account_did($c->app->settings, $account_id); 46 + my $did_method = account_did_method($c->app->settings); 47 + my $did = $body->{did}; 46 48 my $reserved = $body->{did} ? $c->store->get_reserved_signing_key($did) : undef; 47 49 my $keys = ($reserved && !defined $reserved->{claimed_at}) 48 50 ? { 49 51 private_key => $reserved->{private_key}, 50 52 public_key => $reserved->{public_key}, 51 53 public_key_multibase => $reserved->{public_key_multibase}, 54 + signing_key_did => $reserved->{signing_key_did}, 52 55 } 53 56 : $c->repo_manager->generate_signing_key; 57 + my $did_doc; 58 + if (!$did) { 59 + if ($did_method eq 'did:plc') { 60 + my $plc = create_plc_account( 61 + $c->app->settings, 62 + handle => $handle, 63 + signing_key_did => $keys->{signing_key_did}, 64 + ); 65 + $did = $plc->{did}; 66 + $did_doc = $plc->{did_doc}; 67 + } else { 68 + $did = account_did($c->app->settings, $account_id); 69 + } 70 + } 54 71 my $password_record = hash_password($password); 55 - my $did_doc = account_did_doc($c->app->settings, { 72 + $did_doc //= account_did_doc($c->app->settings, { 56 73 account_id => $account_id, 57 74 did => $did, 58 75 handle => $handle, 59 76 public_key_multibase => $keys->{public_key_multibase}, 77 + signing_key_did => $keys->{signing_key_did}, 60 78 }); 61 79 62 80 my $account = $c->store->create_account( ··· 71 89 private_key => $keys->{private_key}, 72 90 public_key => $keys->{public_key}, 73 91 public_key_multibase => $keys->{public_key_multibase}, 92 + signing_key_did => $keys->{signing_key_did}, 74 93 ); 75 94 76 95 my $repo = $c->repo_manager->initialize_repo($account); ··· 78 97 repo_commit_cid => $repo->{cid}, 79 98 repo_root_cid => $repo->{root_cid}, 80 99 repo_rev => $repo->{rev}, 81 - did_doc => account_did_doc($c->app->settings, $account), 100 + did_doc => is_plc_did($account->{did}) ? refresh_plc_did_doc($c->app->settings, $account->{did}) : account_did_doc($c->app->settings, $account), 82 101 ); 83 102 84 103 $c->store->record_invite_code_use( ··· 427 446 private_key => $keys->{private_key}, 428 447 public_key => $keys->{public_key}, 429 448 public_key_multibase => $keys->{public_key_multibase}, 449 + signing_key_did => $keys->{signing_key_did}, 430 450 ); 431 451 } 432 452 return { 433 - signingKey => 'did:key:' . $keys->{public_key_multibase}, 453 + signingKey => $keys->{signing_key_did}, 434 454 }; 435 455 }); 436 456
+116
lib/ATProto/PDS/Crypto/Secp256k1.pm
··· 1 + package ATProto::PDS::Crypto::Secp256k1; 2 + 3 + use v5.34; 4 + use warnings; 5 + use feature 'signatures'; 6 + no warnings 'experimental::signatures'; 7 + 8 + use Exporter 'import'; 9 + use Crypt::PK::ECC; 10 + use Math::BigInt try => 'GMP'; 11 + 12 + use ATProto::PDS::Util::BaseX qw(encode_base58btc decode_base58btc); 13 + 14 + our @EXPORT_OK = qw( 15 + did_key_from_public_key 16 + generate_keypair 17 + public_key_from_did_key 18 + public_key_multibase_from_public_key 19 + signing_did_from_private_key 20 + signing_did_to_public_key_multibase 21 + sign_compact_low_s 22 + ); 23 + 24 + my $SECP256K1_DID_PREFIX = pack('C*', 0xe7, 0x01); 25 + my $SECP256K1_ORDER = Math::BigInt->from_hex('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); 26 + my $SECP256K1_HALF_ORDER = $SECP256K1_ORDER->copy->bdiv(2); 27 + 28 + sub generate_keypair () { 29 + my $pk = Crypt::PK::ECC->new; 30 + $pk->generate_key('secp256k1'); 31 + 32 + my $private_key = $pk->export_key_raw('private'); 33 + my $public_key = $pk->export_key_raw('public'); 34 + my $compressed = $pk->export_key_raw('public_compressed'); 35 + 36 + return { 37 + private_key => $private_key, 38 + public_key => $public_key, 39 + public_key_compressed => $compressed, 40 + public_key_multibase => public_key_multibase_from_public_key($public_key), 41 + signing_key_did => did_key_from_public_key($compressed), 42 + }; 43 + } 44 + 45 + sub signing_did_from_private_key ($private_key) { 46 + my $pk = Crypt::PK::ECC->new; 47 + $pk->import_key_raw($private_key, 'secp256k1'); 48 + return did_key_from_public_key($pk->export_key_raw('public_compressed')); 49 + } 50 + 51 + sub did_key_from_public_key ($public_key) { 52 + my $compressed = _compress_public_key($public_key); 53 + return 'did:key:z' . encode_base58btc($SECP256K1_DID_PREFIX . $compressed); 54 + } 55 + 56 + sub public_key_multibase_from_public_key ($public_key) { 57 + my $uncompressed = _uncompress_public_key($public_key); 58 + return 'z' . encode_base58btc($uncompressed); 59 + } 60 + 61 + sub public_key_from_did_key ($did_key) { 62 + my $copy = $did_key // q(); 63 + $copy =~ s/\Adid:key://; 64 + $copy =~ s/\Az// or die "unsupported did:key encoding: $did_key"; 65 + my $decoded = decode_base58btc($copy); 66 + die "unsupported did:key prefix: $did_key" unless substr($decoded, 0, 2) eq $SECP256K1_DID_PREFIX; 67 + my $compressed = substr($decoded, 2); 68 + return _uncompress_public_key($compressed); 69 + } 70 + 71 + sub signing_did_to_public_key_multibase ($did_key) { 72 + return public_key_multibase_from_public_key(public_key_from_did_key($did_key)); 73 + } 74 + 75 + sub sign_compact_low_s ($private_key, $message) { 76 + my $pk = Crypt::PK::ECC->new; 77 + $pk->import_key_raw($private_key, 'secp256k1'); 78 + my $sig = $pk->sign_message_rfc7518($message, 'SHA256'); 79 + return _normalize_low_s($sig); 80 + } 81 + 82 + sub _compress_public_key ($public_key) { 83 + return $public_key if length($public_key // q()) == 33; 84 + my $pk = Crypt::PK::ECC->new; 85 + $pk->import_key_raw($public_key, 'secp256k1'); 86 + return $pk->export_key_raw('public_compressed'); 87 + } 88 + 89 + sub _uncompress_public_key ($public_key) { 90 + return $public_key if length($public_key // q()) == 65; 91 + my $pk = Crypt::PK::ECC->new; 92 + $pk->import_key_raw($public_key, 'secp256k1'); 93 + return $pk->export_key_raw('public'); 94 + } 95 + 96 + sub _normalize_low_s ($signature) { 97 + die 'expected a compact 64-byte secp256k1 signature' 98 + unless defined $signature && length($signature) == 64; 99 + 100 + my $r = Math::BigInt->from_hex('0x' . unpack('H*', substr($signature, 0, 32))); 101 + my $s = Math::BigInt->from_hex('0x' . unpack('H*', substr($signature, 32, 32))); 102 + if ($s->bcmp($SECP256K1_HALF_ORDER) > 0) { 103 + $s = $SECP256K1_ORDER->copy->bsub($s); 104 + } 105 + 106 + return _bigint_to_32_bytes($r) . _bigint_to_32_bytes($s); 107 + } 108 + 109 + sub _bigint_to_32_bytes ($value) { 110 + my $hex = $value->copy->as_hex; 111 + $hex =~ s/\A0x//; 112 + $hex = ('0' x (64 - length($hex))) . $hex; 113 + return pack('H*', $hex); 114 + } 115 + 116 + 1;
+19 -1
lib/ATProto/PDS/Identity.pm
··· 8 8 use Exporter 'import'; 9 9 use Mojo::URL; 10 10 11 + use ATProto::PDS::PLC qw(account_did_method format_plc_did_doc is_plc_did recommended_did_credentials); 12 + 11 13 our @EXPORT_OK = qw( 12 14 account_did 13 15 account_did_doc ··· 42 44 43 45 sub account_did ($config_or_url, $account_id) { 44 46 die 'account id is required' unless defined $account_id && length $account_id; 47 + my $config = _coerce_config($config_or_url); 48 + die 'did:plc accounts must be provisioned through the PLC flow' 49 + if account_did_method($config) eq 'did:plc'; 45 50 my $did = service_did($config_or_url); 46 51 return "$did:users:$account_id"; 47 52 } ··· 52 57 my $did = $account->{did} // account_did($config, $account->{account_id} // $account->{id}); 53 58 my $handle = $account->{handle}; 54 59 60 + if (is_plc_did($did)) { 61 + return $account->{did_doc} if $account->{did_doc}; 62 + return format_plc_did_doc($did, recommended_did_credentials($config, $account)) 63 + if $account->{signing_key_did}; 64 + } 65 + 55 66 my %doc = ( 56 67 '@context' => ['https://www.w3.org/ns/did/v1'], 57 68 id => $did, ··· 63 74 ); 64 75 $doc{alsoKnownAs} = ["at://$handle"] if defined $handle && length $handle; 65 76 if (my $multibase = $account->{public_key_multibase}) { 77 + my $type = ($account->{signing_key_did} // q()) =~ /\Adid:key:/ ? 'EcdsaSecp256k1VerificationKey2019' : 'Multikey'; 78 + if ($type eq 'EcdsaSecp256k1VerificationKey2019') { 79 + $doc{'@context'} = [ 80 + 'https://www.w3.org/ns/did/v1', 81 + 'https://w3id.org/security/suites/secp256k1-2019/v1', 82 + ]; 83 + } 66 84 $doc{verificationMethod} = [{ 67 85 id => "$did#atproto", 68 - type => 'Multikey', 86 + type => $type, 69 87 controller => $did, 70 88 publicKeyMultibase => $multibase, 71 89 }];
+218
lib/ATProto/PDS/PLC.pm
··· 1 + package ATProto::PDS::PLC; 2 + 3 + use v5.34; 4 + use warnings; 5 + use feature 'signatures'; 6 + no warnings 'experimental::signatures'; 7 + 8 + use Exporter 'import'; 9 + use Digest::SHA qw(sha256); 10 + use Mojo::JSON qw(decode_json encode_json); 11 + use Mojo::URL; 12 + use Mojo::UserAgent; 13 + use Mojo::Util qw(url_escape); 14 + 15 + use ATProto::PDS::Crypto::Secp256k1 qw( 16 + public_key_multibase_from_public_key 17 + sign_compact_low_s 18 + signing_did_from_private_key 19 + signing_did_to_public_key_multibase 20 + ); 21 + use ATProto::PDS::Repo::CID; 22 + use ATProto::PDS::Repo::DagCbor qw(encode_dag_cbor); 23 + use ATProto::PDS::Util::BaseX qw(base64url_encode encode_base32); 24 + 25 + our @EXPORT_OK = qw( 26 + account_did_method 27 + create_plc_account 28 + create_signed_plc_operation 29 + format_plc_did_doc 30 + is_plc_did 31 + plc_rotation_did 32 + plc_rotation_private_key 33 + plc_update_handle 34 + recommended_did_credentials 35 + refresh_plc_did_doc 36 + submit_plc_operation 37 + ); 38 + 39 + sub account_did_method ($config) { 40 + return $config->{account_did_method} // 'did:web'; 41 + } 42 + 43 + sub is_plc_did ($did) { 44 + return defined($did) && $did =~ /\Adid:plc:/ ? 1 : 0; 45 + } 46 + 47 + sub plc_rotation_private_key ($config) { 48 + my $hex = $config->{plc_rotation_private_key_hex} 49 + // die 'plc_rotation_private_key_hex is required when account_did_method is did:plc'; 50 + return pack('H*', $hex); 51 + } 52 + 53 + sub plc_rotation_did ($config) { 54 + return signing_did_from_private_key(plc_rotation_private_key($config)); 55 + } 56 + 57 + sub recommended_did_credentials ($config, $account) { 58 + my @rotation_keys = (plc_rotation_did($config)); 59 + unshift @rotation_keys, $config->{plc_recovery_did_key} 60 + if defined($config->{plc_recovery_did_key}) && length($config->{plc_recovery_did_key}); 61 + 62 + return { 63 + alsoKnownAs => ($account->{handle} ? [ 'at://' . $account->{handle} ] : []), 64 + verificationMethods => { 65 + atproto => $account->{signing_key_did}, 66 + }, 67 + rotationKeys => \@rotation_keys, 68 + services => { 69 + atproto_pds => { 70 + type => 'AtprotoPersonalDataServer', 71 + endpoint => $config->{base_url}, 72 + }, 73 + }, 74 + }; 75 + } 76 + 77 + sub create_plc_account ($config, %args) { 78 + my $credentials = recommended_did_credentials($config, { 79 + handle => $args{handle}, 80 + signing_key_did => $args{signing_key_did}, 81 + }); 82 + 83 + my %unsigned = ( 84 + type => 'plc_operation', 85 + verificationMethods => $credentials->{verificationMethods}, 86 + rotationKeys => $credentials->{rotationKeys}, 87 + alsoKnownAs => $credentials->{alsoKnownAs}, 88 + services => $credentials->{services}, 89 + prev => undef, 90 + ); 91 + 92 + my $operation = create_signed_plc_operation($config, \%unsigned); 93 + my $did = _did_for_create_op($operation); 94 + submit_plc_operation($config, $did, $operation); 95 + 96 + return { 97 + did => $did, 98 + operation => $operation, 99 + did_doc => format_plc_did_doc($did, { 100 + alsoKnownAs => $operation->{alsoKnownAs}, 101 + verificationMethods => $operation->{verificationMethods}, 102 + services => $operation->{services}, 103 + }), 104 + }; 105 + } 106 + 107 + sub plc_update_handle ($config, $account, $handle) { 108 + my $last_op = get_last_plc_operation($config, $account->{did}); 109 + my @aka = grep { !defined($_) || $_ !~ /\Aat:\/\// } @{ $last_op->{alsoKnownAs} || [] }; 110 + unshift @aka, 'at://' . $handle; 111 + 112 + my $operation = create_signed_plc_operation($config, { 113 + type => 'plc_operation', 114 + verificationMethods => $last_op->{verificationMethods}, 115 + rotationKeys => $last_op->{rotationKeys}, 116 + alsoKnownAs => \@aka, 117 + services => $last_op->{services}, 118 + prev => ATProto::PDS::Repo::CID->for_dag_cbor(encode_dag_cbor($last_op))->to_string, 119 + }); 120 + 121 + submit_plc_operation($config, $account->{did}, $operation); 122 + return refresh_plc_did_doc($config, $account->{did}); 123 + } 124 + 125 + sub create_signed_plc_operation ($config, $operation) { 126 + my %unsigned = %{$operation}; 127 + delete $unsigned{sig}; 128 + my $sig = sign_compact_low_s(plc_rotation_private_key($config), encode_dag_cbor(\%unsigned)); 129 + return { 130 + %unsigned, 131 + sig => base64url_encode($sig), 132 + }; 133 + } 134 + 135 + sub submit_plc_operation ($config, $did, $operation) { 136 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 137 + my $tx = $ua->post( 138 + _plc_endpoint($config, $did) => { 139 + 'Content-Type' => 'application/json', 140 + Accept => 'application/json', 141 + } => json => $operation, 142 + ); 143 + my $res = $tx->result; 144 + die 'PLC operation failed: ' . ($res->body || $res->message || 'unknown error') 145 + unless $res->is_success; 146 + return 1; 147 + } 148 + 149 + sub refresh_plc_did_doc ($config, $did) { 150 + my $data = fetch_plc_document_data($config, $did); 151 + return format_plc_did_doc($did, $data); 152 + } 153 + 154 + sub fetch_plc_document_data ($config, $did) { 155 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 156 + my $tx = $ua->get(_plc_endpoint($config, $did, 'data')); 157 + my $res = $tx->result; 158 + die 'PLC document lookup failed: ' . ($res->body || $res->message || 'unknown error') 159 + unless $res->is_success; 160 + return decode_json($res->body); 161 + } 162 + 163 + sub get_last_plc_operation ($config, $did) { 164 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 165 + my $tx = $ua->get(_plc_endpoint($config, $did, 'log', 'last')); 166 + my $res = $tx->result; 167 + die 'PLC operation lookup failed: ' . ($res->body || $res->message || 'unknown error') 168 + unless $res->is_success; 169 + return decode_json($res->body); 170 + } 171 + 172 + sub format_plc_did_doc ($did, $data) { 173 + return { 174 + '@context' => [ 175 + 'https://www.w3.org/ns/did/v1', 176 + 'https://w3id.org/security/suites/secp256k1-2019/v1', 177 + ], 178 + id => $did, 179 + alsoKnownAs => $data->{alsoKnownAs} || [], 180 + verificationMethod => [ 181 + map { 182 + +{ 183 + id => '#' . $_, 184 + type => 'EcdsaSecp256k1VerificationKey2019', 185 + controller => $did, 186 + publicKeyMultibase => signing_did_to_public_key_multibase($data->{verificationMethods}{$_}), 187 + } 188 + } sort keys %{ $data->{verificationMethods} || {} } 189 + ], 190 + service => [ 191 + map { 192 + +{ 193 + id => '#' . $_, 194 + type => $data->{services}{$_}{type}, 195 + serviceEndpoint => $data->{services}{$_}{endpoint}, 196 + } 197 + } sort keys %{ $data->{services} || {} } 198 + ], 199 + }; 200 + } 201 + 202 + sub _did_for_create_op ($operation) { 203 + my $hash = sha256(encode_dag_cbor($operation)); 204 + return 'did:plc:' . substr(encode_base32($hash), 0, 24); 205 + } 206 + 207 + sub _plc_url ($config) { 208 + return $config->{plc_url} // 'https://plc.directory'; 209 + } 210 + 211 + sub _plc_endpoint ($config, @segments) { 212 + my $base = Mojo::URL->new(_plc_url($config))->to_string; 213 + $base =~ s{/+\z}{}; 214 + my $path = join '/', map { url_escape($_, '^A-Za-z0-9\\-._~') } @segments; 215 + return $base . '/' . $path; 216 + } 217 + 218 + 1;
+3 -15
lib/ATProto/PDS/Repo/Manager.pm
··· 6 6 no warnings 'experimental::signatures'; 7 7 8 8 use JSON::PP qw(decode_json); 9 - use Crypt::PK::Ed25519; 10 9 10 + use ATProto::PDS::Crypto::Secp256k1 qw(generate_keypair sign_compact_low_s); 11 11 use ATProto::PDS::Repo::Bytes; 12 12 use ATProto::PDS::Repo::CAR qw(read_car write_car); 13 13 use ATProto::PDS::Repo::CID; 14 14 use ATProto::PDS::Repo::DagCbor qw(encode_dag_cbor); 15 15 use ATProto::PDS::Repo::MST qw(build_mst); 16 - use ATProto::PDS::Util::BaseX qw(encode_base58btc); 17 16 use ATProto::PDS::Util::TID qw(next_tid); 18 17 19 18 sub new ($class, %args) { ··· 26 25 } 27 26 28 27 sub generate_signing_key ($self) { 29 - my $pk = Crypt::PK::Ed25519->new; 30 - $pk->generate_key; 31 - my $private = $pk->export_key_raw('private'); 32 - my $public = $pk->export_key_raw('public'); 33 - my $multibase = 'z' . encode_base58btc(pack('C*', 0xed, 0x01) . $public); 34 - return { 35 - private_key => $private, 36 - public_key => $public, 37 - public_key_multibase => $multibase, 38 - }; 28 + return generate_keypair(); 39 29 } 40 30 41 31 sub initialize_repo ($self, $account) { ··· 128 118 prev => $latest ? ATProto::PDS::Repo::CID->from_string($latest->{cid}) : undef, 129 119 }; 130 120 my $unsigned_bytes = encode_dag_cbor($unsigned); 131 - my $pk = Crypt::PK::Ed25519->new; 132 - $pk->import_key_raw($account->{private_key}, 'private'); 133 - my $sig = $pk->sign_message($unsigned_bytes); 121 + my $sig = sign_compact_low_s($account->{private_key}, $unsigned_bytes); 134 122 my $commit = { %$unsigned, sig => ATProto::PDS::Repo::Bytes->new($sig) }; 135 123 my $commit_bytes = encode_dag_cbor($commit); 136 124 my $commit_cid = ATProto::PDS::Repo::CID->for_dag_cbor($commit_bytes);
+15 -5
lib/ATProto/PDS/Store/SQLite.pm
··· 106 106 INSERT INTO accounts ( 107 107 id, account_id, did, handle, email, password_hash, password_salt, 108 108 created_at, updated_at, deactivated_at, deleted_at, email_confirmed_at, 109 - did_doc_json, private_key, public_key, public_key_multibase, 109 + did_doc_json, private_key, public_key, public_key_multibase, signing_key_did, 110 110 repo_commit_cid, repo_root_cid, repo_rev, invites_disabled, invite_note 111 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 111 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 112 112 }, 113 113 undef, 114 114 $account_id, ··· 127 127 $args{private_key}, 128 128 $args{public_key}, 129 129 $args{public_key_multibase}, 130 + $args{signing_key_did}, 130 131 $args{repo_commit_cid}, 131 132 $args{repo_root_cid}, 132 133 $args{repo_rev}, ··· 141 142 my %allowed = map { $_ => 1 } qw( 142 143 handle email password_hash password_salt updated_at deactivated_at deleted_at 143 144 email_confirmed_at invites_disabled invite_note 144 - did_doc private_key public_key public_key_multibase 145 + did_doc private_key public_key public_key_multibase signing_key_did 145 146 repo_commit_cid repo_root_cid repo_rev 146 147 ); 147 148 my (@sets, @bind); ··· 1011 1012 $self->dbh->do( 1012 1013 q{ 1013 1014 INSERT INTO reserved_signing_keys ( 1014 - did, private_key, public_key, public_key_multibase, created_at, claimed_at 1015 - ) VALUES (?, ?, ?, ?, ?, ?) 1015 + did, private_key, public_key, public_key_multibase, signing_key_did, created_at, claimed_at 1016 + ) VALUES (?, ?, ?, ?, ?, ?, ?) 1016 1017 ON CONFLICT(did) DO UPDATE SET 1017 1018 private_key = excluded.private_key, 1018 1019 public_key = excluded.public_key, 1019 1020 public_key_multibase = excluded.public_key_multibase, 1021 + signing_key_did = excluded.signing_key_did, 1020 1022 created_at = excluded.created_at, 1021 1023 claimed_at = excluded.claimed_at 1022 1024 }, ··· 1025 1027 $args{private_key}, 1026 1028 $args{public_key}, 1027 1029 $args{public_key_multibase}, 1030 + $args{signing_key_did}, 1028 1031 $now, 1029 1032 $args{claimed_at}, 1030 1033 ); ··· 1436 1439 status_json TEXT 1437 1440 ) 1438 1441 }, 1442 + ], 1443 + }, 1444 + { 1445 + version => 4, 1446 + statements => [ 1447 + q{ALTER TABLE accounts ADD COLUMN signing_key_did TEXT}, 1448 + q{ALTER TABLE reserved_signing_keys ADD COLUMN signing_key_did TEXT}, 1439 1449 ], 1440 1450 }, 1441 1451 );
+19 -23
lib/ATProto/PDS/Util/BaseX.pm
··· 4 4 use warnings; 5 5 6 6 use Exporter 'import'; 7 + use Math::BigInt try => 'GMP'; 7 8 use MIME::Base64 qw(encode_base64 decode_base64); 8 9 9 10 our @EXPORT_OK = qw( ··· 59 60 sub encode_base58btc { 60 61 my ($bytes) = @_; 61 62 return '' unless defined $bytes; 62 - return '1' x _leading_zero_bytes($bytes) unless length $bytes; 63 + return q() unless length $bytes; 64 + 65 + my $zeroes = _leading_zero_bytes($bytes); 66 + my $value = Math::BigInt->from_hex('0x' . unpack('H*', $bytes)); 67 + my $out = q(); 63 68 64 - my @digits = (0); 65 - for my $byte (unpack('C*', $bytes)) { 66 - my $carry = $byte; 67 - for my $i (0 .. $#digits) { 68 - my $value = $digits[$i] * 256 + $carry; 69 - $digits[$i] = $value % 58; 70 - $carry = int($value / 58); 71 - } 72 - push @digits, ($carry % 58), do { $carry = int($carry / 58) } while $carry; 69 + while ($value->bcmp(0) > 0) { 70 + my ($quotient, $remainder) = $value->copy->bdiv(58); 71 + $out = substr($BASE58_ALPHABET, $remainder->numify, 1) . $out; 72 + $value = $quotient; 73 73 } 74 74 75 - my $zeroes = _leading_zero_bytes($bytes); 76 - my $out = ('1' x $zeroes) . join('', map { substr($BASE58_ALPHABET, $_, 1) } reverse @digits); 77 - return $out; 75 + return ('1' x $zeroes) . $out; 78 76 } 79 77 80 78 sub decode_base58btc { 81 79 my ($text) = @_; 82 80 return '' unless defined $text && length $text; 83 81 84 - my @bytes = (0); 82 + my ($leading) = $text =~ /\A(1*)/; 83 + my $value = Math::BigInt->new(0); 85 84 for my $char (split //, $text) { 86 85 die "invalid base58 character: $char" unless exists $BASE58_INDEX{$char}; 87 - my $carry = $BASE58_INDEX{$char}; 88 - for my $i (0 .. $#bytes) { 89 - my $value = $bytes[$i] * 58 + $carry; 90 - $bytes[$i] = $value & 0xff; 91 - $carry = $value >> 8; 92 - } 93 - push @bytes, ($carry & 0xff), do { $carry >>= 8 } while $carry; 86 + $value->bmul(58)->badd($BASE58_INDEX{$char}); 94 87 } 95 88 96 - my $zeroes = ($text =~ tr/1//); 97 - return ("\0" x $zeroes) . pack('C*', reverse @bytes); 89 + my $hex = $value->as_hex; 90 + $hex =~ s/\A0x//; 91 + $hex = '0' . $hex if length($hex) % 2; 92 + my $bytes = $value->is_zero ? q() : pack('H*', $hex); 93 + return ("\0" x length($leading)) . $bytes; 98 94 } 99 95 100 96 sub base64url_encode {
+143
script/differential-validate
··· 164 164 return $tx->result; 165 165 } 166 166 167 + sub post_empty ($origin, $nsid, $headers = {}) { 168 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 169 + my $tx = $ua->post("$origin/xrpc/$nsid" => $headers); 170 + return $tx->result; 171 + } 172 + 167 173 sub get_form ($origin, $nsid, $query, $headers = {}) { 168 174 my $ua = Mojo::UserAgent->new(max_redirects => 0); 169 175 my $url = Mojo::URL->new("$origin/xrpc/$nsid")->query($query); ··· 346 352 return $json->encode($left) eq $json->encode($right); 347 353 } 348 354 355 + sub normalize_did_doc ($doc, $expected_did, $expected_handle, $expected_origin) { 356 + my @services = grep { (($_->{id} // q()) eq '#atproto_pds') || (($_->{id} // q()) =~ /atproto_pds\z/) } @{ $doc->{service} || [] }; 357 + my @methods = grep { (($_->{id} // q()) eq '#atproto') || (($_->{id} // q()) =~ /#atproto\z/) } @{ $doc->{verificationMethod} || [] }; 358 + my $service = $services[0] || {}; 359 + my $method = $methods[0] || {}; 360 + return { 361 + id_matches => (($doc->{id} // q()) eq $expected_did) ? 1 : 0, 362 + handle_matches => (((($doc->{alsoKnownAs} || [])->[0]) // q()) eq "at://$expected_handle") ? 1 : 0, 363 + service_endpoint_ok => (($service->{serviceEndpoint} // q()) eq $expected_origin) ? 1 : 0, 364 + service_type_ok => (($service->{type} // q()) eq 'AtprotoPersonalDataServer') ? 1 : 0, 365 + verification_type_set => defined($method->{type}) && length($method->{type}) ? 1 : 0, 366 + verification_key_set => defined($method->{publicKeyMultibase}) && ($method->{publicKeyMultibase} =~ /\Az/) ? 1 : 0, 367 + }; 368 + } 369 + 370 + sub normalize_plc_credentials ($json, $expected_handle, $expected_origin) { 371 + return { 372 + handle_matches => (((($json->{alsoKnownAs} || [])->[0]) // q()) eq "at://$expected_handle") ? 1 : 0, 373 + verification_is_did => (($json->{verificationMethods}{atproto} // q()) =~ /\Adid:key:/) ? 1 : 0, 374 + rotation_key_count => 0 + @{ $json->{rotationKeys} || [] }, 375 + all_rotation_are_did => @{ $json->{rotationKeys} || [] } 376 + ? ((grep { ($_ // q()) =~ /\Adid:key:/ } @{ $json->{rotationKeys} || [] }) == @{ $json->{rotationKeys} || [] } ? 1 : 0) 377 + : 0, 378 + service_endpoint_ok => (($json->{services}{atproto_pds}{endpoint} // q()) eq $expected_origin) ? 1 : 0, 379 + service_type_ok => (($json->{services}{atproto_pds}{type} // q()) eq 'AtprotoPersonalDataServer') ? 1 : 0, 380 + }; 381 + } 382 + 383 + sub normalize_signed_plc_operation ($json, $expected_handle, $expected_origin) { 384 + my $op = $json->{operation} || {}; 385 + return { 386 + type_ok => (($op->{type} // q()) eq 'plc_operation') ? 1 : 0, 387 + handle_matches => (((($op->{alsoKnownAs} || [])->[0]) // q()) eq "at://$expected_handle") ? 1 : 0, 388 + verification_is_did => (($op->{verificationMethods}{atproto} // q()) =~ /\Adid:key:/) ? 1 : 0, 389 + rotation_key_count => 0 + @{ $op->{rotationKeys} || [] }, 390 + service_endpoint_ok => (($op->{services}{atproto_pds}{endpoint} // q()) eq $expected_origin) ? 1 : 0, 391 + has_prev => defined($op->{prev}) && length($op->{prev}) ? 1 : 0, 392 + has_sig => defined($op->{sig}) && length($op->{sig}) ? 1 : 0, 393 + }; 394 + } 395 + 396 + sub normalize_xrpc_error ($res) { 397 + my $json = $res->json || {}; 398 + return { 399 + status => $res->code, 400 + error => $json->{error}, 401 + }; 402 + } 403 + 349 404 note('Preparing official reference runtime'); 350 405 setup_reference_runtime(); 406 + 407 + my $diff_account_did_method = $ENV{PERLDS_DIFF_ACCOUNT_DID_METHOD} // 'did:web'; 408 + note("Differential mode: $diff_account_did_method"); 351 409 352 410 my $plc_port = free_port(); 353 411 my $reference_port = free_port(); ··· 408 466 base_url => "http://127.0.0.1:$perl_port", 409 467 service_handle_domain => 'test', 410 468 service_did_method => 'did:web', 469 + account_did_method => $diff_account_did_method, 470 + ($diff_account_did_method eq 'did:plc' 471 + ? ( 472 + plc_url => $plc_info->{origin}, 473 + plc_rotation_private_key_hex => ('11' x 32), 474 + ) 475 + : ()), 411 476 jwt_secret => 'perlds-jwt-secret', 412 477 admin_password => 'perlds-admin-secret', 413 478 data_dir => File::Spec->catdir($tmp, 'perlds-data'), ··· 475 540 $server{$name}{did} = $json->{did}; 476 541 $server{$name}{access} = $json->{accessJwt}; 477 542 $server{$name}{refresh} = $json->{refreshJwt}; 543 + $server{$name}{create_account} = $json; 478 544 479 545 check(($json->{handle} // q()) eq $server{$name}{handle}, "$name createAccount returns normalized handle"); 480 546 check(defined $json->{did} && $json->{did} =~ /\Adid:/, "$name createAccount returns a DID"); 481 547 check(defined $json->{accessJwt} && length $json->{accessJwt}, "$name createAccount returns an access token"); 482 548 check(defined $json->{refreshJwt} && length $json->{refreshJwt}, "$name createAccount returns a refresh token"); 549 + check(($json->{did} // q()) =~ /\Adid:plc:/, "$name createAccount returns a PLC DID") 550 + if $diff_account_did_method eq 'did:plc'; 483 551 } 484 552 485 553 note('Comparing resolveHandle'); ··· 496 564 check($res->is_success, "$name getSession succeeds"); 497 565 next unless $res->is_success; 498 566 my $json = $res->json; 567 + $server{$name}{session} = $json; 499 568 check(($json->{did} // q()) eq $server{$name}{did}, "$name getSession returns the created DID"); 500 569 check(($json->{handle} // q()) eq $server{$name}{handle}, "$name getSession returns the created handle"); 570 + } 571 + 572 + if ($diff_account_did_method eq 'did:plc') { 573 + note('Comparing PLC identity semantics'); 574 + for my $name (sort keys %server) { 575 + my $res = get_json( 576 + $server{$name}{origin}, 577 + 'com.atproto.identity.getRecommendedDidCredentials', 578 + undef, 579 + auth_header($server{$name}{access}), 580 + ); 581 + check($res->is_success, "$name getRecommendedDidCredentials succeeds"); 582 + next unless $res->is_success; 583 + $server{$name}{recommended_did} = normalize_plc_credentials( 584 + $res->json || {}, 585 + $server{$name}{handle}, 586 + $server{$name}{origin}, 587 + ); 588 + } 589 + 590 + check( 591 + same_hash($server{reference}{recommended_did}, $server{perlds}{recommended_did}), 592 + 'getRecommendedDidCredentials matches the official reference PDS semantics', 593 + ); 594 + 595 + note('Comparing PLC signature requests'); 596 + for my $name (sort keys %server) { 597 + my $res = post_empty( 598 + $server{$name}{origin}, 599 + 'com.atproto.identity.requestPlcOperationSignature', 600 + auth_header($server{$name}{access}), 601 + ); 602 + check($res->is_success, "$name requestPlcOperationSignature succeeds"); 603 + } 604 + 605 + note('Comparing PLC handle updates'); 606 + for my $name (sort keys %server) { 607 + my $new_handle = $name eq 'reference' ? 'alice-renamed-ref.test' : 'alice-renamed-perl.test'; 608 + my $res = post_json( 609 + $server{$name}{origin}, 610 + 'com.atproto.identity.updateHandle', 611 + { handle => $new_handle }, 612 + auth_header($server{$name}{access}), 613 + ); 614 + check($res->is_success, "$name updateHandle succeeds for PLC accounts"); 615 + next unless $res->is_success; 616 + $server{$name}{renamed_handle} = $new_handle; 617 + 618 + my $handle_res = get_form( 619 + $server{$name}{origin}, 620 + 'com.atproto.identity.resolveHandle', 621 + { handle => $new_handle }, 622 + ); 623 + check($handle_res->is_success, "$name resolveHandle finds the renamed PLC handle"); 624 + check(($handle_res->json->{did} // q()) eq $server{$name}{did}, "$name renamed PLC handle still resolves to the same DID") 625 + if $handle_res->is_success; 626 + } 627 + 628 + note('Comparing PLC token requirements'); 629 + for my $name (sort keys %server) { 630 + my $res = post_json( 631 + $server{$name}{origin}, 632 + 'com.atproto.identity.signPlcOperation', 633 + {}, 634 + auth_header($server{$name}{access}), 635 + ); 636 + check(!$res->is_success, "$name signPlcOperation rejects missing tokens"); 637 + $server{$name}{missing_plc_token_error} = normalize_xrpc_error($res); 638 + } 639 + 640 + check( 641 + same_hash($server{reference}{missing_plc_token_error}, $server{perlds}{missing_plc_token_error}), 642 + 'signPlcOperation matches the official reference PDS token requirement semantics', 643 + ); 501 644 } 502 645 503 646 my $record = {
+59
t/crypto-interop.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use File::Spec; 6 + use FindBin qw($Bin); 7 + use JSON::PP qw(decode_json); 8 + use Test::More; 9 + 10 + BEGIN { 11 + require lib; 12 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 13 + lib->import( 14 + File::Spec->catdir($root, 'lib'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 16 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 17 + ); 18 + } 19 + 20 + use ATProto::PDS::Crypto::Secp256k1 qw( 21 + did_key_from_public_key 22 + public_key_from_did_key 23 + signing_did_from_private_key 24 + ); 25 + 26 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 27 + my $fixture_path = File::Spec->catfile( 28 + $root, 29 + 'tools', 30 + 'reference', 31 + 'atproto', 32 + 'interop-test-files', 33 + 'crypto', 34 + 'w3c_didkey_K256.json', 35 + ); 36 + 37 + open my $fh, '<', $fixture_path or die "open($fixture_path): $!"; 38 + local $/; 39 + my $vectors = decode_json(<$fh>); 40 + close $fh; 41 + 42 + for my $vector (@{$vectors}) { 43 + my $private_key = pack('H*', $vector->{privateKeyBytesHex}); 44 + my $did_key = $vector->{publicDidKey}; 45 + 46 + is( 47 + signing_did_from_private_key($private_key), 48 + $did_key, 49 + "private key fixture derives the official did:key $did_key", 50 + ); 51 + 52 + is( 53 + did_key_from_public_key(public_key_from_did_key($did_key)), 54 + $did_key, 55 + "did:key round-trips through our secp256k1 codec for $did_key", 56 + ); 57 + } 58 + 59 + done_testing;
+233
t/plc-identity.t
··· 1 + use v5.34; 2 + use warnings; 3 + use Config (); 4 + use File::Spec; 5 + use File::Temp qw(tempdir); 6 + use FindBin qw($Bin); 7 + use IO::Socket::INET; 8 + use POSIX qw(WNOHANG); 9 + use Test::More; 10 + use Time::HiRes qw(sleep time); 11 + 12 + BEGIN { 13 + require lib; 14 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 15 + lib->import( 16 + File::Spec->catdir($root, 'lib'), 17 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 18 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 19 + ); 20 + } 21 + 22 + use JSON::PP qw(decode_json); 23 + use Test::Mojo; 24 + use ATProto::PDS; 25 + 26 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 27 + my $tmp = tempdir(CLEANUP => 1); 28 + my @children; 29 + 30 + END { 31 + local $?; 32 + for my $child (reverse @children) { 33 + next unless $child->{pid}; 34 + next unless kill 0, $child->{pid}; 35 + kill 'TERM', $child->{pid}; 36 + for (1 .. 40) { 37 + last if waitpid($child->{pid}, WNOHANG) == $child->{pid}; 38 + sleep 0.1; 39 + } 40 + kill 'KILL', $child->{pid} if kill 0, $child->{pid}; 41 + waitpid($child->{pid}, 0); 42 + } 43 + $? = 0; 44 + } 45 + 46 + sub free_port { 47 + my $sock = IO::Socket::INET->new( 48 + LocalAddr => '127.0.0.1', 49 + LocalPort => 0, 50 + Proto => 'tcp', 51 + Listen => 5, 52 + ReuseAddr => 1, 53 + ) or die "unable to allocate a port: $!"; 54 + my $port = $sock->sockport; 55 + close $sock; 56 + return $port; 57 + } 58 + 59 + sub slurp { 60 + my ($path) = @_; 61 + open my $fh, '<', $path or die "open($path): $!"; 62 + local $/; 63 + return <$fh>; 64 + } 65 + 66 + sub spawn_plc_mock { 67 + my ($ready_file, $log_file) = @_; 68 + my $pid = fork; 69 + die "fork failed: $!" unless defined $pid; 70 + 71 + if ($pid == 0) { 72 + open STDOUT, '>', $log_file or die "open($log_file): $!"; 73 + open STDERR, '>&', \*STDOUT or die "dup stdout failed"; 74 + chdir $root or die "chdir($root): $!"; 75 + $ENV{PERLDS_READY_FILE} = $ready_file; 76 + $ENV{PERLDS_PLC_PORT} = free_port(); 77 + $ENV{PERLDS_PLC_HOST} = '127.0.0.1'; 78 + exec 'fnm', 'exec', '--using=20', '--', 'node', 79 + File::Spec->catfile($root, 'tools', 'differential', 'plc-mock.cjs'); 80 + die "exec failed: $!"; 81 + } 82 + 83 + push @children, { pid => $pid }; 84 + return $pid; 85 + } 86 + 87 + sub wait_for_ready { 88 + my ($path, $timeout) = @_; 89 + $timeout //= 20; 90 + my $deadline = time + $timeout; 91 + while (time < $deadline) { 92 + if (-f $path) { 93 + return decode_json(slurp($path)); 94 + } 95 + sleep 0.1; 96 + } 97 + die "timed out waiting for $path"; 98 + } 99 + 100 + my $plc_ready = File::Spec->catfile($tmp, 'plc.ready.json'); 101 + my $plc_log = File::Spec->catfile($tmp, 'plc.log'); 102 + spawn_plc_mock($plc_ready, $plc_log); 103 + my $plc = wait_for_ready($plc_ready); 104 + 105 + my $app = ATProto::PDS->new( 106 + project_root => $root, 107 + settings => { 108 + base_url => 'http://127.0.0.1:7755', 109 + service_handle_domain => 'test', 110 + service_did_method => 'did:web', 111 + account_did_method => 'did:plc', 112 + plc_url => $plc->{origin}, 113 + plc_rotation_private_key_hex => ('11' x 32), 114 + jwt_secret => 'plc-secret', 115 + admin_password => 'admin-secret', 116 + db_path => File::Spec->catfile($tmp, 'plc.sqlite'), 117 + data_dir => File::Spec->catdir($tmp, 'data'), 118 + }, 119 + ); 120 + 121 + my $t = Test::Mojo->new($app); 122 + 123 + $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 124 + handle => 'alice.test', 125 + email => 'alice@test.com', 126 + password => 'hunter22', 127 + })->status_is(200); 128 + 129 + my $created = $t->tx->res->json; 130 + my $did = $created->{did}; 131 + my $access = $created->{accessJwt}; 132 + 133 + like($did, qr/\Adid:plc:/, 'createAccount returns a did:plc identifier'); 134 + is($created->{didDoc}{id}, $did, 'didDoc matches the created did'); 135 + is($created->{didDoc}{alsoKnownAs}[0], 'at://alice.test', 'didDoc carries the handle'); 136 + like($created->{didDoc}{verificationMethod}[0]{type}, qr/Secp256k1/, 'didDoc uses a secp256k1 verification method'); 137 + 138 + $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 139 + did => $did, 140 + })->status_is(200) 141 + ->json_is('/didDoc/id', $did) 142 + ->json_is('/didDoc/alsoKnownAs/0', 'at://alice.test'); 143 + 144 + $t->get_ok('/xrpc/com.atproto.identity.getRecommendedDidCredentials' => { 145 + Authorization => "Bearer $access", 146 + })->status_is(200) 147 + ->json_is('/alsoKnownAs/0', 'at://alice.test') 148 + ->json_like('/verificationMethods/atproto' => qr/\Adid:key:/) 149 + ->json_like('/rotationKeys/0' => qr/\Adid:key:/) 150 + ->json_is('/services/atproto_pds/endpoint', 'http://127.0.0.1:7755'); 151 + 152 + $t->post_ok('/xrpc/com.atproto.identity.updateHandle' => { 153 + Authorization => "Bearer $access", 154 + } => json => { 155 + handle => 'alice-renamed.test', 156 + })->status_is(200); 157 + 158 + $t->get_ok('/xrpc/com.atproto.identity.resolveHandle' => form => { 159 + handle => 'alice-renamed.test', 160 + })->status_is(200) 161 + ->json_is('/did', $did); 162 + 163 + $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 164 + did => $did, 165 + })->status_is(200) 166 + ->json_is('/didDoc/alsoKnownAs/0', 'at://alice-renamed.test'); 167 + 168 + $t->post_ok('/xrpc/com.atproto.identity.requestPlcOperationSignature' => { 169 + Authorization => "Bearer $access", 170 + })->status_is(200); 171 + 172 + my $token = $app->store->latest_action_token( 173 + did => $did, 174 + purpose => 'plc_operation', 175 + ); 176 + ok($token && $token->{token}, 'requestPlcOperationSignature issues a PLC email token'); 177 + 178 + $t->post_ok('/xrpc/com.atproto.identity.signPlcOperation' => { 179 + Authorization => "Bearer $access", 180 + } => json => {})->status_is(400) 181 + ->json_is('/error', 'InvalidRequest'); 182 + 183 + $t->post_ok('/xrpc/com.atproto.identity.signPlcOperation' => { 184 + Authorization => "Bearer $access", 185 + } => json => { 186 + token => $token->{token}, 187 + rotationKeys => [ 188 + 'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme', 189 + 'did:key:zQ3shjyJXUaRJC2GC43mX8aPrUhoTdoiongXhZjsdTzPKYZUM', 190 + ], 191 + })->status_is(200); 192 + 193 + my $signed = $t->tx->res->json->{operation}; 194 + is($signed->{type}, 'plc_operation', 'signPlcOperation returns a PLC operation'); 195 + is($signed->{alsoKnownAs}[0], 'at://alice-renamed.test', 'signed operation preserves the current handle'); 196 + is_deeply( 197 + $signed->{rotationKeys}, 198 + [ 199 + 'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme', 200 + 'did:key:zQ3shjyJXUaRJC2GC43mX8aPrUhoTdoiongXhZjsdTzPKYZUM', 201 + ], 202 + 'signed operation applies the requested rotation keys and keeps the server rotation key', 203 + ); 204 + ok(length($signed->{sig} // q()) > 10, 'signed operation contains a signature'); 205 + like($signed->{prev} // q(), qr/\Ab/, 'signed operation references the prior PLC op by CID'); 206 + 207 + $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 208 + Authorization => "Bearer $access", 209 + } => json => { 210 + operation => { 211 + %{$signed}, 212 + alsoKnownAs => ['at://alice-signed.test'], 213 + }, 214 + })->status_is(400) 215 + ->json_is('/error', 'InvalidRequest'); 216 + 217 + $t->post_ok('/xrpc/com.atproto.identity.submitPlcOperation' => { 218 + Authorization => "Bearer $access", 219 + } => json => { 220 + operation => $signed, 221 + })->status_is(200); 222 + 223 + $t->get_ok('/xrpc/com.atproto.identity.resolveHandle' => form => { 224 + handle => 'alice-renamed.test', 225 + })->status_is(200) 226 + ->json_is('/did', $did); 227 + 228 + $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 229 + did => $did, 230 + })->status_is(200) 231 + ->json_is('/didDoc/alsoKnownAs/0', 'at://alice-renamed.test'); 232 + 233 + done_testing;
+32
t/reference-differential-plc.t
··· 1 + use v5.34; 2 + use warnings; 3 + 4 + use Config (); 5 + use FindBin qw($Bin); 6 + use File::Spec; 7 + use Test::More; 8 + 9 + BEGIN { 10 + require lib; 11 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 12 + lib->import( 13 + File::Spec->catdir($root, 'lib'), 14 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 15 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 16 + ); 17 + } 18 + 19 + plan skip_all => 'set PERLDS_RUN_REFERENCE_DIFF=1 to run the official PLC differential harness' 20 + unless $ENV{PERLDS_RUN_REFERENCE_DIFF}; 21 + 22 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 23 + my $script = File::Spec->catfile($root, 'script', 'differential-validate'); 24 + 25 + local $ENV{PERLDS_DIFF_ACCOUNT_DID_METHOD} = 'did:plc'; 26 + my $output = qx{$^X $script 2>&1}; 27 + my $code = $? >> 8; 28 + 29 + diag($output) if $code; 30 + is($code, 0, 'reference PLC differential harness exits successfully'); 31 + 32 + done_testing;