diff --git a/.buildkite/eslint_flow_jest.yml b/.buildkite/eslint_flow_jest.yml
--- a/.buildkite/eslint_flow_jest.yml
+++ b/.buildkite/eslint_flow_jest.yml
@@ -4,6 +4,7 @@
       - '(pkill flow || true)'
       - 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y'
       - '. /root/.cargo/env'
+      - 'apt update && apt install -y cmake'
       - 'yarn cleaninstall --frozen-lockfile --skip-optional --network-timeout 180000'
       - 'yarn eslint --max-warnings=0 && yarn workspace lib flow && yarn workspace web flow && yarn workspace landing flow && yarn workspace native flow && yarn workspace keyserver flow && yarn workspace desktop flow && yarn workspace electron-update-server flow'
       - 'yarn workspace lib test && yarn workspace keyserver test'
diff --git a/.buildkite/jsi_codegen.yml b/.buildkite/jsi_codegen.yml
--- a/.buildkite/jsi_codegen.yml
+++ b/.buildkite/jsi_codegen.yml
@@ -2,6 +2,9 @@
   - label: 'JSI Codegen'
     command:
       - '(pkill flow || true)'
+      - 'apt update && apt install -y cmake'
+      - 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y'
+      - '. /root/.cargo/env'
       - 'yarn cleaninstall --frozen-lockfile --skip-optional --network-timeout 180000'
       - 'cd native && yarn codegen-jsi && git diff --exit-code'
     retry:
diff --git a/.github/workflows/eslint_flow_jest.yml b/.github/workflows/eslint_flow_jest.yml
--- a/.github/workflows/eslint_flow_jest.yml
+++ b/.github/workflows/eslint_flow_jest.yml
@@ -12,6 +12,10 @@
     steps:
       - uses: actions/checkout@v3
 
+      - name: sudo ./install_protobuf.sh
+        working-directory: ./scripts
+        run: sudo ./install_protobuf.sh
+
       - name: npm install -g yarn
         run: npm install -g yarn
 
diff --git a/.github/workflows/jsi_codegen.yml b/.github/workflows/jsi_codegen.yml
--- a/.github/workflows/jsi_codegen.yml
+++ b/.github/workflows/jsi_codegen.yml
@@ -12,6 +12,10 @@
     steps:
       - uses: actions/checkout@v3
 
+      - name: sudo ./install_protobuf.sh
+        working-directory: ./scripts
+        run: sudo ./install_protobuf.sh
+
       - name: npm install -g yarn
         run: npm install -g yarn
 
diff --git a/.github/workflows/macos_ci.yml b/.github/workflows/macos_ci.yml
--- a/.github/workflows/macos_ci.yml
+++ b/.github/workflows/macos_ci.yml
@@ -41,6 +41,10 @@
           security import $CERTIFICATE_PATH -P "$MACOS_BUILD_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
           security list-keychain -d user -s $KEYCHAIN_PATH
 
+      - name: sudo ./install_protobuf.sh
+        working-directory: ./scripts
+        run: sudo ./install_protobuf.sh
+
       - name: npm install -g yarn
         run: npm install -g yarn
 
diff --git a/.github/workflows/remove_harbormaster_tags.yml b/.github/workflows/remove_harbormaster_tags.yml
--- a/.github/workflows/remove_harbormaster_tags.yml
+++ b/.github/workflows/remove_harbormaster_tags.yml
@@ -11,6 +11,10 @@
     steps:
       - uses: actions/checkout@v3
 
+      - name: sudo ./install_protobuf.sh
+        working-directory: ./scripts
+        run: sudo ./install_protobuf.sh
+
       - name: npm install -g yarn
         run: npm install -g yarn
 
diff --git a/keyserver/Dockerfile b/keyserver/Dockerfile
--- a/keyserver/Dockerfile
+++ b/keyserver/Dockerfile
@@ -49,11 +49,17 @@
 
 # We need rsync in the prod-build yarn script
 # We need mariadb-client so we can use mysqldump for backups
+# We need cmake to install protobuf (prereq for rust-node-addon)
 RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
   rsync \
   mariadb-client \
+  cmake \
   && rm -rf /var/lib/apt/lists/*
 
+# Install protobuf manually to ensure that we have the correct version
+COPY scripts/install_protobuf.sh scripts/
+RUN cd scripts && ./install_protobuf.sh
+
 #-------------------------------------------------------------------------------
 # STEP 2: DEVOLVE PRIVILEGES
 # Create another user to run the rest of the commands
@@ -100,7 +106,7 @@
 # We run yarn cleaninstall before copying most of the files in for build caching
 #-------------------------------------------------------------------------------
 
-# Copy in package.json and yarn.lock files
+# Copy in package.json files, yarn.lock files, and relevant installation scripts
 COPY --chown=comm package.json yarn.lock postinstall.sh ./
 COPY --chown=comm keyserver/package.json keyserver/.flowconfig keyserver/
 COPY --chown=comm lib/package.json lib/.flowconfig lib/
@@ -109,6 +115,8 @@
 COPY --chown=comm landing/package.json landing/.flowconfig landing/
 COPY --chown=comm desktop/package.json desktop/
 COPY --chown=comm keyserver/addons/rust-node-addon/package.json \
+  keyserver/addons/rust-node-addon/install_ci_deps.sh \
+  keyserver/addons/rust-node-addon/postinstall.sh \
   keyserver/addons/rust-node-addon/
 COPY --chown=comm native/expo-modules/android-lifecycle/package.json \
   native/expo-modules/android-lifecycle/
@@ -120,6 +128,9 @@
 COPY --chown=comm keyserver/addons/rust-node-addon/Cargo.toml \
   keyserver/addons/rust-node-addon/
 
+# Copy in comm-opaque library, a dependency of rust-node-addon
+COPY --chown=comm shared/comm-opaque shared/comm-opaque/
+
 # Copy in files needed for patch-package
 COPY --chown=comm patches patches/
 
diff --git a/keyserver/addons/rust-node-addon/Cargo.toml b/keyserver/addons/rust-node-addon/Cargo.toml
--- a/keyserver/addons/rust-node-addon/Cargo.toml
+++ b/keyserver/addons/rust-node-addon/Cargo.toml
@@ -9,11 +9,24 @@
 
 [dependencies]
 # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
-napi = { version = "2.10.1", default-features = false, features = ["napi4"] }
+napi = { version = "2.10.1", default-features = false, features = [
+  "napi4",
+  "tokio_rt",
+] }
 napi-derive = { version = "2.9.1", default-features = false }
+opaque-ke = "1.2"
+rand = "0.8"
+tonic = "0.8"
+tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
+tokio-stream = "0.1"
+tracing = "0.1"
+prost = "0.11"
+comm-opaque = {path = "../../../shared/comm-opaque"}
+lazy_static = "1.4"
 
 [build-dependencies]
 napi-build = "2.0.1"
+tonic-build = "0.8"
 
 [profile.release]
 lto = true
diff --git a/keyserver/addons/rust-node-addon/build.rs b/keyserver/addons/rust-node-addon/build.rs
--- a/keyserver/addons/rust-node-addon/build.rs
+++ b/keyserver/addons/rust-node-addon/build.rs
@@ -2,4 +2,6 @@
 
 fn main() {
   napi_build::setup();
+  tonic_build::compile_protos("../../../shared/protos/identity.proto")
+    .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
 }
diff --git a/keyserver/addons/rust-node-addon/index.js b/keyserver/addons/rust-node-addon/index.js
--- a/keyserver/addons/rust-node-addon/index.js
+++ b/keyserver/addons/rust-node-addon/index.js
@@ -3,7 +3,13 @@
 const { platform, arch } = process;
 
 type RustAPI = {
-  +sum: (a: number, b: number) => number,
+  +registerUser: (
+    userId: string,
+    deviceId: string,
+    username: string,
+    password: string,
+    userPublicKey: string,
+  ) => Promise<string>,
 };
 
 async function getRustAPI(): Promise<RustAPI> {
@@ -28,8 +34,8 @@
     throw new Error('Failed to load native binding');
   }
 
-  const { sum } = nativeBinding.default;
-  return { sum };
+  const { registerUser } = nativeBinding.default;
+  return { registerUser };
 }
 
 export { getRustAPI };
diff --git a/keyserver/addons/rust-node-addon/install_ci_deps.sh b/keyserver/addons/rust-node-addon/install_ci_deps.sh
new file mode 100755
--- /dev/null
+++ b/keyserver/addons/rust-node-addon/install_ci_deps.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -eo pipefail
+
+# We can skip this script if it's not part of a Buildkite workflow
+if [[ -z "$BUILDKITE" ]];
+then
+  exit
+fi
+
+# Install protobuf if it's not already installed
+if ! command -v protoc >/dev/null;
+then
+  echo "Installing protobuf"
+  SCRIPT_DIR=$(cd "$(dirname "$0")"; pwd -P)
+  bash "${SCRIPT_DIR}/../../../scripts/install_protobuf.sh"
+fi
diff --git a/keyserver/addons/rust-node-addon/package.json b/keyserver/addons/rust-node-addon/package.json
--- a/keyserver/addons/rust-node-addon/package.json
+++ b/keyserver/addons/rust-node-addon/package.json
@@ -30,10 +30,11 @@
   },
   "scripts": {
     "artifacts": "napi artifacts",
-    "build": "napi build --platform napi --release",
+    "build": "yarn install-ci-deps && napi build --platform napi --release",
     "build:debug": "napi build --platform napi",
     "version": "napi version",
-    "postinstall": "yarn build",
-    "clean": "rm -rf target/ && rm -rf napi/ && rm -rf node_modules/"
+    "postinstall": "bash ./postinstall.sh",
+    "clean": "rm -rf target/ && rm -rf napi/ && rm -rf node_modules/",
+    "install-ci-deps": "bash ./install_ci_deps.sh"
   }
 }
diff --git a/keyserver/addons/rust-node-addon/postinstall.sh b/keyserver/addons/rust-node-addon/postinstall.sh
new file mode 100755
--- /dev/null
+++ b/keyserver/addons/rust-node-addon/postinstall.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Skip Windows
+if [[ "$OSTYPE" == "msys" ]]; then
+  exit 0
+fi
+
+yarn build
diff --git a/keyserver/addons/rust-node-addon/src/identity_client.rs b/keyserver/addons/rust-node-addon/src/identity_client.rs
new file mode 100644
--- /dev/null
+++ b/keyserver/addons/rust-node-addon/src/identity_client.rs
@@ -0,0 +1,306 @@
+use lazy_static::lazy_static;
+use napi::bindgen_prelude::*;
+use opaque_ke::{
+  ClientLogin, ClientLoginFinishParameters, ClientLoginStartParameters,
+  ClientLoginStartResult, ClientRegistration,
+  ClientRegistrationFinishParameters, CredentialFinalization,
+  CredentialResponse, RegistrationResponse, RegistrationUpload,
+};
+use rand::{rngs::OsRng, CryptoRng, Rng};
+use tokio::sync::mpsc;
+use tokio_stream::wrappers::ReceiverStream;
+use tonic::Request;
+use tracing::{error, instrument};
+mod identity {
+  tonic::include_proto!("identity");
+}
+use comm_opaque::Cipher;
+use identity::identity_service_client::IdentityServiceClient;
+use identity::{
+  pake_login_response::Data::AccessToken,
+  pake_login_response::Data::PakeCredentialResponse,
+  registration_request::Data::PakeCredentialFinalization as RegistrationPakeCredentialFinalization,
+  registration_request::Data::PakeRegistrationRequestAndUserId,
+  registration_request::Data::PakeRegistrationUploadAndCredentialRequest,
+  registration_response::Data::PakeLoginResponse as RegistrationPakeLoginResponse,
+  registration_response::Data::PakeRegistrationResponse,
+  PakeLoginResponse as PakeLoginResponseStruct,
+  PakeRegistrationRequestAndUserId as PakeRegistrationRequestAndUserIdStruct,
+  PakeRegistrationUploadAndCredentialRequest as PakeRegistrationUploadAndCredentialRequestStruct,
+  RegistrationRequest, RegistrationResponse as RegistrationResponseMessage,
+};
+use std::env::var;
+
+lazy_static! {
+  static ref IDENTITY_SERVICE_SOCKET_ADDR: String =
+    var("COMM_IDENTITY_SERVICE_SOCKET_ADDR")
+      .unwrap_or("https://[::1]:50051".to_string());
+}
+
+#[napi]
+#[instrument(skip_all)]
+pub async fn register_user(
+  user_id: String,
+  device_id: String,
+  username: String,
+  password: String,
+  user_public_key: String,
+) -> Result<String> {
+  let mut identity_client =
+    IdentityServiceClient::connect(IDENTITY_SERVICE_SOCKET_ADDR.as_str())
+      .await
+      .map_err(|_| Error::from_status(Status::GenericFailure))?;
+
+  // Create a RegistrationRequest channel and use ReceiverStream to turn the
+  // MPSC receiver into a Stream for outbound messages
+  let (tx, rx) = mpsc::channel(1);
+  let stream = ReceiverStream::new(rx);
+  let request = Request::new(stream);
+
+  // `response` is the Stream for inbound messages
+  let mut response = identity_client
+    .register_user(request)
+    .await
+    .map_err(|_| Error::from_status(Status::GenericFailure))?
+    .into_inner();
+
+  // Start PAKE registration on client and send initial registration request
+  // to Identity service
+  let mut client_rng = OsRng;
+  let (registration_request, client_registration) = pake_registration_start(
+    &mut client_rng,
+    user_id,
+    &password,
+    device_id,
+    username,
+    user_public_key,
+  )?;
+  send_to_mpsc(tx.clone(), registration_request).await?;
+
+  // Handle responses from Identity service sequentially, making sure we get
+  // messages in the correct order
+
+  // Finish PAKE registration and begin PAKE login; send the final
+  // registration request and initial login request together to reduce the
+  // number of trips
+  let message = response
+    .message()
+    .await
+    .map_err(|_| Error::from_status(Status::GenericFailure))?;
+  let client_login = handle_registration_response(
+    message,
+    &mut client_rng,
+    client_registration,
+    &password,
+    tx.clone(),
+  )
+  .await?;
+
+  // Finish PAKE login; send final login request to Identity service
+  let message = response
+    .message()
+    .await
+    .map_err(|_| Error::from_status(Status::GenericFailure))?;
+  handle_registration_credential_response(message, client_login, tx)
+    .await
+    .map_err(|_| Error::from_status(Status::GenericFailure))?;
+
+  // Return access token
+  let message = response
+    .message()
+    .await
+    .map_err(|_| Error::from_status(Status::GenericFailure))?;
+  handle_registration_token_response(message)
+}
+
+fn handle_unexpected_response<T: std::fmt::Debug>(message: Option<T>) -> Error {
+  error!("Received an unexpected message: {:?}", message);
+  Error::from_status(Status::GenericFailure)
+}
+
+async fn send_to_mpsc<T>(tx: mpsc::Sender<T>, request: T) -> Result<()> {
+  if let Err(e) = tx.send(request).await {
+    error!("Response was dropped: {}", e);
+    return Err(Error::from_status(Status::GenericFailure));
+  }
+  Ok(())
+}
+
+fn pake_login_start(
+  rng: &mut (impl Rng + CryptoRng),
+  password: &str,
+) -> Result<ClientLoginStartResult<Cipher>> {
+  ClientLogin::<Cipher>::start(
+    rng,
+    password.as_bytes(),
+    ClientLoginStartParameters::default(),
+  )
+  .map_err(|e| {
+    error!("Failed to start PAKE login: {}", e);
+    Error::from_status(Status::GenericFailure)
+  })
+}
+
+fn pake_login_finish(
+  credential_response_bytes: &[u8],
+  client_login: ClientLogin<Cipher>,
+) -> Result<CredentialFinalization<Cipher>> {
+  client_login
+    .finish(
+      CredentialResponse::deserialize(credential_response_bytes).map_err(
+        |e| {
+          error!("Could not deserialize credential response bytes: {}", e);
+          Error::from_status(Status::GenericFailure)
+        },
+      )?,
+      ClientLoginFinishParameters::default(),
+    )
+    .map_err(|e| {
+      error!("Failed to finish PAKE login: {}", e);
+      Error::from_status(Status::GenericFailure)
+    })
+    .map(|res| res.message)
+}
+
+fn pake_registration_start(
+  rng: &mut (impl Rng + CryptoRng),
+  user_id: String,
+  password: &str,
+  device_id: String,
+  username: String,
+  user_public_key: String,
+) -> Result<(RegistrationRequest, ClientRegistration<Cipher>)> {
+  let client_registration_start_result =
+    ClientRegistration::<Cipher>::start(rng, password.as_bytes()).map_err(
+      |e| {
+        error!("Failed to start PAKE registration: {}", e);
+        Error::from_status(Status::GenericFailure)
+      },
+    )?;
+  let pake_registration_request =
+    client_registration_start_result.message.serialize();
+  Ok((
+    RegistrationRequest {
+      data: Some(PakeRegistrationRequestAndUserId(
+        PakeRegistrationRequestAndUserIdStruct {
+          user_id,
+          device_id,
+          pake_registration_request,
+          username,
+          user_public_key,
+        },
+      )),
+    },
+    client_registration_start_result.state,
+  ))
+}
+
+async fn handle_registration_response(
+  message: Option<RegistrationResponseMessage>,
+  client_rng: &mut (impl Rng + CryptoRng),
+  client_registration: ClientRegistration<Cipher>,
+  password: &str,
+  tx: mpsc::Sender<RegistrationRequest>,
+) -> Result<ClientLogin<Cipher>> {
+  if let Some(RegistrationResponseMessage {
+    data: Some(PakeRegistrationResponse(registration_response_bytes)),
+    ..
+  }) = message
+  {
+    let pake_registration_upload = pake_registration_finish(
+      client_rng,
+      &registration_response_bytes,
+      client_registration,
+    )?
+    .serialize();
+    let client_login_start_result = pake_login_start(client_rng, password)?;
+
+    // `registration_request` is a gRPC message containing serialized bytes to
+    // complete PAKE registration and begin PAKE login
+    let registration_request = RegistrationRequest {
+      data: Some(PakeRegistrationUploadAndCredentialRequest(
+        PakeRegistrationUploadAndCredentialRequestStruct {
+          pake_registration_upload,
+          pake_credential_request: client_login_start_result
+            .message
+            .serialize()
+            .map_err(|e| {
+              error!("Could not serialize credential request: {}", e);
+              Error::from_status(Status::GenericFailure)
+            })?,
+        },
+      )),
+    };
+    send_to_mpsc(tx, registration_request).await?;
+    Ok(client_login_start_result.state)
+  } else {
+    Err(handle_unexpected_response(message))
+  }
+}
+
+async fn handle_registration_credential_response(
+  message: Option<RegistrationResponseMessage>,
+  client_login: ClientLogin<Cipher>,
+  tx: mpsc::Sender<RegistrationRequest>,
+) -> Result<()> {
+  if let Some(RegistrationResponseMessage {
+    data:
+      Some(RegistrationPakeLoginResponse(PakeLoginResponseStruct {
+        data: Some(PakeCredentialResponse(credential_response_bytes)),
+      })),
+  }) = message
+  {
+    let registration_request = RegistrationRequest {
+      data: Some(RegistrationPakeCredentialFinalization(
+        pake_login_finish(&credential_response_bytes, client_login)?
+          .serialize()
+          .map_err(|e| {
+            error!("Could not serialize credential request: {}", e);
+            Error::from_status(Status::GenericFailure)
+          })?,
+      )),
+    };
+    send_to_mpsc(tx, registration_request).await
+  } else {
+    Err(handle_unexpected_response(message))
+  }
+}
+
+fn handle_registration_token_response(
+  message: Option<RegistrationResponseMessage>,
+) -> Result<String> {
+  if let Some(RegistrationResponseMessage {
+    data:
+      Some(RegistrationPakeLoginResponse(PakeLoginResponseStruct {
+        data: Some(AccessToken(access_token)),
+      })),
+  }) = message
+  {
+    Ok(access_token)
+  } else {
+    Err(handle_unexpected_response(message))
+  }
+}
+
+fn pake_registration_finish(
+  rng: &mut (impl Rng + CryptoRng),
+  registration_response_bytes: &[u8],
+  client_registration: ClientRegistration<Cipher>,
+) -> Result<RegistrationUpload<Cipher>> {
+  client_registration
+    .finish(
+      rng,
+      RegistrationResponse::deserialize(registration_response_bytes).map_err(
+        |e| {
+          error!("Could not deserialize registration response bytes: {}", e);
+          Error::from_status(Status::GenericFailure)
+        },
+      )?,
+      ClientRegistrationFinishParameters::default(),
+    )
+    .map_err(|e| {
+      error!("Failed to finish PAKE registration: {}", e);
+      Error::from_status(Status::GenericFailure)
+    })
+    .map(|res| res.message)
+}
diff --git a/keyserver/addons/rust-node-addon/src/lib.rs b/keyserver/addons/rust-node-addon/src/lib.rs
--- a/keyserver/addons/rust-node-addon/src/lib.rs
+++ b/keyserver/addons/rust-node-addon/src/lib.rs
@@ -1,9 +1,4 @@
-#![deny(clippy::all)]
+pub mod identity_client;
 
 #[macro_use]
 extern crate napi_derive;
-
-#[napi]
-pub fn sum(a: i32, b: i32) -> i32 {
-  a + b
-}
diff --git a/scripts/install_protobuf.sh b/scripts/install_protobuf.sh
--- a/scripts/install_protobuf.sh
+++ b/scripts/install_protobuf.sh
@@ -22,3 +22,5 @@
 
 popd || exit # build
 popd || exit # protobuf
+
+rm -rf protobuf