diff --git a/briar-headless/README.md b/briar-headless/README.md index 29aa1cec5..95cf1b043 100644 --- a/briar-headless/README.md +++ b/briar-headless/README.md @@ -71,10 +71,79 @@ Returns a JSON array of contacts: ### Adding a contact -*Not yet implemented* +The first step is to get your own link: -The only workaround is to add a contact to the Briar app running on a rooted Android phone -and then move its database (and key files) to the headless peer. +`GET /v1/contacts/add/link` + +Returns a JSON object with a `briar://` link that needs to be sent to the contact you want to add +outside of Briar via an external channel. + +```json +{ + "link": "briar://wvui4uvhbfv4tzo6xwngknebsxrafainnhldyfj63x6ipp4q2vigy" +} +``` + +Once you have received the link of your future contact, you can add them +by posting the link together with an arbitrary nickname (or alias): + +`POST /v1/contacts/add` + +The link and the alias should be posted as a JSON object: + +```json +{ + "link": "briar://ddnsyffpsenoc3yzlhr24aegfq2pwan7kkselocill2choov6sbhs", + "alias": "A nickname for the new contact" +} +``` + +This starts the process of adding the contact. +Until it is completed, a pending contact is returned as JSON: + +```json +{ + "pendingContactId": "jsTgWcsEQ2g9rnomeK1g/hmO8M1Ix6ZIGWAjgBtlS9U=", + "alias": "ztatsaajzeegraqcizbbfftofdekclatyht", + "state": "adding_contact", + "timestamp": 1557838312175 +} +``` + +The state can be one of these values: + + * `waiting_for_connection` + * `connected` + * `adding_contact` + * `failed` + +If you want to get informed about state changes, +you can use the Websocket API (below) to listen for events. + +The following events are relevant here: + + * `PendingContactStateChangedEvent` + * `PendingContactRemovedEvent` + * `ContactAddedRemotelyEvent` (when the pending contact becomes an actual contact) + +It is possible to get a list of all pending contacts: + +`GET /v1/contacts/add/pending` + +This will return a JSON array of pending contacts formatted as shown above. + +To remove a pending contact and abort the process of adding it: + +`DELETE /v1/contacts/add/pending` + +The `pendingContactId` of the pending contact to delete +needs to be provided in the request body as follows: + +```json +{ + "pendingContactId": "jsTgWcsEQ2g9rnomeK1g/hmO8M1Ix6ZIGWAjgBtlS9U=" +} +``` ### Removing a contact @@ -204,3 +273,9 @@ it will send a JSON object to connected websocket clients: Note that the JSON object in `data` is exactly what the REST API returns when listing private messages. + +# TODO + + * PendingContactStateChangedEvent + * PendingContactRemovedEvent + * ContactAddedRemotelyEvent \ No newline at end of file diff --git a/briar-headless/build.gradle b/briar-headless/build.gradle index 916d92f82..c9b463af1 100644 --- a/briar-headless/build.gradle +++ b/briar-headless/build.gradle @@ -25,6 +25,7 @@ dependencies { testImplementation project(path: ':bramble-api', configuration: 'testOutput') testImplementation project(path: ':bramble-core', configuration: 'testOutput') + testImplementation project(path: ':briar-core', configuration: 'testOutput') def junitVersion = '5.4.2' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt index e16f3eb4c..6dffbeb0a 100644 --- a/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt +++ b/briar-headless/src/main/java/org/briarproject/briar/headless/Router.kt @@ -64,6 +64,16 @@ constructor( path("/v1") { path("/contacts") { get { ctx -> contactController.list(ctx) } + path("add") { + post { ctx -> contactController.addPendingContact(ctx) } + path("link") { + get { ctx -> contactController.link(ctx) } + } + path("pending") { + get { ctx -> contactController.listPendingContacts(ctx) } + delete { ctx -> contactController.removePendingContact(ctx) } + } + } path("/:contactId") { delete { ctx -> contactController.delete(ctx) } } diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt index d7a0bfbc4..cb454ddba 100644 --- a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt +++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactController.kt @@ -5,6 +5,10 @@ import io.javalin.Context interface ContactController { fun list(ctx: Context): Context + fun link(ctx: Context): Context + fun addPendingContact(ctx: Context): Context + fun listPendingContacts(ctx: Context): Context + fun removePendingContact(ctx: Context): Context fun delete(ctx: Context): Context } diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt index d09c44dbf..1b7a639c1 100644 --- a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt +++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/ContactControllerImpl.kt @@ -1,10 +1,17 @@ package org.briarproject.briar.headless.contact +import com.fasterxml.jackson.databind.ObjectMapper import io.javalin.Context import io.javalin.NotFoundResponse import org.briarproject.bramble.api.contact.ContactManager +import org.briarproject.bramble.api.contact.PendingContactId import org.briarproject.bramble.api.db.NoSuchContactException +import org.briarproject.bramble.api.db.NoSuchPendingContactException import org.briarproject.briar.headless.getContactIdFromPathParam +import org.briarproject.briar.headless.getFromJson +import org.briarproject.briar.headless.json.JsonDict +import org.spongycastle.util.encoders.Base64 +import org.spongycastle.util.encoders.DecoderException import javax.annotation.concurrent.Immutable import javax.inject.Inject import javax.inject.Singleton @@ -13,7 +20,8 @@ import javax.inject.Singleton @Singleton internal class ContactControllerImpl @Inject -constructor(private val contactManager: ContactManager) : ContactController { +constructor(private val contactManager: ContactManager, private val objectMapper: ObjectMapper) : + ContactController { override fun list(ctx: Context): Context { val contacts = contactManager.contacts.map { contact -> @@ -22,6 +30,44 @@ constructor(private val contactManager: ContactManager) : ContactController { return ctx.json(contacts) } + override fun link(ctx: Context): Context { + val linkDict = JsonDict("link" to contactManager.handshakeLink) + return ctx.json(linkDict) + } + + override fun addPendingContact(ctx: Context): Context { + val link = ctx.getFromJson(objectMapper, "link") + val alias = ctx.getFromJson(objectMapper, "alias") + val pendingContact = contactManager.addPendingContact(link, alias) + return ctx.json(pendingContact.output()) + } + + override fun listPendingContacts(ctx: Context): Context { + val pendingContacts = contactManager.pendingContacts.map { pendingContact -> + pendingContact.output() + } + return ctx.json(pendingContacts) + } + + override fun removePendingContact(ctx: Context): Context { + // construct and check PendingContactId + val pendingContactString = ctx.getFromJson(objectMapper, "pendingContactId") + val pendingContactBytes = try { + Base64.decode(pendingContactString) + } catch (e: DecoderException) { + throw NotFoundResponse() + } + if (pendingContactBytes.size != PendingContactId.LENGTH) throw NotFoundResponse() + val id = PendingContactId(pendingContactBytes) + // remove + try { + contactManager.removePendingContact(id) + } catch (e: NoSuchPendingContactException) { + throw NotFoundResponse() + } + return ctx + } + override fun delete(ctx: Context): Context { val contactId = ctx.getContactIdFromPathParam() try { diff --git a/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt new file mode 100644 index 000000000..ac224e307 --- /dev/null +++ b/briar-headless/src/main/java/org/briarproject/briar/headless/contact/OutputPendingContact.kt @@ -0,0 +1,18 @@ +package org.briarproject.briar.headless.contact + +import org.briarproject.bramble.api.contact.PendingContact +import org.briarproject.bramble.api.contact.PendingContactState.* +import org.briarproject.briar.headless.json.JsonDict + +internal fun PendingContact.output() = JsonDict( + "pendingContactId" to id.bytes, + "alias" to alias, + "state" to when(state) { + WAITING_FOR_CONNECTION -> "waiting_for_connection" + CONNECTED -> "connected" + ADDING_CONTACT -> "adding_contact" + FAILED -> "failed" + else -> throw AssertionError() + }, + "timestamp" to timestamp +) \ No newline at end of file diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/BriarHeadlessTestApp.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/BriarHeadlessTestApp.kt index f349de62b..3bf488150 100644 --- a/briar-headless/src/test/java/org/briarproject/briar/headless/BriarHeadlessTestApp.kt +++ b/briar-headless/src/test/java/org/briarproject/briar/headless/BriarHeadlessTestApp.kt @@ -4,6 +4,7 @@ import dagger.Component import org.briarproject.bramble.BrambleCoreEagerSingletons import org.briarproject.bramble.BrambleCoreModule import org.briarproject.bramble.account.AccountModule +import org.briarproject.bramble.api.crypto.CryptoComponent import org.briarproject.bramble.event.DefaultEventExecutorModule import org.briarproject.bramble.test.TestSecureRandomModule import org.briarproject.briar.BriarCoreEagerSingletons @@ -25,5 +26,7 @@ import javax.inject.Singleton internal interface BriarHeadlessTestApp : BrambleCoreEagerSingletons, BriarCoreEagerSingletons { fun getRouter(): Router + fun getCryptoComponent(): CryptoComponent + fun getTestDataCreator(): TestDataCreator } diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/IntegrationTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/IntegrationTest.kt index 0884f34ff..2ef97aced 100644 --- a/briar-headless/src/test/java/org/briarproject/briar/headless/IntegrationTest.kt +++ b/briar-headless/src/test/java/org/briarproject/briar/headless/IntegrationTest.kt @@ -4,6 +4,7 @@ import io.javalin.Javalin import io.javalin.core.util.Header.AUTHORIZATION import khttp.responses.Response import org.briarproject.bramble.BrambleCoreModule +import org.briarproject.bramble.api.crypto.CryptoComponent import org.briarproject.briar.BriarCoreModule import org.briarproject.briar.api.test.TestDataCreator import org.junit.jupiter.api.AfterAll @@ -22,6 +23,7 @@ abstract class IntegrationTest { private val dataDir = File("tmp") protected lateinit var api: Javalin + protected lateinit var crypto: CryptoComponent protected lateinit var testDataCreator: TestDataCreator private lateinit var router: Router @@ -33,6 +35,7 @@ abstract class IntegrationTest { BrambleCoreModule.initEagerSingletons(app) BriarCoreModule.initEagerSingletons(app) router = app.getRouter() + crypto = app.getCryptoComponent() testDataCreator = app.getTestDataCreator() api = router.start(token, port, false) @@ -52,10 +55,18 @@ abstract class IntegrationTest { return khttp.get(url, getAuthTokenHeader("wrongToken")) } + protected fun post(url: String, data: String) : Response { + return khttp.post(url, getAuthTokenHeader(token), data = data) + } + protected fun delete(url: String) : Response { return khttp.delete(url, getAuthTokenHeader(token)) } + protected fun delete(url: String, data: String) : Response { + return khttp.delete(url, getAuthTokenHeader(token), data = data) + } + protected fun deleteWithWrongToken(url: String) : Response { return khttp.delete(url, getAuthTokenHeader("wrongToken")) } diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerIntegrationTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerIntegrationTest.kt index f00024620..96cd0b0ad 100644 --- a/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerIntegrationTest.kt +++ b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerIntegrationTest.kt @@ -1,8 +1,11 @@ package org.briarproject.briar.headless.contact +import org.briarproject.bramble.api.contact.HandshakeLinkConstants.BASE32_LINK_BYTES import org.briarproject.briar.headless.IntegrationTest import org.briarproject.briar.headless.url +import org.briarproject.briar.test.BriarTestUtils.getRealHandshakeLink import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class ContactControllerIntegrationTest: IntegrationTest() { @@ -33,6 +36,51 @@ class ContactControllerIntegrationTest: IntegrationTest() { assertEquals(testContactName, author.getString("name")) } + @Test + fun `returns own handshake link`() { + val response = get("$url/contacts/add/link") + assertEquals(200, response.statusCode) + val link = response.jsonObject.getString("link") + assertTrue(link.startsWith("briar://")) + assertEquals(BASE32_LINK_BYTES + 8, link.length) + } + + @Test + fun `returns list of pending contacts`() { + // retrieve empty list of pending contacts + var response = get("$url/contacts/add/pending") + assertEquals(200, response.statusCode) + assertEquals(0, response.jsonArray.length()) + + // add one pending contact + val alias = "AliasFoo" + val json = """{ + "link": "${getRealHandshakeLink(crypto)}", + "alias": "$alias" + }""" + response = post("$url/contacts/add", json) + assertEquals(200, response.statusCode) + + // get added contact as only list item + response = get("$url/contacts/add/pending") + assertEquals(200, response.statusCode) + assertEquals(1, response.jsonArray.length()) + val jsonObject = response.jsonArray.getJSONObject(0) + assertEquals(alias, jsonObject.getString("alias")) + assertEquals("waiting_for_connection", jsonObject.getString("state")) + + // remove pending contact again + val idString = jsonObject.getString("pendingContactId") + val deleteJson = """{"pendingContactId": "$idString"}""" + response = delete("$url/contacts/add/pending", deleteJson) + assertEquals(200, response.statusCode) + + // list of pending contacts should be empty now + response = get("$url/contacts/add/pending") + assertEquals(200, response.statusCode) + assertEquals(0, response.jsonArray.length()) + } + @Test fun `deleting contact need authentication token`() { val response = deleteWithWrongToken("$url/contacts/1") diff --git a/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt index 34f14b393..a0498ea6e 100644 --- a/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt +++ b/briar-headless/src/test/java/org/briarproject/briar/headless/contact/ContactControllerTest.kt @@ -7,15 +7,21 @@ import io.mockk.every import io.mockk.just import org.briarproject.bramble.api.contact.Contact import org.briarproject.bramble.api.contact.ContactId +import org.briarproject.bramble.api.contact.PendingContactId import org.briarproject.bramble.api.db.NoSuchContactException +import org.briarproject.bramble.api.db.NoSuchPendingContactException import org.briarproject.bramble.identity.output +import org.briarproject.bramble.test.TestUtils.getPendingContact +import org.briarproject.bramble.test.TestUtils.getRandomBytes import org.briarproject.briar.headless.ControllerTest +import org.briarproject.briar.headless.json.JsonDict import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test internal class ContactControllerTest : ControllerTest() { - private val controller = ContactControllerImpl(contactManager) + private val controller = ContactControllerImpl(contactManager, objectMapper) + private val pendingContact = getPendingContact() @Test fun testEmptyContactList() { @@ -31,6 +37,79 @@ internal class ContactControllerTest : ControllerTest() { controller.list(ctx) } + @Test + fun testLink() { + val link = "briar://link" + every { contactManager.handshakeLink } returns link + every { ctx.json(JsonDict("link" to link)) } returns ctx + controller.link(ctx) + } + + @Test + fun testAddPendingContact() { + val link = "briar://link123" + val alias = "Alias123" + val body = """{ + "link": "$link", + "alias": "$alias" + }""" + every { ctx.body() } returns body + every { contactManager.addPendingContact(link, alias) } returns pendingContact + every { ctx.json(pendingContact.output()) } returns ctx + controller.addPendingContact(ctx) + } + + @Test + fun testListPendingContacts() { + every { contactManager.pendingContacts } returns listOf(pendingContact) + every { ctx.json(listOf(pendingContact.output())) } returns ctx + controller.listPendingContacts(ctx) + } + + @Test + fun testRemovePendingContact() { + val id = pendingContact.id + every { ctx.body() } returns """{"pendingContactId": ${toJson(id.bytes)}}""" + every { contactManager.removePendingContact(id) } just Runs + controller.removePendingContact(ctx) + } + + @Test + fun testRemovePendingContactInvalidId() { + every { ctx.body() } returns """{"pendingContactId": "foo"}""" + assertThrows(NotFoundResponse::class.java) { + controller.removePendingContact(ctx) + } + } + + @Test + fun testRemovePendingContactTooShortId() { + val bytes = getRandomBytes(PendingContactId.LENGTH - 1) + every { ctx.body() } returns """{"pendingContactId": ${toJson(bytes)}}""" + assertThrows(NotFoundResponse::class.java) { + controller.removePendingContact(ctx) + } + } + + @Test + fun testRemovePendingContactTooLongId() { + val bytes = getRandomBytes(PendingContactId.LENGTH + 1) + every { ctx.body() } returns """{"pendingContactId": ${toJson(bytes)}}""" + assertThrows(NotFoundResponse::class.java) { + controller.removePendingContact(ctx) + } + } + + @Test + fun testRemovePendingContactNonexistentId() { + val id = pendingContact.id + every { ctx.body() } returns """{"pendingContactId": ${toJson(id.bytes)}}""" + every { contactManager.removePendingContact(id) } throws NoSuchPendingContactException() + assertThrows(NotFoundResponse::class.java) { + controller.removePendingContact(ctx) + } + } + @Test fun testDelete() { every { ctx.pathParam("contactId") } returns "1" @@ -80,4 +159,17 @@ internal class ContactControllerTest : ControllerTest() { assertJsonEquals(json, author.output()) } + @Test + fun testOutputPendingContact() { + val json = """ + { + "pendingContactId": ${toJson(pendingContact.id.bytes)}, + "alias": "${pendingContact.alias}", + "state": "${pendingContact.state.name.toLowerCase()}", + "timestamp": ${pendingContact.timestamp} + } + """ + assertJsonEquals(json, pendingContact.output()) + } + }