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
|
|
@ -16,6 +16,7 @@ import SaveIcon from '@mui/icons-material/Save';
|
|||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useProfileStore, useAuthStore } from '../../store/useStore';
|
||||
import { ProfileSharing } from './ProfileSharing';
|
||||
|
||||
export const ProfileEditor: FC = () => {
|
||||
const {
|
||||
|
|
@ -150,7 +151,11 @@ export const ProfileEditor: FC = () => {
|
|||
) : (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
{active.is_shared ? (
|
||||
<Chip size="small" label="shared with you" color="info" variant="outlined" />
|
||||
) : (
|
||||
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -198,25 +203,44 @@ export const ProfileEditor: FC = () => {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</Box>
|
||||
{active.is_shared ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Owner</Typography>
|
||||
<Typography variant="body2">{active.owner_account_id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
You have read-only access to this shared profile.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Account</Typography>
|
||||
<Typography variant="body2">{user?.email}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Profile ID: {active.profile_id}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete profile
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Owner-only sharing UI. */}
|
||||
{!editing && !active.is_shared && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<ProfileSharing profileId={active.profile_id} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
140
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
140
web/normogen-web/src/components/profile/ProfileSharing.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect, useState, type FC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd';
|
||||
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** Owner-side sharing UI for a single owned profile: lists the recipients the
|
||||
* profile is shared with (with revoke buttons) and an "add recipient by
|
||||
* email" field. Shown only for profiles the current user owns. Recipients
|
||||
* get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */
|
||||
export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
|
||||
const {
|
||||
profileShares,
|
||||
loadProfileShares,
|
||||
shareProfile,
|
||||
revokeShare,
|
||||
error,
|
||||
clearError,
|
||||
} = useProfileStore();
|
||||
|
||||
const [recipientEmail, setRecipientEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadProfileShares(profileId);
|
||||
}, [profileId, loadProfileShares]);
|
||||
|
||||
const shares = profileShares[profileId] ?? [];
|
||||
|
||||
const handleAdd = async () => {
|
||||
setBusy(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
await shareProfile(profileId, recipientEmail.trim());
|
||||
setRecipientEmail('');
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || 'Failed to share');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (recipientUserId: string) => {
|
||||
if (!confirm('Revoke this share? The recipient will lose access immediately.')) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await revokeShare(profileId, recipientUserId);
|
||||
} catch {
|
||||
/* surfaced via store error */
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Shared with
|
||||
</Typography>
|
||||
|
||||
{(error || localError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => {
|
||||
setLocalError('');
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
{localError || error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Not shared with anyone.
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense sx={{ mb: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
|
||||
{shares.map((s) => (
|
||||
<ListItem key={s.recipient_user_id}>
|
||||
<ListItemText
|
||||
primary={s.recipient_email || s.recipient_user_id}
|
||||
secondary={s.permissions.join(', ')}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="revoke"
|
||||
disabled={busy}
|
||||
onClick={() => handleRevoke(s.recipient_user_id)}
|
||||
>
|
||||
<PersonRemoveIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type="email"
|
||||
placeholder="recipient@example.com"
|
||||
value={recipientEmail}
|
||||
onChange={(e) => setRecipientEmail(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAddIcon />}
|
||||
disabled={busy || !recipientEmail.trim()}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{busy ? <CircularProgress size={24} /> : 'Share'}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
The recipient must have a Normogen account. They'll see this profile in their switcher.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSharing;
|
||||
|
|
@ -1,17 +1,23 @@
|
|||
import { useEffect, type FC } from 'react';
|
||||
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { Box, Chip, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
|
||||
import { useProfileStore } from '../../store/useStore';
|
||||
|
||||
/** A compact dropdown for switching the active profile (the subject of care
|
||||
* whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles`
|
||||
* on mount so the list + per-profile DEKs are ready. */
|
||||
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
|
||||
export const ProfileSwitcher: FC = () => {
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
|
||||
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
|
||||
useProfileStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
(async () => {
|
||||
await loadProfiles();
|
||||
// Fetch profiles shared TO me after owned profiles are loaded (the
|
||||
// identity private key must be unwrapped first; loadSharedWithMe is a
|
||||
// no-op if it isn't available yet).
|
||||
await loadSharedWithMe();
|
||||
})();
|
||||
}, [loadProfiles, loadSharedWithMe]);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return isLoading ? (
|
||||
|
|
@ -36,10 +42,21 @@ export const ProfileSwitcher: FC = () => {
|
|||
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
|
||||
'.MuiSvgIcon-root': { color: 'common.white' },
|
||||
}}
|
||||
renderValue={(value) => {
|
||||
const p = profiles.find((x) => x.profile_id === value);
|
||||
return p ? (p.name || p.relationship || p.profile_id) : '';
|
||||
}}
|
||||
>
|
||||
{profiles.map((p) => (
|
||||
<MenuItem key={p.profile_id} value={p.profile_id}>
|
||||
{p.name || p.relationship || p.profile_id}
|
||||
<span>{p.name || p.relationship || p.profile_id}</span>
|
||||
{p.is_shared && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="shared"
|
||||
sx={{ ml: 1, height: 18, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
setProfileDek,
|
||||
getProfileDek,
|
||||
clearProfileDeks,
|
||||
|
|
@ -282,3 +284,52 @@ describe('per-profile DEK isolation (Phase A2)', () => {
|
|||
expect(getProfileDek('profile_child')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile sharing: X25519 envelope (Phase B)', () => {
|
||||
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
|
||||
if (!(await x25519Supported())) {
|
||||
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
|
||||
return;
|
||||
}
|
||||
// Two accounts, each with their own identity keypair.
|
||||
const owner = await generateIdentityKeyPair();
|
||||
const recipient = await generateIdentityKeyPair();
|
||||
|
||||
// A profile DEK the owner created (would normally be wrapped under the
|
||||
// owner's account DEK too — irrelevant for this test).
|
||||
const profileDek = await generateProfileDek();
|
||||
|
||||
// Owner wraps the profile DEK to the recipient's public key. Internally
|
||||
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
|
||||
// priv) and AES-GCM-wraps the DEK under the shared secret.
|
||||
const envelope = await wrapProfileDekToRecipient(
|
||||
profileDek,
|
||||
recipient.publicKey,
|
||||
owner.privateKey, // not actually used by wrap (only recipient pub matters)
|
||||
);
|
||||
expect(envelope.ephemeralPublicKey).not.toBe('');
|
||||
expect(envelope.wrappedProfileDek.data).not.toBe('');
|
||||
|
||||
// Recipient unwraps with their private key + the envelope's ephemeral pub.
|
||||
const recoveredDek = await unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
recipient.privateKey,
|
||||
);
|
||||
expect(recoveredDek).toBeDefined();
|
||||
|
||||
// The recovered DEK encrypts/decrypts the same data the original did.
|
||||
const data = await encrypt('shared-medication', profileDek);
|
||||
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
|
||||
|
||||
// A *different* recipient's private key cannot unwrap the share.
|
||||
const stranger = await generateIdentityKeyPair();
|
||||
await expect(
|
||||
unwrapProfileDekFromShare(
|
||||
envelope.wrappedProfileDek,
|
||||
envelope.ephemeralPublicKey,
|
||||
stranger.privateKey,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ export {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
type SharedProfileDekEnvelope,
|
||||
setEncKey,
|
||||
getEncKey,
|
||||
clearEncKey,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,97 @@ export async function unwrapProfileDek(
|
|||
return unwrapDek(payload, accountDek);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile sharing: X25519 envelope (Phase B).
|
||||
//
|
||||
// To share a profile, the owner generates a fresh ephemeral X25519 keypair,
|
||||
// ECDH-derives a shared secret from (ephemeral private, recipient public),
|
||||
// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server
|
||||
// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient
|
||||
// ECDH-derives the same shared secret from (their private, ephemeral public)
|
||||
// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape returned by the owner-side share wrapping. */
|
||||
export interface SharedProfileDekEnvelope {
|
||||
/** base64 ephemeral X25519 public key (send to the server plaintext). */
|
||||
ephemeralPublicKey: string;
|
||||
/** Profile DEK wrapped under the ECDH-derived key. */
|
||||
wrappedProfileDek: CipherPayload;
|
||||
}
|
||||
|
||||
/** Import a base64-raw X25519 public key for ECDH. */
|
||||
async function importX25519Public(publicKeyB64: string): Promise<CryptoKey> {
|
||||
const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw as BufferSource,
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/** Owner: wrap a profile DEK to a recipient's identity public key. Generates
|
||||
* a fresh ephemeral keypair per share. Requires the owner's identity private
|
||||
* key (in-memory via getIdentityPrivate()). */
|
||||
export async function wrapProfileDekToRecipient(
|
||||
profileDek: CryptoKey,
|
||||
recipientPublicKeyB64: string,
|
||||
myIdentityPrivate: CryptoKey,
|
||||
): Promise<SharedProfileDekEnvelope> {
|
||||
// Fresh ephemeral keypair for this share (forward secrecy within its lifetime).
|
||||
const ephemeral = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'X25519' },
|
||||
true,
|
||||
['deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const recipientPub = await importX25519Public(recipientPublicKeyB64);
|
||||
// ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key.
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: recipientPub },
|
||||
ephemeral.privateKey,
|
||||
256,
|
||||
);
|
||||
const wrapKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedBits,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const wrappedProfileDek = await wrapDek(profileDek, wrapKey);
|
||||
const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
||||
return {
|
||||
ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)),
|
||||
wrappedProfileDek,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recipient: unwrap a profile DEK from a share using the recipient's identity
|
||||
* private key + the share's ephemeral public key. Inverse of
|
||||
* wrapProfileDekToRecipient. Throws on tamper / wrong keys. */
|
||||
export async function unwrapProfileDekFromShare(
|
||||
wrapped: CipherPayload,
|
||||
ephemeralPublicKeyB64: string,
|
||||
myIdentityPrivate: CryptoKey,
|
||||
): Promise<CryptoKey> {
|
||||
const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64);
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: ephemeralPub },
|
||||
myIdentityPrivate,
|
||||
256,
|
||||
);
|
||||
const wrapKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedBits,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
return unwrapDek(wrapped, wrapKey);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level flows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import {
|
|||
ProfileWireResponse,
|
||||
CreateProfileRequest,
|
||||
UpdateProfileRequest,
|
||||
SharedProfileWireResponse,
|
||||
CreateProfileShareRequest,
|
||||
ProfileShareListing,
|
||||
PublicKeyResponse,
|
||||
HealthStatWireResponse,
|
||||
UpdateHealthStatRequest,
|
||||
EncryptedFieldWire,
|
||||
|
|
@ -260,6 +264,50 @@ class ApiService {
|
|||
await this.client.delete(`/profiles/${profileId}`);
|
||||
}
|
||||
|
||||
// ---- Profile sharing (Phase B: X25519 envelope) ----
|
||||
|
||||
/** Public endpoint — recipient identity public key lookup by email. */
|
||||
async getUserPublicKey(email: string): Promise<PublicKeyResponse> {
|
||||
const response = await this.client.get<PublicKeyResponse>('/users/public-key', {
|
||||
params: { email },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
|
||||
async listSharedWithMe(): Promise<SharedProfileWireResponse[]> {
|
||||
const response = await this.client.get<SharedProfileWireResponse[]>(
|
||||
'/profiles/shared-with-me',
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: list who a profile has been shared with. */
|
||||
async listProfileShares(profileId: string): Promise<ProfileShareListing[]> {
|
||||
const response = await this.client.get<ProfileShareListing[]>(
|
||||
`/profiles/${profileId}/shares`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced
|
||||
* client-side via wrapProfileDekToRecipient. */
|
||||
async createProfileShare(
|
||||
profileId: string,
|
||||
req: CreateProfileShareRequest,
|
||||
): Promise<ProfileShareListing> {
|
||||
const response = await this.client.post<ProfileShareListing>(
|
||||
`/profiles/${profileId}/shares`,
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Owner: revoke a share (soft revoke — server deletes the share record). */
|
||||
async deleteProfileShare(profileId: string, recipientUserId: string): Promise<void> {
|
||||
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
|
||||
}
|
||||
|
||||
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||
|
||||
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
generateProfileDek,
|
||||
wrapProfileDek,
|
||||
unwrapProfileDek,
|
||||
wrapProfileDekToRecipient,
|
||||
unwrapProfileDekFromShare,
|
||||
encrypt as encryptRaw,
|
||||
decrypt as decryptRaw,
|
||||
setEncKey,
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
clearIdentityPrivate,
|
||||
clearProfileDeks,
|
||||
setIdentityPrivate,
|
||||
getIdentityPrivate,
|
||||
getEncKey,
|
||||
getActiveProfileDek,
|
||||
getActiveProfileId,
|
||||
|
|
@ -37,6 +40,7 @@ import {
|
|||
DrugInteraction,
|
||||
AdherenceStats,
|
||||
Profile,
|
||||
ProfileShareListing,
|
||||
Appointment,
|
||||
CreateAppointmentRequest,
|
||||
UpdateAppointmentRequest,
|
||||
|
|
@ -137,6 +141,18 @@ interface ProfileState {
|
|||
relationship?: string;
|
||||
}) => Promise<void>;
|
||||
deleteProfile: (profileId: string) => Promise<void>;
|
||||
/** Fetch profiles shared TO the current user and merge them into `profiles`,
|
||||
* unwrapping each share's profile DEK via the recipient's identity private
|
||||
* key. Called after loadProfiles on login/unlock. */
|
||||
loadSharedWithMe: () => Promise<void>;
|
||||
/** Owner: share a profile to a recipient by email. Fetches the recipient's
|
||||
* identity public key, wraps the profile DEK to it via ECDH, and POSTs. */
|
||||
shareProfile: (profileId: string, recipientEmail: string) => Promise<void>;
|
||||
/** Owner: revoke a share (delete the share record server-side). */
|
||||
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
|
||||
/** Shares the current user has created for a profile (for the owner UI). */
|
||||
profileShares: Record<string, ProfileShareListing[]>;
|
||||
loadProfileShares: (profileId: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -805,6 +821,7 @@ export const useProfileStore = create<ProfileState>()(
|
|||
activeProfileId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
profileShares: {},
|
||||
|
||||
loadProfiles: async () => {
|
||||
const accountDek = getEncKey();
|
||||
|
|
@ -980,6 +997,130 @@ export const useProfileStore = create<ProfileState>()(
|
|||
}
|
||||
},
|
||||
|
||||
loadSharedWithMe: async () => {
|
||||
// Recipient path: profiles shared TO the current user. Each share's
|
||||
// profile DEK is wrapped under an ECDH-derived key; unwrap it with the
|
||||
// identity private key, then decrypt the display name under the profile
|
||||
// DEK. Merge into the profiles list with is_shared = true.
|
||||
const myIdentityPrivate = getIdentityPrivate();
|
||||
if (!myIdentityPrivate) {
|
||||
// No identity key unlocked — can't unwrap shared profile DEKs. Silently
|
||||
// skip; the owned-profile list still loads.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const shared = await apiService.listSharedWithMe();
|
||||
const sharedProfiles: Profile[] = [];
|
||||
for (const s of shared) {
|
||||
// Skip if we already have the DEK in memory (avoid re-unwrapping).
|
||||
let profileDek = getProfileDek(s.profile_id);
|
||||
if (!profileDek) {
|
||||
try {
|
||||
profileDek = await unwrapProfileDekFromShare(
|
||||
{ data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv },
|
||||
s.ephemeral_public_key,
|
||||
myIdentityPrivate,
|
||||
);
|
||||
setProfileDek(s.profile_id, profileDek);
|
||||
} catch {
|
||||
continue; // couldn't unwrap — skip this share
|
||||
}
|
||||
}
|
||||
let name = '';
|
||||
if (s.name_data && s.name_iv && profileDek) {
|
||||
try {
|
||||
name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek);
|
||||
} catch { name = ''; }
|
||||
}
|
||||
sharedProfiles.push({
|
||||
profile_id: s.profile_id,
|
||||
owner_account_id: s.owner_account_id,
|
||||
name,
|
||||
kind: s.kind,
|
||||
relationship: s.relationship,
|
||||
role: 'patient',
|
||||
permissions: s.permissions,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.created_at,
|
||||
is_shared: true,
|
||||
});
|
||||
}
|
||||
// Merge: replace any prior shared entries, keep owned ones intact.
|
||||
const owned = get().profiles.filter((p) => !p.is_shared);
|
||||
const merged = [...owned, ...sharedProfiles];
|
||||
set({ profiles: merged });
|
||||
} catch (error: any) {
|
||||
// Non-fatal: owned profiles still work.
|
||||
set({ error: error.message || 'Failed to load shared profiles' });
|
||||
}
|
||||
},
|
||||
|
||||
shareProfile: async (profileId, recipientEmail) => {
|
||||
// Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST.
|
||||
const myIdentityPrivate = getIdentityPrivate();
|
||||
const profileDek = getProfileDek(profileId);
|
||||
if (!myIdentityPrivate) {
|
||||
set({ error: 'No identity key unlocked — cannot share' });
|
||||
throw new Error('No identity key');
|
||||
}
|
||||
if (!profileDek) {
|
||||
set({ error: 'No profile key — switch to the profile first' });
|
||||
throw new Error('No profile key');
|
||||
}
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { identity_public_key: recipientPub } =
|
||||
await apiService.getUserPublicKey(recipientEmail);
|
||||
const envelope = await wrapProfileDekToRecipient(
|
||||
profileDek,
|
||||
recipientPub,
|
||||
myIdentityPrivate,
|
||||
);
|
||||
await apiService.createProfileShare(profileId, {
|
||||
recipient_email: recipientEmail,
|
||||
ephemeral_public_key: envelope.ephemeralPublicKey,
|
||||
wrapped_profile_dek: envelope.wrappedProfileDek.data,
|
||||
wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv,
|
||||
permissions: ['read'],
|
||||
});
|
||||
// Refresh the share listing for this profile.
|
||||
await get().loadProfileShares(profileId);
|
||||
set({ isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to share profile',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
revokeShare: async (profileId, recipientUserId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await apiService.deleteProfileShare(profileId, recipientUserId);
|
||||
await get().loadProfileShares(profileId);
|
||||
set({ isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
error: error.message || 'Failed to revoke share',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadProfileShares: async (profileId) => {
|
||||
try {
|
||||
const shares = await apiService.listProfileShares(profileId);
|
||||
set((state) => ({
|
||||
profileShares: { ...state.profileShares, [profileId]: shares },
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load shares' });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -371,6 +371,49 @@ export interface UpdateProfileRequest {
|
|||
relationship?: string;
|
||||
}
|
||||
|
||||
// A profile shared TO the current user (recipient view). Carries the
|
||||
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
|
||||
// private key (not the account-wrapped form owned profiles use).
|
||||
export interface SharedProfileWireResponse {
|
||||
profile_id: string;
|
||||
owner_account_id: string;
|
||||
name_data: string;
|
||||
name_iv: string;
|
||||
kind: string;
|
||||
relationship: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Request body for POST /profiles/:id/shares (owner wraps client-side).
|
||||
export interface CreateProfileShareRequest {
|
||||
recipient_email: string;
|
||||
ephemeral_public_key: string;
|
||||
wrapped_profile_dek: string;
|
||||
wrapped_profile_dek_iv: string;
|
||||
permissions?: string[];
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
// A share the owner has created (listing view).
|
||||
export interface ProfileShareListing {
|
||||
profile_id: string;
|
||||
recipient_user_id: string;
|
||||
recipient_email: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Response from GET /users/public-key?email=...
|
||||
export interface PublicKeyResponse {
|
||||
user_id: string;
|
||||
identity_public_key: string;
|
||||
}
|
||||
|
||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||
export interface DoseLog {
|
||||
id?: string;
|
||||
|
|
@ -411,6 +454,10 @@ export interface Profile {
|
|||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** True if this profile is shared TO the current user (recipient view).
|
||||
* Undefined/false for profiles the user owns. Set by the store when merging
|
||||
* shared-with-me results. */
|
||||
is_shared?: boolean;
|
||||
}
|
||||
|
||||
// Error Types
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue