Skip to content

Commit 1987f00

Browse files
vveerrggclaude
andcommitted
feat: read shared profiles from iOS app via App Groups
Enable the Safari extension to read profiles shared by the iOS app through the App Groups container and shared Keychain. On startup, background.js calls sendNativeMessage to check for shared profiles and merges them into local storage (new profiles added, existing matched by pubKey). Adds App Groups + Keychain entitlements for iOS App and Extension targets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e6c485 commit 1987f00

7 files changed

Lines changed: 280 additions & 0 deletions

File tree

apple/NostrKey.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@
113113
A1000005000000000000000B /* security in Resources */ = {isa = PBXBuildFile; fileRef = A10000050000000000000013 /* security */; };
114114
A1000006000000000000000A /* vault in Resources */ = {isa = PBXBuildFile; fileRef = A10000060000000000000014 /* vault */; };
115115
A1000006000000000000000B /* vault in Resources */ = {isa = PBXBuildFile; fileRef = A10000060000000000000014 /* vault */; };
116+
B1000003000000000000000A /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000000A /* SharedStorage.swift */; };
117+
B1000003000000000000000B /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000000A /* SharedStorage.swift */; };
118+
B1000004000000000000000A /* SharedKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000002000000000000000A /* SharedKeychain.swift */; };
119+
B1000004000000000000000B /* SharedKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000002000000000000000A /* SharedKeychain.swift */; };
116120
/* End PBXBuildFile section */
117121

118122
/* Begin PBXContainerItemProxy section */
@@ -195,6 +199,10 @@
195199
941B042E2978CDF900CA291E /* Icon-32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-32.png"; sourceTree = "<group>"; };
196200
941B042F2978CDF900CA291E /* Icon-16.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-16.png"; sourceTree = "<group>"; };
197201
941B04302978CDF900CA291E /* Icon-64.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-64.png"; sourceTree = "<group>"; };
202+
B1000001000000000000000A /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = "<group>"; };
203+
B1000002000000000000000A /* SharedKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychain.swift; sourceTree = "<group>"; };
204+
B1000005000000000000000A /* nostrkey.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nostrkey.entitlements; sourceTree = "<group>"; };
205+
B1000006000000000000000A /* nostrkey.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nostrkey.entitlements; sourceTree = "<group>"; };
198206
944A6E02299F2FBB0032C2E3 /* experimental */ = {isa = PBXFileReference; lastKnownFileType = folder; path = experimental; sourceTree = "<group>"; };
199207
944A6E12299F39D30032C2E3 /* permission */ = {isa = PBXFileReference; lastKnownFileType = folder; path = permission; sourceTree = "<group>"; };
200208
944A6E38299F46270032C2E3 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
@@ -299,6 +307,8 @@
299307
isa = PBXGroup;
300308
children = (
301309
941B03A1296FA90400CA291E /* SafariWebExtensionHandler.swift */,
310+
B1000001000000000000000A /* SharedStorage.swift */,
311+
B1000002000000000000000A /* SharedKeychain.swift */,
302312
);
303313
path = "Shared (Extension)";
304314
sourceTree = "<group>";
@@ -354,6 +364,7 @@
354364
isa = PBXGroup;
355365
children = (
356366
941B03BC296FA90400CA291E /* Info.plist */,
367+
B1000005000000000000000A /* nostrkey.entitlements */,
357368
);
358369
path = "iOS (App)";
359370
sourceTree = "<group>";
@@ -370,6 +381,7 @@
370381
isa = PBXGroup;
371382
children = (
372383
941B03D2296FA90400CA291E /* Info.plist */,
384+
B1000006000000000000000A /* nostrkey.entitlements */,
373385
);
374386
path = "iOS (Extension)";
375387
sourceTree = "<group>";
@@ -673,6 +685,8 @@
673685
buildActionMask = 2147483647;
674686
files = (
675687
941B03EA296FA90400CA291E /* SafariWebExtensionHandler.swift in Sources */,
688+
B1000003000000000000000A /* SharedStorage.swift in Sources */,
689+
B1000004000000000000000A /* SharedKeychain.swift in Sources */,
676690
);
677691
runOnlyForDeploymentPostprocessing = 0;
678692
};
@@ -681,6 +695,8 @@
681695
buildActionMask = 2147483647;
682696
files = (
683697
941B03EB296FA90400CA291E /* SafariWebExtensionHandler.swift in Sources */,
698+
B1000003000000000000000B /* SharedStorage.swift in Sources */,
699+
B1000004000000000000000B /* SharedKeychain.swift in Sources */,
684700
);
685701
runOnlyForDeploymentPostprocessing = 0;
686702
};
@@ -833,6 +849,7 @@
833849
941B03FF296FA90400CA291E /* Debug */ = {
834850
isa = XCBuildConfiguration;
835851
buildSettings = {
852+
CODE_SIGN_ENTITLEMENTS = "iOS (Extension)/nostrkey.entitlements";
836853
CODE_SIGN_STYLE = Automatic;
837854
CURRENT_PROJECT_VERSION = 5.0.0;
838855
GENERATE_INFOPLIST_FILE = YES;
@@ -863,6 +880,7 @@
863880
941B0400296FA90400CA291E /* Release */ = {
864881
isa = XCBuildConfiguration;
865882
buildSettings = {
883+
CODE_SIGN_ENTITLEMENTS = "iOS (Extension)/nostrkey.entitlements";
866884
CODE_SIGN_STYLE = Automatic;
867885
CURRENT_PROJECT_VERSION = 5.0.0;
868886
GENERATE_INFOPLIST_FILE = YES;
@@ -897,6 +915,7 @@
897915
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
898916
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
899917
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
918+
CODE_SIGN_ENTITLEMENTS = "iOS (App)/nostrkey.entitlements";
900919
CODE_SIGN_STYLE = Automatic;
901920
CURRENT_PROJECT_VERSION = 5.0.0;
902921
DEVELOPMENT_TEAM = H48PW6TC25;
@@ -940,6 +959,7 @@
940959
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
941960
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
942961
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
962+
CODE_SIGN_ENTITLEMENTS = "iOS (App)/nostrkey.entitlements";
943963
CODE_SIGN_STYLE = Automatic;
944964
CURRENT_PROJECT_VERSION = 5.0.0;
945965
DEVELOPMENT_TEAM = H48PW6TC25;

apple/Shared (Extension)/SafariWebExtensionHandler.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,44 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
1717
let message = item.userInfo?[SFExtensionMessageKey]
1818
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg)
1919

20+
// Check if the message contains a recognized action
21+
if let dict = message as? [String: Any],
22+
let action = dict["action"] as? String {
23+
switch action {
24+
case "getSharedProfiles":
25+
handleGetSharedProfiles(context: context)
26+
return
27+
default:
28+
break
29+
}
30+
}
31+
32+
// Legacy echo behavior
2033
let response = NSExtensionItem()
2134
response.userInfo = [ SFExtensionMessageKey: [ "Response to": message ] ]
2235

2336
context.completeRequest(returningItems: [response], completionHandler: nil)
2437
}
2538

39+
/// Read profiles from the App Group shared container and attach private keys
40+
/// from the shared Keychain.
41+
private func handleGetSharedProfiles(context: NSExtensionContext) {
42+
let profiles = SharedStorage.shared.loadProfiles()
43+
44+
// Attach private keys from shared Keychain
45+
var fullProfiles: [[String: Any]] = []
46+
for var profile in profiles {
47+
if let id = profile["id"] as? String {
48+
if let privKey = SharedKeychain.shared.loadPrivateKey(profileId: id) {
49+
profile["privKey"] = privKey
50+
}
51+
}
52+
fullProfiles.append(profile)
53+
}
54+
55+
let response = NSExtensionItem()
56+
response.userInfo = [SFExtensionMessageKey: ["profiles": fullProfiles]]
57+
context.completeRequest(returningItems: [response], completionHandler: nil)
58+
}
59+
2660
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import Foundation
2+
import Security
3+
4+
/// Keychain wrapper using a shared access group for App Groups.
5+
/// Stores private keys (nsec/hex) per profile ID, accessible by both the iOS app
6+
/// and the Safari extension.
7+
final class SharedKeychain {
8+
static let shared = SharedKeychain()
9+
10+
private let accessGroup = "H48PW6TC25.group.com.nostrkey"
11+
private let servicePrefix = "nostrkey.nsec."
12+
13+
private init() {}
14+
15+
/// Store a private key for a given profile ID.
16+
func savePrivateKey(profileId: String, privKey: String) {
17+
let service = servicePrefix + profileId
18+
guard let data = privKey.data(using: .utf8) else { return }
19+
20+
// Delete existing item first (update = delete + add)
21+
deletePrivateKey(profileId: profileId)
22+
23+
let query: [String: Any] = [
24+
kSecClass as String: kSecClassGenericPassword,
25+
kSecAttrService as String: service,
26+
kSecAttrAccessGroup as String: accessGroup,
27+
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
28+
kSecValueData as String: data
29+
]
30+
31+
SecItemAdd(query as CFDictionary, nil)
32+
}
33+
34+
/// Retrieve the private key for a given profile ID.
35+
func loadPrivateKey(profileId: String) -> String? {
36+
let service = servicePrefix + profileId
37+
38+
let query: [String: Any] = [
39+
kSecClass as String: kSecClassGenericPassword,
40+
kSecAttrService as String: service,
41+
kSecAttrAccessGroup as String: accessGroup,
42+
kSecReturnData as String: true,
43+
kSecMatchLimit as String: kSecMatchLimitOne
44+
]
45+
46+
var result: AnyObject?
47+
let status = SecItemCopyMatching(query as CFDictionary, &result)
48+
49+
guard status == errSecSuccess,
50+
let data = result as? Data,
51+
let key = String(data: data, encoding: .utf8) else {
52+
return nil
53+
}
54+
return key
55+
}
56+
57+
/// Delete the private key for a given profile ID.
58+
func deletePrivateKey(profileId: String) {
59+
let service = servicePrefix + profileId
60+
61+
let query: [String: Any] = [
62+
kSecClass as String: kSecClassGenericPassword,
63+
kSecAttrService as String: service,
64+
kSecAttrAccessGroup as String: accessGroup
65+
]
66+
67+
SecItemDelete(query as CFDictionary)
68+
}
69+
70+
/// List all profile IDs that have stored private keys.
71+
func listProfileIds() -> [String] {
72+
let query: [String: Any] = [
73+
kSecClass as String: kSecClassGenericPassword,
74+
kSecAttrAccessGroup as String: accessGroup,
75+
kSecReturnAttributes as String: true,
76+
kSecMatchLimit as String: kSecMatchLimitAll
77+
]
78+
79+
var result: AnyObject?
80+
let status = SecItemCopyMatching(query as CFDictionary, &result)
81+
82+
guard status == errSecSuccess,
83+
let items = result as? [[String: Any]] else {
84+
return []
85+
}
86+
87+
return items.compactMap { item in
88+
guard let service = item[kSecAttrService as String] as? String,
89+
service.hasPrefix(servicePrefix) else {
90+
return nil
91+
}
92+
return String(service.dropFirst(servicePrefix.count))
93+
}
94+
}
95+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Foundation
2+
3+
/// Wrapper around the App Group shared UserDefaults container.
4+
/// Stores profile metadata (name, pubKey/npub, active, relays) — never private keys.
5+
final class SharedStorage {
6+
static let shared = SharedStorage()
7+
8+
private let suiteName = "group.com.nostrkey"
9+
private let profilesKey = "nostrkey_shared_profiles"
10+
11+
private var defaults: UserDefaults? {
12+
UserDefaults(suiteName: suiteName)
13+
}
14+
15+
private init() {}
16+
17+
/// Save profile metadata to the shared container.
18+
/// Private keys must NOT be included — use SharedKeychain instead.
19+
func saveProfiles(_ profiles: [[String: Any]]) {
20+
guard let defaults = defaults else { return }
21+
if let data = try? JSONSerialization.data(withJSONObject: profiles),
22+
let json = String(data: data, encoding: .utf8) {
23+
defaults.set(json, forKey: profilesKey)
24+
}
25+
}
26+
27+
/// Load profile metadata from the shared container.
28+
func loadProfiles() -> [[String: Any]] {
29+
guard let defaults = defaults,
30+
let json = defaults.string(forKey: profilesKey),
31+
let data = json.data(using: .utf8),
32+
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
33+
return []
34+
}
35+
return array
36+
}
37+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.application-groups</key>
6+
<array>
7+
<string>group.com.nostrkey</string>
8+
</array>
9+
</dict>
10+
</plist>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.application-groups</key>
6+
<array>
7+
<string>group.com.nostrkey</string>
8+
</array>
9+
<key>keychain-access-groups</key>
10+
<array>
11+
<string>$(AppIdentifierPrefix)group.com.nostrkey</string>
12+
</array>
13+
</dict>
14+
</plist>

src/background.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,78 @@ let blockCrossOriginFrames = true;
130130
} catch (e) {
131131
log(`[STARTUP] Platform sync init error (non-fatal): ${e.message}`);
132132
}
133+
134+
// Check for profiles shared from the iOS app via App Groups (Safari only)
135+
try {
136+
if (typeof browser !== 'undefined' && browser.runtime.sendNativeMessage) {
137+
const response = await browser.runtime.sendNativeMessage(
138+
'com.nostrkey.Extension',
139+
{ action: 'getSharedProfiles' }
140+
);
141+
if (response && response.profiles && response.profiles.length > 0) {
142+
const local = await storage.get({ profiles: [] });
143+
const merged = mergeSharedProfiles(local.profiles, response.profiles);
144+
if (merged.changed) {
145+
await storage.set({ profiles: merged.profiles });
146+
log(`[STARTUP] Merged ${response.profiles.length} shared profile(s) from iOS app`);
147+
}
148+
}
149+
}
150+
} catch (e) {
151+
// Not Safari, or shared storage unavailable — ignore
152+
log(`[STARTUP] Shared profiles check skipped: ${e.message}`);
153+
}
133154
})();
134155

156+
/**
157+
* Merge profiles shared from the iOS app into the local profile list.
158+
* For each shared profile, if no local profile has the same pubKey, add it.
159+
* If a local profile has the same pubKey, keep the one with the newer updatedAt.
160+
* @returns {{ profiles: Array, changed: boolean }}
161+
*/
162+
function mergeSharedProfiles(localProfiles, sharedProfiles) {
163+
let changed = false;
164+
const profiles = [...localProfiles];
165+
166+
for (const shared of sharedProfiles) {
167+
if (!shared.pubKey) continue;
168+
169+
const localIndex = profiles.findIndex(p => p.pubKey === shared.pubKey);
170+
171+
if (localIndex === -1) {
172+
// New profile from app — add it
173+
profiles.push({
174+
name: shared.name || 'Shared Profile',
175+
privKey: shared.privKey || '',
176+
pubKey: shared.pubKey,
177+
hosts: {},
178+
relays: shared.relays || [],
179+
type: 'local',
180+
updatedAt: shared.lastSyncedAt ? new Date(shared.lastSyncedAt).getTime() : Date.now(),
181+
});
182+
changed = true;
183+
} else {
184+
// Existing profile — update if shared is newer and has a key we don't
185+
const local = profiles[localIndex];
186+
const localTime = local.updatedAt || 0;
187+
const sharedTime = shared.lastSyncedAt ? new Date(shared.lastSyncedAt).getTime() : 0;
188+
189+
if (sharedTime > localTime && shared.privKey && !local.privKey) {
190+
profiles[localIndex] = {
191+
...local,
192+
privKey: shared.privKey,
193+
name: shared.name || local.name,
194+
relays: shared.relays || local.relays,
195+
updatedAt: sharedTime,
196+
};
197+
changed = true;
198+
}
199+
}
200+
}
201+
202+
return { profiles, changed };
203+
}
204+
135205
/**
136206
* Reset the auto-lock inactivity timer.
137207
*/

0 commit comments

Comments
 (0)