Files
libsignal/swift/Sources/LibSignalClient/RegistrationService.swift
Jordan Rose 0d48e043d1 chat: Use LanguageList all the way up to the bridge layer
And save a few bytes with no spaces after commas.
2025-07-09 11:15:01 -07:00

642 lines
28 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalFfi
public enum RegistrationError: Error {
/// The session ID is not valid.
///
/// Thrown when attempting to make a request, or when a response is received with a structurally
/// invalid validation session ID.
case invalidSessionId(String)
/// The session is already verified or not in a state to request a code because requested information
/// hasn't been provided yet.
///
/// When the websocket transport is in use, this corresponds to a `HTTP 409` response to a POST request to `/v1/verification/session/{sessionId}/code`.
case notReadyForVerification(String)
/// No session with the specified ID could be found.
///
/// When the websocket transport is in use, this corresponds to a `HTTP 404` response to requests to endpoints with the `/v1/verification/session` prefix.
case sessionNotFound(String)
/// The request to send a verification code with the given transport could not be fulfilled, but may
/// succeed with a different transport.
///
/// When the websocket transport is in use, this corresponds to a `HTTP 418` response to a POST request to `/v1/verification/session/{sessionId/code`.
case sendVerificationFailed(String)
/// The attempt to send a verification code failed because an external service (e.g. the SMS
/// provider) refused to deliver the code.
///
/// When the websocket transport is in use, this corresponds to a `HTTP 440` response to a POST request to `/v1/verification/session/{sessionId}/code`.
///
/// - Parameter message: The server-provided reason for the failure.
/// This will likely be one of "providerUnavailable", "providerRejected", or "illegalArgument".
/// - Parameter permanentFailure: Indicates whether the failure is permanent, as opposed to temporary.
/// A client may try again in response to a temporary failure after a reasonable delay.
case codeNotDeliverable(message: String, permanentFailure: Bool)
/// The information provided was not accepted (e.g push challenge or captcha verification failed).
///
/// When the websocket transport is in use, this corresponds to an `HTTP 403` response to a POST request to `/v1/verification/session/{sessionId}`.
case sessionUpdateRejected(String)
/// The returned list of SVR2 credentials could not be parsed.
///
/// When the websocket transport is in use, this corresponds to an `HTTP 422` response to a POST request to `/v2/backup/auth/check`.
case credentialsCouldNotBeParsed(String)
/// A device transfer is possible and was not explicitly skipped.
///
/// When the websocket transport is in use, this corresponds to an `HTTP 409` response to a POST request to `/v1/registration`.
case deviceTransferPossible(String)
/// Registration recovery password verification failed.
///
/// When the websocket transport is in use, this corresponds to an `HTTP 403` response to a POST request to `/v1/registration`.
case recoveryVerificationFailed(String)
/// A registration request was made for an account that has registration lock enabled.
///
/// When the websocket transport is in use, this corresponds to a `HTTP 423` response to a POST request to `/v1/registration`.
///
/// - Parameter timeRemaining: How much time is remaining before the existing registration lock expires.
/// - Parameter svr2Username: The SVR username to use to recover the secret used to derive the registration lock password.
/// - Parameter svr2Password: The SVR password to use to recover the secret used to derive the registration lock password.
case registrationLock(timeRemaining: TimeInterval, svr2Username: String, svr2Password: String)
/// An unknow error occurred during registration.
case unknown(String)
}
/// Client for the Signal registration service.
///
/// This wraps a ``Net`` to provide a reliable registration service client.
///
public class RegistrationService: NativeHandleOwner<SignalMutPointerRegistrationService>, @unchecked Sendable {
private let asyncContext: TokioAsyncContext
override internal class func destroyNativeHandle(
_ nativeHandle: NonNull<SignalMutPointerRegistrationService>
) -> SignalFfiErrorRef? {
signal_registration_service_destroy(nativeHandle.pointer)
}
internal init(owned: NonNull<SignalMutPointerRegistrationService>, asyncContext: TokioAsyncContext) {
self.asyncContext = asyncContext
super.init(owned: owned)
}
required init(owned: NonNull<SignalMutPointerRegistrationService>) {
fatalError("must not be invoked directly")
}
/// Starts a new registration session.
///
/// Asynchronously connects to the registration session and requests a new session.
/// If successful, returns an initialized ``RegistrationService``. Otherwise an error is thrown.
///
/// With the websocket transport, this makes a POST request to `/v1/verification/session`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError`` if the request fails with a known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public static func createSession(
_ net: Net,
e164: String,
pushToken: String?,
mcc: String? = nil,
mnc: String? = nil
) async throws -> RegistrationService {
let connectChatBridge = SignalFfiConnectChatBridgeStruct.forManager(net.connectionManager)
let service: SignalMutPointerRegistrationService =
try await net.asyncContext.invokeAsyncFunction { promise, tokioContext in
SignalFfiRegistrationCreateSessionRequest.withNativeStruct(
e164: e164,
pushToken: pushToken,
mcc: mcc,
mnc: mnc
) { request in
withUnsafePointer(to: connectChatBridge) { connectChatBridge in
signal_registration_service_create_session(
promise,
tokioContext.const(),
request,
SignalConstPointerFfiConnectChatBridgeStruct(
raw: connectChatBridge
)
)
}
}
}
return RegistrationService(owned: NonNull(service)!, asyncContext: net.asyncContext)
}
/// Resumes a previous registration session.
///
/// Asynchronously connects to the registration session and requests a new session.
/// If successful, returns an initialized ``RegistrationService``. Otherwise an error is thrown.
///
/// With the websocket transport, this makes a GET request to `/v1/verification/session/{sessionId}`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError/sessionNotFound(_:)`` if the session can't be resumed.
/// - A different ``RegistrationError`` if the request fails with another known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public static func resumeSession(
_ net: Net,
sessionId: String,
number: String
) async throws -> RegistrationService {
let connectChatBridge = SignalFfiConnectChatBridgeStruct.forManager(net.connectionManager)
let service: SignalMutPointerRegistrationService =
try await net.asyncContext.invokeAsyncFunction { promise, tokioContext in
withUnsafePointer(
to: connectChatBridge
) { connectChatBridge in
signal_registration_service_resume_session(
promise,
tokioContext.const(),
sessionId,
number,
SignalConstPointerFfiConnectChatBridgeStruct(
raw: connectChatBridge
)
)
}
}
return RegistrationService(owned: NonNull(service)!)
}
/// Request a push challenge sent to the provided APN token.
///
/// With the websocket transport, this makes a PATCH request to `/v1/verification/session/{sessionId}`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError`` if the request fails with a known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func requestPushChallenge(apnPushToken: String) async throws {
let _: Bool = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle {
signal_registration_service_request_push_challenge(
promise,
asyncContext.const(),
$0.const(),
apnPushToken
)
}
}
}
/// Submit the result of a push challenge.
///
/// With the websocket transport, this makes a PATCH request to `/v1/verification/session/{sessionId}`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError`` if the request fails with a known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func submitPushChallenge(pushChallenge: String) async throws {
let _: Bool = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle {
signal_registration_service_submit_push_challenge(
promise,
asyncContext.const(),
$0.const(),
pushChallenge
)
}
}
}
/// Request that a verification code be sent via the given transport method.
///
/// With the websocket transport, this makes a POST request to `/v1/verification/session/{sessionId}/code`.
///
/// The `languages` parameter should be a list of languages in Accept-Language syntax.
/// Note that "quality weighting" can be left out; the Signal server will always consider the
/// list to be in priority order.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError/sendVerificationFailed(_:)`` if the code couldn't be sent.
/// - ``RegistrationError/codeNotDeliverable(message:permanentFailure:)`` if the code couldn't be delivered.
/// - A different ``RegistrationError`` if the request fails with another known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func requestVerificationCode(
transport: VerificationTransport,
client: String,
languages: [String]
) async throws {
let _: Bool = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
languages.withUnsafeBorrowedBytestringArray { languages in
self.withNativeHandle {
signal_registration_service_request_verification_code(
promise,
asyncContext.const(),
$0.const(),
transport.description,
client,
languages
)
}
}
}
}
/// Submit a received verification code.
///
/// With the websocket transport, this makes a PUT request to `/v1/verification/session/{sessionId}`/code.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError/notReadyForVerification(_:)`` if uncompleted challenges remain.
/// - A different ``RegistrationError`` if the request fails with another known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func submitVerificationCode(code: String) async throws {
let _: Bool = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle {
signal_registration_service_submit_verification_code(
promise,
asyncContext.const(),
$0.const(),
code
)
}
}
}
/// Submit the result of a completed captcha challenge.
///
/// With the websocket transport, this makes a PATCH request to `/v1/verification/session/{sessionId}`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError`` if the request fails with a recognized error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func submitCaptcha(captchaValue: String) async throws {
let _: Bool = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle {
signal_registration_service_submit_verification_code(
promise,
asyncContext.const(),
$0.const(),
captchaValue
)
}
}
}
/// Check that the given list of SVR credentials is valid.
///
/// # Return
/// If the request succeeds, returns a map of submitted credential to check result.
///
/// With the websocket transport, this makes a POST request to `/v2/backup/auth/check`.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError`` if the request fails with a recognized error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func checkSvr2Credentials(svrTokens: [String]) async throws -> [String: Svr2CredentialsResult] {
var result = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
svrTokens.withUnsafeBorrowedBytestringArray { svrTokens in
self.withNativeHandle {
signal_registration_service_check_svr2_credentials(
promise,
asyncContext.const(),
$0.const(),
svrTokens
)
}
}
}
return try invokeFnReturningCheckSvr2CredentialsResponse { out in
out!.update(from: &result, count: 1)
return nil
}
}
/// The ID for this registration validation session.
public var sessionId: String {
return failOnError {
try invokeFnReturningString { out in
self.withNativeHandle {
signal_registration_service_session_id(out, $0.const())
}
}
}
}
/// The session state received from the server with the last completed validation request.
public var sessionState: RegistrationSessionState {
return failOnError {
try invokeFnReturningNativeHandle { out in
self.withNativeHandle { nativeHandle in
signal_registration_service_registration_session(out, nativeHandle.const())
}
}
}
}
/// Send a register account request.
///
/// # Return
/// If the request succeeds, returns a ``RegisterAccountResponse``.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError/registrationLock(timeRemaining:svr2Username:svr2Password:)``
/// - ``RegistrationError/deviceTransferPossible(_:)``
/// - ``RegistrationError/recoveryVerificationFailed(_:)``
/// - A different ``RegistrationError`` if the request fails with another known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public func registerAccount(
accountPassword: String,
skipDeviceTransfer: Bool,
accountAttributes: RegisterAccountAttributes,
apnPushToken: String?,
aciPublicKey: PublicKey,
pniPublicKey: PublicKey,
aciSignedPreKey: SignedPublicPreKey<PublicKey>,
pniSignedPreKey: SignedPublicPreKey<PublicKey>,
aciPqLastResortPreKey: SignedPublicPreKey<KEMPublicKey>,
pniPqLastResortPreKey: SignedPublicPreKey<KEMPublicKey>
) async throws -> RegisterAccountResponse {
let request = RegisterAccountRequst.create(
apnPushToken: apnPushToken,
accountPassword: accountPassword,
skipDeviceTransfer: skipDeviceTransfer,
aciIdentityKey: aciPublicKey,
pniIdentityKey: pniPublicKey,
aciSignedPreKey: aciSignedPreKey,
pniSignedPreKey: pniSignedPreKey,
aciPqSignedPreKey: aciPqLastResortPreKey,
pniPqSignedPreKey: pniPqLastResortPreKey
)
let response = try await self.asyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle { registrationService in
request.withNativeHandle { request in
accountAttributes.withNativeHandle { accountAttributes in
signal_registration_service_register_account(
promise,
asyncContext.const(),
registrationService.const(),
request.const(),
accountAttributes.const()
)
}
}
}
}
return RegisterAccountResponse(owned: NonNull(response)!)
}
/// Send a request to re-register the account.
///
/// This is a `class` method because it uses the account's recovery password to authenticate instead of a verification session.
///
/// # Return
/// If the request succeeds, returns a ``RegisterAccountResponse``.
///
/// - Throws: On failure, throws one of
/// - ``RegistrationError/registrationLock(timeRemaining:svr2Username:svr2Password:)``
/// - ``RegistrationError/deviceTransferPossible(_:)``
/// - ``RegistrationError/recoveryVerificationFailed(_:)``
/// - A different ``RegistrationError`` if the request fails with another known error response.
/// - ``SignalError/rateLimitedError(retryAfter:message:)`` if the server requests a retry later.
/// - Some other `SignalError` if the request can't be completed.
public class func reregisterAccount(
_ net: Net,
e164: String,
accountPassword: String,
skipDeviceTransfer: Bool,
accountAttributes: RegisterAccountAttributes,
apnPushToken: String?,
aciPublicKey: PublicKey,
pniPublicKey: PublicKey,
aciSignedPreKey: SignedPublicPreKey<PublicKey>,
pniSignedPreKey: SignedPublicPreKey<PublicKey>,
aciPqLastResortPreKey: SignedPublicPreKey<KEMPublicKey>,
pniPqLastResortPreKey: SignedPublicPreKey<KEMPublicKey>
) async throws -> RegisterAccountResponse {
let request = RegisterAccountRequst.create(
apnPushToken: apnPushToken,
accountPassword: accountPassword,
skipDeviceTransfer: skipDeviceTransfer,
aciIdentityKey: aciPublicKey,
pniIdentityKey: pniPublicKey,
aciSignedPreKey: aciSignedPreKey,
pniSignedPreKey: pniSignedPreKey,
aciPqSignedPreKey: aciPqLastResortPreKey,
pniPqSignedPreKey: pniPqLastResortPreKey
)
var connectChatBridge = SignalFfiConnectChatBridgeStruct.forManager(net.connectionManager)
let response = try await net.asyncContext.invokeAsyncFunction { promise, asyncContext in
request.withNativeHandle { request in
accountAttributes.withNativeHandle { accountAttributes in
withUnsafePointer(to: &connectChatBridge) { connectChatBridge in
e164.withCString { e164 in
signal_registration_service_reregister_account(
promise,
asyncContext.const(),
SignalConstPointerFfiConnectChatBridgeStruct(raw: connectChatBridge),
e164,
request.const(),
accountAttributes.const()
)
}
}
}
}
}
return RegisterAccountResponse(owned: NonNull(response)!)
}
}
private class RegisterAccountRequst: NativeHandleOwner<SignalMutPointerRegisterAccountRequest> {
override internal class func destroyNativeHandle(
_ nativeHandle: NonNull<SignalMutPointerRegisterAccountRequest>
) -> SignalFfiErrorRef? {
signal_register_account_request_destroy(nativeHandle.pointer)
}
fileprivate static func create(
apnPushToken: String?,
accountPassword: String,
skipDeviceTransfer: Bool,
aciIdentityKey: PublicKey,
pniIdentityKey: PublicKey,
aciSignedPreKey: SignedPublicPreKey<PublicKey>,
pniSignedPreKey: SignedPublicPreKey<PublicKey>,
aciPqSignedPreKey: SignedPublicPreKey<KEMPublicKey>,
pniPqSignedPreKey: SignedPublicPreKey<KEMPublicKey>
) -> Self {
let request: Self = failOnError {
try invokeFnReturningNativeHandle {
signal_register_account_request_create($0)
}
}
failOnError {
try request.withNativeHandle { native in
try checkError(
accountPassword.withCString {
signal_register_account_request_set_account_password(native.const(), $0)
}
)
if let apnPushToken {
try checkError(
apnPushToken.withCString {
signal_register_account_request_set_apn_push_token(native.const(), $0)
}
)
}
if skipDeviceTransfer {
try checkError(signal_register_account_request_set_skip_device_transfer(native.const()))
}
try checkError(
aciIdentityKey.withNativeHandle {
signal_register_account_request_set_identity_public_key(
native.const(),
ServiceIdKind.aci.rawValue,
$0.const()
)
}
)
try checkError(
pniIdentityKey.withNativeHandle {
signal_register_account_request_set_identity_public_key(
native.const(),
ServiceIdKind.pni.rawValue,
$0.const()
)
}
)
try checkError(
aciSignedPreKey.withNativeStruct {
signal_register_account_request_set_identity_signed_pre_key(
native.const(),
ServiceIdKind.aci.rawValue,
$0
)
}
)
try checkError(
pniSignedPreKey.withNativeStruct {
signal_register_account_request_set_identity_signed_pre_key(
native.const(),
ServiceIdKind.pni.rawValue,
$0
)
}
)
try checkError(
aciPqSignedPreKey.withNativeStruct {
signal_register_account_request_set_identity_pq_last_resort_pre_key(
native.const(),
ServiceIdKind.aci.rawValue,
$0
)
}
)
try checkError(
pniPqSignedPreKey.withNativeStruct {
signal_register_account_request_set_identity_pq_last_resort_pre_key(
native.const(),
ServiceIdKind.pni.rawValue,
$0
)
}
)
}
}
return request
}
}
extension SignalFfiConnectChatBridgeStruct {
/// Constructs a ``SignalFfiConnectChatBridgeStruct`` that uses the given
/// ``ConnectionManager`` to create chat connections.
///
/// The caller must ensure that ``Self/destroy`` is called, either manually
/// or by passing the value into a bridge function that will do so.
fileprivate static func forManager(_ connectionManager: ConnectionManager) -> Self {
return SignalFfiConnectChatBridgeStruct(
ctx: Unmanaged.passRetained(connectionManager).toOpaque(),
get_connection_manager: { ctx in
Unmanaged<ConnectionManager>.fromOpaque(ctx!).takeUnretainedValue()
.unsafeNativeHandle
},
destroy: { ctx in _ = Unmanaged<ConnectionManager>.fromOpaque(ctx!).takeRetainedValue()
}
)
}
}
// Exposed for testing.
internal func invokeFnReturningCheckSvr2CredentialsResponse(
fn: (_ out: UnsafeMutablePointer<SignalFfiCheckSvr2CredentialsResponse>?) -> SignalFfiErrorRef?
) throws -> [String: Svr2CredentialsResult] {
let entries = try invokeFnReturningSomeBytestringArray(
fn: { out in
// This is just a named wrapper around a bytestring array.
var wrapper = SignalFfiCheckSvr2CredentialsResponse()
let err = fn(&wrapper)
// Copy the wrapped pointer into the provided output. The outer function
// will also take care of deallocating.
if err == nil {
out!.update(from: &wrapper.entries, count: 1)
}
return err
},
transform: { view in
// The format for entries is a UTF-8 key with the value as a single byte at the end.
let key = String(decoding: view.dropLast(), as: Unicode.UTF8.self)
let valueByte = UInt32(view.last!)
let value =
switch SignalSvr2CredentialsResult(valueByte) {
case SignalSvr2CredentialsResultInvalid:
Svr2CredentialsResult.invalid
case SignalSvr2CredentialsResultMatch:
Svr2CredentialsResult.match
case SignalSvr2CredentialsResultNoMatch:
Svr2CredentialsResult.noMatch
default:
fatalError("unknown SVR2 credentials result value \(valueByte)")
}
return (key, value)
}
)
return Dictionary(uniqueKeysWithValues: entries)
}
extension SignalMutPointerRegistrationService: SignalMutPointer {
public typealias ConstPointer = SignalConstPointerRegistrationService
public init(untyped: OpaquePointer?) {
self.init(raw: untyped)
}
public func toOpaque() -> OpaquePointer? {
self.raw
}
public func const() -> Self.ConstPointer {
SignalConstPointerRegistrationService(raw: self.raw)
}
}
extension SignalConstPointerRegistrationService: SignalConstPointer {
public func toOpaque() -> OpaquePointer? {
self.raw
}
}