diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index 16cd787..eb6f337 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -208,12 +208,17 @@ class AbsdkBridge internal constructor( private inline fun withActiveSession( profileId: ProviderProfileId, block: () -> DomainResult, - ): DomainResult = if (hasActiveSession(profileId)) { - block() - } else { - DomainResult.Failure( - DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"), - ) + ): DomainResult { + if (!hasActiveSession(profileId)) { + return DomainResult.Failure( + DomainError.TokenInvalid(detail = "No active ABSDK session for this provider"), + ) + } + return block().also { result -> + if (result is DomainResult.Failure && result.error is DomainError.TokenInvalid) { + activeSession.value = null + } + } } private fun hasActiveSession(profileId: ProviderProfileId): Boolean = @@ -299,10 +304,16 @@ class AbsdkBridge internal constructor( private const val DISABLED_STATUS = "0" private const val ENABLED_STATUS = "1" - fun create(): AbsdkBridge = AbsdkBridge( - client = VendorAbsdkClient.create(), - dispatcher = Dispatchers.IO, - ) + @OptIn(CleartextAbsdkTransport::class) + private val processInstance: AbsdkBridge by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + AbsdkBridge( + client = VendorAbsdkClient.create(), + dispatcher = Dispatchers.IO, + ) + } + + @CleartextAbsdkTransport + fun create(): AbsdkBridge = processInstance } } diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt new file mode 100644 index 0000000..9cd8dcd --- /dev/null +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/CleartextAbsdkTransport.kt @@ -0,0 +1,22 @@ +package com.flagship.abox.manager.absdk.bridge + +/** + * Marks construction of the original ABSDK 0630 client, whose base endpoint is hardcoded to HTTP. + * + * Opting in acknowledges that transport confidentiality and integrity are not provided by the + * vendor JAR. App integration must remain disabled until the deployment has an approved transport + * control and the security limitation is presented to the responsible integrator. + */ +@RequiresOptIn( + message = "ABSDK 0630 uses a hardcoded cleartext HTTP transport. " + + "Do not enable it without an approved transport control.", + level = RequiresOptIn.Level.ERROR, +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, +) +annotation class CleartextAbsdkTransport diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt index 4abdb2f..081106d 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/VendorAbsdkClient.kt @@ -4,6 +4,7 @@ import com.dhc.absdk.ABRet import com.dhc.absdk.ABSDK import com.dhc.absdk.IrInfo +@CleartextAbsdkTransport internal class VendorAbsdkClient private constructor( private val sdk: ABSDK, ) : AbsdkClient { diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt new file mode 100644 index 0000000..05bbd41 --- /dev/null +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkFactoryTest.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.absdk.bridge + +import org.junit.Assert.assertSame +import org.junit.Test + +@OptIn(CleartextAbsdkTransport::class) +class AbsdkFactoryTest { + @Test + fun productionFactoryReturnsOneProcessInstance() { + assertSame(AbsdkBridge.create(), AbsdkBridge.create()) + } +} diff --git a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt index 294b264..182f692 100644 --- a/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt +++ b/absdk-bridge/src/test/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkSessionBridgeTest.kt @@ -91,6 +91,25 @@ class AbsdkSessionBridgeTest { assertNull(bridge.observeSession(profile).first()) } + @Test + fun deviceTokenErrorClearsSessionAndBlocksFurtherCalls() = runTest { + val client = ScriptedAbsdkClient().apply { + socketStatusResponses = ArrayDeque( + listOf(ScriptedAbsdkClient.failure("10002", "expired")), + ) + } + val bridge = bridge(client) + bridge.login(profile, credentials) + + val first = bridge.getStatus(profile, DeviceName("socket")) + + assertTrue((first as DomainResult.Failure).error is DomainError.TokenInvalid) + assertNull(bridge.observeSession(profile).first()) + val second = bridge.getStatus(profile, DeviceName("socket")) + assertTrue((second as DomainResult.Failure).error is DomainError.TokenInvalid) + assertEquals(listOf("login:demo", "socket-status:socket"), client.calls) + } + private fun bridge(client: ScriptedAbsdkClient): AbsdkBridge = AbsdkBridge(client, Dispatchers.Unconfined) } diff --git a/docs/architecture.md b/docs/architecture.md index 83ce72a..c00b4c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,9 +102,11 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证 - 不提供设备目录; - 不调用未确认的 `setHostInfo`; - logout 只清除 Bridge 本地会话,因为 JAR 没有公开 logout 或 Token setter; +- 生产工厂始终返回进程内同一个 Bridge,使互斥锁和本地会话与厂商单例保持一致; +- 真实工厂标记为 `@CleartextAbsdkTransport`,调用方必须显式 opt-in 承认厂商 JAR 的硬编码 HTTP 风险; - 当前直接引用归档 Sample 目录中的唯一一份 JAR,不复制厂商二进制。 -Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 +Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 ## 9. 后续边界 diff --git a/docs/protocol/absdk-0630.md b/docs/protocol/absdk-0630.md index 107335e..6ae46c1 100644 --- a/docs/protocol/absdk-0630.md +++ b/docs/protocol/absdk-0630.md @@ -108,6 +108,8 @@ commons-logging-1.1.3.jar JAR 内含宽松的 TLS 信任实现。正式产品不得把这一历史传输行为扩展为 App 其他网络请求的安全策略,也不得记录密码、Token、API Key 或敏感红外载荷。 +真实 Bridge 工厂使用 `@CleartextAbsdkTransport` 作为编译期 opt-in 门禁,并且当前没有接入 Android App。该标记只防止误接入,不会为链路提供加密。生产集成前必须确定并批准 HTTPS、受控加密代理或其他能够提供机密性与完整性的传输控制;在此之前不得启用真实 ABox 入口。 + ## 6. `setHostInfo` 的已确认行为 `setHostInfo(url, hotelName, roomNo, apikey)` 向以下端点发送初始化请求: