diff --git a/README.md b/README.md index 7b00d9a..3ab2769 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,18 @@ Debug App 通过 USB 连接本机后先执行 `http://127.0.0.1:8080`、BUYER 账号、设备 ID 和只显示一次的设备密钥登录。 正式版后台地址只接受 HTTPS。App 每次由采购员点击“获取任务”,不会自动领单。 +局域网 Debug 联调先生成本机 CA 和服务端证书,再启动服务: + +```powershell +.\generate-local-tls.ps1 -IPAddress 192.168.0.224 +.\start-backend.bat --migrate +adb push .\.local\cmroubao-tls\ca.crt /sdcard/Download/cmroubao-local-ca.crt +``` + +在手机系统设置中从下载目录安装 `cmroubao-local-ca.crt` 为 CA 证书;Debug APK 才会 +信任该用户安装的 CA。App 后端地址填写 `https://192.168.0.224:8080`。证书与私钥均 +位于被忽略的 `.local/`,不可提交或共享;Windows 防火墙还必须允许入站 TCP 8080。 + ## 文档入口 - [AI 开发入口](docs/00-ai-start-here.md) diff --git a/android-buyer/app/src/main/res/xml/network_security_config.xml b/android-buyer/app/src/main/res/xml/network_security_config.xml index 9d0b208..97ad604 100644 --- a/android-buyer/app/src/main/res/xml/network_security_config.xml +++ b/android-buyer/app/src/main/res/xml/network_security_config.xml @@ -7,4 +7,9 @@ + + + + + diff --git a/backend-api/README.md b/backend-api/README.md index 26119d8..e2962d6 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -89,3 +89,8 @@ App 可每 30 秒 best-effort 滑动续期;过期后原设备 heartbeat 只同 默认 loopback 可使用 HTTP 开发。局域网监听必须同时设置 certificate/private key, 服务直接使用 TLS 启动,不会降级为明文。完整 API 合约见 [`../docs/api.md`](../docs/api.md)。 + +仓库根目录的 `generate-local-tls.ps1 -IPAddress ` 可为 Debug 联调生成 +`.local/cmroubao-tls/` 下的本地 CA 和带 IP SAN 的服务端证书;生成后 +`start-backend.bat` 自动使用证书监听 `0.0.0.0:8080`。手机只安装 `ca.crt`,绝不 +复制 `ca.key` 或 `server.key`。 diff --git a/generate-local-tls.ps1 b/generate-local-tls.ps1 new file mode 100644 index 0000000..9060dbd --- /dev/null +++ b/generate-local-tls.ps1 @@ -0,0 +1,153 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [System.Net.IPAddress]$IPAddress, + [string[]]$DnsName = @("localhost") +) + +$ErrorActionPreference = "Stop" + +$outputDirectory = Join-Path $PSScriptRoot ".local\cmroubao-tls" +$caCertificatePath = Join-Path $outputDirectory "ca.crt" +$caPrivateKeyPath = Join-Path $outputDirectory "ca.key" +$serverCertificatePath = Join-Path $outputDirectory "server.crt" +$serverPrivateKeyPath = Join-Path $outputDirectory "server.key" + +if ((Test-Path -LiteralPath $serverCertificatePath -PathType Leaf) -or + (Test-Path -LiteralPath $serverPrivateKeyPath -PathType Leaf)) { + throw "TLS server certificate already exists: $outputDirectory. Rotate it explicitly before regenerating." +} +if (-not (Test-Path -LiteralPath $outputDirectory)) { + New-Item -ItemType Directory -Path $outputDirectory | Out-Null +} + +function Export-PemCertificate { + param( + [Parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [Parameter(Mandatory)] + [string]$Path + ) + + [System.IO.File]::WriteAllText( + $Path, + $Certificate.ExportCertificatePem(), + [System.Text.UTF8Encoding]::new($false) + ) +} + +function Export-PemPrivateKey { + param( + [Parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [Parameter(Mandatory)] + [string]$Path + ) + + $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) + try { + [System.IO.File]::WriteAllText( + $Path, + $key.ExportPkcs8PrivateKeyPem(), + [System.Text.UTF8Encoding]::new($false) + ) + } finally { + $key.Dispose() + } +} + +$notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5) +$notAfter = $notBefore.AddYears(2) +$hashAlgorithm = [System.Security.Cryptography.HashAlgorithmName]::SHA256 +$signaturePadding = [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + +$caKey = [System.Security.Cryptography.RSA]::Create(4096) +try { + $caRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=cmroubao local development CA", + $caKey, + $hashAlgorithm, + $signaturePadding + ) + $caRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true) + ) + $caRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::CrlSign, + $true + ) + ) + $caRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($caRequest.PublicKey, $false) + ) + $caCertificate = $caRequest.CreateSelfSigned($notBefore, $notAfter) + try { + Export-PemCertificate -Certificate $caCertificate -Path $caCertificatePath + Export-PemPrivateKey -Certificate $caCertificate -Path $caPrivateKeyPath + + $serverKey = [System.Security.Cryptography.RSA]::Create(2048) + try { + $serverRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=$IPAddress", + $serverKey, + $hashAlgorithm, + $signaturePadding + ) + $serverRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $false) + ) + $serverRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment, + $true + ) + ) + $serverAuth = [System.Security.Cryptography.OidCollection]::new() + $serverAuth.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.1")) | Out-Null + $serverRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($serverAuth, $false) + ) + $subjectAlternativeNames = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() + $subjectAlternativeNames.AddIpAddress($IPAddress) + foreach ($name in $DnsName) { + if (-not [string]::IsNullOrWhiteSpace($name)) { + $subjectAlternativeNames.AddDnsName($name.Trim()) + } + } + $serverRequest.CertificateExtensions.Add($subjectAlternativeNames.Build()) + $serverRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($serverRequest.PublicKey, $false) + ) + $serial = New-Object byte[] 16 + [System.Security.Cryptography.RandomNumberGenerator]::Fill($serial) + $serverCertificate = $serverRequest.Create($caCertificate, $notBefore, $notAfter, $serial) + $serverCertificateWithKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey( + $serverCertificate, + $serverKey + ) + try { + Export-PemCertificate -Certificate $serverCertificateWithKey -Path $serverCertificatePath + Export-PemPrivateKey -Certificate $serverCertificateWithKey -Path $serverPrivateKeyPath + } finally { + $serverCertificateWithKey.Dispose() + $serverCertificate.Dispose() + } + } finally { + $serverKey.Dispose() + } + } finally { + $caCertificate.Dispose() + } +} finally { + $caKey.Dispose() +} + +Write-Host "Created local CA certificate: $caCertificatePath" +Write-Host "Created TLS server certificate: $serverCertificatePath" +Write-Host "Server SAN IP: $IPAddress" +Write-Host "Install only ca.crt on the Debug phone. Never copy ca.key or server.key to the phone." diff --git a/start-backend.bat b/start-backend.bat index c8bcf58..1caf467 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -3,6 +3,23 @@ setlocal EnableExtensions DisableDelayedExpansion set "PROJECT_ROOT=%~dp0" set "BACKEND_ROOT=%PROJECT_ROOT%backend-api" +set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls" +set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt" +set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key" + +if not defined CMROUBAO_HTTP_ADDR ( + if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" ( + set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080" + ) +) +if not defined CMROUBAO_TLS_CERT_FILE ( + if not defined CMROUBAO_TLS_KEY_FILE ( + if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" ( + set "CMROUBAO_TLS_CERT_FILE=%LOCAL_TLS_CERT%" + set "CMROUBAO_TLS_KEY_FILE=%LOCAL_TLS_KEY%" + ) + ) +) if not "%~1"=="" if /i not "%~1"=="--migrate" ( echo Usage: %~nx0 [--migrate] @@ -52,7 +69,9 @@ if /i "%~1"=="--migrate" ( if errorlevel 1 goto :startup_failed ) -if defined CMROUBAO_HTTP_ADDR ( +if defined CMROUBAO_TLS_CERT_FILE ( + echo Starting cmroubao backend with TLS at %CMROUBAO_HTTP_ADDR%... +) else if defined CMROUBAO_HTTP_ADDR ( echo Starting cmroubao backend with configured CMROUBAO_HTTP_ADDR... ) else ( echo Starting cmroubao backend at 127.0.0.1:8080...