Skip to content

Commit 9ef0145

Browse files
committed
fix: refactor inventory key management to support multi-user sharing
1 parent 116940f commit 9ef0145

File tree

3 files changed

+93
-71
lines changed

3 files changed

+93
-71
lines changed

src/hooks/useInventory.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import { useSharing } from './useSharing';
1313

1414
export function useInventory() {
1515
const { ndk, activeUser } = useNDK();
16-
const { sharedKey } = useInventoryKey();
16+
const { keys, myKey } = useInventoryKey();
1717
const queryClient = useQueryClient();
1818
const { allAuthorPubkeys } = useSharing();
1919

2020
const { data: items = [], isLoading: loading } = useQuery({
21-
queryKey: ['inventory', activeUser?.pubkey, sharedKey ? 'encrypted' : 'plaintext'],
21+
queryKey: ['inventory', activeUser?.pubkey, keys.size],
2222
queryFn: async () => {
2323
if (!ndk || !activeUser) return [];
2424

@@ -31,7 +31,7 @@ export function useInventory() {
3131
const loadedItems: InventoryItem[] = [];
3232
for (const event of events) {
3333
try {
34-
const item = await eventToInventoryItem(event, sharedKey || null);
34+
const item = await eventToInventoryItem(event, keys);
3535
if (item) loadedItems.push(item);
3636
} catch (e) {
3737
console.warn('Failed to parse item:', e);
@@ -54,8 +54,8 @@ export function useInventory() {
5454

5555
const dTag = item.id;
5656

57-
if (sharedKey) {
58-
const encrypted = await encryptInventoryData(JSON.stringify(contentObj), sharedKey);
57+
if (myKey) {
58+
const encrypted = await encryptInventoryData(JSON.stringify(contentObj), myKey);
5959
event.content = encrypted;
6060
event.tags = [['d', dTag], ['s', 'encrypted']];
6161
} else {
@@ -145,12 +145,15 @@ export function useInventory() {
145145
};
146146
}
147147

148-
async function eventToInventoryItem(event: NDKEvent, sharedKey: Uint8Array | null): Promise<InventoryItem | null> {
148+
async function eventToInventoryItem(event: NDKEvent, keys: Map<string, Uint8Array>): Promise<InventoryItem | null> {
149149
try {
150150
let content = event.content;
151151
let parsed;
152-
if (content.startsWith('ivt1-') && sharedKey) {
153-
const decrypted = await decryptInventoryData(content, sharedKey);
152+
if (content.startsWith('ivt1-')) {
153+
const key = keys.get(event.pubkey);
154+
if (!key) return null; // Can't decrypt
155+
156+
const decrypted = await decryptInventoryData(content, key);
154157
if (!decrypted) return null;
155158
parsed = JSON.parse(decrypted);
156159
} else {

src/hooks/useInventoryKey.ts

Lines changed: 81 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -12,82 +12,97 @@ export function useInventoryKey() {
1212
const { toast } = useToast();
1313
const queryClient = useQueryClient();
1414

15-
// Query: Get the keychain event (either mine or one shared with me)
16-
const { data: keychainEvent, isLoading: isLoadingKeychain } = useQuery({
17-
queryKey: ['inventory-keychain', activeUser?.pubkey],
15+
// Query: Get all relevant keychain events (mine + those shared with me)
16+
const { data: keychains = [], isLoading: isLoadingKeychains } = useQuery({
17+
queryKey: ['inventory-keychains', activeUser?.pubkey],
1818
queryFn: async () => {
19-
if (!ndk || !activeUser) return null;
19+
if (!ndk || !activeUser) return [];
2020

21-
// 1. Try to fetch my own keychain first
22-
const myEvent = await ndk.fetchEvent({
21+
// 1. Fetch own keychain
22+
const myPromise = ndk.fetchEvent({
2323
kinds: [KEYCHAIN_KIND],
2424
authors: [activeUser.pubkey],
2525
'#d': [KEYCHAIN_D_TAG],
2626
});
2727

28-
if (myEvent) return myEvent;
28+
// 2. Fetch shared keychains
29+
const sharedPromise = (async () => {
30+
const sharedWithMeEvents = await ndk.fetchEvents({
31+
kinds: [30078], // SHARING_KIND
32+
'#d': ['inventory-shares'], // SHARING_D_TAG
33+
'#p': [activeUser.pubkey],
34+
});
2935

30-
// 2. If I don't have a keychain, check who has shared with me
31-
// We can't use useSharing() here to avoid circular dependency
32-
const sharedWithMeEvents = await ndk.fetchEvents({
33-
kinds: [30078], // SHARING_KIND
34-
'#d': ['inventory-shares'], // SHARING_D_TAG
35-
'#p': [activeUser.pubkey],
36-
});
36+
const sharers = Array.from(sharedWithMeEvents).map(e => e.pubkey);
37+
if (sharers.length === 0) return [];
3738

38-
const sharers = Array.from(sharedWithMeEvents).map(e => e.pubkey);
39-
if (sharers.length === 0) return null;
39+
const events = await ndk.fetchEvents({
40+
kinds: [KEYCHAIN_KIND],
41+
authors: sharers,
42+
'#d': [KEYCHAIN_D_TAG],
43+
});
44+
return Array.from(events);
45+
})();
4046

41-
// 3. Fetch keychains from those sharers
42-
const sharedKeychains = await ndk.fetchEvents({
43-
kinds: [KEYCHAIN_KIND],
44-
authors: sharers,
45-
'#d': [KEYCHAIN_D_TAG],
46-
});
47+
const [myEvent, sharedEvents] = await Promise.all([myPromise, sharedPromise]);
4748

48-
// 4. Find one that has a key for me
49-
for (const event of sharedKeychains) {
50-
const hasKeyForMe = event.tags.some(t => t[0] === 'p' && t[1] === activeUser.pubkey);
51-
if (hasKeyForMe) return event;
52-
}
49+
const allEvents = sharedEvents;
50+
if (myEvent) allEvents.push(myEvent);
5351

54-
return null;
52+
return allEvents;
5553
},
5654
enabled: !!ndk && !!activeUser
5755
});
5856

59-
// Query: Extract and decrypt the symmetric key for the current user
60-
const { data: sharedKey, isLoading: isLoadingKey } = useQuery({
61-
queryKey: ['inventory-shared-key', activeUser?.pubkey, keychainEvent?.id],
57+
// Query: Extract and decrypt keys
58+
const { data: keysData, isLoading: isLoadingKeys } = useQuery({
59+
queryKey: ['inventory-keys', activeUser?.pubkey, keychains.length],
6260
queryFn: async () => {
63-
if (!ndk || !activeUser || !keychainEvent) return null;
64-
if (!ndk.signer) return null;
65-
66-
// Find tag for me: ['p', my_pub, encrypted_key]
67-
const myTag = keychainEvent.tags.find(t => t[0] === 'p' && t[1] === activeUser.pubkey);
68-
if (!myTag || !myTag[2]) return null;
69-
70-
const encryptedKey = myTag[2];
71-
// The sender is the author of the keychain event (could be self or sharer)
72-
const senderUser = new NDKUser({ pubkey: keychainEvent.pubkey });
73-
74-
try {
75-
// NDK decrypt: (sender, value)
76-
const decryptedHex = await ndk.signer.decrypt(senderUser, encryptedKey);
77-
return hexToBytes(decryptedHex);
78-
} catch (e) {
79-
console.error('Decryption failed:', e);
80-
return null;
61+
if (!ndk || !activeUser || !ndk.signer || keychains.length === 0) {
62+
return { keys: new Map<string, Uint8Array>(), myKey: null as Uint8Array | null, myKeychain: null as NDKEvent | null };
8163
}
64+
65+
const keys = new Map<string, Uint8Array>();
66+
let myKey: Uint8Array | null = null;
67+
let myKeychain: NDKEvent | null = null;
68+
69+
await Promise.all(keychains.map(async (event) => {
70+
// Find tag for me: ['p', my_pub, encrypted_key]
71+
const myTag = event.tags.find(t => t[0] === 'p' && t[1] === activeUser.pubkey);
72+
if (!myTag || !myTag[2]) return;
73+
74+
try {
75+
const encryptedKey = myTag[2];
76+
const senderUser = new NDKUser({ pubkey: event.pubkey });
77+
78+
const decryptedHex = await ndk!.signer!.decrypt(senderUser, encryptedKey);
79+
const keyBytes = hexToBytes(decryptedHex);
80+
81+
keys.set(event.pubkey, keyBytes);
82+
83+
if (event.pubkey === activeUser.pubkey) {
84+
myKey = keyBytes;
85+
myKeychain = event;
86+
}
87+
} catch (e) {
88+
console.warn(`Failed to decrypt key from ${event.pubkey}`, e);
89+
}
90+
}));
91+
92+
return { keys, myKey, myKeychain };
8293
},
83-
enabled: !!ndk && !!activeUser && !!keychainEvent
94+
enabled: !!ndk && !!activeUser && keychains.length > 0
8495
});
8596

97+
const keys = keysData?.keys || new Map<string, Uint8Array>();
98+
const myKey = keysData?.myKey || null;
99+
const myKeychain = keysData?.myKeychain || null; // For mutations
100+
86101
// Mutation: Initialize standard key if missing
87102
const initializeKey = useMutation({
88103
mutationFn: async () => {
89104
if (!ndk || !activeUser || !ndk.signer) throw new Error('Not logged in');
90-
if (sharedKey) return sharedKey;
105+
if (myKey) return myKey;
91106

92107
const newKey = generateInventoryKey();
93108
const newKeyHex = bytesToHex(newKey);
@@ -106,23 +121,23 @@ export function useInventoryKey() {
106121
return newKey;
107122
},
108123
onSuccess: () => {
109-
queryClient.invalidateQueries({ queryKey: ['inventory-keychain'] });
110-
queryClient.invalidateQueries({ queryKey: ['inventory-shared-key'] });
124+
queryClient.invalidateQueries({ queryKey: ['inventory-keychains'] });
125+
queryClient.invalidateQueries({ queryKey: ['inventory-keys'] });
111126
}
112127
});
113128

114129
// Mutation: Add Reader
115130
const addReader = useMutation({
116131
mutationFn: async (targetPubkey: string) => {
117-
if (!ndk || !activeUser || !sharedKey || !keychainEvent) throw new Error('Not ready');
132+
if (!ndk || !activeUser || !myKey || !myKeychain) throw new Error('Not ready or no personal key');
118133
if (!ndk.signer) throw new Error('No signer');
119134

120-
const keyHex = bytesToHex(sharedKey);
135+
const keyHex = bytesToHex(myKey);
121136
const targetUser = new NDKUser({ pubkey: targetPubkey });
122137

123138
const encryptedForTarget = await ndk.signer.encrypt(targetUser, keyHex);
124139

125-
const existingTags = keychainEvent.tags.filter(t => t[0] !== 'd');
140+
const existingTags = myKeychain.tags.filter(t => t[0] !== 'd');
126141
const filteredTags = existingTags.filter(t => !(t[0] === 'p' && t[1] === targetPubkey));
127142

128143
const event = new NDKEvent(ndk);
@@ -135,17 +150,17 @@ export function useInventoryKey() {
135150
await event.publish();
136151
},
137152
onSuccess: () => {
138-
queryClient.invalidateQueries({ queryKey: ['inventory-keychain'] });
153+
queryClient.invalidateQueries({ queryKey: ['inventory-keychains'] }); // To update myKeychain ref
139154
}
140155
});
141156

142157
// Mutation: Remove Reader
143158
const removeReader = useMutation({
144159
mutationFn: async (targetPubkey: string) => {
145-
if (!ndk || !keychainEvent) return;
160+
if (!ndk || !myKeychain) return;
146161
if (targetPubkey === activeUser?.pubkey) return;
147162

148-
const newTags = keychainEvent.tags.filter(t => !(t[0] === 'p' && t[1] === targetPubkey));
163+
const newTags = myKeychain.tags.filter(t => !(t[0] === 'p' && t[1] === targetPubkey));
149164

150165
const event = new NDKEvent(ndk);
151166
event.kind = KEYCHAIN_KIND;
@@ -154,15 +169,19 @@ export function useInventoryKey() {
154169
await event.publish();
155170
},
156171
onSuccess: () => {
157-
queryClient.invalidateQueries({ queryKey: ['inventory-keychain'] });
172+
queryClient.invalidateQueries({ queryKey: ['inventory-keychains'] });
158173
}
159174
});
160175

161176
return {
162-
sharedKey,
163-
isLoading: isLoadingKeychain || isLoadingKey,
177+
keys,
178+
sharedKey: myKey || keys.values().next().value || null, // Fallback for backward compatibility
179+
myKey, // Explicitly expose myKey for writing
180+
isLoading: isLoadingKeychains || isLoadingKeys,
164181
initializeKey,
165182
addReader,
166183
removeReader
167184
};
168185
}
186+
187+

src/test/InventorySharing.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('useInventory Sharing Logic', () => {
3434

3535
// Default mocks
3636
(useNDK as any).mockReturnValue({ ndk: mockNdk, activeUser: mockUser });
37-
(useInventoryKey as any).mockReturnValue({ sharedKey: null });
37+
(useInventoryKey as any).mockReturnValue({ keys: new Map(), myKey: null, sharedKey: null });
3838

3939
// Default mock implementation
4040
mockUseQuery.mockImplementation(({ queryFn, enabled }: any) => {

0 commit comments

Comments
 (0)