feat: profile sharing via X25519 envelope (Phase B, #3)
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.
Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
(find_for_recipient, find_for_profile, find_active [checks active +
expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
GET /users/public-key (public), and authorize_profile_read — the
share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
and health stats: when profile_id is specified, resolve the owner via
the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
permission}.rs, handlers/{shares,permissions}.rs, middleware/
permission.rs (dead), the shares collection field + methods in
mongodb_impl.rs, the shares index, and the 5 /api/shares +
/api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
isolation, share-to-self/nonexistent/keyless rejections, expired
share treated as absent.
Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
identity private key), shareProfile, revokeShare, loadProfileShares.
Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).
Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.
⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.
Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
This commit is contained in:
parent
015a99a7fe
commit
04520539aa
25 changed files with 1875 additions and 953 deletions
513
backend/src/handlers/profile_share.rs
Normal file
513
backend/src/handlers/profile_share.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
//! Profile sharing (Phase B).
|
||||
//!
|
||||
//! Implements the X25519 envelope: an owner wraps a profile DEK to a
|
||||
//! recipient's identity public key via ECDH, storing the opaque wrapped blob
|
||||
//! keyed by (profileId, recipientUserId). Recipients unwrap with their own
|
||||
//! identity private key. The server never sees the profile DEK.
|
||||
//!
|
||||
//! Also provides `authorize_profile_read` — the share-gate used by the data
|
||||
//! handlers (medications, appointments, health stats) to admit recipients.
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use mongodb::bson::{oid::ObjectId, DateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::jwt::Claims,
|
||||
config::AppState,
|
||||
models::profile::ProfileRepository,
|
||||
models::profile_share::{ProfileShare, ProfileShareRepository},
|
||||
};
|
||||
|
||||
fn profile_repo(state: &AppState) -> ProfileRepository {
|
||||
ProfileRepository::new(state.db.get_database().collection("profiles"))
|
||||
}
|
||||
|
||||
fn share_repo(state: &AppState) -> ProfileShareRepository {
|
||||
ProfileShareRepository::new(state.db.get_database().collection("profile_shares"))
|
||||
}
|
||||
|
||||
/// The shared profile as seen by the recipient — the profile's metadata
|
||||
/// (display name blob, kind, relationship) plus the per-share ECDH-wrapped DEK
|
||||
/// the recipient unwraps with their identity private key. The server returns
|
||||
/// the wrapped DEK from the share record verbatim and cannot read it.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SharedProfileResponse {
|
||||
pub profile_id: String,
|
||||
pub owner_account_id: String,
|
||||
pub name_data: String,
|
||||
pub name_iv: String,
|
||||
pub kind: String,
|
||||
pub relationship: String,
|
||||
/// Per-share ephemeral X25519 public key (base64 raw). The recipient
|
||||
/// combines it with their identity private key to derive the wrap key.
|
||||
pub ephemeral_public_key: String,
|
||||
/// Profile DEK wrapped under the ECDH-derived key (opaque).
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime,
|
||||
}
|
||||
|
||||
/// A share as listed by the owner (no wrapped DEK on this view — it's only
|
||||
/// useful to the recipient; the owner already has the profile DEK directly).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ShareListingResponse {
|
||||
pub profile_id: String,
|
||||
pub recipient_user_id: String,
|
||||
pub recipient_email: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Share-gate: resolve the owner userId for a profile the caller may read.
|
||||
// Used by the data handlers to admit recipients.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// If the caller (claims.sub) owns the profile, returns Ok(owner_user_id).
|
||||
/// Otherwise checks for an active share; if present, returns Ok(owner_user_id)
|
||||
/// (the profile's owner — recipients read "as the owner" for that profile).
|
||||
/// Returns Err(404 response) if the caller has no read access.
|
||||
pub async fn authorize_profile_read(
|
||||
state: &AppState,
|
||||
claims: &Claims,
|
||||
profile_id: &str,
|
||||
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
|
||||
let profiles = profile_repo(state);
|
||||
// Owner path.
|
||||
if let Ok(Some(owned)) = profiles
|
||||
.find_by_profile_id_owned(profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
return Ok(owned.owner_account_id);
|
||||
}
|
||||
// Recipient path: an active (unexpired, not-revoked) share for this pair.
|
||||
let shares = share_repo(state);
|
||||
match shares.find_active(profile_id, &claims.sub).await {
|
||||
Ok(Some(share)) => Ok(share.owner_user_id),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Share lookup failed for {}: {}", profile_id, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/profiles/:id/shares — owner shares a profile to a recipient.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateShareRequest {
|
||||
/// The recipient, identified by email.
|
||||
pub recipient_email: String,
|
||||
/// Ephemeral X25519 public key (base64 raw) generated by the owner's
|
||||
/// client for this share.
|
||||
pub ephemeral_public_key: String,
|
||||
/// Profile DEK wrapped (client-side, via ECDH) to the recipient. Opaque.
|
||||
pub wrapped_profile_dek: String,
|
||||
pub wrapped_profile_dek_iv: String,
|
||||
#[serde(default = "default_read_permissions")]
|
||||
pub permissions: Vec<String>,
|
||||
/// Optional RFC-3339 expiry; null/absent = never expires.
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
fn default_read_permissions() -> Vec<String> {
|
||||
vec!["read".to_string()]
|
||||
}
|
||||
|
||||
pub async fn create_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
Json(req): Json<CreateShareRequest>,
|
||||
) -> Result<(StatusCode, Json<ShareListingResponse>), (StatusCode, Json<serde_json::Value>)> {
|
||||
// Verify the caller owns the profile.
|
||||
let profiles = profile_repo(&state);
|
||||
let owner = match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(p)) => p.owner_account_id,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in create_share failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve recipient by email.
|
||||
let recipient = match state.db.find_user_by_email(&req.recipient_email).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "recipient not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Recipient lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
let recipient_id = match recipient.id {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "invalid recipient state" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Can't share to an account without an identity public key (pre-A1).
|
||||
if recipient
|
||||
.identity_public_key
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "recipient has no identity public key (account predates the keypair feature)"
|
||||
})),
|
||||
));
|
||||
}
|
||||
// Don't share to yourself.
|
||||
if recipient_id == claims.sub {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "cannot share a profile with yourself" })),
|
||||
));
|
||||
}
|
||||
|
||||
let expires_at = match req.expires_at.as_deref() {
|
||||
Some(s) => match DateTime::parse_rfc3339_str(s) {
|
||||
Ok(dt) => Some(dt),
|
||||
Err(_) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid expires_at (expected RFC 3339)" })),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let now = DateTime::now();
|
||||
let share = ProfileShare {
|
||||
id: None,
|
||||
profile_id: profile_id.clone(),
|
||||
owner_user_id: owner.clone(),
|
||||
recipient_user_id: recipient_id.clone(),
|
||||
ephemeral_public_key: req.ephemeral_public_key,
|
||||
wrapped_profile_dek: req.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
|
||||
permissions: req.permissions.clone(),
|
||||
expires_at,
|
||||
created_at: now,
|
||||
active: true,
|
||||
};
|
||||
|
||||
let shares = share_repo(&state);
|
||||
if let Err(e) = shares.upsert(&share).await {
|
||||
tracing::error!("Share create failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ShareListingResponse {
|
||||
profile_id,
|
||||
recipient_user_id: recipient_id,
|
||||
recipient_email: req.recipient_email,
|
||||
permissions: req.permissions,
|
||||
created_at: now,
|
||||
active: true,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id/shares — owner lists who they've shared a profile with.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_shares(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<Vec<ShareListingResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Owner-only: confirm ownership.
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in list_shares failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let shares = share_repo(&state);
|
||||
let rows = match shares.find_for_profile(&profile_id).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("Share list failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve each recipient's email for display.
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for s in rows {
|
||||
let email = match ObjectId::parse_str(&s.recipient_user_id) {
|
||||
Ok(oid) => match state.db.find_user_by_id(&oid).await {
|
||||
Ok(Some(u)) => u.email,
|
||||
_ => String::new(),
|
||||
},
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
out.push(ShareListingResponse {
|
||||
profile_id: s.profile_id,
|
||||
recipient_user_id: s.recipient_user_id,
|
||||
recipient_email: email,
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
active: s.active,
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/profiles/:id/shares/:recipient — soft revoke (hard delete).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn delete_share(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path((profile_id, recipient_user_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Owner-only: confirm ownership.
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles
|
||||
.find_by_profile_id_owned(&profile_id, &claims.sub)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup in delete_share failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let shares = share_repo(&state);
|
||||
match shares.delete(&profile_id, &recipient_user_id).await {
|
||||
Ok(true) => Ok(StatusCode::NO_CONTENT),
|
||||
Ok(false) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "share not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Share delete failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/shared-with-me — recipient lists profiles shared with them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_shared_with_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<Vec<SharedProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let shares = share_repo(&state);
|
||||
let my_shares = match shares.find_for_recipient(&claims.sub).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("shared-with-me list failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Join each share with its profile's metadata. Only return shares for
|
||||
// profiles that still exist and are still active/unexpired (find_active
|
||||
// semantics, re-checked here to avoid listing a just-expired share).
|
||||
let profiles = profile_repo(&state);
|
||||
let mut out = Vec::with_capacity(my_shares.len());
|
||||
for s in my_shares {
|
||||
// Skip expired/inactive — find_for_recipient returns all rows; filter
|
||||
// to currently-usable ones for the recipient's view.
|
||||
if !s.active {
|
||||
continue;
|
||||
}
|
||||
if let Some(exp) = s.expires_at {
|
||||
if exp <= DateTime::now() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let profile = match profiles.find_by_profile_id(&s.profile_id).await {
|
||||
Ok(Some(p)) => p,
|
||||
_ => continue, // profile deleted; skip silently
|
||||
};
|
||||
out.push(SharedProfileResponse {
|
||||
profile_id: profile.profile_id,
|
||||
owner_account_id: profile.owner_account_id,
|
||||
name_data: profile.name,
|
||||
name_iv: profile.name_iv,
|
||||
kind: profile.kind,
|
||||
relationship: profile.relationship,
|
||||
ephemeral_public_key: s.ephemeral_public_key,
|
||||
wrapped_profile_dek: s.wrapped_profile_dek,
|
||||
wrapped_profile_dek_iv: s.wrapped_profile_dek_iv,
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/profiles/:id — extend the existing profile GET to admit recipients.
|
||||
// This stands in for "the recipient can fetch one shared profile's metadata".
|
||||
// Implemented here so the share-aware logic lives with the share code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch a single profile's metadata, admitting both owner and share-recipient.
|
||||
/// Returns the owner's-view `ProfileResponse` shape (the wrapped profile DEK
|
||||
/// there is account-wrapped — recipients ignore it and use the share's
|
||||
/// ECDH-wrapped DEK from /profiles/shared-with-me instead).
|
||||
pub async fn get_profile_shared_aware(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(profile_id): Path<String>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// authorize_profile_read admits owner or active-share recipient.
|
||||
let _owner = authorize_profile_read(&state, &claims, &profile_id).await?;
|
||||
let profiles = profile_repo(&state);
|
||||
match profiles.find_by_profile_id(&profile_id).await {
|
||||
Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile fetch failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export ProfileResponse from the profile handlers for the shared-aware GET.
|
||||
pub use crate::handlers::profile::ProfileResponse;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/users/public-key — look up a recipient's identity public key.
|
||||
// Public (no auth) — public keys are not secret.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PublicKeyQuery {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicKeyResponse {
|
||||
pub user_id: String,
|
||||
pub identity_public_key: String,
|
||||
}
|
||||
|
||||
pub async fn get_public_key(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PublicKeyQuery>,
|
||||
) -> Result<Json<PublicKeyResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user = match state.db.find_user_by_email(&q.email).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) | Err(_) => {
|
||||
// Don't leak whether the email exists.
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "no public key for this address" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
let user_id = user
|
||||
.id
|
||||
.ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "invalid user state" })),
|
||||
))?
|
||||
.to_string();
|
||||
let pk = user.identity_public_key.unwrap_or_default();
|
||||
if pk.is_empty() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "no public key for this address" })),
|
||||
));
|
||||
}
|
||||
Ok(Json(PublicKeyResponse {
|
||||
user_id,
|
||||
identity_public_key: pk,
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue