Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sync conect #281

Merged
merged 17 commits into from
Mar 31, 2023
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
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/sync_crypto",
"state" : {
"revision" : "df751674ee842d129c50a183668b033d02c2980d",
"version" : "0.0.1"
"revision" : "2ab6ab6f0f96b259c14c2de3fc948935fc16ac78",
"version" : "0.2.0"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ let package = Package(
.package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "6.4.3"),
.package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.0.0"),
.package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "1.2.1"),
.package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.0.1"),
.package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"),
.package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"),
.package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "4.4.4"),
.package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "1.4.0"),
Expand Down
16 changes: 16 additions & 0 deletions Sources/DDGSync/DDGSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ public class DDGSync: DDGSyncing {
updateIsAuthenticated()
}

public func remoteConnect() throws -> RemoteConnecting {
guard try dependencies.secureStore.account() == nil else {
ayoy marked this conversation as resolved.
Show resolved Hide resolved
throw SyncError.accountAlreadyExists
}
let info = try dependencies.crypter.prepareForConnect()
return try dependencies.createRemoteConnector(info)
}

public func transmitRecoveryKey(_ connectCode: SyncCode.ConnectCode) async throws {
guard try dependencies.secureStore.account() != nil else {
throw SyncError.accountNotFound
}

try await dependencies.createRecoveryKeyTransmitter().send(connectCode)
}

