Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions WireNetwork/Sources/WireNetwork/APIs/Rest/MLSAPI/MLSAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ public protocol MLSAPI {

func postCommitBundle(_ bundle: CommitBundle) async throws -> [UpdateEvent]

/// Upload MLS key packages for a client.
///
/// - Parameters:
/// - clientID: The client ID to upload key packages for.
/// - keyPackages: The key packages to upload.
///
/// Available from ``APIVersion`` v5.
///

func uploadKeyPackages(clientID: String, keyPackages: KeyPackageUpload) async throws

/// Reset an MLS Conversation to epoch 0
/// - Parameters:
/// - epoch: current epoch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public enum MLSAPIError: Error, Equatable {

case mlsProtocolError(message: String)

/// Key package credential does not match qualified client ID

case mlsIdentityMismatch

/// The group ID version of the conversation is not supported by one of the federated backends

case mlsGroupIdNotSupported(message: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class MLSAPIV0: MLSAPI, VersionedAPI {
throw MLSAPIError.unsupportedEndpointForAPIVersion
}

func uploadKeyPackages(clientID: String, keyPackages: KeyPackageUpload) async throws {
throw MLSAPIError.unsupportedEndpointForAPIVersion
}

func resetMLSConversation(epoch: UInt64, groupID: String) async throws {
throw MLSAPIError.unsupportedEndpointForAPIVersion
}
Expand Down
59 changes: 59 additions & 0 deletions WireNetwork/Sources/WireNetwork/APIs/Rest/MLSAPI/MLSAPIV5.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,45 @@ class MLSAPIV5: MLSAPIV4 {

}

override func uploadKeyPackages(clientID: String, keyPackages: KeyPackageUpload) async throws {
let body = try JSONEncoder.defaultEncoder.encode(keyPackages.toNetworkModel())

let path = "\(pathPrefix)/mls/key-packages/self/\(clientID)"

let request = try URLRequestBuilder(path: path)
.withMethod(.post)
.withBody(body, contentType: .json)
.build()

let (data, response) = try await apiService.executeRequest(
request,
requiringAccessToken: true
)

return try ResponseParser()
.success(
code: .created
)
.failure(
code: .badRequest,
error: MLSAPIError.invalidRequestBody
)
.failure(
code: .badRequest,
label: "mls-protocol-error",
wrappingMessage: { MLSAPIError.mlsProtocolError(message: $0) }
)
.failure(
code: .forbidden,
label: "mls-identity-mismatch",
error: MLSAPIError.mlsIdentityMismatch
)
.parse(
code: response.statusCode,
data: data
)
}

}

private struct BackendMLSPublicKeysResponseV5: Decodable, ToAPIModelConvertible {
Expand All @@ -107,3 +146,23 @@ struct CommitBundleResponseV5: Decodable, ToAPIModelConvertible {
}

}

struct KeyPackageUploadV0: Equatable, Sendable, Encodable {

enum CodingKeys: String, CodingKey {
case keyPackages = "key_packages"
}

let keyPackages: [String]

}

extension KeyPackageUpload {

func toNetworkModel() -> KeyPackageUploadV0 {
KeyPackageUploadV0(
keyPackages: keyPackages.map(\.base64EncodedData)
)
}

}
27 changes: 27 additions & 0 deletions WireNetwork/Sources/WireNetwork/Components/ResponseParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,33 @@ struct ResponseParser<Success> {
}
}

func failure(
code: HTTPStatusCode,
label: String,
wrappingMessage: @escaping (String) -> some Error
) -> ResponseParser<Success> {
addParseBlock(
code: code,
prioritize: true
) { data in
guard let data else {
return nil
}

guard
let failure = try? decoder.decode(
FailureResponseV0.self,
from: data
),
failure.label == label
else {
return nil
}

throw wrappingMessage(failure.message)
}
}

