diff --git a/.github/workflows/p2p-preview.yml b/.github/workflows/p2p-preview.yml index e2545f774..57355e7a2 100644 --- a/.github/workflows/p2p-preview.yml +++ b/.github/workflows/p2p-preview.yml @@ -105,5 +105,5 @@ jobs: echo "Artifact: [simhub-p2p-tested-package](${TEST_APK_URL})" echo "Package includes APK, SHA-256 and Android-runner test evidence." echo "Public signaling is retired; the preview build carries the fake test description only." - echo "PR #84 remains draft and must not be merged without manual approval." + echo "Dev experimental preview: real-device and public-network acceptance remain separate release gates." } >> "$GITHUB_STEP_SUMMARY" diff --git a/android/app/src/androidTest/java/com/blackkcold/simhub/p2p/PeerInstrumentedTest.java b/android/app/src/androidTest/java/com/blackkcold/simhub/p2p/PeerInstrumentedTest.java index 9f7fadd7c..f6a9e6e78 100644 --- a/android/app/src/androidTest/java/com/blackkcold/simhub/p2p/PeerInstrumentedTest.java +++ b/android/app/src/androidTest/java/com/blackkcold/simhub/p2p/PeerInstrumentedTest.java @@ -29,11 +29,13 @@ public final class PeerInstrumentedTest { PeerPairingManager manager=new PeerPairingManager(c);String offerQr=manager.offer("Sandbox Node","testSmsReceiptSurvivesRouteSwitchAndExpiry".equals(testName.getMethodName()),new JSONArray()); JSONObject qr=new JSONObject(new String(PeerCrypto.unb64(offerQr.substring(12)),java.nio.charset.StandardCharsets.UTF_8));String offerBody=qr.getString("body"); JSONObject offer=new JSONObject(new String(PeerCrypto.unb64(offerBody),java.nio.charset.StandardCharsets.UTF_8));PeerCrypto.verify(offer.getString("publicKey"),offerBody,qr.getString("signature")); - JSONObject response=new JSONObject().put("v",1).put("kind","response").put("offer",offerBody).put("offerHash",PeerCrypto.digest(PeerCrypto.utf8(offerBody))).put("publicKey",pub).put("ephemeral",epub).put("name","Sandbox Controller"); + JSONObject response=new JSONObject().put("v",offer.getInt("v")).put("kind","response").put("reversePermissions","").put("endpoints",new JSONArray()).put("offer",offerBody).put("offerHash",PeerCrypto.digest(PeerCrypto.utf8(offerBody))).put("publicKey",pub).put("ephemeral",epub).put("name","Sandbox Controller"); String reply=PeerCrypto.b64(PeerCrypto.utf8(response.toString()));String replyQr="simhub-peer:"+PeerCrypto.b64(PeerCrypto.utf8(new JSONObject().put("body",reply).put("signature",PeerCrypto.sign(identity.getPrivate(),reply)).toString())); - String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),offer.getString("publicKey"),offer.getString("ephemeral"),pub,epub,offer.getString("permissions")); + String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),offer.getString("publicKey"),offer.getString("ephemeral"),pub,epub,offer.getString("permissions")+(offer.optInt("v")==2?"|":"")); root=PeerCrypto.derive(PeerCrypto.dh(ephemeral.getPrivate(),PeerCrypto.publicKey(offer.getString("ephemeral"))),PeerCrypto.utf8(PeerCrypto.digest(PeerCrypto.utf8(transcript))),"content-root"); assertEquals(controllerId,manager.accept(replyQr));assertTrue(Arrays.equals(root,store.key(controllerId))); + assertEquals(2,store.peer(controllerId).getInt("protocolVersion")); + assertFalse(PeerGrantPolicy.allows(store.peer(controllerId),"sms.page",false)); try{manager.accept(replyQr);fail("replayed QR must fail");}catch(SecurityException expected){} } @After public void tearDown()throws Exception{PeerCenterConnector.closePeer(controllerId);server.close();store.revoke(controllerId);Arrays.fill(root,(byte)0);} @@ -49,6 +51,44 @@ private JSONObject rpc(String id,JSONObject req)throws Exception{ try{DirectPeerTransport.write(socket.getOutputStream(),PeerProtocol.seal(session,controllerId,to,id,expires,req));return PeerProtocol.open(session,to,controllerId,DirectPeerTransport.read(socket.getInputStream()),System.currentTimeMillis()/1000);}finally{Arrays.fill(session,(byte)0);} } } + @Test public void testRelayLiteCiphertextQueueAndScopedAcknowledgement()throws Exception{ + Context c=InstrumentationRegistry.getInstrumentation().getTargetContext(); + RelayLiteMailbox box=RelayLiteMailbox.get(c); + box.setEnabled(true); + try{ + long now=System.currentTimeMillis()/1000; + String id=UUID.randomUUID().toString(); + JSONObject inner=PeerOfflineProtocol.seal(root,controllerId,store.myId(),id,now, + now+600,new JSONObject().put("action","device.state").put("marker","sensitive-sandbox-data")); + JSONObject accepted=box.enqueue(controllerId,store.myId(),inner); + assertEquals("accepted",accepted.getString("state")); + assertFalse(accepted.getBoolean("duplicate")); + assertTrue(box.enqueue(controllerId,store.myId(),inner).getBoolean("duplicate")); + JSONObject messages=box.pull(store.myId(),4); + assertEquals(1,messages.getJSONArray("items").length()); + JSONObject first=messages.getJSONArray("items").getJSONObject(0); + assertFalse(first.toString().contains("sensitive-sandbox-data")); + assertEquals("sensitive-sandbox-data",PeerOfflineProtocol.open(root,controllerId, + store.myId(),first.getJSONObject("frame"),now).getString("marker")); + assertFalse(box.ack(controllerId,controllerId,id)); + assertTrue(box.ack(store.myId(),controllerId,id)); + assertEquals(0,box.pull(store.myId(),4).getJSONArray("items").length()); + }finally{box.setEnabled(false);} + } + + @Test public void testRelayLiteOutboundIdempotenceAfterRetry()throws Exception{ + JSONObject body=new JSONObject().put("action","device.state"); + long now=System.currentTimeMillis()/1000,expires=now+600; + String id=UUID.randomUUID().toString(); + JSONObject first=store.liteOutbound(controllerId,id,body,expires); + JSONObject retry=store.liteOutbound(controllerId,id,body,expires); + assertEquals(first.toString(),retry.toString()); + try{ + store.liteOutbound(controllerId,id,new JSONObject().put("action","sms.send"),expires); + fail("mutated request reused a logical id"); + }catch(SecurityException expected){} + store.clearLiteOutbound(controllerId,id); + } @Test public void testAuthenticatedDirectStateAndRevocation()throws Exception{ assertEquals("succeeded",rpc(new JSONObject().put("action","device.state")).getString("state")); store.revoke(controllerId);try{rpc(new JSONObject().put("action","device.state"));fail("revoked peer accepted");}catch(java.io.IOException expected){} diff --git a/android/app/src/main/java/com/blackkcold/simhub/PeerActivity.kt b/android/app/src/main/java/com/blackkcold/simhub/PeerActivity.kt index 4a17a1084..072be7877 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/PeerActivity.kt +++ b/android/app/src/main/java/com/blackkcold/simhub/PeerActivity.kt @@ -44,6 +44,9 @@ class PeerActivity:ComponentActivity(){ private var tab by mutableIntStateOf(0) private var pairingRole by mutableIntStateOf(-1) // -1 = closed; 0 = SIM node; 1 = controller private var allowSend by mutableStateOf(false) + // Independent opt-in on scanning side; never infer reciprocal SMS access. + private var reverseRead by mutableStateOf(false) + private var reverseSend by mutableStateOf(false) private var selected by mutableStateOf(null) private var remoteState by mutableStateOf(null) private var peerEpoch=0L @@ -61,16 +64,21 @@ class PeerActivity:ComponentActivity(){ private var sendRequest:JSONObject?=null private var sendId:String?=null private var channelIndex by mutableIntStateOf(0) - private var discovery:PeerDiscovery?=null - private val permissions=registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){reload()} + private var relayLiteEnabled by mutableStateOf(false) + private val permissions=registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){ + if(Build.VERSION.SDK_INT>=37 + &&checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")==PackageManager.PERMISSION_GRANTED + &&PeerService.enabled()&&!PeerService.localListening())PeerService.start(this) + reload() + } private val role=registerForActivityResult(ActivityResultContracts.StartActivityForResult()){reload()} private fun store()=PeerStore.get(this) override fun onCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState);UiLocale.apply(this);reload() setContent{HubTheme(if(pairingRole==0||selected?.optString("localRole")=="NODE")"node" else "controller"){Surface(Modifier.fillMaxSize()){Screen()}}} + // PeerService owns NSD for the entire user-authorized listener lifetime. + // Multiple activity-owned discovery sessions can contend on Android NSD. lifecycleScope.launch{repeatOnLifecycle(Lifecycle.State.STARTED){ - discovery=PeerDiscovery(this@PeerActivity) - try{discovery?.start()}catch(_:Exception){notice="局域网发现不可用;请检查本地网络权限"} - try{while(isActive){delay(12000);reload();if(selected!=null&&tab==1&&!busy&&!historyMode)load(false)}}finally{discovery?.close();discovery=null} + while(isActive){delay(12000);reload();if(selected!=null&&tab==1&&!busy&&!historyMode)load(false)} }} } override fun onStop(){super.onStop();peerEpoch++;selectionRefresh?.cancel();ControllerAccessGate.clearAll();inbox.clear();remoteState=null} @@ -79,6 +87,7 @@ class PeerActivity:ComponentActivity(){ peers=(0 until stored.length()).map{stored.getJSONObject(it)} // Remote WebRTC may run even when Android 17 denies LAN listener access. listening=PeerService.enabled() + relayLiteEnabled=RelayLiteMailbox.get(applicationContext).enabled() val secrets=SecretStore(applicationContext) recentSendIds=peers.mapNotNull{p-> val id=p.optString("id") @@ -93,18 +102,62 @@ class PeerActivity:ComponentActivity(){ else->"设备暂时不可达。请检查两端网络与前台服务;无 Relay 授权时不会隐藏转发短信。" }}}finally{busy=false;reload()}}} private fun requestAccess(){if(!SmsAccessPolicy.rolePermitsAccess(this)){getSystemService(RoleManager::class.java)?.let{role.launch(it.createRequestRoleIntent(RoleManager.ROLE_SMS))};return};val list=mutableListOf(Manifest.permission.READ_SMS,Manifest.permission.RECEIVE_SMS,Manifest.permission.READ_PHONE_STATE);if(Build.VERSION.SDK_INT>=33)list.add(Manifest.permission.POST_NOTIFICATIONS);if(Build.VERSION.SDK_INT>=37)list.add("android.permission.ACCESS_LOCAL_NETWORK");if(SmsSendPolicy.canSend(this)||getSystemService(RoleManager::class.java)?.isRoleHeld(RoleManager.ROLE_SMS)==true)list.add(Manifest.permission.SEND_SMS);permissions.launch(list.toTypedArray())} + private fun requestLanAccess(){ + if(Build.VERSION.SDK_INT>=37 + &&checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")!=PackageManager.PERMISSION_GRANTED) + permissions.launch(arrayOf("android.permission.ACCESS_LOCAL_NETWORK")) + else startNode() + } private fun startNode(){PeerService.start(this);lifecycleScope.launch{delay(800);reload();notice=PeerService.error()}} private fun scan(){IntentIntegrator(this).setDesiredBarcodeFormats(IntentIntegrator.QR_CODE).setPrompt("扫描另一台手机的 P2P 二维码").setBeepEnabled(false).setOrientationLocked(false).initiateScan()} @Deprecated("ZXing compatibility") override fun onActivityResult(requestCode:Int,resultCode:Int,data:Intent?){super.onActivityResult(requestCode,resultCode,data);val result:IntentResult?=IntentIntegrator.parseActivityResult(requestCode,resultCode,data);val contents=result?.contents?:return - work{val resultQr=withContext(Dispatchers.IO){val manager=PeerPairingManager(applicationContext);when(PeerPairingManager.kind(contents)){"offer"->manager.respond(contents,Build.MODEL);"response"->{manager.accept(contents);""};"signal-binding"->{manager.acceptSignalingBindingQr(contents);"SIGNALING_BOUND"};else->throw SecurityException("peer_qr_kind_rejected")}};qr=if(resultQr=="SIGNALING_BOUND")"" else resultQr;PeerService.start(this@PeerActivity);notice=when(resultQr){""->"双向扫码已完成。本地网络可尝试直接连接;跨网络使用需要另外完成可信中心信令授权。";"SIGNALING_BOUND"->"已验证节点签名及信令绑定,开始尝试建立 WebRTC 数据直连。";else->"请让 SIM 节点扫描此响应二维码。跨网络连接还需要可信中心信令授权。"}} + work{ + // A reciprocal pairing response must advertise a live LAN listener. + if(PeerPairingManager.kind(contents)=="offer"){ + PeerService.start(this@PeerActivity) + delay(550) + } + val resultQr=withContext(Dispatchers.IO){val manager=PeerPairingManager(applicationContext);when(PeerPairingManager.kind(contents)){"offer"->manager.respond(contents,Build.MODEL,reverseRead,reverseSend);"response"->{manager.accept(contents);""};"signal-binding"->{manager.acceptSignalingBindingQr(contents);"SIGNALING_BOUND"};else->throw SecurityException("peer_qr_kind_rejected")}};qr=if(resultQr=="SIGNALING_BOUND")"" else resultQr;PeerService.start(this@PeerActivity);notice=when(resultQr){""->"双向扫码已完成。本地网络可尝试直接连接;跨网络使用需要另外完成可信中心信令授权。";"SIGNALING_BOUND"->"已验证节点签名及信令绑定,开始尝试建立 WebRTC 数据直连。";else->"请让 SIM 节点扫描此响应二维码。跨网络连接还需要可信中心信令授权。"}} } private suspend fun rpc(p:JSONObject,id:String,request:JSONObject):JSONObject{ return withContext(Dispatchers.IO){PeerHubGateway.request(applicationContext,p.getString("id"),id,request)} } - private suspend fun load(older:Boolean){val p=selected?:return;val generation=peerEpoch;val req=JSONObject().put("action","sms.page").put("limit",100);if(older&&cursor!=null)req.put("cursor",cursor) - val page=rpc(p,UUID.randomUUID().toString(),req);if(generation!=peerEpoch||selected?.optString("id")!=p.optString("id")||!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))return;if(page.optString("state")!="succeeded"){notice="短信读取未完成:${page.optString("reason")}";return};if(inbox.accept(page.toString(),older,older)){cursor=page.optJSONObject("cursor");historyMode=older} + /** One stable request ID per unresolved async operation, including after app restart. */ + private fun pendingKey(peerId:String,action:String,tail:String):String = + "p2p.pending."+PeerCrypto.digest(PeerCrypto.utf8("$peerId|$action|$tail")) + private suspend fun pendingId(key:String):String=withContext(Dispatchers.IO){ + val secrets=SecretStore(applicationContext) + secrets.getString(key) ?: UUID.randomUUID().toString().also{secrets.putString(key,it)} + } + private suspend fun clearPendingId(key:String)=withContext(Dispatchers.IO){ + SecretStore(applicationContext).remove(key) + } + private suspend fun load(older:Boolean){ + val p=selected?:return + val generation=peerEpoch + val req=JSONObject().put("action","sms.page").put("limit",100) + if(older&&cursor!=null)req.put("cursor",cursor) + val key=pendingKey(p.getString("id"),"sms.page",if(older)cursor?.toString()?:"older" else "latest") + val page=rpc(p,pendingId(key),req) + if(generation!=peerEpoch||selected?.optString("id")!=p.optString("id")||!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))return + if(page.optString("state")=="queued"){ + notice="短信读取请求已由 Relay Lite 加密暂存,目标设备在线处理后可自动或手动重试查询结果" + return + } + if(page.optString("state")!="succeeded"){ + notice="短信读取未完成:${page.optString("reason",page.optString("state"))}" + return + } + clearPendingId(key) + if(inbox.accept(page.toString(),older,older)){cursor=page.optJSONObject("cursor");historyMode=older} if(!older){ - val stateResult=rpc(p,UUID.randomUUID().toString(),JSONObject().put("action","device.state")) + val stateKey=pendingKey(p.getString("id"),"device.state","latest") + val stateResult=rpc(p,pendingId(stateKey),JSONObject().put("action","device.state")) + if(stateResult.optString("state")=="queued"){ + notice="设备状态已提交 Relay Lite,待远端回应" + return + } + if(stateResult.optString("state")=="succeeded")clearPendingId(stateKey) if(generation!=peerEpoch||selected?.optString("id")!=p.optString("id")||!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))return remoteState=stateResult.optJSONObject("device") stateResult.optJSONObject("relayHint")?.let{hint->withContext(Dispatchers.IO){val fresh=store().peer(p.getString("id"));fresh.put("relayHint",hint);store().update(fresh)}} @@ -187,7 +240,7 @@ class PeerActivity:ComponentActivity(){ 1->{val p=selected;if(p==null){Text("请先双机扫码,然后选择管理设备",Modifier.padding(24.dp))}else{ Text("${p.optString("name")} · ${connectionLabel(p)} · 端到端加密",Modifier.padding(horizontal=16.dp,vertical=8.dp),style=MaterialTheme.typography.labelMedium) if(!inbox.unlocked)Button(onClick={work{load(false)}},Modifier.padding(16.dp)){Text("连接并读取短信")} - else NativeControllerInbox(inbox,onRefresh={work{load(false)}},onOlder={work{load(true)}},onNewer={work{load(false)}},onOpenLogin={tab=0},onWrite={beginWrite()},onReply={beginWrite(it)},canCompose=p.optString("permissions").contains("send")&&remoteState?.optBoolean("smsSendAllowed")==true) + else NativeControllerInbox(inbox,onRefresh={work{load(false)}},onOlder={work{load(true)}},onNewer={work{load(false)}},onOpenLogin={tab=0},onWrite={beginWrite()},onReply={beginWrite(it)},canCompose=PeerGrantPolicy.allows(p,"sms.send",false)&&remoteState?.optBoolean("smsSendAllowed")==true) }} else->ConnectionScreen() } @@ -242,12 +295,28 @@ class PeerActivity:ComponentActivity(){ Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){Switch(listening,{if(it)startNode() else{PeerService.stop(this@PeerActivity);listening=false}});Text("允许已配对设备连接")} Text("持续监听会显示前台通知;关闭后此 SIM 节点无法通过 P2P 接收请求。",style=MaterialTheme.typography.bodySmall) if(PeerService.enabled()&&!PeerService.localListening())Text("设备直连服务已启动,但局域网监听不可用;可继续完成配对并尝试已授权的公网 WebRTC。局域网使用需检查本地网络权限。",style=MaterialTheme.typography.bodySmall) + if(Build.VERSION.SDK_INT>=37 + &&checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")!=PackageManager.PERMISSION_GRANTED){ + Text("当前未授予局域网设备访问权限;此权限与短信角色独立。",style=MaterialTheme.typography.bodySmall) + OutlinedButton(onClick={requestLanAccess()}){Text("授权局域网连接")} + } OutlinedButton(onClick={requestAccess()}){Text("检查短信读取权限")} Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){Checkbox(allowSend,{allowSend=it});Text("允许本次配对发送短信(默认关闭)")} Button(onClick={work{if(!PeerService.enabled())throw IllegalStateException("start_peer_service_first");qr=withContext(Dispatchers.IO){PeerPairingManager(applicationContext).offer(Build.MODEL,allowSend,PeerService.endpoints())};notice="步骤 2 / 3 · 请使用管理手机扫描二维码,然后使用本机扫描其响应二维码"}},enabled=listening){Text("生成配对二维码")} if(!SmsSendPolicy.canSend(this@PeerActivity))Text("发送短信需要本机系统短信角色及相应权限;仅授权读取时无需切换默认短信应用。",style=MaterialTheme.typography.bodySmall) }else if(pairingRole==1){ Text("步骤 2 / 3 · 扫描节点二维码,再让节点扫描本机响应二维码",style=MaterialTheme.typography.titleSmall) + Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){ + Checkbox(checked=reverseRead,onCheckedChange={reverseRead=it;if(!it)reverseSend=false}) + Text("同时允许对方读取本机短信和状态",style=MaterialTheme.typography.bodySmall) + } + if(reverseRead)Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){ + Checkbox(checked=reverseSend,onCheckedChange={reverseSend=it}) + Text("也允许对方通过本机 SIM 发送短信",style=MaterialTheme.typography.bodySmall) + } + if(Build.VERSION.SDK_INT>=37 + &&checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")!=PackageManager.PERMISSION_GRANTED) + OutlinedButton(onClick={requestLanAccess()}){Text("授权本机局域网连接")} Button(onClick={scan()}){Text("扫描节点二维码")} Text("双向扫码确认身份。跨网络连接需要两端可信中心配置及单独授权;如果本地网络无法互通,可在连接详情中扫码导入节点签名的中心绑定。",style=MaterialTheme.typography.bodySmall) } @@ -258,7 +327,7 @@ class PeerActivity:ComponentActivity(){ }} items(peers,key={it.optString("id")}){p->HubCard(Modifier.fillMaxWidth()){ Text(p.optString("name"),style=MaterialTheme.typography.titleMedium) - Text("${if(p.optString("localRole")=="NODE")"已授权管理终端" else "SIM 节点"} · ${pairLabel(p)}",style=MaterialTheme.typography.bodySmall) + Text("${if(p.optInt("protocolVersion",1)>=2)"对等设备" else if(p.optString("localRole")=="NODE")"已授权管理终端" else "SIM 节点"} · ${pairLabel(p)}",style=MaterialTheme.typography.bodySmall) Text(connectionLabel(p),color=if(PeerCenterConnector.dataChannelOnline(p.optString("id")))MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) Text(if(p.optBoolean("relayConfigured"))"授权:P2P + Relay · Relay 优先" else "授权:仅 P2P · 未配置备用路径",style=MaterialTheme.typography.bodySmall) if(p.optString("pairState")=="PENDING")Text("等待节点扫描响应二维码;完成双向确认后才能读取短信。",style=MaterialTheme.typography.bodySmall) @@ -267,13 +336,13 @@ class PeerActivity:ComponentActivity(){ Text("Android ${state.optString("androidVersion")} · ${state.optString("appVersion")} · ${if(state.optBoolean("smsSendAllowed"))"系统允许发送" else "当前仅允许读取"}") } // Full identity fingerprints and ICE diagnostics are available only in device details. - if(p.optString("localRole")=="CONTROLLER"&&p.optString("pairState")!="REVOKED")OutlinedButton(onClick={work{ + if(PeerGrantPolicy.allows(p,"sms.page",false)&&p.optString("pairState")!="REVOKED")OutlinedButton(onClick={work{ val rows=withContext(Dispatchers.IO){store().outbound(p.getString("id"))} pendingCommands=(0 until rows.length()).map{rows.getJSONObject(it)} pendingTarget=p if(pendingCommands.isEmpty())notice="没有待确认的短信命令" }}){Text("查看待确认命令")} - if(p.optString("localRole")=="CONTROLLER"&&p.optString("pairState")!="REVOKED"){ + if(PeerGrantPolicy.allows(p,"sms.page",false)&&p.optString("pairState")!="REVOKED"){ recentSendIds[p.optString("id")]?.let{commandId-> OutlinedButton(onClick={work{ val result=rpc(p,UUID.randomUUID().toString(),JSONObject().put("action","command.status").put("commandId",commandId)) @@ -290,11 +359,11 @@ class PeerActivity:ComponentActivity(){ } } Row(horizontalArrangement=Arrangement.spacedBy(8.dp)){ - if(p.optString("localRole")=="CONTROLLER"&&p.optString("pairState")!="REVOKED"){ + if(PeerGrantPolicy.allows(p,"sms.page",false)&&p.optString("pairState")!="REVOKED"){ Button(onClick={select(p)}){Text(if(p.optString("pairState")=="PENDING")"验证连接" else "管理短信")} OutlinedButton(onClick={selected=p;remoteState=null;tab=2}){Text("连接详情")} } - if(p.optString("localRole")=="NODE"&&p.optString("pairState")!="REVOKED")OutlinedButton(onClick={selected=p;remoteState=null;tab=2}){Text("连接 / 授权")} + if(!PeerGrantPolicy.allows(p,"sms.page",false)&&p.optString("pairState")!="REVOKED")OutlinedButton(onClick={selected=p;remoteState=null;tab=2}){Text("连接 / 授权")} if(p.optString("pairState")!="REVOKED")TextButton(onClick={revokeTarget=p}){Text(if(p.optString("pairState")=="PENDING")"取消配对" else "撤销授权")} } }} @@ -314,9 +383,84 @@ class PeerActivity:ComponentActivity(){ Text("同一局域网优先本地直连。跨网络可选择已注册的可信中心仅传递端到端加密的建连信令,需要双方已有对应中心账户或设备配置,以及单独授权。") Text("本功能不提供公共信令服务器。未配置 Relay 时不启用 TURN、信箱或业务中继,受限 NAT 下可能无法连接。中心可以看到关联及网络元数据,但不能解密信令或短信。") Text("Relay 独立配置;同时配置时优先 Relay,失败后尝试已授权 P2P。") + HorizontalDivider() + Text("Android Relay Lite · 密文中继",style=MaterialTheme.typography.titleMedium) + Text("仅为明确授权的已配对设备提供密文存储转发。此手机不因此获得公网地址;若希望跨运营商连接,仍需双方实际可达的加密传输路径。",style=MaterialTheme.typography.bodySmall) + Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){ + Switch(checked=relayLiteEnabled,onCheckedChange={enabled-> + work{ + withContext(Dispatchers.IO){RelayLiteMailbox.get(applicationContext).setEnabled(enabled)} + relayLiteEnabled=enabled + if(enabled)PeerService.start(this@PeerActivity) + notice=if(enabled)"已允许此设备提供 Relay Lite;仍须逐个允许对端转发。" else "Relay Lite 已关闭;本地待发队列保留至过期。" + } + }) + Text("本机提供 Relay Lite",style=MaterialTheme.typography.bodySmall) + } + OutlinedButton(onClick={work{ + val stats=withContext(Dispatchers.IO){RelayLiteMailbox.get(applicationContext).stats()} + notice="Relay Lite:待处理 ${stats.optInt("queued")} 条,密文 ${stats.optLong("bytes")} 字节" + }}){Text("查看密文队列状态")} + OutlinedButton(onClick={work{ + val count=withContext(Dispatchers.IO){RelayLiteClient.pollAll(applicationContext)} + notice="Relay Lite 已检查已授权信箱;本次确认处理 $count 条" + }}){Text("立即检查 Relay Lite 信箱")} + HorizontalDivider() (selected?.let{listOf(it)}?:emptyList()).filter{it.optString("pairState")!="REVOKED"}.forEach{p-> Text("${p.optString("name")} · ${connectionLabel(p)}") - Text("P2P 授权:${pairLabel(p)} · 读取${if(p.optString("permissions").split(",").contains("send"))"、发送" else "(只读)"}",style=MaterialTheme.typography.bodySmall) + val grantsFrom=if(p.optInt("protocolVersion",1)>=2)p.optString("grantsFromRemote","") + else if(p.optString("localRole")=="CONTROLLER")p.optString("permissions","") else "" + val grantsTo=if(p.optInt("protocolVersion",1)>=2)p.optString("grantsToRemote","") + else if(p.optString("localRole")=="NODE")p.optString("permissions","") else "" + Text("可管理对方:${if(grantsFrom.contains("read"))"读取" else "未授权"}${if(grantsFrom.contains("send"))"、发送" else ""} · 对方管理本机:${if(grantsTo.contains("read"))"读取" else "未授权"}${if(grantsTo.contains("send"))"、发送" else ""}",style=MaterialTheme.typography.bodySmall) + if("ACTIVE"==p.optString("pairState")){ + Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){ + Switch(checked=p.optBoolean("relayLiteAllowRemote"),onCheckedChange={enabled-> + work{withContext(Dispatchers.IO){ + val fresh=store().peer(p.getString("id")) + fresh.put("relayLiteAllowRemote",enabled) + store().update(fresh) + };notice=if(enabled)"已授权该设备使用本机 Relay Lite(不授予短信内容权限)" else "已撤销该设备的本机中继授权"} + }) + Text("允许此设备使用本机 Relay Lite",style=MaterialTheme.typography.bodySmall) + } + Row(verticalAlignment=androidx.compose.ui.Alignment.CenterVertically){ + Switch(checked=p.optBoolean("relayLiteInbox"),onCheckedChange={enabled-> + work{withContext(Dispatchers.IO){ + val fresh=store().peer(p.getString("id")) + fresh.put("relayLiteInbox",enabled) + store().update(fresh) + };if(enabled)PeerService.start(this@PeerActivity) + notice=if(enabled)"将定期从该设备拉取发给本机的密文" else "已停止从该设备轮询 Relay Lite"} + }) + Text("从该设备接收 Relay Lite 密文",style=MaterialTheme.typography.bodySmall) + } + Text("通过其他已配对设备转发到本设备的消息:",style=MaterialTheme.typography.bodySmall) + val current=p.optString("relayLiteVia","") + Text(if(p.optBoolean("relayLiteConfigured"))"当前选用中继:${peers.find{it.optString("id")==current}?.optString("name")?:"不可用"}" else "未配置中继(默认直连)",style=MaterialTheme.typography.bodySmall) + if(p.optBoolean("relayLiteConfigured"))TextButton(onClick={work{ + withContext(Dispatchers.IO){ + val fresh=store().peer(p.getString("id")) + fresh.put("relayLiteConfigured",false).remove("relayLiteVia") + store().update(fresh) + } + notice="已关闭此设备的 Relay Lite 兜底" + }}){Text("关闭此设备 Relay Lite 兜底")} + peers.filter{other->other.optString("id")!=p.optString("id") + &&other.optString("pairState")=="ACTIVE"}.forEach{candidate-> + OutlinedButton(onClick={work{ + withContext(Dispatchers.IO){ + val live=store().peer(candidate.getString("id")) + if(!"ACTIVE".equals(live.optString("pairState")))throw SecurityException("relay_lite_peer_not_active") + val target=store().peer(p.getString("id")) + target.put("relayLiteConfigured",true) + .put("relayLiteVia",candidate.getString("id")) + store().update(target) + } + notice="已为此设备选择中继 ${candidate.optString("name")};需对端及中继均确认转发授权,并验证可达性" + }}){Text("选用 ${candidate.optString("name")} 作为 Relay Lite")} + } + } Text("连接阶段:${discoveryLabel(p.optString("discoveryState","WAITING"))} · 上次成功路径:${routeLabel(p.optString("activeTransport"))}",style=MaterialTheme.typography.bodySmall) Text(diagnosticLabel(p),color=MaterialTheme.colorScheme.onSurfaceVariant,style=MaterialTheme.typography.bodySmall) if(p.optLong("lastSyncAt",0)>0)Text("最后通信:${java.text.SimpleDateFormat("MM-dd HH:mm",java.util.Locale.getDefault()).format(java.util.Date(p.optLong("lastSyncAt")*1000))}",style=MaterialTheme.typography.labelSmall) @@ -436,7 +580,7 @@ class PeerActivity:ComponentActivity(){ else->"未连接 · 等待设备上线" } } - private fun routeLabel(route:String)=when(route){"LAN"->"局域网直连";"DIRECT"->"P2P 直连";"TURN"->"加密 TURN 中继";"MAILBOX"->"加密信箱";"LEGACY_RELAY"->"Relay";else->"上次路径未知"} + private fun routeLabel(route:String)=when(route){"LAN"->"局域网直连";"DIRECT"->"P2P 直连";"TURN"->"加密 TURN 中继";"MAILBOX"->"加密信箱";"LEGACY_RELAY"->"Relay";"RELAY_LITE"->"Android Relay Lite";"RELAY_LITE_QUEUED"->"Android Relay Lite(等待目标处理)";else->"上次路径未知"} } @Composable fun PeerEntryCard(){val context=androidx.compose.ui.platform.LocalContext.current;HubCard(Modifier.fillMaxWidth()){ diff --git a/android/app/src/main/java/com/blackkcold/simhub/PeerHubGateway.kt b/android/app/src/main/java/com/blackkcold/simhub/PeerHubGateway.kt index c149a6b15..ecb5d8f15 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/PeerHubGateway.kt +++ b/android/app/src/main/java/com/blackkcold/simhub/PeerHubGateway.kt @@ -41,6 +41,7 @@ object PeerHubGateway { PeerCenterConnector.active(peerId)?.let{coordinator.internet(it)} }catch(_:Exception){} } + RelayLiteTransport.configured(c,store,p)?.let{coordinator.relayLite(it)} return coordinator.request(peerId,id,request) } fun linked(c:Context,origin:String):List{ diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/ConnectionCoordinator.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/ConnectionCoordinator.java index 0baaf0713..dbf4a3706 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/ConnectionCoordinator.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/ConnectionCoordinator.java @@ -7,10 +7,12 @@ public final class ConnectionCoordinator { private final PeerStore store; private HubTransport internet; private HubTransport mailbox; + private HubTransport relayLite; private HubTransport relay; public ConnectionCoordinator(PeerStore store){this.store=store;} public void internet(HubTransport value){internet=value;} public void mailbox(HubTransport value){mailbox=value;} + public void relayLite(HubTransport value){if(value!=null&&!"RELAY_LITE".equals(value.route()))throw new SecurityException("invalid_lite_adapter");relayLite=value;} public void relay(HubTransport value){if(value!=null&&!"LEGACY_RELAY".equals(value.route()))throw new SecurityException("invalid_relay_adapter");relay=value;} public JSONObject request(String peerId,String requestId,JSONObject request)throws Exception{ JSONObject peer=store.peer(peerId);if("REVOKED".equals(peer.optString("pairState")))throw new SecurityException("peer_revoked"); @@ -26,11 +28,18 @@ public JSONObject request(String peerId,String requestId,JSONObject request)thro if(internet!=null){ try{return accept(peer,internet,requestId,request);}catch(Exception e){if(e instanceof SecurityException)throw e;failed=e;} } + // Only an explicitly selected, separately paired Android relay is eligible. + if(relayLite!=null&&peer.optBoolean("relayLiteConfigured",false) + &&policy!=ConnectionPolicy.RELAY_ONLY){ + try{return accept(peer,relayLite,requestId,request);} + catch(Exception e){if(e instanceof SecurityException)throw e;failed=e;} + } if(mailbox!=null&&policy.permits("MAILBOX",peer.optBoolean("fallbackConsent"),"ACTIVE".equals(peer.optString("relayBindingState"))))return accept(peer,mailbox,requestId,request); peer.put("activeTransport","OFFLINE");store.update(peer);throw failed; } private JSONObject accept(JSONObject peer,HubTransport transport,String id,JSONObject req)throws Exception{ JSONObject result=transport.request(peer,id,req);peer=store.peer(peer.getString("id"));if("REVOKED".equals(peer.optString("pairState")))throw new SecurityException("peer_revoked"); - peer.put("activeTransport",transport.route()).put("lastSyncAt",System.currentTimeMillis()/1000);if("PENDING".equals(peer.optString("pairState")))peer.put("pairState","ACTIVE");store.update(peer);return result; + peer.put("activeTransport","RELAY_LITE".equals(transport.route())&&"queued".equals(result.optString("state")) + ?"RELAY_LITE_QUEUED":transport.route()).put("lastSyncAt",System.currentTimeMillis()/1000);if("PENDING".equals(peer.optString("pairState"))&&!"RELAY_LITE".equals(transport.route()))peer.put("pairState","ACTIVE");store.update(peer);return result; } } diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerDiscovery.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerDiscovery.java index 0b4c28ffb..bc7197e96 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerDiscovery.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerDiscovery.java @@ -3,39 +3,113 @@ import android.content.Context; import android.net.nsd.*; import org.json.*; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Set; -/** Discovery supplies endpoint hints only; it can never create a grant or change keys. */ +/** + * NSD is a hint only. Each resolved endpoint is authenticated by PeerProtocol + * before any business RPC; discovery never confers access or changes a key. + * + * Own this from the foreground PeerService, not only from PeerActivity. + * Android NSD implementations can reject concurrent resolveService calls; + * serialize them rather than dropping peers discovered while resolving. + */ public final class PeerDiscovery implements AutoCloseable { + private static final class Pending { + final NsdServiceInfo info; + final String peerId; + Pending(NsdServiceInfo info, String peerId) { this.info=info; this.peerId=peerId; } + } private final NsdManager manager; private final PeerStore store; + private final ArrayDeque pending=new ArrayDeque<>(); + private final Set queuedPeers=new HashSet<>(); private boolean started; private boolean resolving; + private final NsdManager.DiscoveryListener listener=new NsdManager.DiscoveryListener(){ - public void onDiscoveryStarted(String type){} public void onDiscoveryStopped(String type){} - public void onStartDiscoveryFailed(String type,int code){started=false;} - public void onStopDiscoveryFailed(String type,int code){started=false;} + public void onDiscoveryStarted(String type){} + public void onDiscoveryStopped(String type){} + public void onStartDiscoveryFailed(String type,int code){synchronized(PeerDiscovery.this){started=false;}} + public void onStopDiscoveryFailed(String type,int code){synchronized(PeerDiscovery.this){started=false;}} public void onServiceLost(NsdServiceInfo info){} public void onServiceFound(NsdServiceInfo info){ try{ - JSONArray peers=store.list();for(int i=0;i=2 + &&!"".equals(p.optString("grantsFromRemote",""))); + if(!"REVOKED".equals(p.optString("pairState")) + &&clientSide + &&info.getServiceName().startsWith("simhub_"+id.substring(0,16))){ + enqueue(info,id); + } } - }catch(Exception ignored){} + }catch(Exception ignored){/* Discovery cannot change grants. */} } }; + public PeerDiscovery(Context c){manager=c.getSystemService(NsdManager.class);store=PeerStore.get(c);} - public void start(){if(!started){manager.discoverServices("_simhub-peer._tcp.",NsdManager.PROTOCOL_DNS_SD,listener);started=true;}} - private synchronized void resolve(NsdServiceInfo info,String id){ - if(resolving)return;resolving=true; - manager.resolveService(info,new NsdManager.ResolveListener(){ - public void onResolveFailed(NsdServiceInfo i,int e){resolving=false;} - public void onServiceResolved(NsdServiceInfo i){resolving=false;try{ - JSONObject p=store.peer(id);if("REVOKED".equals(p.optString("pairState")))return; - JSONArray endpoints=new JSONArray().put(new JSONObject().put("host",i.getHost().getHostAddress()).put("port",i.getPort())); - p.put("endpoints",endpoints);store.update(p); - }catch(Exception ignored){}} - }); + public synchronized void start(){ + if(started)return; + // Mark as active before starting, so a synchronous failure can be retried. + started=true; + try{manager.discoverServices("_simhub-peer._tcp.",NsdManager.PROTOCOL_DNS_SD,listener);} + catch(RuntimeException error){started=false;throw error;} + } + + private void enqueue(NsdServiceInfo info,String id){ + synchronized(this){ + if(!started||!queuedPeers.add(id))return; + pending.addLast(new Pending(info,id)); + } + resolveNext(); + } + + private void resolveNext(){ + final Pending next; + synchronized(this){ + if(!started||resolving)return; + next=pending.pollFirst(); + if(next==null)return; + resolving=true; + } + try{ + manager.resolveService(next.info,new NsdManager.ResolveListener(){ + public void onResolveFailed(NsdServiceInfo service,int code){finish(next.peerId);} + public void onServiceResolved(NsdServiceInfo service){ + try{ + if(service.getHost()==null||service.getPort()<1024||service.getPort()>65535)return; + synchronized(PeerDiscovery.this){if(!started)return;} + JSONObject peer=store.peer(next.peerId); + if("REVOKED".equals(peer.optString("pairState")))return; + JSONArray endpoints=new JSONArray().put(new JSONObject() + .put("host",service.getHost().getHostAddress()).put("port",service.getPort())); + peer.put("endpoints",endpoints); + store.update(peer); + }catch(Exception ignored){/* Do not persist an unverified endpoint. */} + finally{finish(next.peerId);} + } + }); + }catch(RuntimeException error){finish(next.peerId);} + } + + private void finish(String id){ + synchronized(this){queuedPeers.remove(id);resolving=false;} + resolveNext(); + } + + public synchronized void close(){ + started=false; + pending.clear(); + queuedPeers.clear(); + if(resolving){/* In-flight callbacks may arrive; they check started below. */} + try{manager.stopServiceDiscovery(listener);}catch(Exception ignored){} } - public void close(){if(started)try{manager.stopServiceDiscovery(listener);}catch(Exception ignored){}started=false;} } diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNativeRtcTransport.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNativeRtcTransport.java index 190112428..f16f02c2b 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNativeRtcTransport.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNativeRtcTransport.java @@ -665,9 +665,15 @@ private void incoming(String raw) throws Exception { try { JSONObject plaintext = PeerProtocol.open(key, peerId, store.myId(), frame, System.currentTimeMillis() / 1000); String id = frame.getString("id"); - if ("CONTROLLER".equals(current.optString("localRole"))) { - CompletableFuture future = pending.get(id); - if (future != null) future.complete(plaintext); + CompletableFuture future = pending.get(id); + boolean symmetric = current.optInt("protocolVersion",1)>=2; + if (future != null && (symmetric + ||"CONTROLLER".equals(current.optString("localRole")))) { + // v2 either endpoint can originate an RPC and receive its reply. + future.complete(plaintext); + } else if(!symmetric && "CONTROLLER".equals(current.optString("localRole"))) { + // v1 controllers remain receive-only: ignore unsolicited RPCs. + return; } else { if ("TURN".equals(route) && !ConnectionPolicy.valueOf(current.getString("policy")) .permits("TURN", current.optBoolean("fallbackConsent"), diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNodeServer.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNodeServer.java index 2faad43b2..6b2c3713e 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNodeServer.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerNodeServer.java @@ -18,7 +18,12 @@ public final class PeerNodeServer implements AutoCloseable { private void serve(Socket socket){byte[] root=null,session=null; try(socket){ JSONObject hello=DirectPeerTransport.read(socket.getInputStream());String from=hello.getString("from"),to=store.myId();JSONObject peer=store.peer(from); - if(!"NODE".equals(peer.optString("localRole"))||!"ACTIVE".equals(peer.optString("pairState"))||"RELAY_ONLY".equals(peer.optString("policy")))throw new SecurityException("peer_not_authorized"); + boolean v2=peer.optInt("protocolVersion",1)>=2; + boolean accepted="ACTIVE".equals(peer.optString("pairState")) + ||(v2&&"PENDING".equals(peer.optString("pairState"))); + if(!accepted||(!v2&&!"NODE".equals(peer.optString("localRole"))) + ||"RELAY_ONLY".equals(peer.optString("policy"))) + throw new SecurityException("peer_not_authorized"); root=store.key(from);JSONObject h=PeerProtocol.open(root,from,to,hello,System.currentTimeMillis()/1000); if(!"hello".equals(h.optString("action")))throw new SecurityException("peer_hello_required"); String helloId=hello.getString("id");KeyPair eph=PeerCrypto.ephemeral();byte[] shared=PeerCrypto.dh(eph.getPrivate(),PeerCrypto.publicKey(h.getString("ephemeral"))); diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerPairingManager.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerPairingManager.java index 0e839103e..de8ac5a26 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerPairingManager.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerPairingManager.java @@ -14,37 +14,52 @@ public final class PeerPairingManager { public PeerPairingManager(Context c){store=PeerStore.get(c);secrets=new SecretStore(c);} public String offer(String name,boolean allowSend,JSONArray endpoints)throws Exception{ KeyPair eph=PeerCrypto.ephemeral();String sid=UUID.randomUUID().toString();long expires=System.currentTimeMillis()/1000+300; - JSONObject body=new JSONObject().put("v",1).put("kind","offer").put("sid",sid).put("expires",expires).put("publicKey",store.publicKey()).put("ephemeral",PeerCrypto.b64(eph.getPublic().getEncoded())).put("name",name.substring(0,Math.min(name.length(),80))).put("signalingCapability",PeerCenterProtocol.MODE).put("relayHint",relayHint(store.context())).put("permissions",allowSend?"read,history,state,send":"read,history,state").put("endpoints",endpoints); + JSONObject body=new JSONObject().put("v",2).put("kind","offer").put("sid",sid).put("expires",expires).put("publicKey",store.publicKey()).put("ephemeral",PeerCrypto.b64(eph.getPublic().getEncoded())).put("name",name.substring(0,Math.min(name.length(),80))).put("signalingCapability",PeerCenterProtocol.MODE).put("relayHint",relayHint(store.context())).put("permissions",allowSend?"read,history,state,send":"read,history,state").put("endpoints",endpoints); String packed=PeerCrypto.b64(PeerCrypto.utf8(body.toString())); secrets.putString("p2p.pair.offer",packed);secrets.putBytes("p2p.pair.ephemeral",eph.getPrivate().getEncoded()); return encode(packed,PeerCrypto.sign(store.identity().getPrivate(),packed)); } - public String respond(String qr,String name)throws Exception{ + public String respond(String qr,String name)throws Exception{return respond(qr,name,false,false);} + /** Reverse grants require explicit user consent, never inherited from the QR. */ + public String respond(String qr,String name,boolean allowReverseRead,boolean allowReverseSend)throws Exception{ String raw=decode(qr);JSONObject offer=new JSONObject(new String(PeerCrypto.unb64(raw),java.nio.charset.StandardCharsets.UTF_8)); validateOffer(offer);String node=offer.getString("publicKey");if(node.equals(store.publicKey()))throw new SecurityException("cannot_pair_with_self"); String id=PeerCrypto.digest(PeerCrypto.publicKey(node).getEncoded());requireNew(id); KeyPair eph=PeerCrypto.ephemeral(); - String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),node,offer.getString("ephemeral"),store.publicKey(),PeerCrypto.b64(eph.getPublic().getEncoded()),offer.getString("permissions")); + if(allowReverseSend&&!allowReverseRead)throw new SecurityException("reverse_send_requires_read"); + String reverse=offer.optInt("v")==2?(allowReverseSend?PeerGrantPolicy.READ_SEND:allowReverseRead?PeerGrantPolicy.READ:""):""; + String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),node,offer.getString("ephemeral"),store.publicKey(),PeerCrypto.b64(eph.getPublic().getEncoded()),offer.getString("permissions")+(offer.optInt("v")==2?"|"+reverse:"")); byte[] shared=PeerCrypto.dh(eph.getPrivate(),PeerCrypto.publicKey(offer.getString("ephemeral"))); byte[] root=PeerCrypto.derive(shared,PeerCrypto.utf8(PeerCrypto.digest(PeerCrypto.utf8(transcript))),"content-root"); try{ - JSONObject p=profile(id,node,offer.optString("name","SIM Node"),"CONTROLLER",offer,"PENDING");store.save(p,root); - JSONObject reply=new JSONObject().put("v",1).put("kind","response").put("offer",raw).put("offerHash",PeerCrypto.digest(PeerCrypto.utf8(raw))).put("publicKey",store.publicKey()).put("ephemeral",PeerCrypto.b64(eph.getPublic().getEncoded())).put("name",name.substring(0,Math.min(80,name.length()))); + JSONObject p=offer.optInt("v")==2 + ?profileV2(id,node,offer.optString("name","SIM Node"),"CONTROLLER",offer,"PENDING",reverse,offer.getString("permissions"),offer.getJSONArray("endpoints")) + :profile(id,node,offer.optString("name","SIM Node"),"CONTROLLER",offer,"PENDING"); + store.save(p,root); + JSONObject reply=new JSONObject().put("v",offer.getInt("v")).put("kind","response").put("reversePermissions",reverse).put("endpoints",PeerService.endpoints()).put("offer",raw).put("offerHash",PeerCrypto.digest(PeerCrypto.utf8(raw))).put("publicKey",store.publicKey()).put("ephemeral",PeerCrypto.b64(eph.getPublic().getEncoded())).put("name",name.substring(0,Math.min(80,name.length()))); String packed=PeerCrypto.b64(PeerCrypto.utf8(reply.toString()));return encode(packed,PeerCrypto.sign(store.identity().getPrivate(),packed)); }finally{Arrays.fill(shared,(byte)0);Arrays.fill(root,(byte)0);} } public String accept(String qr)throws Exception{ String raw=decode(qr);JSONObject response=new JSONObject(new String(PeerCrypto.unb64(raw),java.nio.charset.StandardCharsets.UTF_8)); - if(response.optInt("v")!=1||!"response".equals(response.optString("kind")))throw new SecurityException("pair_response_required"); + if((response.optInt("v")!=1&&response.optInt("v")!=2)||!"response".equals(response.optString("kind")))throw new SecurityException("pair_response_required"); String packed=secrets.getString("p2p.pair.offer");byte[] der=secrets.getBytes("p2p.pair.ephemeral"); if(packed==null||der==null||!packed.equals(response.optString("offer"))||!PeerCrypto.digest(PeerCrypto.utf8(packed)).equals(response.optString("offerHash")))throw new SecurityException("pair_session_mismatch"); JSONObject offer=new JSONObject(new String(PeerCrypto.unb64(packed),java.nio.charset.StandardCharsets.UTF_8));validateOffer(offer); + if(offer.getInt("v")!=response.getInt("v"))throw new SecurityException("pair_version_mismatch"); + String reverse=response.optString("reversePermissions",""); + if(offer.optInt("v")==2&&(!PeerGrantPolicy.valid(reverse)||response.optJSONArray("endpoints")==null + ||response.getJSONArray("endpoints").length()>12))throw new SecurityException("invalid_reverse_grants_or_endpoints"); String controller=response.getString("publicKey"),id=PeerCrypto.digest(PeerCrypto.publicKey(controller).getEncoded());requireNew(id); - String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),store.publicKey(),offer.getString("ephemeral"),controller,response.getString("ephemeral"),offer.getString("permissions")); + String transcript=PeerCrypto.pairTranscript(offer.getString("sid"),offer.getLong("expires"),store.publicKey(),offer.getString("ephemeral"),controller,response.getString("ephemeral"),offer.getString("permissions")+(offer.optInt("v")==2?"|"+reverse:"")); PrivateKey privateKey=KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(der)); byte[] shared=PeerCrypto.dh(privateKey,PeerCrypto.publicKey(response.getString("ephemeral"))); byte[] root=PeerCrypto.derive(shared,PeerCrypto.utf8(PeerCrypto.digest(PeerCrypto.utf8(transcript))),"content-root"); - try{store.save(profile(id,controller,response.optString("name","Controller"),"NODE",offer,"ACTIVE"),root);secrets.remove("p2p.pair.offer");secrets.remove("p2p.pair.ephemeral");return id;}finally{Arrays.fill(der,(byte)0);Arrays.fill(shared,(byte)0);Arrays.fill(root,(byte)0);} + try{ + JSONObject p=offer.optInt("v")==2 + ?profileV2(id,controller,response.optString("name","Controller"),"NODE",offer,"ACTIVE",offer.getString("permissions"),reverse,response.getJSONArray("endpoints")) + :profile(id,controller,response.optString("name","Controller"),"NODE",offer,"ACTIVE"); + store.save(p,root);secrets.remove("p2p.pair.offer");secrets.remove("p2p.pair.ephemeral");return id;}finally{Arrays.fill(der,(byte)0);Arrays.fill(shared,(byte)0);Arrays.fill(root,(byte)0);} } /** @@ -92,8 +107,13 @@ public String acceptSignalingBindingQr(String qr)throws Exception{ JSONObject hint=peer.optJSONObject("relayHint"); String origin=hint==null?"":hint.optString("origin",""); com.blackkcold.simhub.CenterTrust trust=com.blackkcold.simhub.CenterTrustStore.get(store.context()).effective(); - if(trust==null||!trust.covers(com.blackkcold.simhub.CenterTrust.ROLE_CONTROLLER,origin)) + // The signed QR advertises the NODE endpoint, not the admin endpoint. + // The trusted center description must independently pin both roles. + if(trust==null)throw new SecurityException("CENTER_SETUP_REQUIRED"); + try{trust.controllerOriginForNode(origin);} + catch(com.blackkcold.simhub.CenterTrust.Invalid invalid){ throw new SecurityException("CENTER_SETUP_REQUIRED"); + } JSONObject old=store.signaling(id); if(old.optBoolean("consent")&&binding.equals(old.optString("bindingId")) &&revision==old.optInt("revision"))return id; @@ -113,6 +133,16 @@ public static JSONObject relayHint(Context c)throws Exception{ private JSONObject profile(String id,String pub,String name,String role,JSONObject offer,String state)throws Exception{ return new JSONObject().put("id",id).put("publicKey",pub).put("name",name).put("localRole",role).put("permissions",offer.getString("permissions")).put("pairState",state).put("policy","P2P_ONLY").put("signalingCapability",offer.optString("signalingCapability","")).put("signalingConsent",false).put("signalingConsentRevision",0).put("signalingCenterBinding","").put("discoveryState","WAITING").put("relayHint",role.equals("CONTROLLER")?offer.optJSONObject("relayHint"):new JSONObject()).put("fallbackConsent",false).put("turnConsent",false).put("relayBindingState","NONE").put("activeTransport","OFFLINE").put("endpoints",role.equals("CONTROLLER")?offer.getJSONArray("endpoints"):new JSONArray()); } + /** Versioned profile preserves legacy roles only for signaling coordination. */ + private JSONObject profileV2(String id,String pub,String name,String role,JSONObject offer,String state, + String toRemote,String fromRemote,JSONArray endpoints)throws Exception{ + if(!PeerGrantPolicy.valid(toRemote)||!PeerGrantPolicy.valid(fromRemote)) + throw new SecurityException("invalid_pair_grants"); + return profile(id,pub,name,role,offer,state) + .put("protocolVersion",2) + .put("grantsToRemote",toRemote).put("grantsFromRemote",fromRemote) + .put("endpoints",endpoints); + } public static String kind(String qr)throws Exception{return new JSONObject(new String(PeerCrypto.unb64(decode(qr)),java.nio.charset.StandardCharsets.UTF_8)).getString("kind");} private static String encode(String body,String sig)throws Exception{return "simhub-peer:"+PeerCrypto.b64(PeerCrypto.utf8(new JSONObject().put("body",body).put("signature",sig).toString()));} private static String decode(String qr)throws Exception{ @@ -121,7 +151,7 @@ private static String decode(String qr)throws Exception{ } private static void validateOffer(JSONObject p)throws Exception{ long now=System.currentTimeMillis()/1000; - if(p.optInt("v")!=1||!"offer".equals(p.optString("kind"))||p.getLong("expires")now+310||!p.getString("sid").matches("[a-f0-9-]{36}")||!Set.of("read,history,state","read,history,state,send").contains(p.optString("permissions")))throw new SecurityException("peer_offer_invalid_or_expired"); + if((p.optInt("v")!=1&&p.optInt("v")!=2)||!"offer".equals(p.optString("kind"))||p.getLong("expires")now+310||!p.getString("sid").matches("[a-f0-9-]{36}")||!Set.of("read,history,state","read,history,state,send").contains(p.optString("permissions")))throw new SecurityException("peer_offer_invalid_or_expired"); PeerCrypto.publicKey(p.getString("publicKey"));PeerCrypto.publicKey(p.getString("ephemeral")); if(p.getJSONArray("endpoints").length()>12)throw new SecurityException("too_many_peer_endpoints"); } diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerRpc.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerRpc.java index 96d21a2a0..e916f3f72 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerRpc.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerRpc.java @@ -18,15 +18,48 @@ public final class PeerRpc { public JSONObject dispatch(String peerId,String id,JSONObject request)throws Exception{ synchronized(store){ JSONObject peer=store.peer(peerId); - if(!"NODE".equals(peer.optString("localRole"))||!"ACTIVE".equals(peer.optString("pairState")))throw new SecurityException("peer_not_authorized"); - String action=request.getString("action");Set grants=new HashSet<>(Arrays.asList(peer.getString("permissions").split(","))); + boolean pendingV2=peer.optInt("protocolVersion",1)>=2 + &&"PENDING".equals(peer.optString("pairState")); + if((!"ACTIVE".equals(peer.optString("pairState"))&&!pendingV2) + ||(peer.optInt("protocolVersion",1)<2&&!"NODE".equals(peer.optString("localRole")))) + throw new SecurityException("peer_not_authorized"); + String action=request.getString("action"); + boolean relayAction=action.startsWith("relay."); + // Relay authorization is orthogonal to SMS/management grants. + if(relayAction&&(!peer.optBoolean("relayLiteAllowRemote",false) + ||!RelayLiteMailbox.get(c).enabled())) + throw new SecurityException("relay_lite_not_authorized"); + if(!relayAction&&!"peer.revoke".equals(action)&&!PeerGrantPolicy.allows(peer,action,true)) + throw new SecurityException("peer_permission_denied"); + // Only an authenticated transport should invoke dispatch; in a v2 + // pending relationship that proves the responder accepted the QR. + if(pendingV2){peer.put("pairState","ACTIVE");store.update(peer);} + String granted=peer.optInt("protocolVersion",1)>=2 + ?peer.optString("grantsToRemote",""):peer.optString("permissions",""); + Set grants=new HashSet<>(Arrays.asList(granted.split(","))); return switch(action){ case "device.state" -> {require(grants,"state");yield state(peer);} case "sms.page" -> {require(grants,"read");require(grants,"history");yield page(peer,request);} - case "events.page" -> {require(grants,"read");yield store.events(peerId,request.optLong("after",0));} + case "events.page" -> {require(grants,"read");yield store.events(peerId,request.optLong("after",0),request.optInt("limit",100),request.optInt("maxBytes",300000));} case "events.ack" -> {require(grants,"read");store.ackEvents(peerId,request.getJSONArray("sequences"));yield new JSONObject().put("state","acknowledged");} case "sms.send" -> {require(grants,"send");yield send(peerId,id,request);} case "command.status" -> {require(grants,"send");String command=request.getString("commandId");String localId="p2p-"+PeerCrypto.digest(PeerCrypto.utf8(peerId+"|"+command));String status=LocalStore.get(c).commandState(localId);JSONObject receipt=store.receipt(peerId,command,null);yield new JSONObject().put("state",status!=null?status:receipt!=null?receipt.optString("state","unknown"):"unknown");} + case "relay.put" -> { + String target=request.getString("target"); + JSONObject recipient=store.peer(target); + if(!"ACTIVE".equals(recipient.optString("pairState")) + ||!recipient.optBoolean("relayLiteAllowRemote",false)) + throw new SecurityException("relay_lite_target_not_authorized"); + yield RelayLiteMailbox.get(c).enqueue(peerId,target,request.getJSONObject("frame")); + } + case "relay.pull" -> { + yield RelayLiteMailbox.get(c).pull(peerId,request.optInt("limit",2)); + } + case "relay.ack" -> { + boolean removed=RelayLiteMailbox.get(c).ack(peerId, + request.getString("source"),request.getString("messageId")); + yield new JSONObject().put("state","acknowledged").put("removed",removed); + } case "peer.revoke" -> {store.revoke(peerId);yield new JSONObject().put("state","revoked");} default -> throw new SecurityException("unsupported_peer_action"); }; @@ -45,7 +78,9 @@ private JSONObject state(JSONObject peer)throws Exception{ } private JSONObject page(JSONObject peer,JSONObject req)throws Exception{ if(!SmsAccessPolicy.canRead(c))return new JSONObject().put("state","rejected").put("reason","read_sms_permission_missing"); - int limit=Math.max(1,Math.min(100,req.optInt("limit",100)));JSONObject cursor=req.optJSONObject("cursor"); + int limit=Math.max(1,Math.min(100,req.optInt("limit",100))); + int maxBytes=Math.max(16000,Math.min(300000,req.optInt("maxBytes",300000))); + JSONObject cursor=req.optJSONObject("cursor"); String selection=Telephony.Sms.TYPE+"<>?";List args=new ArrayList<>();args.add(String.valueOf(Telephony.Sms.MESSAGE_TYPE_DRAFT)); if(cursor!=null){long date=cursor.getLong("date"),id=cursor.getLong("id");if(date<0||id<0)throw new SecurityException("invalid_history_cursor");selection+=" AND (date300000){more=true;break;}rows.put(message);bytes+=size;lastDate=date;lastId=providerId; + if(bytes+size>maxBytes){more=true;break;}rows.put(message);bytes+=size;lastDate=date;lastId=providerId; } } JSONObject out=new JSONObject().put("state","succeeded").put("type","snapshot").put("v",1).put("messages",rows).put("hasMore",more); diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerService.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerService.java index bedeb547e..8c9ac7a1d 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerService.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerService.java @@ -16,6 +16,10 @@ /** User-started listener. No boot auto-start or background service launch. */ public final class PeerService extends Service { private PeerNodeServer server; + private volatile long relayLiteNextCheckMs; + private volatile long relayLiteBackoffMs=60_000L; + // Discovery must outlive PeerActivity; peer endpoints change after Wi-Fi handover. + private PeerDiscovery discovery; private final java.util.concurrent.ScheduledExecutorService relayPump=java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); private NsdManager nsd; private ConnectivityManager connectivity; @@ -43,29 +47,66 @@ public static JSONArray endpoints()throws Exception{ for(InetAddress address:addresses)if(a.length()<12)a.put(new JSONObject().put("host",address.getHostAddress()).put("port",service.server.port())); return a; } + /** Called on initial startup and again after an Android 17 LAN permission grant. */ + private synchronized void startLocalIfPermitted() throws Exception { + if(server!=null)return; + boolean allowed=Build.VERSION.SDK_INT<37 + ||checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")==PackageManager.PERMISSION_GRANTED; + if(!allowed){failure="local_network_permission_required";return;} + server=new PeerNodeServer(PeerStore.get(this)); + new Thread(server::run,"simhub-peer-listener").start(); + nsd=getSystemService(NsdManager.class); + NsdServiceInfo info=new NsdServiceInfo(); + info.setServiceName("simhub_"+PeerStore.get(this).myId().substring(0,16)); + info.setServiceType("_simhub-peer._tcp."); + info.setPort(server.port()); + registration=new NsdManager.RegistrationListener(){ + public void onRegistrationFailed(NsdServiceInfo i,int code){} + public void onUnregistrationFailed(NsdServiceInfo i,int code){} + public void onServiceRegistered(NsdServiceInfo i){} + public void onServiceUnregistered(NsdServiceInfo i){} + }; + nsd.registerService(info,NsdManager.PROTOCOL_DNS_SD,registration); + try{discovery=new PeerDiscovery(this);discovery.start();} + catch(Exception discoveryError){failure="peer_discovery_unavailable";} + if("local_network_permission_required".equals(failure))failure=""; + } + private synchronized void pollRelayLite(boolean networkChanged){ + long now=android.os.SystemClock.elapsedRealtime(); + if(networkChanged){relayLiteBackoffMs=60_000L;relayLiteNextCheckMs=0;} + if(now{ + int count=RelayLiteClient.pollAll(PeerService.this); + synchronized(PeerService.this){ + relayLiteBackoffMs=count>0?60_000L:Math.min(300_000L,relayLiteBackoffMs*2); + relayLiteNextCheckMs=android.os.SystemClock.elapsedRealtime()+relayLiteBackoffMs; + } + }); + } public void onCreate(){super.onCreate(); NotificationManager nm=getSystemService(NotificationManager.class);nm.createNotificationChannel(new NotificationChannel("simhub-p2p","P2P device access",NotificationManager.IMPORTANCE_LOW)); Intent open=new Intent(this,com.blackkcold.simhub.PeerActivity.class);PendingIntent pi=PendingIntent.getActivity(this,741,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); startForeground(741,new NotificationCompat.Builder(this,"simhub-p2p").setSmallIcon(R.drawable.ic_simhub).setContentTitle("SIM Hub · P2P").setContentText("用户已开启设备直连 · 仅接受已配对设备").setContentIntent(pi).setOngoing(true).build()); try{ running=this;failure=""; - boolean localAllowed=Build.VERSION.SDK_INT<37||checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK")==PackageManager.PERMISSION_GRANTED; - if(!localAllowed)failure="local_network_permission_required"; - if(localAllowed){ - server=new PeerNodeServer(PeerStore.get(this)); - new Thread(server::run,"simhub-peer-listener").start(); - nsd=getSystemService(NsdManager.class);NsdServiceInfo info=new NsdServiceInfo();info.setServiceName("simhub_"+PeerStore.get(this).myId().substring(0,16));info.setServiceType("_simhub-peer._tcp.");info.setPort(server.port()); - registration=new NsdManager.RegistrationListener(){public void onRegistrationFailed(NsdServiceInfo i,int e){}public void onUnregistrationFailed(NsdServiceInfo i,int e){}public void onServiceRegistered(NsdServiceInfo i){}public void onServiceUnregistered(NsdServiceInfo i){}}; - nsd.registerService(info,NsdManager.PROTOCOL_DNS_SD,registration); - } + startLocalIfPermitted(); connectivity=getSystemService(ConnectivityManager.class); - networkCallback=new ConnectivityManager.NetworkCallback(){public void onAvailable(Network n){PeerCenterConnector.networkChangedAll();}public void onLost(Network n){PeerCenterConnector.networkChangedAll();}}; + networkCallback=new ConnectivityManager.NetworkCallback(){ + public void onAvailable(Network n){ + PeerCenterConnector.networkChangedAll(); + pollRelayLite(true); + } + public void onLost(Network n){PeerCenterConnector.networkChangedAll();} + }; connectivity.registerDefaultNetworkCallback(networkCallback); refresh(); relayPump.scheduleWithFixedDelay(()->{try{ if(new com.blackkcold.simhub.AgentConfig(this).isEnrolled()&&!com.blackkcold.simhub.RelayForegroundService.isRunning()){ com.blackkcold.simhub.ApiClient api=new com.blackkcold.simhub.ApiClient(this);api.fetchCommands();api.flushCommandAcks(); } + // Inbox sync is opt-in: empty installations do not make outbound calls. + pollRelayLite(false); }catch(Exception ignored){}},1,5,java.util.concurrent.TimeUnit.SECONDS); }catch(Exception e){failure=e instanceof SecurityException?"local_network_permission_required":"listener_start_failed";stopSelf();} } @@ -79,7 +120,14 @@ private void refresh(){com.blackkcold.simhub.AgentExecutors.io().execute(()->{ }catch(Exception ignored){} try{if(new com.blackkcold.simhub.AgentConfig(this).isEnrolled())new com.blackkcold.simhub.ApiClient(this).putState();}catch(Exception ignored){} });} - public int onStartCommand(Intent i,int flags,int id){refresh();return START_NOT_STICKY;} - public void onDestroy(){running=null;relayPump.shutdownNow();PeerCenterConnector.closeAll();if(connectivity!=null&&networkCallback!=null)try{connectivity.unregisterNetworkCallback(networkCallback);}catch(Exception ignored){}if(server!=null)server.close();if(nsd!=null&®istration!=null)try{nsd.unregisterService(registration);}catch(Exception ignored){}stopForeground(STOP_FOREGROUND_REMOVE);super.onDestroy();} + public int onStartCommand(Intent i,int flags,int id){ + // A permission request may have completed while this service was running + // with a WebRTC-only path; do not require force-stopping the application. + try{startLocalIfPermitted();} + catch(Exception error){failure="listener_start_failed";} + refresh(); + return START_NOT_STICKY; + } + public void onDestroy(){running=null;relayPump.shutdownNow();PeerCenterConnector.closeAll();if(connectivity!=null&&networkCallback!=null)try{connectivity.unregisterNetworkCallback(networkCallback);}catch(Exception ignored){}if(discovery!=null)discovery.close();if(server!=null)server.close();if(nsd!=null&®istration!=null)try{nsd.unregisterService(registration);}catch(Exception ignored){}stopForeground(STOP_FOREGROUND_REMOVE);super.onDestroy();} public IBinder onBind(Intent i){return null;} } diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerStore.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerStore.java index 1aa7c45d0..6acc29c9d 100644 --- a/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerStore.java +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/PeerStore.java @@ -16,14 +16,22 @@ public final class PeerStore extends SQLiteOpenHelper { private final Context context; private static final String IDENTITY="simhub-p2p-identity-v1"; public static synchronized PeerStore get(Context c){if(INSTANCE==null)INSTANCE=new PeerStore(c.getApplicationContext());return INSTANCE;} - private PeerStore(Context c){super(c,"simhub-peers-v1.db",null,2);context=c;setWriteAheadLoggingEnabled(true);} + private PeerStore(Context c){super(c,"simhub-peers-v1.db",null,3);context=c;setWriteAheadLoggingEnabled(true);} public void onCreate(SQLiteDatabase db){ db.execSQL("CREATE TABLE peers(id TEXT PRIMARY KEY,profile TEXT NOT NULL)"); db.execSQL("CREATE TABLE receipts(peer TEXT NOT NULL,id TEXT NOT NULL,digest TEXT NOT NULL,result TEXT,state TEXT NOT NULL,PRIMARY KEY(peer,id))"); createEvents(db); + createLiteOutbox(db); db.execSQL("CREATE TABLE outbox(peer TEXT NOT NULL,id TEXT NOT NULL,cipher TEXT NOT NULL,expires INTEGER NOT NULL,PRIMARY KEY(peer,id))"); } - public void onUpgrade(SQLiteDatabase db,int a,int b){if(a<2)createEvents(db);} + public void onUpgrade(SQLiteDatabase db,int a,int b){ + if(a<2)createEvents(db); + if(a<3)createLiteOutbox(db); + } + private static void createLiteOutbox(SQLiteDatabase db){ + db.execSQL("CREATE TABLE IF NOT EXISTS lite_outbox(peer TEXT NOT NULL,id TEXT NOT NULL,digest TEXT NOT NULL,expires INTEGER NOT NULL,frame TEXT NOT NULL,PRIMARY KEY(peer,id))"); + db.execSQL("CREATE INDEX IF NOT EXISTS lite_outbox_expiry ON lite_outbox(expires)"); + } private static void createEvents(SQLiteDatabase db){db.execSQL("CREATE TABLE IF NOT EXISTS peer_events(seq INTEGER PRIMARY KEY AUTOINCREMENT,peer TEXT NOT NULL,event TEXT NOT NULL,kind TEXT NOT NULL,cipher TEXT NOT NULL,occurred INTEGER NOT NULL,UNIQUE(peer,event))");} public synchronized KeyPair identity()throws Exception{ @@ -44,9 +52,9 @@ public synchronized void save(JSONObject p,byte[] key)throws Exception{ } public synchronized JSONObject peer(String id)throws Exception{try(Cursor c=getReadableDatabase().rawQuery("SELECT profile FROM peers WHERE id=?",new String[]{id})){if(!c.moveToFirst())throw new SecurityException("peer_not_authorized");return new JSONObject(c.getString(0));}} public synchronized JSONArray list(){JSONArray a=new JSONArray();try(Cursor c=getReadableDatabase().rawQuery("SELECT profile FROM peers ORDER BY id",null)){while(c.moveToNext())try{a.put(new JSONObject(c.getString(0)));}catch(Exception e){throw new IllegalStateException(e);}}return a;} - public synchronized void update(JSONObject p)throws Exception{JSONObject previous=peer(p.getString("id"));if("REVOKED".equals(previous.optString("pairState"))&&!"REVOKED".equals(p.optString("pairState")))throw new SecurityException("peer_revoked");for(String field:List.of("publicKey","localRole","permissions"))if(!previous.optString(field).equals(p.optString(field)))throw new SecurityException("immutable_peer_grant");ContentValues v=new ContentValues();v.put("profile",p.toString());if(getWritableDatabase().update("peers",v,"id=?",new String[]{p.getString("id")})!=1)throw new SecurityException("peer_not_authorized");} + public synchronized void update(JSONObject p)throws Exception{JSONObject previous=peer(p.getString("id"));if("REVOKED".equals(previous.optString("pairState"))&&!"REVOKED".equals(p.optString("pairState")))throw new SecurityException("peer_revoked");for(String field:List.of("publicKey","localRole","permissions","protocolVersion","grantsToRemote","grantsFromRemote"))if(!previous.optString(field).equals(p.optString(field)))throw new SecurityException("immutable_peer_grant");ContentValues v=new ContentValues();v.put("profile",p.toString());if(getWritableDatabase().update("peers",v,"id=?",new String[]{p.getString("id")})!=1)throw new SecurityException("peer_not_authorized");} public byte[] key(String id)throws Exception{JSONObject p=peer(id);if("REVOKED".equals(p.optString("pairState")))throw new SecurityException("peer_revoked");byte[] key=new SecretStore(context).getBytes("p2p.content."+id);if(key==null||key.length!=32)throw new SecurityException("peer_key_unavailable");return key;} - public synchronized void revoke(String id)throws Exception{JSONObject p=peer(id);p.put("pairState","REVOKED").put("activeTransport","OFFLINE");update(p);new SecretStore(context).remove("p2p.content."+id);getWritableDatabase().delete("outbox","peer=?",new String[]{id});getWritableDatabase().delete("peer_events","peer=?",new String[]{id});} + public synchronized void revoke(String id)throws Exception{JSONObject p=peer(id);p.put("pairState","REVOKED").put("activeTransport","OFFLINE");update(p);new SecretStore(context).remove("p2p.content."+id);getWritableDatabase().delete("outbox","peer=?",new String[]{id});getWritableDatabase().delete("peer_events","peer=?",new String[]{id});getWritableDatabase().delete("lite_outbox","peer=?",new String[]{id});RelayLiteMailbox.get(context).revokePeer(id);} /** Atomic durable claim. A crash after dispatch remains 'indeterminate', never blindly resends. */ public synchronized JSONObject receipt(String peer,String id,String digest)throws Exception{ try(Cursor c=getReadableDatabase().rawQuery("SELECT digest,result FROM receipts WHERE peer=? AND id=?",new String[]{peer,id})){ @@ -76,12 +84,17 @@ public synchronized boolean event(String peer,String id,String kind,long occurre }finally{java.util.Arrays.fill(key,(byte)0);} } public synchronized JSONObject events(String peer,long after)throws Exception{ + return events(peer,after,100,300000); + } + public synchronized JSONObject events(String peer,long after,int requestedLimit,int requestedBytes)throws Exception{ + int limit=Math.max(1,Math.min(100,requestedLimit)); + int maxBytes=Math.max(16000,Math.min(300000,requestedBytes)); JSONArray rows=new JSONArray();int bytes=0;byte[] key=key(peer); - try(Cursor c=getReadableDatabase().rawQuery("SELECT seq,event,kind,cipher,occurred FROM peer_events WHERE peer=? AND seq>? ORDER BY seq LIMIT 100",new String[]{peer,String.valueOf(Math.max(0,after))})){ + try(Cursor c=getReadableDatabase().rawQuery("SELECT seq,event,kind,cipher,occurred FROM peer_events WHERE peer=? AND seq>? ORDER BY seq LIMIT ?",new String[]{peer,String.valueOf(Math.max(0,after)),String.valueOf(limit)})){ while(c.moveToNext()){ JSONObject cipher=new JSONObject(c.getString(3));JSONObject payload=new JSONObject(new String(PeerCrypto.crypt(false,key,PeerCrypto.unb64(cipher.getString("iv")),"event|"+peer+"|"+c.getString(1)+"|"+c.getString(2),PeerCrypto.unb64(cipher.getString("ct"))),java.nio.charset.StandardCharsets.UTF_8)); JSONObject row=new JSONObject().put("seq",c.getLong(0)).put("id",c.getString(1)).put("kind",c.getString(2)).put("occurred",c.getLong(4)).put("payload",payload); - int size=PeerCrypto.utf8(row.toString()).length;if(bytes+size>300000)break;bytes+=size;rows.put(row); + int size=PeerCrypto.utf8(row.toString()).length;if(bytes+size>maxBytes)break;bytes+=size;rows.put(row); } }finally{java.util.Arrays.fill(key,(byte)0);} return new JSONObject().put("state","succeeded").put("events",rows); @@ -89,6 +102,47 @@ public synchronized JSONObject events(String peer,long after)throws Exception{ public synchronized void ackEvents(String peer,JSONArray sequences)throws Exception{ if(sequences.length()>100)throw new SecurityException("too_many_event_acks");SQLiteDatabase db=getWritableDatabase();db.beginTransaction();try{for(int i=0;inow+PeerOfflineProtocol.MAX_TTL_SECONDS) + throw new SecurityException("relay_lite_expiry_invalid"); + String digest=PeerCrypto.digest(PeerCrypto.utf8(request.toString())); + SQLiteDatabase db=getWritableDatabase();db.beginTransaction(); + try{ + db.delete("lite_outbox","expires<=?",new String[]{String.valueOf(now)}); + try(Cursor c=db.rawQuery("SELECT digest,expires,frame FROM lite_outbox WHERE peer=? AND id=?", + new String[]{peer,id})){ + if(c.moveToFirst()){ + if(!digest.equals(c.getString(0)))throw new SecurityException("relay_lite_request_id_reused"); + if(c.getLong(1)<=now)throw new SecurityException("relay_lite_request_expired"); + JSONObject saved=new JSONObject(c.getString(2)); + db.setTransactionSuccessful(); + return saved; + } + } + try(Cursor count=db.rawQuery("SELECT COUNT(*) FROM lite_outbox WHERE peer=?",new String[]{peer})){ + if(count.moveToFirst()&&count.getLong(0)>=256) + throw new IllegalStateException("relay_lite_outbound_full"); + } + byte[] root=key(peer); + JSONObject sealed; + try{sealed=PeerOfflineProtocol.seal(root,myId(),peer,id,now,expiry,request);} + finally{java.util.Arrays.fill(root,(byte)0);} + ContentValues row=new ContentValues(); + row.put("peer",peer);row.put("id",id);row.put("digest",digest); + row.put("expires",expiry);row.put("frame",sealed.toString()); + db.insertOrThrow("lite_outbox",null,row); + db.setTransactionSuccessful(); + return sealed; + }finally{db.endTransaction();} + } + public synchronized void clearLiteOutbound(String peer,String id){ + getWritableDatabase().delete("lite_outbox","peer=? AND id=?",new String[]{peer,id}); + } public synchronized void queueOutbound(String peer,String id,JSONObject request)throws Exception{ byte[] key=key(peer);long expires=request.getLong("commandExpires");try{ JSONObject frame=PeerProtocol.seal(key,myId(),peer,id,expires,request);ContentValues values=new ContentValues();values.put("peer",peer);values.put("id",id);values.put("cipher",frame.toString());values.put("expires",expires); diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteClient.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteClient.java new file mode 100644 index 000000000..6dd399c6b --- /dev/null +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteClient.java @@ -0,0 +1,130 @@ +package com.blackkcold.simhub.p2p; + +import android.content.Context; +import com.blackkcold.simhub.SecretStore; +import org.json.JSONArray; +import org.json.JSONObject; +import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Bounded poll-and-ACK worker, run only for explicitly configured relay inboxes. */ +public final class RelayLiteClient { + private static final AtomicBoolean RUNNING=new AtomicBoolean(false); + private RelayLiteClient(){} + private static String responseKey(String from,String id){return "p2p.relayLite.response."+from+"."+id;} + private static String sentKey(String from,String id){return "p2p.relayLite.sent."+from+"."+id;} + + /** A manual sync never silently enables any network path or SMS permission. */ + public static int pollAll(Context context){ + if(!RUNNING.compareAndSet(false,true))return 0; + try{ + PeerStore store=PeerStore.get(context); + JSONArray profiles=store.list();int processed=0; + for(int i=0;i=2&&"PENDING".equals(source.optString("pairState")))) + throw new SecurityException("relay_lite_source_revoked"); + if(!id.equals(frame.getString("id")))throw new SecurityException("relay_lite_id_mismatch"); + byte[] root=store.key(from); + final JSONObject decrypted; + try{decrypted=PeerOfflineProtocol.open(root,from,localId,frame,System.currentTimeMillis()/1000);} + finally{Arrays.fill(root,(byte)0);} + SecretStore secrets=new SecretStore(c); + if("relay.response".equals(decrypted.optString("action"))){ + JSONObject result=decrypted.getJSONObject("result"); + // Persist locally under the encrypted Android secret store before ACK. + secrets.putString(responseKey(from,id),result.toString()); + store.clearLiteOutbound(from,id); + String state=result.optString("state"); + if("submitted".equals(state)||"succeeded".equals(state)||"failed".equals(state) + ||"delivered".equals(state)||"expired".equals(state))store.clearOutbound(from,id); + return; + } + String stored=secrets.getString(sentKey(from,id)); + JSONObject responseFrame; + if(stored!=null){ + responseFrame=new JSONObject(stored); + }else{ + JSONObject result; + try{ + // Relay Lite envelopes are capped at 64 KiB, unlike live RPC. + // Bound offline SMS history pages before applying provider work. + if("sms.page".equals(decrypted.optString("action")) + ||"events.page".equals(decrypted.optString("action"))){ + decrypted.put("limit",Math.min(10,decrypted.optInt("limit",100))); + decrypted.put("maxBytes",32000); + } + result=new PeerRpc(store).dispatch(from,id,decrypted); + } + catch(SecurityException rejected){result=new JSONObject().put("state","failed").put("reason","relay_lite_peer_permission_denied");} + // A response may be larger than one bounded offline frame. Fail closed + // with a small explicit error rather than misrepresenting delivery. + long now=System.currentTimeMillis()/1000; + byte[] contentRoot=store.key(from); + try{ + JSONObject responsePayload=new JSONObject().put("action","relay.response").put("result",result); + try{responseFrame=PeerOfflineProtocol.seal(contentRoot,localId,from,id,now, + now+PeerOfflineProtocol.MAX_TTL_SECONDS,responsePayload);} + catch(SecurityException tooLarge){ + responseFrame=PeerOfflineProtocol.seal(contentRoot,localId,from,id,now, + now+PeerOfflineProtocol.MAX_TTL_SECONDS, + new JSONObject().put("action","relay.response").put("result", + new JSONObject().put("state","failed").put("reason","relay_lite_response_too_large"))); + } + }finally{Arrays.fill(contentRoot,(byte)0);} + secrets.putString(sentKey(from,id),responseFrame.toString()); + } + JSONObject accepted=RelayLiteTransport.requestRelay(c,store,relayId, + UUID.randomUUID().toString(),new JSONObject().put("action","relay.put") + .put("target",from).put("frame",responseFrame)); + if(!"accepted".equals(accepted.optString("state"))) + throw new IllegalStateException("relay_lite_response_unconfirmed"); + } + + public static JSONObject cachedResult(Context c,String peerId,String requestId){ + try{ + String saved=new SecretStore(c).getString(responseKey(peerId,requestId)); + return saved==null?null:new JSONObject(saved); + }catch(Exception unavailable){return null;} + } +} diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteMailbox.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteMailbox.java new file mode 100644 index 000000000..0a72b8c36 --- /dev/null +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteMailbox.java @@ -0,0 +1,153 @@ +package com.blackkcold.simhub.p2p; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import org.json.JSONArray; +import org.json.JSONObject; +import java.nio.charset.StandardCharsets; + +/** + * Android-hosted, bounded and durable ciphertext-only mailbox. + * + * This database never receives SMS plaintext or AB pair keys. Authorization is + * checked by PeerRpc BEFORE these methods are called. Pull/ACK are scoped to + * the authenticated destination identity, never to a user-supplied identity. + */ +public final class RelayLiteMailbox extends SQLiteOpenHelper { + private static RelayLiteMailbox INSTANCE; + private static final int MAX_FRAME=PeerOfflineProtocol.MAX_FRAME_BYTES; + private static final int MAX_ROWS=2000,MAX_ROWS_PER_TARGET=500; + private static final int MAX_BYTES=50*1024*1024,MAX_BYTES_PER_TARGET=10*1024*1024; + private final Context context; + public static synchronized RelayLiteMailbox get(Context c){ + if(INSTANCE==null)INSTANCE=new RelayLiteMailbox(c.getApplicationContext()); + return INSTANCE; + } + private RelayLiteMailbox(Context c){ + super(c,"simhub-relay-lite-v1.db",null,1); + context=c;setWriteAheadLoggingEnabled(true); + } + @Override public void onCreate(SQLiteDatabase db){ + db.execSQL("CREATE TABLE envelopes(source TEXT NOT NULL,target TEXT NOT NULL,id TEXT NOT NULL,expires INTEGER NOT NULL,bytes INTEGER NOT NULL,cipher TEXT NOT NULL,created INTEGER NOT NULL,PRIMARY KEY(source,target,id))"); + db.execSQL("CREATE INDEX relay_lite_target ON envelopes(target,created)"); + db.execSQL("CREATE INDEX relay_lite_expiry ON envelopes(expires)"); + } + @Override public void onUpgrade(SQLiteDatabase db,int oldVersion,int newVersion){ + throw new IllegalStateException("relay_lite_upgrade_requires_migration"); + } + public boolean enabled(){ + return context.getSharedPreferences("simhub-relay-lite",Context.MODE_PRIVATE) + .getBoolean("enabled",false); + } + public void setEnabled(boolean enabled){ + context.getSharedPreferences("simhub-relay-lite",Context.MODE_PRIVATE) + .edit().putBoolean("enabled",enabled).apply(); + } + private static void requirePeerId(String id){ + if(id==null||!id.matches("[A-Za-z0-9_-]{16,80}")) + throw new SecurityException("relay_lite_peer_id_invalid"); + } + private static void purge(SQLiteDatabase db,long now){ + db.delete("envelopes","expires<=?",new String[]{String.valueOf(now)}); + } + private static long[] usage(SQLiteDatabase db,String selection,String[] args){ + try(Cursor c=db.rawQuery("SELECT COUNT(*),COALESCE(SUM(bytes),0) FROM envelopes "+selection,args)){ + if(!c.moveToFirst())throw new IllegalStateException("relay_lite_usage_unavailable"); + return new long[]{c.getLong(0),c.getLong(1)}; + } + } + /** + * Caller identity is derived from the authenticated outer E2EE Peer RPC; + * the inner frame's source must match this identity. Never trust a body + * field to authenticate the depositor. + */ + public synchronized JSONObject enqueue(String authenticatedSource,String target,JSONObject inner)throws Exception{ + if(!enabled())throw new SecurityException("relay_lite_disabled"); + requirePeerId(authenticatedSource);requirePeerId(target); + if(authenticatedSource.equals(target))throw new SecurityException("relay_lite_self_route"); + if(inner==null||!PeerOfflineProtocol.DOMAIN.equals(inner.optString("domain")) + ||inner.optInt("v")!=1 + ||!authenticatedSource.equals(inner.optString("from")) + ||!target.equals(inner.optString("to"))) + throw new SecurityException("relay_lite_inner_binding_invalid"); + String id=inner.getString("id");requirePeerId(id); + long now=System.currentTimeMillis()/1000,expires=inner.getLong("expires"); + if(expires<=now||expires>now+PeerOfflineProtocol.MAX_TTL_SECONDS) + throw new SecurityException("relay_lite_expiry_invalid"); + String cipher=inner.toString(); + int bytes=cipher.getBytes(StandardCharsets.UTF_8).length; + if(bytes>MAX_FRAME||bytes<32)throw new SecurityException("relay_lite_frame_size"); + SQLiteDatabase db=getWritableDatabase();db.beginTransaction(); + try{ + purge(db,now); + try(Cursor existing=db.rawQuery("SELECT cipher FROM envelopes WHERE source=? AND target=? AND id=?", + new String[]{authenticatedSource,target,id})){ + if(existing.moveToFirst()){ + if(!cipher.equals(existing.getString(0))) + throw new SecurityException("relay_lite_message_id_reused"); + db.setTransactionSuccessful(); + return new JSONObject().put("state","accepted").put("duplicate",true); + } + } + long[] total=usage(db,"",null); + long[] perTarget=usage(db,"WHERE target=?",new String[]{target}); + if(total[0]>=MAX_ROWS||total[1]+bytes>MAX_BYTES + ||perTarget[0]>=MAX_ROWS_PER_TARGET||perTarget[1]+bytes>MAX_BYTES_PER_TARGET) + throw new IllegalStateException("relay_lite_queue_full"); + ContentValues v=new ContentValues(); + v.put("source",authenticatedSource);v.put("target",target); + v.put("id",id);v.put("expires",expires);v.put("bytes",bytes); + v.put("cipher",cipher);v.put("created",now); + if(db.insertOrThrow("envelopes",null,v)==-1) + throw new IllegalStateException("relay_lite_enqueue_failed"); + db.setTransactionSuccessful(); + return new JSONObject().put("state","accepted").put("duplicate",false); + }finally{db.endTransaction();} + } + /** A recipient can only pull ciphertext addressed to its authenticated ID. */ + public synchronized JSONObject pull(String authenticatedTarget,int requested)throws Exception{ + if(!enabled())throw new SecurityException("relay_lite_disabled"); + requirePeerId(authenticatedTarget); + int limit=Math.max(1,Math.min(4,requested)); + long now=System.currentTimeMillis()/1000; + SQLiteDatabase db=getWritableDatabase(); + JSONArray items=new JSONArray(); + db.beginTransaction(); + try{ + purge(db,now); + try(Cursor c=db.rawQuery("SELECT source,id,cipher FROM envelopes WHERE target=? ORDER BY created ASC,id ASC LIMIT ?", + new String[]{authenticatedTarget,String.valueOf(limit)})){ + while(c.moveToNext()) + items.put(new JSONObject().put("source",c.getString(0)).put("id",c.getString(1)) + .put("frame",new JSONObject(c.getString(2)))); + } + db.setTransactionSuccessful(); + }finally{db.endTransaction();} + return new JSONObject().put("state","succeeded").put("items",items); + } + /** Application ACK after authenticated AEAD opening and processing only. */ + public synchronized boolean ack(String authenticatedTarget,String source,String id){ + requirePeerId(authenticatedTarget);requirePeerId(source);requirePeerId(id); + return getWritableDatabase().delete("envelopes","target=? AND source=? AND id=?", + new String[]{authenticatedTarget,source,id})>0; + } + public synchronized void revokePeer(String peer){ + requirePeerId(peer); + getWritableDatabase().delete("envelopes","source=? OR target=?",new String[]{peer,peer}); + } + public synchronized JSONObject stats()throws Exception{ + long now=System.currentTimeMillis()/1000; + SQLiteDatabase db=getWritableDatabase(); + db.beginTransaction(); + try{ + purge(db,now); + long[] totals=usage(db,"",null); + db.setTransactionSuccessful(); + return new JSONObject().put("enabled",enabled()) + .put("queued",totals[0]).put("bytes",totals[1]); + }finally{db.endTransaction();} + } +} diff --git a/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteTransport.java b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteTransport.java new file mode 100644 index 000000000..412edcbaf --- /dev/null +++ b/android/app/src/main/java/com/blackkcold/simhub/p2p/RelayLiteTransport.java @@ -0,0 +1,70 @@ +package com.blackkcold.simhub.p2p; + +import android.content.Context; +import com.blackkcold.simhub.SecretStore; +import org.json.JSONObject; +import java.util.Arrays; +import java.util.UUID; + +/** One explicitly selected peer can carry opaque offline ciphertext to a target. */ +public final class RelayLiteTransport implements HubTransport { + private final Context context; + private final PeerStore store; + private final String relayId; + private RelayLiteTransport(Context context,PeerStore store,String relayId){ + this.context=context.getApplicationContext();this.store=store;this.relayId=relayId; + } + public String route(){return "RELAY_LITE";} + + public static RelayLiteTransport configured(Context c,PeerStore store,JSONObject target){ + if(!target.optBoolean("relayLiteConfigured",false))return null; + String relay=target.optString("relayLiteVia",""); + if(relay.isEmpty()||relay.equals(target.optString("id")))return null; + try{ + JSONObject profile=store.peer(relay); + if(!"ACTIVE".equals(profile.optString("pairState")))return null; + return new RelayLiteTransport(c,store,relay); + }catch(Exception missing){return null;} + } + + /** No implicit Relay enrollment or multi-hop: use a fresh direct/RTC route to C. */ + static JSONObject requestRelay(Context c,PeerStore store,String relay,String id,JSONObject body)throws Exception{ + JSONObject profile=store.peer(relay); + if(!"ACTIVE".equals(profile.optString("pairState")))throw new SecurityException("relay_lite_peer_not_active"); + ConnectionCoordinator hop=new ConnectionCoordinator(store); + if(profile.optBoolean("signalingConsent")){ + try{ + PeerCenterConnector.ensure(c.getApplicationContext(),relay); + PeerCenterConnector.active(relay).route(); // retain only authenticated channel + hop.internet(PeerCenterConnector.active(relay)); + }catch(Exception ignored){/* LAN may still be available. */} + } + return hop.request(relay,id,body); + } + + public JSONObject request(JSONObject target,String id,JSONObject request)throws Exception{ + String targetId=target.getString("id"),sourceId=store.myId(); + JSONObject previouslyDelivered=RelayLiteClient.cachedResult(context,targetId,id); + if(previouslyDelivered!=null)return previouslyDelivered; + String action=request.getString("action"); + if(!PeerGrantPolicy.allows(target,action,false)) + throw new SecurityException("relay_lite_target_grant_missing"); + // These status/command requests are asynchronous. The app must not + // pretend a relay acceptance means the target executed the command. + long now=System.currentTimeMillis()/1000; + long expiry=now+PeerOfflineProtocol.MAX_TTL_SECONDS; + if("sms.send".equals(action)) + expiry=Math.min(expiry,request.optLong("commandExpires",now)); + if(expiry<=now)throw new SecurityException("relay_lite_command_expired"); + // Store the exact sealed frame before network transmission. If the + // first ACK is lost, retry with the same IV, ciphertext, and request id. + final JSONObject encrypted=store.liteOutbound(targetId,id,request,expiry); + JSONObject packet=new JSONObject().put("action","relay.put") + .put("target",targetId).put("frame",encrypted); + JSONObject result=requestRelay(context,store,relayId,UUID.randomUUID().toString(),packet); + if(!"accepted".equals(result.optString("state"))) + throw new IllegalStateException("relay_lite_enqueue_unconfirmed"); + return new JSONObject().put("state","queued").put("route","RELAY_LITE") + .put("requestId",id).put("relayAccepted",true); + } +} diff --git a/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerGrantPolicy.java b/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerGrantPolicy.java new file mode 100644 index 000000000..feff94c0b --- /dev/null +++ b/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerGrantPolicy.java @@ -0,0 +1,39 @@ +package com.blackkcold.simhub.p2p; + +import org.json.JSONObject; +import java.util.Set; +import java.util.HashSet; +import java.util.Arrays; + +/** A peer may expose and consume services independently. A transport is never a grant. */ +public final class PeerGrantPolicy { + private PeerGrantPolicy(){} + public static final String READ="read,history,state"; + public static final String READ_SEND="read,history,state,send"; + + public static boolean valid(String grants){ + return "".equals(grants)||READ.equals(grants)||READ_SEND.equals(grants); + } + + public static boolean allows(JSONObject peer, String action, boolean serving) { + if(peer==null||"REVOKED".equals(peer.optString("pairState")))return false; + String grants; + if(peer.optInt("protocolVersion",1)>=2){ + grants=peer.optString(serving?"grantsToRemote":"grantsFromRemote",""); + }else{ + String role=peer.optString("localRole"); + if(serving&&!"NODE".equals(role))return false; + if(!serving&&!"CONTROLLER".equals(role))return false; + grants=peer.optString("permissions",""); + } + if(!valid(grants))return false; + Set permissions=new HashSet<>(Arrays.asList(grants.split(","))); + return switch(action) { + case "device.state" -> permissions.contains("state"); + case "sms.page" -> permissions.contains("read")&&permissions.contains("history"); + case "events.page","events.ack" -> permissions.contains("read"); + case "sms.send","command.status" -> permissions.contains("send"); + default -> false; + }; + } +} diff --git a/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerOfflineProtocol.java b/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerOfflineProtocol.java new file mode 100644 index 000000000..a2da2a76b --- /dev/null +++ b/android/peer-core/src/main/java/com/blackkcold/simhub/p2p/PeerOfflineProtocol.java @@ -0,0 +1,62 @@ +package com.blackkcold.simhub.p2p; + +import org.json.JSONObject; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * Distinct end-to-end encrypted store-and-forward envelope. The intermediate + * Relay Lite only carries opaque frames: it never derives the content key. + * Offline envelopes intentionally have a separate key/domain/TTL from live RPC. + */ +public final class PeerOfflineProtocol { + private PeerOfflineProtocol(){} + public static final String DOMAIN="simhub-peer-offline-v1"; + public static final long MAX_TTL_SECONDS=72L*3600; + public static final int MAX_FRAME_BYTES=64*1024; + + private static void requireId(String s){ + if(s==null||!s.matches("[A-Za-z0-9_-]{16,80}")) + throw new SecurityException("offline_peer_identifier_invalid"); + } + private static String aad(String from,String to,String id,long exp){ + return DOMAIN+"|"+from+"|"+to+"|"+id+"|"+exp; + } + private static byte[] offlineKey(byte[] contentRoot)throws Exception{ + if(contentRoot==null||contentRoot.length!=32)throw new SecurityException("offline_key_invalid"); + return PeerCrypto.derive(contentRoot,PeerCrypto.utf8(DOMAIN),"offline-message"); + } + public static JSONObject seal(byte[] contentRoot,String from,String to,String id,long now,long expires, + JSONObject payload)throws Exception{ + requireId(from);requireId(to);requireId(id); + if(from.equals(to)||expires<=now||expires>now+MAX_TTL_SECONDS) + throw new SecurityException("offline_expiry_invalid"); + byte[] key=offlineKey(contentRoot),nonce=PeerCrypto.random(12); + try{ + byte[] ct=PeerCrypto.crypt(true,key,nonce,aad(from,to,id,expires),PeerCrypto.utf8(payload.toString())); + JSONObject frame=new JSONObject().put("v",1).put("domain",DOMAIN) + .put("from",from).put("to",to).put("id",id).put("expires",expires) + .put("iv",PeerCrypto.b64(nonce)).put("ct",PeerCrypto.b64(ct)); + if(frame.toString().getBytes(StandardCharsets.UTF_8).length>MAX_FRAME_BYTES) + throw new SecurityException("offline_frame_too_large"); + return frame; + }finally{Arrays.fill(key,(byte)0);Arrays.fill(nonce,(byte)0);} + } + public static JSONObject open(byte[] contentRoot,String from,String to,JSONObject frame,long now)throws Exception{ + requireId(from);requireId(to); + if(frame==null||frame.toString().getBytes(StandardCharsets.UTF_8).length>MAX_FRAME_BYTES + ||frame.optInt("v")!=1||!DOMAIN.equals(frame.optString("domain")) + ||!from.equals(frame.optString("from"))||!to.equals(frame.optString("to"))) + throw new SecurityException("offline_domain_mismatch"); + String id=frame.getString("id");requireId(id); + long exp=frame.getLong("expires"); + if(exp<=now||exp>now+MAX_TTL_SECONDS) + throw new SecurityException("offline_frame_expired"); + byte[] key=offlineKey(contentRoot); + try{ + return new JSONObject(new String(PeerCrypto.crypt(false,key, + PeerCrypto.unb64(frame.getString("iv")),aad(from,to,id,exp), + PeerCrypto.unb64(frame.getString("ct"))),StandardCharsets.UTF_8)); + }finally{Arrays.fill(key,(byte)0);} + } +} diff --git a/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerGrantPolicyTest.java b/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerGrantPolicyTest.java new file mode 100644 index 000000000..4f75631f0 --- /dev/null +++ b/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerGrantPolicyTest.java @@ -0,0 +1,44 @@ +package com.blackkcold.simhub.p2p; + +import org.junit.Test; +import org.json.JSONObject; +import static org.junit.Assert.*; + +public final class PeerGrantPolicyTest { + private static JSONObject peer(String role,int version,String to,String from)throws Exception{ + return new JSONObject().put("pairState","ACTIVE").put("localRole",role) + .put("protocolVersion",version).put("permissions",PeerGrantPolicy.READ) + .put("grantsToRemote",to).put("grantsFromRemote",from); + } + @Test public void v1KeepsStrictNodeControllerRoles()throws Exception { + JSONObject node=peer("NODE",1,"",""); + JSONObject controller=peer("CONTROLLER",1,"",""); + assertTrue(PeerGrantPolicy.allows(node,"sms.page",true)); + assertFalse(PeerGrantPolicy.allows(node,"sms.page",false)); + assertTrue(PeerGrantPolicy.allows(controller,"sms.page",false)); + assertFalse(PeerGrantPolicy.allows(controller,"sms.page",true)); + assertFalse(PeerGrantPolicy.allows(node,"sms.send",true)); + } + @Test public void v2IsDirectionalAndCannotDerivePermissionsFromTransport()throws Exception { + JSONObject p=peer("CONTROLLER",2,PeerGrantPolicy.READ,""); + assertTrue(PeerGrantPolicy.allows(p,"sms.page",true)); + assertFalse(PeerGrantPolicy.allows(p,"sms.page",false)); + assertFalse(PeerGrantPolicy.allows(p,"sms.send",true)); + p.put("grantsFromRemote",PeerGrantPolicy.READ_SEND); + assertTrue(PeerGrantPolicy.allows(p,"sms.send",false)); + assertFalse(PeerGrantPolicy.allows(p,"sms.send",true)); + p.put("grantsToRemote",PeerGrantPolicy.READ_SEND); + assertTrue(PeerGrantPolicy.allows(p,"sms.send",true)); + p.put("pairState","REVOKED"); + assertFalse(PeerGrantPolicy.allows(p,"sms.send",true)); + } + @Test public void malformedOrUnknownGrantsAlwaysFailClosed()throws Exception { + assertTrue(PeerGrantPolicy.valid("")); + assertTrue(PeerGrantPolicy.valid(PeerGrantPolicy.READ)); + assertFalse(PeerGrantPolicy.valid("send")); + assertFalse(PeerGrantPolicy.valid("read,history,state,send,admin")); + JSONObject p=peer("NODE",2,"read,history,state,admin",""); + assertFalse(PeerGrantPolicy.allows(p,"sms.page",true)); + assertFalse(PeerGrantPolicy.allows(p,"peer.revoke",true)); + } +} diff --git a/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerOfflineProtocolTest.java b/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerOfflineProtocolTest.java new file mode 100644 index 000000000..e9b4a58f1 --- /dev/null +++ b/android/peer-core/src/test/java/com/blackkcold/simhub/p2p/PeerOfflineProtocolTest.java @@ -0,0 +1,30 @@ +package com.blackkcold.simhub.p2p; + +import org.junit.Test; +import org.json.JSONObject; +import java.util.Arrays; +import static org.junit.Assert.*; + +public final class PeerOfflineProtocolTest { + private static final String A="0123456789abcdef0123456789abcdef"; + private static final String B="abcdef0123456789abcdef0123456789"; + @Test public void storeAndForwardSurvivesOfflineWindowWithoutExposingContent()throws Exception{ + byte[] key=PeerCrypto.random(32);long now=1800000000L,exp=now+86400; + JSONObject event=new JSONObject().put("action","sms.event").put("body","confidential otp 777777"); + JSONObject envelope=PeerOfflineProtocol.seal(key,A,B,"message012345678901",now,exp,event); + assertFalse(envelope.toString().contains("777777")); + assertEquals(event.toString(),PeerOfflineProtocol.open(key,A,B,envelope,now+50000).toString()); + assertThrows(SecurityException.class,()->PeerOfflineProtocol.open(key,A,B,envelope,exp)); + assertThrows(SecurityException.class,()->PeerOfflineProtocol.open(key,B,A,envelope,now)); + Arrays.fill(key,(byte)0); + } + @Test public void domainKeyAndBindingDoNotDowngradeToLiveRpc()throws Exception{ + byte[] key=PeerCrypto.random(32);long now=1800000000L; + JSONObject envelope=PeerOfflineProtocol.seal(key,A,B,"message012345678901",now,now+3600,new JSONObject().put("action","device.state")); + JSONObject changed=new JSONObject(envelope.toString()).put("id","message012345678902"); + assertThrows(Exception.class,()->PeerOfflineProtocol.open(key,A,B,changed,now)); + assertThrows(SecurityException.class,()->PeerProtocol.open(key,A,B,envelope,now)); + assertThrows(SecurityException.class,()->PeerOfflineProtocol.seal(key,A,B,"message012345678901",now,now+PeerOfflineProtocol.MAX_TTL_SECONDS+1,new JSONObject())); + Arrays.fill(key,(byte)0); + } +} diff --git a/docs/p2p/TEST_GUIDE.zh-CN.md b/docs/p2p/TEST_GUIDE.zh-CN.md index 5a2caab41..a1ccfbc4e 100644 --- a/docs/p2p/TEST_GUIDE.zh-CN.md +++ b/docs/p2p/TEST_GUIDE.zh-CN.md @@ -41,9 +41,9 @@ python3 scripts/test_center_config_packaging.py `.github/workflows/p2p-preview.yml`: - `preview` 作业以 `-PcenterConfigFake=true` 构建预览包,运行沙盒脚本、泄漏扫描与受控打包负向测试,并启动真实 coturn 运行 TURN 沙盒。 -- `android-sandbox` 作业在 API 35 模拟器安装预览包与仪器包,运行 `PeerInstrumentedTest`,要求 `OK (13 tests)`,并在通过后导出 APK、SHA-256 与测试记录。 +- `android-sandbox` 作业在 API 35 模拟器安装预览包与仪器包,运行 `PeerInstrumentedTest`,要求 `OK (15 tests)`,并在通过后导出 APK、SHA-256 与测试记录。 -仪器测试覆盖:生产 Keystore/双扫码/TCP、撤销、独立 Relay 授权、路由策略、重复短信回执幂等、1,205 行历史分页,以及本轮新增的「原生引擎就绪 + 未授权中心时辅助路径失败关闭 + 默认仅 STUN」与「节点中心绑定授权码签名、防篡改与明确同意」。 +仪器测试覆盖:生产 Keystore/双扫码/TCP、撤销、独立 Relay 授权、路由策略、重复短信回执幂等、1,205 行历史分页、Relay Lite 密文队列、按收件人鉴权的 ACK、离线请求密文持久化与重试,以及原生 RTC 引擎、可信中心授权码和失败关闭。模拟器测试不等于跨真机网络验收。 **操作补充(已有可信中心)**:两台设备先完成双向扫码,节点进入「连接 / 授权」明确同意信令辅助建连;节点绑定成功后生成 5 分钟有效的签名绑定二维码,管理端进入「连接详情」扫码确认,才会尝试 WebRTC 公网直连。同一局域网不需要此步骤。此步骤不是新的公共服务器,不启用数据 Relay,也不能保证受限 NAT 下成功;无已有可信中心仍只能尝试同网直连。 diff --git a/scripts/export_p2p_preview.py b/scripts/export_p2p_preview.py index 2d9ca657c..7ebc41d31 100644 --- a/scripts/export_p2p_preview.py +++ b/scripts/export_p2p_preview.py @@ -16,7 +16,7 @@ APK = ROOT / "android/app/build/outputs/apk/p2pPreview/app-p2pPreview.apk" TEST_RESULTS = ROOT / "android-sandbox-results.txt" TEST_GUIDE = ROOT / "docs/p2p/TEST_GUIDE.zh-CN.md" -EXPECTED_COUNT = 13 +EXPECTED_COUNT = 15 # Keep in sync with PeerInstrumentedTest; never weaken acceptance. def export_package(root: Path = ROOT) -> Path: diff --git a/scripts/test_p2p_preview_export.py b/scripts/test_p2p_preview_export.py index a52e3b335..5aa3a1e7f 100644 --- a/scripts/test_p2p_preview_export.py +++ b/scripts/test_p2p_preview_export.py @@ -23,7 +23,7 @@ def setUp(self): guide.parent.mkdir(parents=True) guide.write_text("test guide", encoding="utf-8") self.evidence = self.root / "android-sandbox-results.txt" - self.evidence.write_text("............\nTime: 33.327\n\nOK (13 tests)\n", + self.evidence.write_text("............\nTime: 33.327\n\nOK (15 tests)\n", encoding="utf-8") def test_verified_apk_and_digest(self): @@ -36,8 +36,8 @@ def test_verified_apk_and_digest(self): self.assertEqual((self.root / "P2P-TEST-GUIDE.md").read_text(), "test guide") def test_bad_or_absent_test_evidence_never_exports(self): - for text in ("FAILURES!!!\nTests run: 13, Failures: 1\n", - "OK (12 tests)\n", "OK (13 tests)\nFAILURES!!!\n"): + for text in ("FAILURES!!!\nTests run: 15, Failures: 1\n", + "OK (14 tests)\n", "OK (15 tests)\nFAILURES!!!\n"): with self.subTest(text=text): self.evidence.write_text(text) with self.assertRaisesRegex(RuntimeError, "INSTRUMENTATION_NOT_PASSED"):