public func sender() throws -> UpdatesSending {
return try dependencies.createUpdatesSender(persistence)
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/DDGSync/DDGSyncing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ public protocol DDGSyncing {
*/
func login(_ recoveryKey: SyncCode.RecoveryKey, deviceName: String, deviceType: String) async throws

/**
Returns a device id and temporary secret key ready for display and allows callers attempt to fetch the transmitted recovery key.
*/
func remoteConnect() throws -> RemoteConnecting

/**
Sends this device's recovery key to the server encrypted using supplied key
*/
func transmitRecoveryKey(_ connectCode: SyncCode.ConnectCode) async throws

/**
Creates an atomic sender. Add items to the sender and then call send to send them all in a single PATCH. Will automatically re-try if there is a network failure.
*/
Expand Down Expand Up @@ -155,3 +165,13 @@ public struct SavedSiteFolder: Codable {
}

}

public protocol RemoteConnecting {

var code: String { get }

func pollForRecoveryKey() async throws -> SyncCode.RecoveryKey?

func stopPolling()

}
3 changes: 3 additions & 0 deletions Sources/DDGSync/SyncError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public enum SyncError: Error {

case failedToEncryptValue(_ message: String)
case failedToDecryptValue(_ message: String)
case failedToPrepareForConnect(_ message: String)
case failedToOpenSealedBox(_ message: String)
case failedToSealData(_ message: String)

case failedToWriteSecureStore(status: OSStatus)
case failedToReadSecureStore(status: OSStatus)
Expand Down
10 changes: 8 additions & 2 deletions Sources/DDGSync/SyncModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import Foundation

public struct SyncAccount: Codable {
public struct SyncAccount: Codable, Sendable {
public let deviceId: String
public let deviceName: String
public let deviceType: String
Expand All @@ -40,7 +40,7 @@ public struct SyncAccount: Codable {
}
}

public struct RegisteredDevice: Codable {
public struct RegisteredDevice: Codable, Sendable {
public let id: String
public let name: String
public let type: String
Expand All @@ -60,6 +60,12 @@ public struct ExtractedLoginInfo {
public let stretchedPrimaryKey: Data
}

public struct ConnectInfo {
public let deviceID: String
public let publicKey: Data
public let secretKey: Data
}

public struct SyncCode: Codable {

public enum Base64Error: Error {
Expand Down
12 changes: 5 additions & 7 deletions Sources/DDGSync/internal/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ struct AccountManager: AccountManaging {
token: result.token)
}

func login(_ recoveryKey: SyncCode.RecoveryKey, deviceName: String, deviceType: String) async throws -> (account: SyncAccount, devices: [RegisteredDevice]) {
func login(_ recoveryKey: SyncCode.RecoveryKey, deviceName: String, deviceType: String) async throws -> LoginResult {
let deviceId = UUID().uuidString

let recoveryInfo = try crypter.extractLoginInfo(recoveryKey: recoveryKey)
Expand All @@ -93,9 +93,7 @@ struct AccountManager: AccountManaging {
deviceType: encryptedDeviceType
)

guard let paramJson = try? JSONEncoder.snakeCaseKeys.encode(params) else {
fatalError()
}
let paramJson = try JSONEncoder.snakeCaseKeys.encode(params)

let request = api.createRequest(
url: endpoints.login,
Expand Down Expand Up @@ -125,17 +123,17 @@ struct AccountManager: AccountManaging {

let secretKey = try crypter.extractSecretKey(protectedSecretKey: protectedSecretKey, stretchedPrimaryKey: recoveryInfo.stretchedPrimaryKey)

return try (
return LoginResult(
account: SyncAccount(
deviceId: deviceId,
deviceId: params.deviceId,
deviceName: deviceName,
deviceType: deviceType,
userId: recoveryInfo.userId,
primaryKey: recoveryInfo.primaryKey,
secretKey: secretKey,
token: token
),
devices: result.devices.map {
devices: try result.devices.map {
RegisteredDevice(
id: $0.deviceId,
name: try crypter.base64DecodeAndDecrypt($0.deviceName, using: recoveryInfo.primaryKey),
Expand Down
37 changes: 37 additions & 0 deletions Sources/DDGSync/internal/Crypter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,43 @@ struct Crypter: Crypting {
return Data(secretKeyBytes)
}

func prepareForConnect() throws -> ConnectInfo {
var publicKeyBytes = [UInt8](repeating: 0, count: Int(DDGSYNCCRYPTO_PUBLIC_KEY_SIZE.rawValue))
var secretKeyBytes = [UInt8](repeating: 0, count: Int(DDGSYNCCRYPTO_PRIVATE_KEY_SIZE.rawValue))
let result = ddgSyncPrepareForConnect(&publicKeyBytes, &secretKeyBytes)
guard DDGSYNCCRYPTO_OK == result else {
throw SyncError.failedToPrepareForConnect("ddgSyncPrepareForConnect failed: \(result)")
}
return ConnectInfo(deviceID: UUID().uuidString,
publicKey: Data(publicKeyBytes),
secretKey: Data(secretKeyBytes))
}

func seal(_ data: Data, secretKey: Data) throws -> Data {
var rawBytes = data.safeBytes
var secretKeyBytes = secretKey.safeBytes
var encryptedBytes = [UInt8](repeating: 0, count: rawBytes.count + Int(DDGSYNCCRYPTO_SEAL_EXTRA_BYTES_SIZE.rawValue))
let result = ddgSyncSeal(&encryptedBytes, &secretKeyBytes, &rawBytes, UInt64(rawBytes.count))
guard DDGSYNCCRYPTO_OK == result else {
throw SyncError.failedToSealData("ddgSyncSeal failed: \(result)")
}
return Data(encryptedBytes)
}

func unseal(encryptedData: Data, publicKey: Data, secretKey: Data) throws -> Data {
var encryptedBytes = encryptedData.safeBytes
var rawBytes = [UInt8](repeating: 0, count: encryptedBytes.count - Int(DDGSYNCCRYPTO_SEAL_EXTRA_BYTES_SIZE.rawValue))

var publicKeyBytes = publicKey.safeBytes
var secretKeyBytes = secretKey.safeBytes

let result = ddgSyncSealOpen(&encryptedBytes, UInt64(encryptedBytes.count), &publicKeyBytes, &secretKeyBytes, &rawBytes)
guard DDGSYNCCRYPTO_OK == result else {
throw SyncError.failedToOpenSealedBox("ddgSyncSealOpen failed: \(result)")
}
return Data(rawBytes)
}

}

extension Data {
Expand Down
3 changes: 3 additions & 0 deletions Sources/DDGSync/internal/Endpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct Endpoints {
let signup: URL
let login: URL
let logoutDevice: URL
let connect: URL

/// Optionally has the data type(s) appended to it, e.g. `sync/bookmarks`, `sync/type1,type2,type3`
let syncGet: URL
Expand All @@ -33,6 +34,8 @@ struct Endpoints {
signup = baseUrl.appendingPathComponent("sync/signup")
login = baseUrl.appendingPathComponent("sync/login")
logoutDevice = baseUrl.appendingPathComponent("sync/logout-device")
connect = baseUrl.appendingPathComponent("sync/connect")

syncGet = baseUrl.appendingPathComponent("sync")
syncPatch = baseUrl.appendingPathComponent("sync/data")
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/DDGSync/internal/LoginResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// LoginResult.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

struct LoginResult: Sendable {
let account: SyncAccount
let devices: [RegisteredDevice]
}
8 changes: 8 additions & 0 deletions Sources/DDGSync/internal/ProductionDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ struct ProductionDependencies: SyncDependencies {
responseHandler = ResponseHandler(persistence: persistence, crypter: crypter)
}

func createRemoteConnector(_ info: ConnectInfo) throws -> RemoteConnecting {
return try RemoteConnector(crypter: crypter, api: api, endpoints: endpoints, connectInfo: info)
}

func createUpdatesSender(_ persistence: LocalDataPersisting) throws -> UpdatesSending {
return UpdatesSender(fileStorageUrl: fileStorageUrl, persistence: persistence, dependencies: self)
}
Expand All @@ -61,4 +65,8 @@ struct ProductionDependencies: SyncDependencies {
return UpdatesFetcher(persistence: persistence, dependencies: self)
}

func createRecoveryKeyTransmitter() throws -> RecoveryKeyTransmitting {
return RecoveryKeyTransmitter(endpoints: endpoints, api: api, storage: secureStore, crypter: crypter)
}

}
63 changes: 63 additions & 0 deletions Sources/DDGSync/internal/RecoveryKeyTransmitter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// RecoveryKeyTransmitter.swift
// DuckDuckGo
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import os

struct RecoveryKeyTransmitter: RecoveryKeyTransmitting {

let endpoints: Endpoints
let api: RemoteAPIRequestCreating
let storage: SecureStoring
let crypter: Crypting

func send(_ code: SyncCode.ConnectCode) async throws {
guard let account = try storage.account() else {
throw SyncError.accountNotFound
}

guard let token = try storage.account()?.token else {
throw SyncError.noToken
}

let recoveryKey = try JSONEncoder.snakeCaseKeys.encode(
SyncCode.RecoveryKey(userId: account.userId, primaryKey: account.primaryKey)
)

let encryptedRecoveryKey = try crypter.seal(recoveryKey, secretKey: code.secretKey)

let body = try JSONEncoder.snakeCaseKeys.encode(
ConnectRequest(deviceId: code.deviceId, encryptedRecoveryKey: encryptedRecoveryKey)
)

let request = api.createRequest(url: endpoints.connect,
method: .POST,
headers: ["Authorization": "Bearer \(token)"],
parameters: [:],
body: body,
contentType: "application/json")
Comment on lines +49 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nitpick, this feels to me like the user of the API code has to care too much about the format of the request (like having to format the authorization header manually, and specify the content type string).

I assume we'll be needing to make similar calls in a number of other places, so it would be nice to have it handled more succinctly from the caller's perspective. What do you think?

Copy link
Contributor Author

@brindy brindy Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree the authorization header could be hidden away (other headers are already added elsewhere), but everything else is the responsibility of the api call to set, so laying it out like this shows the intent. If I were to change anything anything it might be to do something like this:

api.createAuthenticatedRequest(...)

Maybe body could be more explicitly jsonBody then there'd be no need to set the content type either, I suppose.

I think we should see what we end up doing for the actual data sync and then refactor then. I don't really want to define something now that might get changed later tbh.

_ = try await request.execute()
}

struct ConnectRequest: Encodable {
let deviceId: String
let encryptedRecoveryKey: Data
}

}
Loading