func failure(
code: HTTPStatusCode,
label: String? = nil,
Expand Down
37 changes: 37 additions & 0 deletions WireNetwork/Sources/WireNetwork/Models/MLS/KeyPackage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation

/// A base64-encoded MLS key package.

public struct KeyPackage: Equatable, Sendable {

/// The base64-encoded key package data.

public let base64EncodedData: String

/// Create a new `KeyPackage`.
///
/// - Parameter base64EncodedData: The base64-encoded key package data.

public init(base64EncodedData: String) {
self.base64EncodedData = base64EncodedData
}

}
37 changes: 37 additions & 0 deletions WireNetwork/Sources/WireNetwork/Models/MLS/KeyPackageUpload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Wire
// Copyright (C) 2026 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation

/// Payload to upload MLS key packages.

public struct KeyPackageUpload: Equatable, Sendable {

/// The list of base64-encoded key packages to upload.

public let keyPackages: [KeyPackage]

/// Create a new `KeyPackageUpload`.
///
/// - Parameter keyPackages: The list of key packages to upload.

public init(keyPackages: [KeyPackage]) {
self.keyPackages = keyPackages
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,89 @@ final class MLSAPITests: XCTestCase {
try await api.resetMLSConversation(epoch: Scaffolding.epoch, groupID: Scaffolding.groupID)
}

// MARK: - Upload key packages

func testUploadKeyPackagesRequest() async throws {
// Given
let apiVersions = APIVersion.v5.andNextVersions

// Then
try await apiSnapshotHelper.verifyRequest(for: apiVersions) { sut in
// When
_ = try await sut.uploadKeyPackages(
clientID: Scaffolding.clientID,
keyPackages: Scaffolding.keyPackageUpload
)
}
}

func testUploadKeyPackages_SuccessResponse_201_V5_And_Next_Versions() async throws {
// Given
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
let testedVersions = APIVersion.v5.andNextVersions

for version in testedVersions {
let apiService = MockAPIServiceProtocol.withResponses([
(.created, nil)
])
let sut = version.buildAPI(apiService: apiService)

taskGroup.addTask {
// When
try await sut.uploadKeyPackages(
clientID: Scaffolding.clientID,
keyPackages: Scaffolding.keyPackageUpload
)
}

for try await _ in taskGroup {
// Then - no assertion needed, just checking it doesn't throw
}
}
}
}

func testUploadKeyPackages_givenV5AndProtocolErrorResponse() async throws {
// Given
let apiService = MockAPIServiceProtocol.withError(
statusCode: .badRequest,
label: "mls-protocol-error",
message: "something went wrong"
)

let api = MLSAPIV5(apiService: apiService)

// Then
await XCTAssertThrowsErrorAsync(
MLSAPIError.mlsProtocolError(message: "something went wrong")
) {
// When
try await api.uploadKeyPackages(
clientID: Scaffolding.clientID,
keyPackages: Scaffolding.keyPackageUpload
)
}
}

func testUploadKeyPackages_givenV5AndIdentityMismatchErrorResponse() async throws {
// Given
let apiService = MockAPIServiceProtocol.withError(
statusCode: .forbidden,
label: "mls-identity-mismatch"
)

let api = MLSAPIV5(apiService: apiService)

// Then
await XCTAssertThrowsErrorAsync(MLSAPIError.mlsIdentityMismatch) {
// When
try await api.uploadKeyPackages(
clientID: Scaffolding.clientID,
keyPackages: Scaffolding.keyPackageUpload
)
}
}

}

private extension APIVersion {
Expand Down Expand Up @@ -250,4 +333,17 @@ private enum Scaffolding {
UpdateEvent.unknown(eventType: "some event")
]

static let clientID = "60f85e4b15ad3786"

static let keyPackageUpload = KeyPackageUpload(
keyPackages: [
KeyPackage(
base64EncodedData: "pQABARn//wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f/GEEY="
),
KeyPackage(
base64EncodedData: "pQABARn//wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E="
)
]
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v10/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v11/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v12/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v13/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v14/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v15/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v5/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v6/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v7/mls/key-packages/self/60f85e4b15ad3786"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
curl \
--request POST \
--header "Content-Type: application/json" \
--data "{\"key_packages\":[\"pQABARn\/\/wKhAFggwO2Any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFggQU\/vrXc9MrQxPNubQz4NI0uNtF6qdJ0J0mF9XB2f\/GEEY=\",\"pQABARn\/\/wKhAFgg0C2BN+Mxl7dLoDHNx7ZgUE7MR6hEqTmhoQrLmR5MQqYDoQChAFggJQvUqsCdqZ8o4s+OkSlRDPAf8DPQW25uG0+MvxWZxF4E=\"]}" \
"/v8/mls/key-packages/self/60f85e4b15ad3786"
Loading
Loading