- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
343 lines
15 KiB
Swift
343 lines
15 KiB
Swift
import CryptoKit
|
|
import XCTest
|
|
|
|
@testable import MultiSimAdmin
|
|
|
|
/// Recomputes `contracts/test-vectors.json` in Swift.
|
|
///
|
|
/// The Go agent, the Node reference implementation and this app share no code, so agreement here is
|
|
/// evidence rather than a tautology: three encoders producing the same bytes for the same inputs is
|
|
/// what makes "the phone signs, the agent verifies" true across a language boundary. A failure in
|
|
/// this file means the encoding drifted, not that this side is broken.
|
|
final class RelayWireTests: XCTestCase {
|
|
/// The vector file lives in the relay repository. Both paths are tried so the test works from
|
|
/// the app package and from a checkout where the two repositories sit side by side; when
|
|
/// neither exists the test is skipped rather than failed, because a missing sibling repository
|
|
/// is not an encoding bug.
|
|
private static let candidatePaths = [
|
|
ProcessInfo.processInfo.environment["MSA_RELAY_VECTORS"],
|
|
"/Users/chick/.local/opt/simadmin-relay/contracts/test-vectors.json",
|
|
FileManager.default.currentDirectoryPath + "/../../simadmin-relay/contracts/test-vectors.json",
|
|
FileManager.default.currentDirectoryPath + "/../../../simadmin-relay/contracts/test-vectors.json",
|
|
].compactMap { $0 }
|
|
|
|
private func loadVectors() throws -> [String: Any] {
|
|
for path in Self.candidatePaths where FileManager.default.fileExists(atPath: path) {
|
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw XCTSkip("vectors at \(path) are not a JSON object")
|
|
}
|
|
return object
|
|
}
|
|
throw XCTSkip("simadmin-relay/contracts/test-vectors.json not found; set MSA_RELAY_VECTORS")
|
|
}
|
|
|
|
private func hex(_ data: Data) -> String {
|
|
data.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private func unhex(_ text: String) -> Data {
|
|
var out = Data()
|
|
var index = text.startIndex
|
|
while index < text.endIndex {
|
|
guard let next = text.index(index, offsetBy: 2, limitedBy: text.endIndex),
|
|
let byte = UInt8(text[index..<next], radix: 16)
|
|
else {
|
|
XCTFail("not hex: \(text)")
|
|
return Data()
|
|
}
|
|
out.append(byte)
|
|
index = next
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// The envelope in the vector file is JSON, so its byte fields are base64 there while the
|
|
/// key-material sections are hex. Both decoders are needed and mixing them up produces an empty
|
|
/// Data that fails as a length error three assertions later.
|
|
private func unbase64(_ text: String) -> Data {
|
|
guard let data = Data(base64Encoded: text) else {
|
|
XCTFail("not base64: \(text)")
|
|
return Data()
|
|
}
|
|
return data
|
|
}
|
|
|
|
/// RFC 8032 seed halves, which are the 32-byte raw representations CryptoKit wants. The vector
|
|
/// file stores the full 64-byte expanded form that Go's ed25519 package uses.
|
|
private func seed(_ expanded: String) -> Data {
|
|
unhex(String(expanded.prefix(64)))
|
|
}
|
|
|
|
// MARK: - Envelope encoding
|
|
|
|
func testCanonicalBytesAndSignaturesMatchTheGoAgent() throws {
|
|
let vectors = try loadVectors()
|
|
guard let canonical = vectors["canonical"] as? [String: Any],
|
|
let input = canonical["input"] as? [String: Any],
|
|
let bytes = canonical["bytes"] as? [String: String],
|
|
let signatures = canonical["signature"] as? [String: String],
|
|
let publicKeyHex = canonical["publicKey"] as? String,
|
|
let responsePublicKeyHex = canonical["responsePublicKey"] as? String
|
|
else {
|
|
return XCTFail("the canonical section is missing or malformed")
|
|
}
|
|
|
|
let envelope = RelayWire.Envelope(
|
|
v: input["v"] as? UInt64 ?? 0,
|
|
node: input["node"] as? String ?? "",
|
|
req: input["req"] as? String ?? "",
|
|
app: input["app"] as? String,
|
|
ts: input["ts"] as? UInt64 ?? 0,
|
|
ek: unbase64(input["ek"] as? String ?? ""),
|
|
alg: input["alg"] as? String ?? "",
|
|
p: unbase64(input["p"] as? String ?? "")
|
|
)
|
|
|
|
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: false)), bytes["request"])
|
|
XCTAssertEqual(hex(try RelayWire.canonical(envelope, response: true)), bytes["response"])
|
|
|
|
let appSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
|
let agentSigningKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(agentSignKeyHex))
|
|
|
|
// The public halves published in the vector file must be the ones these private keys derive,
|
|
// or the vector proves nothing about key ownership.
|
|
XCTAssertEqual(hex(appSigningKey.publicKey.rawRepresentation), publicKeyHex)
|
|
XCTAssertEqual(hex(agentSigningKey.publicKey.rawRepresentation), responsePublicKeyHex)
|
|
|
|
// CryptoKit's Ed25519 signing is randomised, unlike Go's and Node's deterministic RFC 8032
|
|
// implementation: signing the same bytes twice yields two different, equally valid
|
|
// signatures. So the cross-language check here cannot be "the bytes are equal" — it is the
|
|
// stronger property that actually matters, which is that each side verifies what the other
|
|
// produced. Both directions are asserted, because a phone whose signatures the agent
|
|
// rejected would be a total failure that byte-equality would never have caught either.
|
|
try RelayWire.verifyRequest(
|
|
envelopeWithSignature(envelope, unhex(signatures["request"] ?? ""), app: true),
|
|
publicKey: appSigningKey.publicKey
|
|
)
|
|
try RelayWire.verifyResponse(
|
|
envelopeWithSignature(envelope, unhex(signatures["response"] ?? ""), app: false),
|
|
publicKey: agentSigningKey.publicKey
|
|
)
|
|
|
|
var signedRequest = envelope
|
|
try RelayWire.signRequest(&signedRequest, privateKey: appSigningKey)
|
|
try RelayWire.verifyRequest(signedRequest, publicKey: appSigningKey.publicKey)
|
|
// And the Go side must accept what this side signed: the vectors cannot prove that from
|
|
// here, so it is asserted by the agent's own test suite against a signature captured from
|
|
// a device. What can be checked locally is that the signing input is the vector's bytes.
|
|
XCTAssertEqual(
|
|
hex(try RelayWire.canonical(signedRequest, response: false)),
|
|
bytes["request"],
|
|
"the phone must sign exactly the bytes the agent verifies"
|
|
)
|
|
|
|
// Domain separation: a request signature must not verify as a response signature.
|
|
XCTAssertThrowsError(try RelayWire.verifyResponse(signedRequest, publicKey: appSigningKey.publicKey))
|
|
}
|
|
|
|
/// Rebuilds an envelope carrying a signature produced elsewhere, since the response form has no
|
|
/// `app` field and the request form does.
|
|
private func envelopeWithSignature(
|
|
_ envelope: RelayWire.Envelope,
|
|
_ signature: Data,
|
|
app: Bool
|
|
) -> RelayWire.Envelope {
|
|
var copy = envelope
|
|
copy.app = app ? envelope.app : nil
|
|
copy.s = signature
|
|
return copy
|
|
}
|
|
|
|
/// RFC 8032 test vector private keys, repeated here so the test does not depend on the vector
|
|
/// file to know whose key is whose.
|
|
private let privateKeyHex =
|
|
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
|
|
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
|
private let agentSignKeyHex =
|
|
"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"
|
|
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
|
|
|
func testAOneByteChangeBreaksTheSignature() throws {
|
|
let envelope = RelayWire.Envelope(
|
|
node: "nd_7f3a9c2e",
|
|
req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
app: "ap_5b1d",
|
|
ts: 1_727_289_600,
|
|
ek: Data(repeating: 7, count: 32),
|
|
p: Data([0, 1, 2, 0xFE, 0xFF])
|
|
)
|
|
let key = try Curve25519.Signing.PrivateKey(rawRepresentation: seed(privateKeyHex))
|
|
var signed = envelope
|
|
try RelayWire.signRequest(&signed, privateKey: key)
|
|
try RelayWire.verifyRequest(signed, publicKey: key.publicKey)
|
|
|
|
// Retargeting the node is what a compromised relay would try first.
|
|
var retargeted = signed
|
|
retargeted.node = "nd_other001"
|
|
XCTAssertThrowsError(try RelayWire.verifyRequest(retargeted, publicKey: key.publicKey)) { error in
|
|
XCTAssertEqual(error as? RelayWire.Failure, .signatureDoesNotVerify)
|
|
}
|
|
// Swapping the ciphertext is the second thing it would try.
|
|
var edited = signed
|
|
edited.p = Data([9, 9, 9])
|
|
XCTAssertThrowsError(try RelayWire.verifyRequest(edited, publicKey: key.publicKey))
|
|
// The ephemeral public key is covered, so a substituted one cannot be hidden.
|
|
var swapped = signed
|
|
swapped.ek = Data(repeating: 3, count: 32)
|
|
XCTAssertThrowsError(try RelayWire.verifyRequest(swapped, publicKey: key.publicKey))
|
|
}
|
|
|
|
// MARK: - Key derivation and AEAD
|
|
|
|
func testKeyDerivationAndAEADMatchTheGoAgent() throws {
|
|
let vectors = try loadVectors()
|
|
guard let x25519 = vectors["x25519"] as? [String: String],
|
|
let aead = vectors["aead"] as? [String: String]
|
|
else {
|
|
return XCTFail("the x25519 or aead section is missing")
|
|
}
|
|
|
|
let privateKey = try Curve25519.KeyAgreement.PrivateKey(
|
|
rawRepresentation: unhex(x25519["appPrivate"] ?? "")
|
|
)
|
|
let shared = try RelayWire.sharedSecret(
|
|
privateKey: privateKey,
|
|
peerPublic: unhex(x25519["agentPublic"] ?? "")
|
|
)
|
|
XCTAssertEqual(hex(shared), x25519["sharedSecret"])
|
|
|
|
let request = try RelayWire.deriveKey(
|
|
shared: shared,
|
|
nodeID: "nd_7f3a9c2e",
|
|
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
response: false
|
|
)
|
|
XCTAssertEqual(hex(request.key.withUnsafeBytes { Data($0) }), x25519["requestKey"])
|
|
XCTAssertEqual(hex(request.iv), x25519["requestIV"])
|
|
|
|
let response = try RelayWire.deriveKey(
|
|
shared: shared,
|
|
nodeID: "nd_7f3a9c2e",
|
|
requestID: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
response: true
|
|
)
|
|
XCTAssertEqual(hex(response.key.withUnsafeBytes { Data($0) }), x25519["responseKey"])
|
|
XCTAssertEqual(hex(response.iv), x25519["responseIV"])
|
|
// Direction separation: the same shared secret must not produce the same key or nonce.
|
|
XCTAssertNotEqual(x25519["requestKey"], x25519["responseKey"])
|
|
XCTAssertNotEqual(x25519["requestIV"], x25519["responseIV"])
|
|
|
|
let sealed = try RelayWire.seal(
|
|
unhex(aead["plaintext"] ?? ""),
|
|
key: request.key,
|
|
iv: request.iv
|
|
)
|
|
XCTAssertEqual(hex(sealed), aead["ciphertext"])
|
|
let opened = try RelayWire.open(sealed, key: request.key, iv: request.iv)
|
|
XCTAssertEqual(hex(opened), aead["plaintext"])
|
|
}
|
|
|
|
func testHKDFMatchesRFC5869() throws {
|
|
let vectors = try loadVectors()
|
|
guard let hkdf = vectors["hkdf"] as? [String: Any] else {
|
|
return XCTFail("the hkdf section is missing")
|
|
}
|
|
let output = try RelayWire.hkdf(
|
|
secret: unhex(hkdf["secret"] as? String ?? ""),
|
|
salt: unhex(hkdf["salt"] as? String ?? ""),
|
|
info: unhex(hkdf["info"] as? String ?? ""),
|
|
length: hkdf["length"] as? Int ?? 0
|
|
)
|
|
XCTAssertEqual(hex(output), hkdf["output"] as? String)
|
|
}
|
|
|
|
/// A tampered ciphertext must fail to authenticate rather than decrypt to something.
|
|
func testATamperedCiphertextDoesNotOpen() throws {
|
|
let key = SymmetricKey(data: Data(repeating: 1, count: 32))
|
|
let iv = Data(repeating: 2, count: 12)
|
|
var sealed = try RelayWire.seal(Data("hello".utf8), key: key, iv: iv)
|
|
sealed[sealed.count - 1] ^= 0x01
|
|
XCTAssertThrowsError(try RelayWire.open(sealed, key: key, iv: iv)) { error in
|
|
XCTAssertEqual(error as? RelayWire.Failure, .ciphertextFailedToAuthenticate)
|
|
}
|
|
}
|
|
|
|
// MARK: - Intent digest
|
|
|
|
func testIntentDigestMatchesTheGoAgent() throws {
|
|
let vectors = try loadVectors()
|
|
guard let intent = vectors["intent"] as? [String: String] else {
|
|
return XCTFail("the intent section is missing")
|
|
}
|
|
let digest = try RelayWire.intentDigest(
|
|
method: intent["method"] ?? "",
|
|
path: intent["path"] ?? "",
|
|
body: Data((intent["body"] ?? "").utf8)
|
|
)
|
|
XCTAssertEqual(hex(digest), intent["digest"])
|
|
|
|
// Formatting must not matter; content must.
|
|
let reordered = try RelayWire.intentDigest(
|
|
method: "post",
|
|
path: "/api/sms/send",
|
|
body: Data(#"{"content":"hello","phone_number":"+8613800000000"}"#.utf8)
|
|
)
|
|
XCTAssertEqual(hex(reordered), intent["digest"])
|
|
let edited = try RelayWire.intentDigest(
|
|
method: "POST",
|
|
path: "/api/sms/send",
|
|
body: Data(#"{"phone_number":"+8613800000001","content":"hello"}"#.utf8)
|
|
)
|
|
XCTAssertNotEqual(hex(edited), intent["digest"])
|
|
}
|
|
|
|
// MARK: - Canonical JSON
|
|
|
|
func testCanonicalJSONMatchesTheGoAgent() throws {
|
|
let vectors = try loadVectors()
|
|
guard let pairs = vectors["canonicalJson"] as? [[String: String]] else {
|
|
return XCTFail("the canonicalJson section is missing")
|
|
}
|
|
for pair in pairs {
|
|
let input = pair["input"] ?? ""
|
|
let expected = pair["output"] ?? ""
|
|
let got = try CanonicalJSON.canonicalize(input)
|
|
XCTAssertEqual(String(decoding: got, as: UTF8.self), expected, "canonical JSON for \(input)")
|
|
}
|
|
}
|
|
|
|
func testCanonicalJSONRejectsEveryInputTheVectorsMarkInvalid() throws {
|
|
let vectors = try loadVectors()
|
|
guard let rejected = vectors["canonicalJsonRejected"] as? [String] else {
|
|
return XCTFail("the canonicalJsonRejected section is missing")
|
|
}
|
|
for input in rejected {
|
|
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
|
}
|
|
}
|
|
|
|
func testCanonicalJSONRejectsWhatTheProtocolWillNotHash() {
|
|
for input in ["{", #"{"a":1} trailing"#, #"{"a":+1}"#, #"{"a":.5}"#, #"{"a":NaN}"#,
|
|
#"{"a":1.0}"#, #"{"a":1e3}"#, #"{"a":01}"#, #"{"a":-0}"#] {
|
|
XCTAssertThrowsError(try CanonicalJSON.canonicalize(input), "accepted \(input)")
|
|
}
|
|
XCTAssertEqual(try CanonicalJSON.canonicalize(""), Data())
|
|
XCTAssertEqual(try CanonicalJSON.canonicalize(" "), Data())
|
|
}
|
|
|
|
/// The three key-sort shortcuts disagree, so this pins the one the protocol uses: UTF-8 byte
|
|
/// order. Swift's default `String` comparison uses Unicode collation, which would put "é"
|
|
/// before "z"; Foundation's `.sortedKeys` does the same.
|
|
func testCanonicalJSONSortsKeysByUTF8Bytes() throws {
|
|
XCTAssertEqual(
|
|
String(decoding: try CanonicalJSON.canonicalize(#"{"é":1,"z":2}"#), as: UTF8.self),
|
|
#"{"z":2,"é":1}"#
|
|
)
|
|
XCTAssertEqual(
|
|
String(decoding: try CanonicalJSON.canonicalize("{\"\u{10000}\":1,\"\u{FFFD}\":2}"), as: UTF8.self),
|
|
"{\"\u{FFFD}\":2,\"\u{10000}\":1}"
|
|
)
|
|
}
|
|
}
|