From 48f7c6205c37ee4d4026dd2cb999ce7e577645ec Mon Sep 17 00:00:00 2001 From: Jeremy Dormitzer Date: Fri, 21 Dec 2018 09:51:58 -0500 Subject: [PATCH] [WIP] Begin implementing actor creation --- src/Actors/ActivityPubActor.php | 14 ++++ src/Actors/ActorService.php | 80 ++++++++++++++++++ src/Collections/CollectionsService.php | 56 +++++++++++++ src/Crypto/RsaKeypair.php | 12 ++- src/Entities/ActivityPubObject.php | 31 +++++++ src/Entities/PrivateKey.php | 61 ++++++++++++++ src/Utils/Util.php | 25 +++++- test/EntityTest.php | 112 +++++++++++++++++++++++++ test/ObjectsServiceTest.php | 8 +- test/RsaKeypairTest.php | 6 ++ test/UtilTest.php | 85 +++++++++++++++++++ 11 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 src/Actors/ActivityPubActor.php create mode 100644 src/Actors/ActorService.php create mode 100644 src/Collections/CollectionsService.php create mode 100644 src/Entities/PrivateKey.php create mode 100644 test/EntityTest.php create mode 100644 test/UtilTest.php diff --git a/src/Actors/ActivityPubActor.php b/src/Actors/ActivityPubActor.php new file mode 100644 index 0000000..911cae8 --- /dev/null +++ b/src/Actors/ActivityPubActor.php @@ -0,0 +1,14 @@ + diff --git a/src/Actors/ActorService.php b/src/Actors/ActorService.php new file mode 100644 index 0000000..588e07d --- /dev/null +++ b/src/Actors/ActorService.php @@ -0,0 +1,80 @@ +objectService = $objectService; + $this->entityManager = $entityManager; + } + + /** + * Creates a new Actor object + * + * Also creates the actor's collections - inbox, outbox, following, + * followers, and liked - and the actor's public/private keypair. + * The collections will have ids generated by appending / + * to the end of the actor's id, e.g. /inbox, /outbox, etc. The public key + * will have its id generated by appending #main-key to the end of the actor's + * id. The private key will be persisted and associated with the actor object. + * + * @param array $fields The actor's fields. The id and type fields are required + * @return ActivityPubActor The created Actor object + */ + public function createActor( $fields ) + { + $requiredFields = array( 'id', 'type' ); + if ( ! Util::arrayKeysExist( $fields, $requiredFields ) ) { + throw new InvalidArgumentException( 'Actors require id and type fields' ); + } + $actorId = rtrim( $fields['id'], '/' ); + $keypair = RsaKeypair::generate(); + $publicKeyField = array( + 'id' => "${actorId}#main-key", + 'owner' => $actorId, + 'publicKeyPem' => $keypair->getPublicKey(), + ); + $fields['publicKey'] = $publicKeyField; + } + + /** + * Returns the actor identified by $id + * + * @param string $id The actor's id + * @return ActivityPubActor The actor + */ + public function getActor( $id ) + { + + } + + /** + * Deletes the actor identified by $id by replacing it with a Tombstone object + * + * Also deletes the actor's collections - inbox, outbox, following, + * followers, and liked - and the actor's public/private keypair. + * @param string $id The actor's id + * @return ActivityPubObject The Tombstone that the actor was replaced with + */ + public function deleteActor( $id ) + { + + } +} +?> diff --git a/src/Collections/CollectionsService.php b/src/Collections/CollectionsService.php new file mode 100644 index 0000000..37f5f27 --- /dev/null +++ b/src/Collections/CollectionsService.php @@ -0,0 +1,56 @@ + diff --git a/src/Crypto/RsaKeypair.php b/src/Crypto/RsaKeypair.php index 9ec1f47..d3375f0 100644 --- a/src/Crypto/RsaKeypair.php +++ b/src/Crypto/RsaKeypair.php @@ -38,6 +38,16 @@ class RsaKeypair return $this->publicKey; } + /** + * Returns the private key as a string + * + * @return string The private key + */ + public function getPrivateKey() + { + return $this->privateKey; + } + /** * Generates a signature for $data * @@ -63,7 +73,7 @@ class RsaKeypair * * @param string $data The data * @param string $signature The signature - * @return boolean + * @return bool */ public function verify( $data, $signature ) { diff --git a/src/Entities/ActivityPubObject.php b/src/Entities/ActivityPubObject.php index 99b68be..fc0507d 100644 --- a/src/Entities/ActivityPubObject.php +++ b/src/Entities/ActivityPubObject.php @@ -49,6 +49,13 @@ class ActivityPubObject implements ArrayAccess */ protected $lastUpdated; + /** + * The private key associated with this object, if any + * @OneToOne(targetEntity="PrivateKey", mappedBy="object", cascade={"all"}) + * @var PrivateKey + */ + protected $privateKey; + public function __construct( DateTime $time = null ) { if ( ! $time ) { $time = new DateTime( "now" ); @@ -254,6 +261,30 @@ class ActivityPubObject implements ArrayAccess $this->lastUpdated = $lastUpdated; } + /** + * Returns true if this object has an associated private key, false if otherwise + * + * @return bool + */ + public function hasPrivateKey() + { + return $this->privateKey !== null; + } + + /** + * Sets the object's private key + * + * @param string $key The new private key value + */ + public function setPrivateKey( string $key ) + { + if ( $this->hasPrivateKey() ) { + $this->privateKey->setKey( $key ); + } else { + $this->privateKey = new PrivateKey( $key, $this ); + } + } + public function offsetExists( $offset ) { return $this->hasField( $offset ); diff --git a/src/Entities/PrivateKey.php b/src/Entities/PrivateKey.php new file mode 100644 index 0000000..35e88d4 --- /dev/null +++ b/src/Entities/PrivateKey.php @@ -0,0 +1,61 @@ +setPrivateKey() + * @param string $key The private key as a string + * @param ActivityPubObject $object The object associated with this key + */ + public function __construct( string $key, ActivityPubObject $object ) + { + $this->key = $key; + $this->object = $object; + } + + /** + * Sets the private key string + * + * Don't call this directly - instead, use ActivityPubObject->setPrivateKey() + * @param string $key The private key as a string + */ + public function setKey( string $key ) + { + $this->key = $key; + } +} +?> diff --git a/src/Utils/Util.php b/src/Utils/Util.php index 42e26df..7aa02a9 100644 --- a/src/Utils/Util.php +++ b/src/Utils/Util.php @@ -3,10 +3,33 @@ namespace ActivityPub\Utils; class Util { - public static function isAssoc(array $arr) + /** + * Returns true if the input array is associative + * + * @param array $arr The array to test + * @return bool True if the array is associative, false otherwise + */ + public static function isAssoc( array $arr ) { if (array() === $arr) return false; return array_keys($arr) !== range(0, count($arr) - 1); } + + /** + * Returns true if all of the specified keys exist in the array + * + * @param array $arr The array to check + * @param array $keys The keys to check the existence of + * @return bool True if all of the keys are in the array, false otherwise + */ + public static function arrayKeysExist( array $arr, array $keys ) + { + foreach ( $keys as $key ) { + if ( ! array_key_exists( $key, $arr ) ) { + return false; + } + } + return true; + } } ?> diff --git a/test/EntityTest.php b/test/EntityTest.php new file mode 100644 index 0000000..6b78382 --- /dev/null +++ b/test/EntityTest.php @@ -0,0 +1,112 @@ + array(), + 'fields' => array(), + 'keys' => array(), + ) ); + } + + protected function setUp() + { + parent::setUp(); + $dbConfig = Setup::createAnnotationMetadataConfiguration( + array( __DIR__ . '/../src/Entities' ), true + ); + $namingStrategy = new PrefixNamingStrategy( '' ); + $dbConfig->setNamingStrategy( $namingStrategy ); + $dbParams = array( + 'driver' => 'pdo_sqlite', + 'path' => __DIR__ . '/db.sqlite', + ); + $this->entityManager = EntityManager::create( $dbParams, $dbConfig ); + $this->dateTimeProvider = new TestDateTimeProvider( + new DateTime( "12:00" ), new DateTime( "12:01" ) + ); + } + + private function getTime( $context ) { + return $this->dateTimeProvider + ->getTime( $context ) + ->format( "Y-m-d H:i:s" ); + } + + public function testItCreatesAnObjectWithAPrivateKey() + { + $object = new ActivityPubObject( $this->dateTimeProvider->getTime( 'create' ) ); + $privateKey = 'a private key'; + $object->setPrivateKey( $privateKey ); + $this->entityManager->persist( $object ); + $this->entityManager->flush(); + $now = $this->getTime( 'create' ); + $expected = new ArrayDataSet( array( + 'objects' => array( + array( 'id' => 1, 'created' => $now, 'lastUpdated' => $now ), + ), + 'keys' => array( + array( 'id' => 1, 'object_id' => 1, 'key' => $privateKey ) + ), + ) ); + $expectedObjectsTable = $expected->getTable( 'objects' ); + $expectedKeysTable = $expected->getTable( 'keys' ); + $objectsQueryTable = $this->getConnection()->createQueryTable( + 'objects', 'SELECT * FROM objects' + ); + $keysQueryTable = $this->getConnection()->createQueryTable( + 'keys', 'SELECT * FROM keys' + ); + $this->assertTablesEqual( $expectedObjectsTable, $objectsQueryTable ); + $this->assertTablesEqual( $expectedKeysTable, $keysQueryTable ); + } + + public function itUpdatesAPrivateKey() + { + $object = new ActivityPubObject( $this->dateTimeProvider->getTime( 'create' ) ); + $privateKey = 'a private key'; + $object->setPrivateKey( $privateKey ); + $this->entityManager->persist( $object ); + $this->entityManager->flush(); + $newPrivateKey = 'a new private key'; + $object->setPrivateKey( $newPrivateKey ); + $this->entityManager->persiste( $object ); + $this->entityManager->flush(); + $now = $this->getTime( 'create' ); + $expected = new ArrayDataSet( array( + 'objects' => array( + array( 'id' => 1, 'created' => $now, 'lastUpdated' => $now ), + ), + 'keys' => array( + array( 'id' => 1, 'object_id' => 1, 'key' => $newPrivateKey ) + ), + ) ); + $expectedObjectsTable = $expected->getTable( 'objects' ); + $expectedKeysTable = $expected->getTable( 'keys' ); + $objectsQueryTable = $this->getConnection()->createQueryTable( + 'objects', 'SELECT * FROM objects' + ); + $keysQueryTable = $this->getConnection()->createQueryTable( + 'keys', 'SELECT * FROM keys' + ); + $this->assertTablesEqual( $expectedObjectsTable, $objectsQueryTable ); + $this->assertTablesEqual( $expectedKeysTable, $keysQueryTable ); + } +} +?> diff --git a/test/ObjectsServiceTest.php b/test/ObjectsServiceTest.php index 5301070..92ebb4a 100644 --- a/test/ObjectsServiceTest.php +++ b/test/ObjectsServiceTest.php @@ -38,8 +38,12 @@ class ObjectsServiceTest extends SQLiteTestCase 'path' => __DIR__ . '/db.sqlite', ); $this->entityManager = EntityManager::create( $dbParams, $dbConfig ); - $this->dateTimeProvider = new TestDateTimeProvider( new DateTime( "12:00" ), new DateTime( "12:01" ) ); - $this->objectsService = new ObjectsService( $this->entityManager, $this->dateTimeProvider ); + $this->dateTimeProvider = new TestDateTimeProvider( + new DateTime( "12:00" ), new DateTime( "12:01" ) + ); + $this->objectsService = new ObjectsService( + $this->entityManager, $this->dateTimeProvider + ); } private function getTime( $context ) { diff --git a/test/RsaKeypairTest.php b/test/RsaKeypairTest.php index 568db9b..2de80cf 100644 --- a/test/RsaKeypairTest.php +++ b/test/RsaKeypairTest.php @@ -13,6 +13,12 @@ class RsaKeypairTest extends TestCase $keypair = RsaKeypair::generate(); $this->assertStringStartsWith( '-----BEGIN PUBLIC KEY-----', $keypair->getPublicKey() ); $this->assertStringEndsWith( '-----END PUBLIC KEY-----', $keypair->getPublicKey() ); + $this->assertStringStartsWith( + '-----BEGIN RSA PRIVATE KEY-----', $keypair->getPrivateKey() + ); + $this->assertStringEndsWith( + '-----END RSA PRIVATE KEY-----', $keypair->getPrivateKey() + ); } public function testItSignsAndValidatesSignatures() diff --git a/test/UtilTest.php b/test/UtilTest.php new file mode 100644 index 0000000..47edf0e --- /dev/null +++ b/test/UtilTest.php @@ -0,0 +1,85 @@ + 'bar' ); + $isAssoc = Util::isAssoc( $arr ); + $this->assertTrue( $isAssoc ); + } + + public function testItReturnsFalseForNonAssoc() + { + $arr = array( 'foo', 'bar' ); + $isAssoc = Util::isAssoc( $arr ); + $this->assertFalse( $isAssoc ); + } + + public function testItHandlesMixedArray() + { + $arr = array( 'foo' => 'bar', 'baz' ); + $isAssoc = Util::isAssoc( $arr ); + $this->assertTrue( $isAssoc ); + } + + public function testItChecksEmptyArrayIsAssoc() + { + $arr = array(); + $isAssoc = Util::isAssoc( $arr ); + $this->assertFalse( $isAssoc ); + } + + public function testArrayKeysExist() + { + $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); + $keys = array( 'foo', 'baz' ); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertTrue( $keysExist ); + } + + public function testItChecksForAllKeys() + { + $arr = array( 'foo' => 'bar' ); + $keys = array( 'foo', 'baz' ); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertFalse( $keysExist ); + } + + public function testItAllowsExtraKeys() + { + $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); + $keys = array( 'foo' ); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertTrue( $keysExist ); + } + + public function testItHandlesEmptyArray() + { + $arr = array(); + $keys = array( 'foo' ); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertFalse( $keysExist ); + } + + public function testItHandlesEmptyKeys() + { + $arr = array( 'foo' => 'bar', 'baz' => 'qux' ); + $keys = array(); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertTrue( $keysExist ); + } + + public function testItHandlesBothEmpty() + { + $arr = array(); + $keys = array(); + $keysExist = Util::arrayKeysExist( $arr, $keys ); + $this->assertTrue( $keysExist ); + } +} +?>