From cfb1ae6abc8feed930dd8d26bf53d781c30e8e22 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:34:22 +0900 Subject: [PATCH 01/60] =?UTF-8?q?[#12]=20bugfix:=20Secret=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVData/Sources/Storage/Local/Models/Secret.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 6861923..23325fc 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -36,7 +36,7 @@ extension SwiftDataModel { } init( - secretId: UUID = UUID(), + id: UUID = UUID(), name: String, secretType: String, subType: String? = nil, @@ -50,7 +50,7 @@ extension SwiftDataModel { updatedAt: Date? = nil ) { let initialCreatedAt = createdAt - self.secretId = secretId + self.id = id self.name = name self.secretType = secretType self.subType = subType From 5782af98415e8665198635f8b3e996ff511ba4a0 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:42:08 +0900 Subject: [PATCH 02/60] =?UTF-8?q?[#12]=20feat:=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EA=B0=84=20dependency=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVData/Project.swift | 6 +++++- Projects/DVDesign/Project.swift | 5 ++++- Projects/DVDomain/Project.swift | 5 ++++- Projects/DVPresentation/Project.swift | 7 ++++++- Projects/Devault/Project.swift | 8 +++++++- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Projects/DVData/Project.swift b/Projects/DVData/Project.swift index fbc8488..f0f2179 100644 --- a/Projects/DVData/Project.swift +++ b/Projects/DVData/Project.swift @@ -7,7 +7,11 @@ let project = Project.project( .target( name: DVModule.DVData.name, product: Project.product, - sources: .sources + sources: .sources, + dependencies: [ + .domain(), + .core(), + ] ), ] ) diff --git a/Projects/DVDesign/Project.swift b/Projects/DVDesign/Project.swift index e220b87..83c6401 100644 --- a/Projects/DVDesign/Project.swift +++ b/Projects/DVDesign/Project.swift @@ -8,7 +8,10 @@ let project = Project.project( name: DVModule.DVDesign.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .core(), + ] ), .sampleApp( name: DVModule.DVDesign.name, diff --git a/Projects/DVDomain/Project.swift b/Projects/DVDomain/Project.swift index f6761d3..0e4008e 100644 --- a/Projects/DVDomain/Project.swift +++ b/Projects/DVDomain/Project.swift @@ -7,7 +7,10 @@ let project = Project.project( .target( name: DVModule.DVDomain.name, product: Project.product, - sources: .sources + sources: .sources, + dependencies: [ + .core(), + ] ), .tests( name: DVModule.DVDomain.name, diff --git a/Projects/DVPresentation/Project.swift b/Projects/DVPresentation/Project.swift index 1804fda..68afb41 100644 --- a/Projects/DVPresentation/Project.swift +++ b/Projects/DVPresentation/Project.swift @@ -8,7 +8,12 @@ let project = Project.project( name: DVModule.DVPresentation.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .domain(), + .design(), + .core(), + ] ), ] ) diff --git a/Projects/Devault/Project.swift b/Projects/Devault/Project.swift index 803e3b1..3feb11c 100644 --- a/Projects/Devault/Project.swift +++ b/Projects/Devault/Project.swift @@ -11,7 +11,13 @@ let project = Project.project( "CFBundleDisplayName": .string("Devault"), ]), sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .presentation(), + .data(), + .domain(), + .core(), + ] ), ], schemes: [ From 8f4d12e9d6e837b908cc6204a5d1df7bc07f2258 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:52:48 +0900 Subject: [PATCH 03/60] =?UTF-8?q?[#12]=20feat:=20Secret,=20Metadata,=20Pay?= =?UTF-8?q?load=20entity=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/Entity/Secret.swift | 52 +++++++++++++++++++ .../Sources/Entity/SecretMetadata.swift | 19 +++++++ .../Sources/Entity/SecretPayload.swift | 22 ++++++++ Projects/DVDomain/Tests/.gitkeep | 0 Projects/DVDomain/Tests/ExampleTest.swift | 10 ---- 5 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 Projects/DVDomain/Sources/Entity/Secret.swift create mode 100644 Projects/DVDomain/Sources/Entity/SecretMetadata.swift create mode 100644 Projects/DVDomain/Sources/Entity/SecretPayload.swift delete mode 100644 Projects/DVDomain/Tests/.gitkeep delete mode 100644 Projects/DVDomain/Tests/ExampleTest.swift diff --git a/Projects/DVDomain/Sources/Entity/Secret.swift b/Projects/DVDomain/Sources/Entity/Secret.swift new file mode 100644 index 0000000..a807d61 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/Secret.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct Secret: Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + public var deletedAt: Date? + public var createdAt: Date + public var updatedAt: Date + public var payload: SecretPayload + public var metadata: SecretMetadata? + + public init( + id: UUID, + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false, + deletedAt: Date? = nil, + createdAt: Date, + updatedAt: Date, + payload: SecretPayload, + metadata: SecretMetadata? = nil + ) { + self.id = id + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.createdAt = createdAt + self.updatedAt = updatedAt + self.payload = payload + self.metadata = metadata + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift new file mode 100644 index 0000000..742cee0 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretMetadata: Equatable, Identifiable, Sendable { + public var id: UUID + public var metadataJSON: Data + public var schemaVersion: Int + + public init( + id: UUID, + metadataJSON: Data, + schemaVersion: Int + ) { + self.id = id + self.metadataJSON = metadataJSON + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretPayload.swift b/Projects/DVDomain/Sources/Entity/SecretPayload.swift new file mode 100644 index 0000000..c887d53 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretPayload.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretPayload: Equatable, Identifiable, Sendable { + public var id: UUID + public var encryptedData: Data + public var keyTag: String + public var schemaVersion: Int + + public init( + id: UUID, + encryptedData: Data, + keyTag: String, + schemaVersion: Int + ) { + self.id = id + self.encryptedData = encryptedData + self.keyTag = keyTag + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Tests/.gitkeep b/Projects/DVDomain/Tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Projects/DVDomain/Tests/ExampleTest.swift b/Projects/DVDomain/Tests/ExampleTest.swift deleted file mode 100644 index 31145ca..0000000 --- a/Projects/DVDomain/Tests/ExampleTest.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import DVDomain - -@Suite("Example") -struct ExampleTest { - @Test("조건을 만족해야 한다") - func example() { - #expect(1 + 1 == 2) - } -} From b321a556a58f0f1202eab1d6147ed1ccd810f169 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:15:39 +0900 Subject: [PATCH 04/60] =?UTF-8?q?[#12]=20delete:=20DVDomain=20project=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=82=B4=EC=9D=98=20test=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Project.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Projects/DVDomain/Project.swift b/Projects/DVDomain/Project.swift index 0e4008e..5ddeada 100644 --- a/Projects/DVDomain/Project.swift +++ b/Projects/DVDomain/Project.swift @@ -12,9 +12,5 @@ let project = Project.project( .core(), ] ), - .tests( - name: DVModule.DVDomain.name, - dependencies: [DVModule.DVDomain.dependency] - ), ] ) From a1c20f15839730af34fa8d2eb6fb63a9dadcb932 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:17:54 +0900 Subject: [PATCH 05/60] =?UTF-8?q?[#12]=20feat:=20SecretRepository,=20Error?= =?UTF-8?q?=20case=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/Error/SecretRepositoryError.swift | 13 +++++++++++++ .../Repository/Interface/SecretRepository.swift | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift create mode 100644 Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift diff --git a/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift new file mode 100644 index 0000000..2b645bd --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretRepositoryError: Error, Equatable, Sendable { + case notFound(id: UUID) + case duplicateID(id: UUID) + case invalidQuery + case invalidPatch + case corruptedStorage + case storageUnavailable + case persistenceFailed +} diff --git a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift new file mode 100644 index 0000000..f3a5107 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SecretRepository: Sendable { + func create(_ secret: Secret) async throws -> Secret + func fetch(id: UUID) async throws -> Secret? + func fetch(_ query: SecretQuery) async throws -> [Secret] + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func delete(id: UUID) async throws +} From b9468572aacbbbf39e2c8c1fa31eafcb50c7b0b3 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:20:37 +0900 Subject: [PATCH 06/60] =?UTF-8?q?[#12]=20feat:=20SecretPatch,=20SecretQuer?= =?UTF-8?q?y=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Repository/SecretPatch.swift | 52 +++++++++++++++++++ .../Sources/Repository/SecretQuery.swift | 47 +++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 Projects/DVDomain/Sources/Repository/SecretPatch.swift create mode 100644 Projects/DVDomain/Sources/Repository/SecretQuery.swift diff --git a/Projects/DVDomain/Sources/Repository/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift new file mode 100644 index 0000000..406bb8a --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretPatch.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret의 일부 필드 변경 요청을 표현합니다. +public struct SecretPatch: Equatable, Sendable { + public var name: PatchField + public var secretType: PatchField + public var subType: PatchField + public var service: PatchField + public var environment: PatchField + public var expiresAt: PatchField + public var memo: PatchField + public var liked: PatchField + public var deletedAt: PatchField + public var payload: PatchField + public var metadata: PatchField + public var updatedAt: PatchField + + public init( + name: PatchField = .unchanged, + secretType: PatchField = .unchanged, + subType: PatchField = .unchanged, + service: PatchField = .unchanged, + environment: PatchField = .unchanged, + expiresAt: PatchField = .unchanged, + memo: PatchField = .unchanged, + liked: PatchField = .unchanged, + deletedAt: PatchField = .unchanged, + payload: PatchField = .unchanged, + metadata: PatchField = .unchanged, + updatedAt: PatchField = .unchanged + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.payload = payload + self.metadata = metadata + self.updatedAt = updatedAt + } +} + +public enum PatchField: Equatable, Sendable { + case unchanged + case set(Value) +} diff --git a/Projects/DVDomain/Sources/Repository/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/SecretQuery.swift new file mode 100644 index 0000000..effe32b --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretQuery.swift @@ -0,0 +1,47 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret 목록 조회 범위, 필터, 검색어, 정렬 조건을 표현합니다. +public struct SecretQuery: Equatable, Sendable { + public var collection: Collection // 어디 탭/사이드 바 범위에서 볼 것인가 + public var secretType: String? // 어떤 종류의 Secret인가 + public var service: String? // 어떤 서비스인가 + public var environment: String? // 어떤 실행 환경인가 + public var searchText: String? // 사용자가 입력한 텍스트 검색어 + public var sort: Sort // 어떤 순서로 볼 것인가 + + public init( + collection: Collection = .all, + secretType: String? = nil, + service: String? = nil, + environment: String? = nil, + searchText: String? = nil, + sort: Sort = .recentlyAdded + ) { + self.collection = collection + self.secretType = secretType + self.service = service + self.environment = environment + self.searchText = searchText + self.sort = sort + } +} + +public extension SecretQuery { + enum Collection: Equatable, Sendable { + case all + case liked + case expired(referenceDate: Date) + case deleted + case project(id: UUID) + } + + enum Sort: Equatable, Sendable { + case recentlyAdded + case oldestFirst + case expiringSoon + case nameAscending + case nameDescending + } +} From 64e2e6f99aeff2e37755840f630107ab3438ae14 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:59:48 +0900 Subject: [PATCH 07/60] =?UTF-8?q?[#12]=20feat:=20SecretCryptoService=20?= =?UTF-8?q?=ED=8F=AC=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20Error=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Error/SecretCryptoError.swift | 9 ++++++++ .../Interface/SecretCryptoService.swift | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift create mode 100644 Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift diff --git a/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift new file mode 100644 index 0000000..10e28eb --- /dev/null +++ b/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum SecretCryptoError: Error, Equatable, Sendable { + case keyUnavailable + case encryptionFailed + case decryptionFailed + case encodingFailed + case decodingFailed +} diff --git a/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift new file mode 100644 index 0000000..228eebb --- /dev/null +++ b/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +/// Secret payload 암호화와 metadata 인코딩을 수행하는 서비스 프로토콜입니다. +public protocol SecretCryptoService: Sendable { + func encryptPayload( + _ payload: Payload + ) async throws -> SecretPayload + + func decryptPayload( + _ payload: SecretPayload, + as type: Payload.Type + ) async throws -> Payload + + func encodeMetadata( + _ metadata: Metadata + ) throws -> SecretMetadata + + func decodeMetadata( + _ metadata: SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata +} From 3169653cbc2c917abb72b5b8b1c5fc073cf389a0 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:00:25 +0900 Subject: [PATCH 08/60] =?UTF-8?q?[#12]=20fix:=20SecretMetadata,=20Payload?= =?UTF-8?q?=20Entity=EC=97=90=EC=84=9C=20id=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/Entity/SecretMetadata.swift | 5 +---- Projects/DVDomain/Sources/Entity/SecretPayload.swift | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift index 742cee0..97b229d 100644 --- a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift +++ b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift @@ -2,17 +2,14 @@ import Foundation -public struct SecretMetadata: Equatable, Identifiable, Sendable { - public var id: UUID +public struct SecretMetadata: Equatable, Sendable { public var metadataJSON: Data public var schemaVersion: Int public init( - id: UUID, metadataJSON: Data, schemaVersion: Int ) { - self.id = id self.metadataJSON = metadataJSON self.schemaVersion = schemaVersion } diff --git a/Projects/DVDomain/Sources/Entity/SecretPayload.swift b/Projects/DVDomain/Sources/Entity/SecretPayload.swift index c887d53..a20f4fc 100644 --- a/Projects/DVDomain/Sources/Entity/SecretPayload.swift +++ b/Projects/DVDomain/Sources/Entity/SecretPayload.swift @@ -2,19 +2,16 @@ import Foundation -public struct SecretPayload: Equatable, Identifiable, Sendable { - public var id: UUID +public struct SecretPayload: Equatable, Sendable { public var encryptedData: Data public var keyTag: String public var schemaVersion: Int public init( - id: UUID, encryptedData: Data, keyTag: String, schemaVersion: Int ) { - self.id = id self.encryptedData = encryptedData self.keyTag = keyTag self.schemaVersion = schemaVersion From 6ecf9d7f9a399c6839c171a6c033a903036b38aa Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:01:33 +0900 Subject: [PATCH 09/60] =?UTF-8?q?[#12]=20feat:=20PayloadData=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20secret=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretContent/Payload/APIKeyPayload.swift | 11 +++++++++++ .../SecretContent/Payload/CustomPayload.swift | 11 +++++++++++ .../Payload/DatabasePayload.swift | 11 +++++++++++ .../SecretContent/Payload/EnvSetPayload.swift | 11 +++++++++++ .../Payload/Interface/SecretPayloadData.swift | 8 ++++++++ .../Payload/LicenseKeyPayload.swift | 11 +++++++++++ .../Payload/OAuthClientPayload.swift | 13 +++++++++++++ .../SecretContent/Payload/SSHKeyPayload.swift | 13 +++++++++++++ .../Payload/SSLCertPayload.swift | 19 +++++++++++++++++++ .../Payload/ServiceAccountPayload.swift | 11 +++++++++++ 10 files changed, 119 insertions(+) create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift new file mode 100644 index 0000000..997e972 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift new file mode 100644 index 0000000..12d27a4 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct CustomPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift new file mode 100644 index 0000000..e1a0146 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabasePayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var linkString: String + + public init(linkString: String) { + self.linkString = linkString + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift new file mode 100644 index 0000000..3ea6d03 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct EnvSetPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var content: String + + public init(content: String) { + self.content = content + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift new file mode 100644 index 0000000..0b20e47 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 암호화 대상 Secret payload content가 따라야 하는 Domain 계약입니다. +public protocol SecretPayloadData: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift new file mode 100644 index 0000000..baea0c1 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var licenseKey: String + + public init(licenseKey: String) { + self.licenseKey = licenseKey + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift new file mode 100644 index 0000000..99d2a20 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var clientId: String + public var clientSecret: String + + public init(clientId: String, clientSecret: String) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift new file mode 100644 index 0000000..3cde697 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var privateKey: String + public var passphrase: String? + + public init(privateKey: String, passphrase: String? = nil) { + self.privateKey = privateKey + self.passphrase = passphrase + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift new file mode 100644 index 0000000..661af5d --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var certificate: String + public var privateKey: String + public var certificateChain: String? + + public init( + certificate: String, + privateKey: String, + certificateChain: String? = nil + ) { + self.certificate = certificate + self.privateKey = privateKey + self.certificateChain = certificateChain + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift new file mode 100644 index 0000000..2399d6c --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var credentialJSON: String + + public init(credentialJSON: String) { + self.credentialJSON = credentialJSON + } +} From 7b0f66a98e638feed4418415f7e0851fa9f99002 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:02:06 +0900 Subject: [PATCH 10/60] =?UTF-8?q?[#12]=20feat:=20Metadata=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20Secret=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EB=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Metadata/APIKeyMetadata.swift | 11 ++++++++ .../Metadata/DatabaseMetadata.swift | 25 +++++++++++++++++++ .../Interface/SecretMetadataContent.swift | 8 ++++++ .../Metadata/LicenseKeyMetadata.swift | 22 ++++++++++++++++ .../Metadata/OAuthClientMetadata.swift | 13 ++++++++++ .../Metadata/SSHKeyMetadata.swift | 22 ++++++++++++++++ .../Metadata/SSLCertMetadata.swift | 19 ++++++++++++++ .../Metadata/ServiceAccountMetadata.swift | 19 ++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift new file mode 100644 index 0000000..8ef8336 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var scope: String? + + public init(scope: String? = nil) { + self.scope = scope + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift new file mode 100644 index 0000000..92623cd --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabaseMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var host: String? + public var port: Int? + public var databaseName: String? + public var username: String? + public var sslRequired: Bool? + + public init( + host: String? = nil, + port: Int? = nil, + databaseName: String? = nil, + username: String? = nil, + sslRequired: Bool? = nil + ) { + self.host = host + self.port = port + self.databaseName = databaseName + self.username = username + self.sslRequired = sslRequired + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift new file mode 100644 index 0000000..7d24bbe --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 평문 JSON으로 저장 가능한 Secret metadata content가 따라야 하는 Domain 계약입니다. +public protocol SecretMetadataContent: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift new file mode 100644 index 0000000..97c10bc --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var licenseType: String? + public var registrationEmail: String? + public var orderNumber: String? + public var website: String? + + public init( + licenseType: String? = nil, + registrationEmail: String? = nil, + orderNumber: String? = nil, + website: String? = nil + ) { + self.licenseType = licenseType + self.registrationEmail = registrationEmail + self.orderNumber = orderNumber + self.website = website + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift new file mode 100644 index 0000000..c94ed36 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var redirectUri: String? + public var scopes: String? + + public init(redirectUri: String? = nil, scopes: String? = nil) { + self.redirectUri = redirectUri + self.scopes = scopes + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift new file mode 100644 index 0000000..cb82961 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var publicKey: String? + public var keyType: String? + public var host: String? + public var username: String? + + public init( + publicKey: String? = nil, + keyType: String? = nil, + host: String? = nil, + username: String? = nil + ) { + self.publicKey = publicKey + self.keyType = keyType + self.host = host + self.username = username + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift new file mode 100644 index 0000000..0a29ee0 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var domain: String? + public var issuer: String? + public var renewCommand: String? + + public init( + domain: String? = nil, + issuer: String? = nil, + renewCommand: String? = nil + ) { + self.domain = domain + self.issuer = issuer + self.renewCommand = renewCommand + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift new file mode 100644 index 0000000..2658250 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var projectId: String? + public var accountEmail: String? + public var authority: String? + + public init( + projectId: String? = nil, + accountEmail: String? = nil, + authority: String? = nil + ) { + self.projectId = projectId + self.accountEmail = accountEmail + self.authority = authority + } +} From ebd2a64fc27a4c45abdee4bf1dc1a7c9fffd88cb Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:45:47 +0900 Subject: [PATCH 11/60] =?UTF-8?q?[#12]=20feat:=20Secret=20(=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=82=AD=EC=A0=9C,=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=EC=88=98=EC=A0=95)=20=EA=B4=80=EB=A0=A8=20UseCase=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/Secret/CreateSecretUseCase.swift | 14 ++++++++++++++ .../Interface/Secret/DeleteSecretUseCase.swift | 9 +++++++++ .../Interface/Secret/FetchSecretUseCase.swift | 12 ++++++++++++ .../Interface/Secret/PatchSecretUseCase.swift | 16 ++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift new file mode 100644 index 0000000..cc6357e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +public protocol CreateSecretUseCase: Sendable { + func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret + + func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift new file mode 100644 index 0000000..b57c4f1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol DeleteSecretUseCase: Sendable { + func softDelete(id: UUID) async throws -> Secret + func restore(id: UUID) async throws -> Secret + func permanentlyDelete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift new file mode 100644 index 0000000..eaf58d1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -0,0 +1,12 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol FetchSecretUseCase: Sendable { + func fetch(id: UUID) async throws -> Secret? + func fetch(query: SecretQuery) async throws -> [Secret] + func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift new file mode 100644 index 0000000..b4201f2 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -0,0 +1,16 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol PatchSecretUseCase: Sendable { + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret + func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret + func removeMetadata(id: UUID) async throws -> Secret +} From a96718895177a8d050bb1b82d519c9e391e942f1 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:46:22 +0900 Subject: [PATCH 12/60] =?UTF-8?q?[#12]=20feat:=20Secret=20UseCase=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/CreateSecretUseCaseImpl.swift | 81 +++++++++++++++++++ .../Impl/Secret/DeleteSecretUseCaseImpl.swift | 50 ++++++++++++ .../Impl/Secret/FetchSecretUseCaseImpl.swift | 46 +++++++++++ .../Impl/Secret/PatchSecretUseCaseImpl.swift | 75 +++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift new file mode 100644 index 0000000..c8b5f46 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -0,0 +1,81 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct CreateSecretUseCaseImpl: CreateSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let idGenerator: @Sendable () -> UUID + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + idGenerator: @escaping @Sendable () -> UUID = { UUID() }, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.idGenerator = idGenerator + self.dateProvider = dateProvider + } + + public func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload, + metadata: encodedMetadata + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift new file mode 100644 index 0000000..51c4112 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift @@ -0,0 +1,50 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct DeleteSecretUseCaseImpl: DeleteSecretUseCase { + private let repository: any SecretRepository + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.dateProvider = dateProvider + } + + public func softDelete(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(now), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func restore(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func permanentlyDelete(id: UUID) async throws { + do { + try await repository.delete(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift new file mode 100644 index 0000000..260f2bb --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -0,0 +1,46 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct FetchSecretUseCaseImpl: FetchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService + ) { + self.repository = repository + self.cryptoService = cryptoService + } + + public func fetch(id: UUID) async throws -> Secret? { + do { + return try await repository.fetch(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func fetch(query: SecretQuery) async throws -> [Secret] { + do { + return try await repository.fetch(query) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload { + do { + guard let secret = try await repository.fetch(id: id) else { + throw SecretUseCaseError.secretNotFound(id: id) + } + return try await cryptoService.decryptPayload(secret.payload, as: type) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift new file mode 100644 index 0000000..5a101b0 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift @@ -0,0 +1,75 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct PatchSecretUseCaseImpl: PatchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.dateProvider = dateProvider + } + + public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { + do { + let patch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(patch, now: dateProvider()) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret { + do { + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let now = dateProvider() + let patch = SecretPatch( + payload: .set(encryptedPayload), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret { + do { + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(encodedMetadata), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func removeMetadata(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } +} From 5c4638c0eb79163b39e9306bc3a9400dafa5b731 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:46:51 +0900 Subject: [PATCH 13/60] =?UTF-8?q?[#12]=20feat:=20Secret=20create=EC=97=90?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=9C=20input=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=91=9C=ED=98=84=20draft=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/Draft/SecretDraft.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift new file mode 100644 index 0000000..0682ad9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. +public struct SecretDraft: Equatable, Sendable { + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + + public init( + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + } +} From 5b4ffeeef6ae2558d41dbc68c4a5a6684050b931 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:47:26 +0900 Subject: [PATCH 14/60] =?UTF-8?q?[#12]=20feat:=20SecretUseCaseError=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EB=B0=8F=20mapping=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/Error/SecretUseCaseError.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift diff --git a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift new file mode 100644 index 0000000..0dd79f8 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift @@ -0,0 +1,33 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretUseCaseError: Error, Equatable, Sendable { + case invalidName + case invalidSecretType + case secretNotFound(id: UUID) + case repositoryFailure(SecretRepositoryError) + case cryptoFailure(SecretCryptoError) + case unexpected +} + +extension SecretUseCaseError { + static func map(_ error: Error) -> SecretUseCaseError { + if let error = error as? SecretUseCaseError { + return error + } + + if let error = error as? SecretRepositoryError { + if case let .notFound(id) = error { + return .secretNotFound(id: id) + } + return .repositoryFailure(error) + } + + if let error = error as? SecretCryptoError { + return .cryptoFailure(error) + } + + return .unexpected + } +} From cc06960ca6ece786f48a6a29de0fd2dbb404f961 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:49:16 +0900 Subject: [PATCH 15/60] =?UTF-8?q?[#12]=20feat:=20Secret=20UseCase=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?helper=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/SecretUseCaseHelper.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift new file mode 100644 index 0000000..10494f9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +enum SecretUseCaseHelper { + /// Secret 초안의 기본 필수값을 검증합니다. + static func validateDraft(_ draft: SecretDraft) throws { + guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidName + } + + guard !draft.secretType.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidSecretType + } + } + + /// patch에 updatedAt이 없으면 전달받은 시각으로 채웁니다. + static func settingUpdatedAtIfNeeded(_ patch: SecretPatch, now: Date) -> SecretPatch { + var next = patch + if case .unchanged = next.updatedAt { + next.updatedAt = .set(now) + } + return next + } +} From 7a18bfb113892c634d3cadbc67e4090f91d0e8cd Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 18:39:18 +0900 Subject: [PATCH 16/60] =?UTF-8?q?[#12]=20feat:=20Secret,=20SecretPayload,?= =?UTF-8?q?=20SecretMetadata=EC=97=90=20extension=EC=9C=BC=EB=A1=9C=20Enti?= =?UTF-8?q?ty=20=EB=A7=A4=ED=95=91=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/Local/Models/Secret.swift | 26 +++++++++++++++++++ .../Storage/Local/Models/SecretMetadata.swift | 10 +++++++ .../Storage/Local/Models/SecretPayload.swift | 11 ++++++++ 3 files changed, 47 insertions(+) diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 23325fc..0385f11 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import SwiftData import Foundation @@ -69,3 +70,28 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.Secret { + func toDomain() throws -> DVDomain.Secret { + guard let payload else { + throw SecretRepositoryError.corruptedStorage + } + + return DVDomain.Secret( + id: id, + name: name, + secretType: secretType, + subType: subType, + service: service, + environment: environment, + expiresAt: expiresAt, + memo: memo, + liked: liked, + deletedAt: deletedAt, + createdAt: createdAt, + updatedAt: updatedAt, + payload: payload.toDomain(), + metadata: metadata?.toDomain() + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift index 284b29f..2cfa7ab 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -25,3 +26,12 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretMetadata { + func toDomain() -> DVDomain.SecretMetadata { + DVDomain.SecretMetadata( + metadataJSON: metadataJSON, + schemaVersion: schemaVersion + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift index f503ce0..80813e7 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -28,3 +29,13 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretPayload { + func toDomain() -> DVDomain.SecretPayload { + DVDomain.SecretPayload( + encryptedData: encryptedData, + keyTag: keyTag, + schemaVersion: schemaVersion + ) + } +} From 99e0136496f3fc00e1dded3848b1542db9e81dd6 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:13:39 +0900 Subject: [PATCH 17/60] =?UTF-8?q?[#12]=20feat:=20modelContainer=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=95=98=EB=8A=94=20LocalStorage=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/Local/LocalStorage.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Projects/DVData/Sources/Storage/Local/LocalStorage.swift diff --git a/Projects/DVData/Sources/Storage/Local/LocalStorage.swift b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift new file mode 100644 index 0000000..f8a300f --- /dev/null +++ b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftData + +public final class LocalStorage { + public static let shared = LocalStorage() + + private init() { } + + public lazy var modelContainer: ModelContainer = { + let configuration = ModelConfiguration(isStoredInMemoryOnly: false) + + do { + return try ModelContainer( + for: Schema.appSchema, + configurations: configuration + ) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } + }() +} From 8f0fd933f9c46bcb49206357c7feea2b30e0785f Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:14:18 +0900 Subject: [PATCH 18/60] =?UTF-8?q?[#12]=20feat:=20=EA=B8=B0=EB=B3=B8=20Secr?= =?UTF-8?q?et(payload,=20metadata=20=ED=8F=AC=ED=95=A8)=20CRUD=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=ED=95=9C=20SecretReposi?= =?UTF-8?q?toryImpl=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/SecretRepositoryImpl.swift | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift new file mode 100644 index 0000000..7cbbea0 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -0,0 +1,214 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +@ModelActor +public actor SecretRepositoryImpl: SecretRepository { + public init(modelContainer: ModelContainer) { + let modelContext = ModelContext(modelContainer) + self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) + self.modelContainer = modelContainer + } + + public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { + do { + let localSecret = SwiftDataModel.Secret( + id: secret.id, + name: secret.name, + secretType: secret.secretType, + subType: secret.subType, + service: secret.service, + environment: secret.environment, + expiresAt: secret.expiresAt, + memo: secret.memo, + liked: secret.liked, + deletedAt: secret.deletedAt, + createdAt: secret.createdAt, + updatedAt: secret.updatedAt + ) + + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: secret.payload.encryptedData, + keyTag: secret.payload.keyTag, + schemaVersion: secret.payload.schemaVersion, + secret: localSecret + ) + localSecret.payload = localPayload + + modelContext.insert(localSecret) + modelContext.insert(localPayload) + + if let metadata = secret.metadata { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: localSecret + ) + localSecret.metadata = localMetadata + modelContext.insert(localMetadata) + } + + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func fetch(id: UUID) async throws -> DVDomain.Secret? { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + return nil + } + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretFetchDescriptorBuilder로 원하는 조건으로 fetch 후 InMemorySecretQueryFilter로 searchText 보정 + public func fetch(_ query: SecretQuery) async throws -> [DVDomain.Secret] { + do { + let descriptor = SecretFetchDescriptorBuilder.make(from: query) + let localSecrets = try modelContext.fetch(descriptor) + let domainSecrets = try localSecrets.map { try $0.toDomain() } + return InMemorySecretQueryFilter.apply(query, to: domainSecrets) + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretPatch 적용하여 update + public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + apply(patch, to: localSecret) + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func delete(id: UUID) async throws { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + modelContext.delete(localSecret) + try modelContext.save() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } +} + +extension SecretRepositoryImpl { + private func fetchLocalSecret(id: UUID) throws -> SwiftDataModel.Secret? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + /// SecretPatch를 SwiftData model에 반영 + private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) { + if case let .set(name) = patch.name { + secret.name = name + } + if case let .set(secretType) = patch.secretType { + secret.secretType = secretType + } + if case let .set(subType) = patch.subType { + secret.subType = subType + } + if case let .set(service) = patch.service { + secret.service = service + } + if case let .set(environment) = patch.environment { + secret.environment = environment + } + if case let .set(expiresAt) = patch.expiresAt { + secret.expiresAt = expiresAt + } + if case let .set(memo) = patch.memo { + secret.memo = memo + } + if case let .set(liked) = patch.liked { + secret.liked = liked + } + if case let .set(deletedAt) = patch.deletedAt { + secret.deletedAt = deletedAt + } + if case let .set(updatedAt) = patch.updatedAt { + secret.updatedAt = updatedAt + } + if case let .set(payload) = patch.payload { + apply(payload, to: secret) + } + if case let .set(metadata) = patch.metadata { + apply(metadata, to: secret) + } + } + + /// payload가 있으면 업데이트하고, 없으면 새 payload를 만든다. + private func apply(_ payload: DVDomain.SecretPayload, to secret: SwiftDataModel.Secret) { + if let localPayload = secret.payload { + localPayload.encryptedData = payload.encryptedData + localPayload.keyTag = payload.keyTag + localPayload.schemaVersion = payload.schemaVersion + } else { + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: payload.encryptedData, + keyTag: payload.keyTag, + schemaVersion: payload.schemaVersion, + secret: secret + ) + secret.payload = localPayload + modelContext.insert(localPayload) + } + } + + /// metadata가 nil이면 기존 metadata를 삭제하고, 값이 있으면 업데이트 또는 생성한다. + private func apply(_ metadata: DVDomain.SecretMetadata?, to secret: SwiftDataModel.Secret) { + guard let metadata else { + if let localMetadata = secret.metadata { + modelContext.delete(localMetadata) + } + secret.metadata = nil + return + } + + if let localMetadata = secret.metadata { + localMetadata.metadataJSON = metadata.metadataJSON + localMetadata.schemaVersion = metadata.schemaVersion + } else { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: secret + ) + secret.metadata = localMetadata + modelContext.insert(localMetadata) + } + } +} From 341e9a40d67fa6433d5b36b2d3e5363d056c5132 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:14:39 +0900 Subject: [PATCH 19/60] =?UTF-8?q?[#12]=20feat:=20SecretFetchDescriptorBuil?= =?UTF-8?q?der,=20InMemorySecretQueryFilter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/InMemorySecretQueryFilter.swift | 35 ++++++ .../Secret/SecretFetchDescriptorBuilder.swift | 102 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift new file mode 100644 index 0000000..2cbc73b --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +/// SwiftData FetchDescriptor로 처리하기 애매한 조건을 Domain Secret 배열에서 후처리하는 필터. 현재는 searchText만 담당 +enum InMemorySecretQueryFilter { + static func apply(_ query: SecretQuery, to secrets: [DVDomain.Secret]) -> [DVDomain.Secret] { + secrets.filter { matchesSearchText(query.searchText, secret: $0) } + } + + private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool { + guard let searchText else { + return true + } + + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { + return true + } + + let fields = [ + secret.name, + secret.secretType, + secret.subType, + secret.service, + secret.environment, + secret.memo, + ] + + return fields.contains { + $0?.localizedCaseInsensitiveContains(keyword) == true + } + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift new file mode 100644 index 0000000..24bf826 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -0,0 +1,102 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +/// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 +enum SecretFetchDescriptorBuilder { + static func make(from query: SecretQuery) -> FetchDescriptor { + var descriptor = FetchDescriptor( + predicate: predicate(from: query), + sortBy: sortDescriptors(from: query.sort) + ) + descriptor.includePendingChanges = true + return descriptor + } + + private static func predicate(from query: SecretQuery) -> Predicate { + let hasSecretType = !(query.secretType?.isEmpty ?? true) + let secretType = query.secretType ?? "" + let hasService = !(query.service?.isEmpty ?? true) + let service = query.service ?? "" + let hasEnvironment = !(query.environment?.isEmpty ?? true) + let environment = query.environment ?? "" + + switch query.collection { + case .all: + return #Predicate { secret in + secret.deletedAt == nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .liked: + return #Predicate { secret in + secret.deletedAt == nil && + secret.liked && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .expired(referenceDate): + return #Predicate { secret in + secret.deletedAt == nil && + secret.expiresAt != nil && + secret.expiresAt! < referenceDate && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .deleted: + return #Predicate { secret in + secret.deletedAt != nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .project(projectID): + return #Predicate { secret in + secret.deletedAt == nil && + secret.projectLinks.contains { link in + link.project.id == projectID + } && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + } + } + + private static func sortDescriptors( + from sort: SecretQuery.Sort + ) -> [SortDescriptor] { + switch sort { + case .recentlyAdded: + return [ + SortDescriptor(\.createdAt, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .oldestFirst: + return [ + SortDescriptor(\.createdAt, order: .forward), + SortDescriptor(\.updatedAt, order: .forward), + ] + case .expiringSoon: + return [ + SortDescriptor(\.expiresAt, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameAscending: + return [ + SortDescriptor(\.name, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameDescending: + return [ + SortDescriptor(\.name, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + } + } +} From aec73b7831b588e324d3963ea93e7d480d36ce89 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:20:47 +0900 Subject: [PATCH 20/60] =?UTF-8?q?[#12]=20chore:=20Domain=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20Service=20=EC=9E=AC=ED=8F=B4=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/{Security => Service}/Error/SecretCryptoError.swift | 0 .../{Security => Service}/Interface/SecretCryptoService.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Projects/DVDomain/Sources/{Security => Service}/Error/SecretCryptoError.swift (100%) rename Projects/DVDomain/Sources/{Security => Service}/Interface/SecretCryptoService.swift (100%) diff --git a/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift similarity index 100% rename from Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift rename to Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift diff --git a/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift similarity index 100% rename from Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift rename to Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift From 582794c5d3f399e0f6537360de6213bc6ffa4546 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:21:11 +0900 Subject: [PATCH 21/60] =?UTF-8?q?[#12]=20feat:=20payload,=20metadata=20JSO?= =?UTF-8?q?NCoder=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JSONCoder/SecretMetadataJSONCoder.swift | 27 +++++++++++++++++++ .../JSONCoder/SecretPayloadJSONCoder.swift | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift new file mode 100644 index 0000000..f8294a6 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretMetadataJSONCoder: Sendable { + /// Metadata content를 JSON Data로 직렬화한다. + func encode(_ metadata: Metadata) throws -> Data { + do { + return try JSONEncoder().encode(metadata) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Metadata.Type + ) throws -> Metadata { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift new file mode 100644 index 0000000..90b978c --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretPayloadJSONCoder: Sendable { + /// Payload content를 JSON Data로 직렬화한다. + func encode(_ payload: Payload) throws -> Data { + do { + return try JSONEncoder().encode(payload) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 payload content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Payload.Type + ) throws -> Payload { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} From 4c2c8a29a23bf5b0ce44f9a6ac40bbdad68786a9 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:21:23 +0900 Subject: [PATCH 22/60] =?UTF-8?q?[#12]=20feat:=20SecretCryptoServiceImpl?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/SecretCryptoServiceImpl.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift new file mode 100644 index 0000000..6c5f990 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift @@ -0,0 +1,88 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation + +public struct SecretCryptoServiceImpl: SecretCryptoService { + private let masterKeyTag: String + private let keyStore: KeychainKeyStore + private let payloadJSONCoder: SecretPayloadJSONCoder + private let metadataJSONCoder: SecretMetadataJSONCoder + + /// Keychain key tag와 service namespace를 설정해 SecretCryptoService 구현체를 생성한다. + public init( + masterKeyTag: String = "com.devault.masterKey.v1", + keychainService: String = "com.devault.secret" + ) { + self.masterKeyTag = masterKeyTag + self.keyStore = KeychainKeyStore(service: keychainService) + self.payloadJSONCoder = SecretPayloadJSONCoder() + self.metadataJSONCoder = SecretMetadataJSONCoder() + } + + /// Payload content를 JSON으로 직렬화한 뒤 AES-GCM으로 암호화해 저장용 SecretPayload를 만든다. + public func encryptPayload( + _ payload: Payload + ) async throws -> DVDomain.SecretPayload { + do { + let key = try keyStore.getOrCreateSymmetricKey(tag: masterKeyTag) + let json = try payloadJSONCoder.encode(payload) + // nonce + ciphertext + authentication tag 생성 + let sealedBox = try AES.GCM.seal(json, using: key) + + // 3가지를 하나의 Data로 합침 -> DB에 저장 + guard let combined = sealedBox.combined else { + throw SecretCryptoError.encryptionFailed + } + + return DVDomain.SecretPayload( + encryptedData: combined, + keyTag: masterKeyTag, + schemaVersion: Payload.schemaVersion + ) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.encryptionFailed + } + } + + /// 저장된 SecretPayload를 keyTag로 복호화한 뒤 요청한 payload content 타입으로 역직렬화한다. + public func decryptPayload( + _ payload: DVDomain.SecretPayload, + as type: Payload.Type + ) async throws -> Payload { + do { + // 새 key를 만들어도 기존 암호문은 열릴 수 없으므로 key가 없을 때 새 키를 만들지 않음 + let key = try keyStore.getSymmetricKey(tag: payload.keyTag) + let sealedBox = try AES.GCM.SealedBox(combined: payload.encryptedData) + // key가 맞는지, authentication tag가 유효한지 확인 + let json = try AES.GCM.open(sealedBox, using: key) + return try payloadJSONCoder.decode(json, as: type) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.decryptionFailed + } + } + + /// Metadata content를 JSON Data로 직렬화해 저장용 SecretMetadata를 만든다. + public func encodeMetadata( + _ metadata: Metadata + ) throws -> DVDomain.SecretMetadata { + let json = try metadataJSONCoder.encode(metadata) + return DVDomain.SecretMetadata( + metadataJSON: json, + schemaVersion: Metadata.schemaVersion + ) + } + + /// 저장된 SecretMetadata의 JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + public func decodeMetadata( + _ metadata: DVDomain.SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata { + try metadataJSONCoder.decode(metadata.metadataJSON, as: type) + } +} From e61f60b526ba95f1860f54cfaa145cb91bb5d964 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:22:17 +0900 Subject: [PATCH 23/60] =?UTF-8?q?[#12]=20feat:=20=ED=82=A4=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20key=20=EC=A0=91=EA=B7=BC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8B=B4=EC=9D=80=20KeychainKeyStore=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Keychain/KeychainKeyStore.swift | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift new file mode 100644 index 0000000..c065ea0 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift @@ -0,0 +1,108 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation +import Security + +struct KeychainKeyStore: Sendable { + private let service: String + + /// Keychain generic password item을 구분할 service namespace를 설정한다. + init(service: String) { + self.service = service + } + + /// tag에 해당하는 symmetric key를 조회하고, 없으면 새 key를 생성해 Keychain에 저장한다. + func getOrCreateSymmetricKey(tag: String) throws -> SymmetricKey { + if let data = try loadKeyData(tag: tag) { + return SymmetricKey(data: data) + } + + let data = try generateKeyData() + try saveKeyData(data, tag: tag) + return SymmetricKey(data: data) + } + + /// tag에 해당하는 symmetric key를 조회하고, 없으면 keyUnavailable 오류를 던진다. + func getSymmetricKey(tag: String) throws -> SymmetricKey { + guard let data = try loadKeyData(tag: tag) else { + throw SecretCryptoError.keyUnavailable + } + + return SymmetricKey(data: data) + } +} + +extension KeychainKeyStore { + /// Keychain에서 tag에 해당하는 raw key Data를 조회한다. + private func loadKeyData(tag: String) throws -> Data? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + // '키가 없음' -> 암호화 시점에서 key 새로 생성, 복호화 시점에서 keyUnavailable + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess, let data = result as? Data else { + throw SecretCryptoError.keyUnavailable + } + + return data + } + + /// raw key Data를 Keychain에 저장하고, 기존 item이 있으면 갱신한다. + private func saveKeyData(_ data: Data, tag: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + ] + + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + var addQuery = query + attributes.forEach { addQuery[$0.key] = $0.value } + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + return + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + } + + /// AES-256 symmetric key로 사용할 32바이트 랜덤 Data를 생성한다. + private func generateKeyData() throws -> Data { + var bytes = [UInt8](repeating: 0, count: 32) + let count = bytes.count + let status = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!) + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + + return Data(bytes) + } +} From f1ea881c62b138d898876b896b069134a97d254c Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:50:27 +0900 Subject: [PATCH 24/60] =?UTF-8?q?[#12]=20fix:=20SecretRepositoryImpl?= =?UTF-8?q?=EC=97=90=EC=84=9C=20public=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RepositoryImpl/Secret/SecretRepositoryImpl.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index 7cbbea0..d0bafb4 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -6,12 +6,6 @@ import SwiftData @ModelActor public actor SecretRepositoryImpl: SecretRepository { - public init(modelContainer: ModelContainer) { - let modelContext = ModelContext(modelContainer) - self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) - self.modelContainer = modelContainer - } - public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { do { let localSecret = SwiftDataModel.Secret( From 6e619beea7bee96c1d0c2a390c2fa2c7328c52c8 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:51:02 +0900 Subject: [PATCH 25/60] =?UTF-8?q?[#12]=20feat:=20Secret=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EC=A1=B0=ED=9A=8C=20demo=20view=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/SecretUseCaseDemoView.swift | 231 ++++++++++++++++++ Projects/Devault/Sources/ContentView.swift | 24 +- 2 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift diff --git a/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift new file mode 100644 index 0000000..2416a31 --- /dev/null +++ b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift @@ -0,0 +1,231 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import SwiftUI + +public struct SecretUseCaseDemoView: View { + private let createSecretUseCase: any CreateSecretUseCase + private let fetchSecretUseCase: any FetchSecretUseCase + + @State private var selectedSecret: Secret? + @State private var revealedPayload: APIKeyPayload? + @State private var secrets: [Secret] = [] + @State private var statusMessage = "Ready" + @State private var isRunning = false + + public init( + createSecretUseCase: any CreateSecretUseCase, + fetchSecretUseCase: any FetchSecretUseCase + ) { + self.createSecretUseCase = createSecretUseCase + self.fetchSecretUseCase = fetchSecretUseCase + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + controls + status + content + } + .padding(24) + .frame(minWidth: 760, minHeight: 480) + .task { + await fetchAllSecrets() + } + } +} + +private extension SecretUseCaseDemoView { + var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Secret CRUD Demo") + .font(.title2) + .fontWeight(.semibold) + Text("UseCase 기반 생성, 목록 조회, 상세 조회, payload reveal 확인") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + var controls: some View { + HStack(spacing: 10) { + Button { + Task { await createDemoSecret() } + } label: { + Text(isRunning ? "Creating..." : "Demo Create") + } + .disabled(isRunning) + .keyboardShortcut(.defaultAction) + + Button("Refresh") { + Task { await fetchAllSecrets() } + } + .disabled(isRunning) + } + } + + var status: some View { + Text(statusMessage) + .font(.callout) + .foregroundStyle(isRunning ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + var content: some View { + HStack(alignment: .top, spacing: 20) { + list + .frame(minWidth: 260, idealWidth: 300, maxWidth: 340) + Divider() + detail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + var list: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Secrets") + .font(.headline) + Spacer() + Text("\(secrets.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + + List(secrets, selection: selectedSecretBinding) { secret in + VStack(alignment: .leading, spacing: 5) { + Text(secret.name) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + Text(secret.updatedAt.formatted(date: .numeric, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(secret.id) + } + } + } + + var detail: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Detail") + .font(.headline) + Spacer() + Button("Reveal Payload") { + Task { await revealSelectedPayload() } + } + .disabled(isRunning || selectedSecret == nil) + } + + if let selectedSecret { + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { + resultRow("ID", selectedSecret.id.uuidString) + resultRow("Name", selectedSecret.name) + resultRow("Type", selectedSecret.secretType) + resultRow("Service", selectedSecret.service ?? "-") + resultRow("Environment", selectedSecret.environment ?? "-") + resultRow("Liked", selectedSecret.liked ? "true" : "false") + resultRow("Created", selectedSecret.createdAt.formatted(date: .numeric, time: .standard)) + resultRow("Updated", selectedSecret.updatedAt.formatted(date: .numeric, time: .standard)) + resultRow("Memo", selectedSecret.memo ?? "-") + resultRow("Encrypted bytes", "\(selectedSecret.payload.encryptedData.count)") + resultRow("Payload", revealedPayload?.value ?? "Hidden") + } + .font(.callout) + } else { + Text("Select a secret from the list.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } + + var selectedSecretBinding: Binding { + Binding( + get: { selectedSecret?.id }, + set: { id in + selectedSecret = secrets.first { $0.id == id } + revealedPayload = nil + } + ) + } + + func resultRow(_ title: String, _ value: String) -> some View { + GridRow { + Text(title) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .leading) + Text(value) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + func createDemoSecret() async { + await run("Creating secret...") { + let timestamp = Int(Date().timeIntervalSince1970) + let draft = SecretDraft( + name: "Demo Secret \(timestamp)", + secretType: "apiKey", + service: "github", + environment: "development", + memo: "Created from temporary Presentation UseCase demo", + liked: true + ) + let payload = APIKeyPayload(value: "demo-api-key-\(timestamp)") + let metadata = APIKeyMetadata(scope: "repo") + + let created = try await createSecretUseCase.execute( + draft: draft, + payload: payload, + metadata: metadata + ) + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + selectedSecret = try await fetchSecretUseCase.fetch(id: created.id) + revealedPayload = nil + statusMessage = "Created demo secret" + } + } + + func revealSelectedPayload() async { + guard let id = selectedSecret?.id else { return } + + await run("Revealing payload...") { + revealedPayload = try await fetchSecretUseCase.revealPayload(id: id, as: APIKeyPayload.self) + statusMessage = "Payload revealed" + } + } + + func fetchAllSecrets() async { + await run("Fetching secrets...") { + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + if let selectedID = selectedSecret?.id { + selectedSecret = secrets.first { $0.id == selectedID } + } else { + selectedSecret = secrets.first + } + revealedPayload = nil + statusMessage = "Fetched \(secrets.count) secret(s)" + } + } + + func run(_ message: String, operation: () async throws -> Void) async { + isRunning = true + statusMessage = message + + do { + try await operation() + } catch { + statusMessage = "Failed: \(error)" + } + + isRunning = false + } +} diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index 2072de6..b400b1a 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -1,12 +1,24 @@ +import DVData +import DVDomain +import DVPresentation import SwiftUI struct ContentView: View { + private let repository = SecretRepositoryImpl( + modelContainer: LocalStorage.shared.modelContainer + ) + private let cryptoService = SecretCryptoServiceImpl() + var body: some View { - VStack(spacing: 16) { - Text("Devault") - .font(.largeTitle) - .fontWeight(.bold) - } - .frame(width: 400, height: 300) + SecretUseCaseDemoView( + createSecretUseCase: CreateSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService + ), + fetchSecretUseCase: FetchSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService + ) + ) } } From d6596d6ee6cf5cdc07a72bd1cf9fa6330a72dbd6 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:24:46 +0900 Subject: [PATCH 26/60] =?UTF-8?q?[#12]=20feat:=20UserAuthenticationService?= =?UTF-8?q?=20protocol,=20=EA=B5=AC=ED=98=84=EC=B2=B4,=20error=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocalUserAuthenticationServiceImpl.swift | 45 +++++++++++++++++++ .../Error/UserAuthenticationError.swift | 7 +++ .../Interface/UserAuthenticationService.swift | 6 +++ 3 files changed, 58 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift create mode 100644 Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift create mode 100644 Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift new file mode 100644 index 0000000..ed195c8 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift @@ -0,0 +1,45 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import LocalAuthentication + +public struct LocalUserAuthenticationServiceImpl: UserAuthenticationService { + /// LocalAuthentication 기반 사용자 인증 구현체를 생성한다. + public init() {} + + /// Touch ID 또는 시스템 암호로 현재 사용자를 인증한다. + public func authenticate(reason: String) async throws { + let context = LAContext() + context.touchIDAuthenticationAllowableReuseDuration = 0 + + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + throw UserAuthenticationError.unavailable + } + + try await withCheckedThrowingContinuation { continuation in + context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) { success, error in + if success { + continuation.resume() + return + } + + if let error = error as? LAError { + switch error.code { + case .userCancel, .systemCancel, .appCancel: + continuation.resume(throwing: UserAuthenticationError.cancelled) + default: + continuation.resume(throwing: UserAuthenticationError.failed) + } + return + } + + continuation.resume(throwing: UserAuthenticationError.failed) + } + } + } +} diff --git a/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift new file mode 100644 index 0000000..eb3da8b --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum UserAuthenticationError: Error, Equatable, Sendable { + case unavailable + case cancelled + case failed +} diff --git a/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift new file mode 100644 index 0000000..dab2db4 --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift @@ -0,0 +1,6 @@ +// Copyright © 2026 Devault. All rights reserved + +/// 현재 사용자가 민감 작업을 수행할 수 있는지 로컬 인증으로 확인하는 서비스입니다. +public protocol UserAuthenticationService: Sendable { + func authenticate(reason: String) async throws +} From 6de4ec8b1382eb752dd8f7acb5a4be3a7d438e31 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:25:40 +0900 Subject: [PATCH 27/60] =?UTF-8?q?[#12]=20feat:=20payload=20reveal=EC=8B=9C?= =?UTF-8?q?=20authenticate=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B1=B0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift | 5 +++++ .../UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift index 0dd79f8..5a8a30f 100644 --- a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift +++ b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift @@ -8,6 +8,7 @@ public enum SecretUseCaseError: Error, Equatable, Sendable { case secretNotFound(id: UUID) case repositoryFailure(SecretRepositoryError) case cryptoFailure(SecretCryptoError) + case authenticationFailure(UserAuthenticationError) case unexpected } @@ -28,6 +29,10 @@ extension SecretUseCaseError { return .cryptoFailure(error) } + if let error = error as? UserAuthenticationError { + return .authenticationFailure(error) + } + return .unexpected } } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index 260f2bb..0675823 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -5,13 +5,16 @@ import Foundation public struct FetchSecretUseCaseImpl: FetchSecretUseCase { private let repository: any SecretRepository private let cryptoService: any SecretCryptoService + private let authenticationService: any UserAuthenticationService public init( repository: any SecretRepository, - cryptoService: any SecretCryptoService + cryptoService: any SecretCryptoService, + authenticationService: any UserAuthenticationService ) { self.repository = repository self.cryptoService = cryptoService + self.authenticationService = authenticationService } public func fetch(id: UUID) async throws -> Secret? { @@ -38,6 +41,7 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { guard let secret = try await repository.fetch(id: id) else { throw SecretUseCaseError.secretNotFound(id: id) } + try await authenticationService.authenticate(reason: "Reveal secret payload") return try await cryptoService.decryptPayload(secret.payload, as: type) } catch { throw SecretUseCaseError.map(error) From 137a984127b77f6e5617974fac154fb18d8b4ac7 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:26:23 +0900 Subject: [PATCH 28/60] =?UTF-8?q?[#12]=20refactor:=20KeychainKeyStore=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F.=20keychainFailure=20=EC=97=90=EB=9F=AC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Keychain/KeychainKeyStore.swift | 32 +++++++++++-------- .../Service/Error/SecretCryptoError.swift | 1 + 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift index c065ea0..2354c04 100644 --- a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift +++ b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift @@ -23,7 +23,7 @@ struct KeychainKeyStore: Sendable { try saveKeyData(data, tag: tag) return SymmetricKey(data: data) } - + /// tag에 해당하는 symmetric key를 조회하고, 없으면 keyUnavailable 오류를 던진다. func getSymmetricKey(tag: String) throws -> SymmetricKey { guard let data = try loadKeyData(tag: tag) else { @@ -37,13 +37,12 @@ struct KeychainKeyStore: Sendable { extension KeychainKeyStore { /// Keychain에서 tag에 해당하는 raw key Data를 조회한다. private func loadKeyData(tag: String) throws -> Data? { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: tag, + var query = keyQuery(tag: tag) + let attributes: [CFString: Any] = [ kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] + attributes.forEach { query[$0.key] = $0.value } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -54,7 +53,7 @@ extension KeychainKeyStore { } guard status == errSecSuccess, let data = result as? Data else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: status) } return data @@ -62,15 +61,11 @@ extension KeychainKeyStore { /// raw key Data를 Keychain에 저장하고, 기존 item이 있으면 갱신한다. private func saveKeyData(_ data: Data, tag: String) throws { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: tag, - ] + let query = keyQuery(tag: tag) let attributes: [CFString: Any] = [ kSecValueData: data, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] var addQuery = query @@ -81,16 +76,25 @@ extension KeychainKeyStore { if status == errSecDuplicateItem { let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) guard updateStatus == errSecSuccess else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: updateStatus) } return } guard status == errSecSuccess else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: status) } } + /// service와 tag로 Keychain key item을 식별하는 기본 query를 만든다. + private func keyQuery(tag: String) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + ] + } + /// AES-256 symmetric key로 사용할 32바이트 랜덤 Data를 생성한다. private func generateKeyData() throws -> Data { var bytes = [UInt8](repeating: 0, count: 32) diff --git a/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift index 10e28eb..fa5a179 100644 --- a/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift +++ b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift @@ -2,6 +2,7 @@ public enum SecretCryptoError: Error, Equatable, Sendable { case keyUnavailable + case keychainFailure(status: Int32) case encryptionFailed case decryptionFailed case encodingFailed From e69a414909befea28aab41c5cea48fd3aa6a3c2f Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:26:59 +0900 Subject: [PATCH 29/60] =?UTF-8?q?[#12]=20feat:=20ContentView=EC=9D=98=20Se?= =?UTF-8?q?cretUseCaseDemoView=EC=97=90=EC=84=9C=20=EB=B0=9B=EB=8A=94=20us?= =?UTF-8?q?eCase=EC=97=90=20authenticationService=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Devault/Sources/ContentView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index b400b1a..847e9e4 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -8,6 +8,7 @@ struct ContentView: View { modelContainer: LocalStorage.shared.modelContainer ) private let cryptoService = SecretCryptoServiceImpl() + private let authenticationService = LocalUserAuthenticationServiceImpl() var body: some View { SecretUseCaseDemoView( @@ -17,7 +18,8 @@ struct ContentView: View { ), fetchSecretUseCase: FetchSecretUseCaseImpl( repository: repository, - cryptoService: cryptoService + cryptoService: cryptoService, + authenticationService: authenticationService ) ) } From 9ab75e888538e66482f7a644dd61269fb5bf08e9 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sun, 24 May 2026 00:02:03 +0900 Subject: [PATCH 30/60] =?UTF-8?q?[#12]=20fix:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20=EC=A1=B0=ED=9A=8C/=EB=B3=B5=ED=98=B8=ED=99=94=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index 0675823..d08aead 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -38,10 +38,10 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { as type: Payload.Type ) async throws -> Payload { do { + try await authenticationService.authenticate(reason: "Reveal secret payload") guard let secret = try await repository.fetch(id: id) else { throw SecretUseCaseError.secretNotFound(id: id) } - try await authenticationService.authenticate(reason: "Reveal secret payload") return try await cryptoService.decryptPayload(secret.payload, as: type) } catch { throw SecretUseCaseError.map(error) From aefd684b205e5089a39f7cfc412eb05be2bdfe62 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:34:22 +0900 Subject: [PATCH 31/60] =?UTF-8?q?[#12]=20bugfix:=20Secret=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVData/Sources/Storage/Local/Models/Secret.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 6861923..23325fc 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -36,7 +36,7 @@ extension SwiftDataModel { } init( - secretId: UUID = UUID(), + id: UUID = UUID(), name: String, secretType: String, subType: String? = nil, @@ -50,7 +50,7 @@ extension SwiftDataModel { updatedAt: Date? = nil ) { let initialCreatedAt = createdAt - self.secretId = secretId + self.id = id self.name = name self.secretType = secretType self.subType = subType From e80a7949b32e7b3f63999d39c79a2a440f9551fa Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:42:08 +0900 Subject: [PATCH 32/60] =?UTF-8?q?[#12]=20feat:=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EA=B0=84=20dependency=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVData/Project.swift | 6 +++++- Projects/DVDesign/Project.swift | 5 ++++- Projects/DVDomain/Project.swift | 5 ++++- Projects/DVPresentation/Project.swift | 7 ++++++- Projects/Devault/Project.swift | 8 +++++++- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Projects/DVData/Project.swift b/Projects/DVData/Project.swift index fbc8488..f0f2179 100644 --- a/Projects/DVData/Project.swift +++ b/Projects/DVData/Project.swift @@ -7,7 +7,11 @@ let project = Project.project( .target( name: DVModule.DVData.name, product: Project.product, - sources: .sources + sources: .sources, + dependencies: [ + .domain(), + .core(), + ] ), ] ) diff --git a/Projects/DVDesign/Project.swift b/Projects/DVDesign/Project.swift index e220b87..83c6401 100644 --- a/Projects/DVDesign/Project.swift +++ b/Projects/DVDesign/Project.swift @@ -8,7 +8,10 @@ let project = Project.project( name: DVModule.DVDesign.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .core(), + ] ), .sampleApp( name: DVModule.DVDesign.name, diff --git a/Projects/DVDomain/Project.swift b/Projects/DVDomain/Project.swift index f6761d3..0e4008e 100644 --- a/Projects/DVDomain/Project.swift +++ b/Projects/DVDomain/Project.swift @@ -7,7 +7,10 @@ let project = Project.project( .target( name: DVModule.DVDomain.name, product: Project.product, - sources: .sources + sources: .sources, + dependencies: [ + .core(), + ] ), .tests( name: DVModule.DVDomain.name, diff --git a/Projects/DVPresentation/Project.swift b/Projects/DVPresentation/Project.swift index 1804fda..68afb41 100644 --- a/Projects/DVPresentation/Project.swift +++ b/Projects/DVPresentation/Project.swift @@ -8,7 +8,12 @@ let project = Project.project( name: DVModule.DVPresentation.name, product: Project.product, sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .domain(), + .design(), + .core(), + ] ), ] ) diff --git a/Projects/Devault/Project.swift b/Projects/Devault/Project.swift index 803e3b1..3feb11c 100644 --- a/Projects/Devault/Project.swift +++ b/Projects/Devault/Project.swift @@ -11,7 +11,13 @@ let project = Project.project( "CFBundleDisplayName": .string("Devault"), ]), sources: .sources, - resources: .default + resources: .default, + dependencies: [ + .presentation(), + .data(), + .domain(), + .core(), + ] ), ], schemes: [ From 8e705d8c1d30aa6e6b1c94f193552bcacab9a2b9 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 15:52:48 +0900 Subject: [PATCH 33/60] =?UTF-8?q?[#12]=20feat:=20Secret,=20Metadata,=20Pay?= =?UTF-8?q?load=20entity=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/Entity/Secret.swift | 52 +++++++++++++++++++ .../Sources/Entity/SecretMetadata.swift | 19 +++++++ .../Sources/Entity/SecretPayload.swift | 22 ++++++++ Projects/DVDomain/Tests/.gitkeep | 0 Projects/DVDomain/Tests/ExampleTest.swift | 10 ---- 5 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 Projects/DVDomain/Sources/Entity/Secret.swift create mode 100644 Projects/DVDomain/Sources/Entity/SecretMetadata.swift create mode 100644 Projects/DVDomain/Sources/Entity/SecretPayload.swift delete mode 100644 Projects/DVDomain/Tests/.gitkeep delete mode 100644 Projects/DVDomain/Tests/ExampleTest.swift diff --git a/Projects/DVDomain/Sources/Entity/Secret.swift b/Projects/DVDomain/Sources/Entity/Secret.swift new file mode 100644 index 0000000..a807d61 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/Secret.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct Secret: Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + public var deletedAt: Date? + public var createdAt: Date + public var updatedAt: Date + public var payload: SecretPayload + public var metadata: SecretMetadata? + + public init( + id: UUID, + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false, + deletedAt: Date? = nil, + createdAt: Date, + updatedAt: Date, + payload: SecretPayload, + metadata: SecretMetadata? = nil + ) { + self.id = id + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.createdAt = createdAt + self.updatedAt = updatedAt + self.payload = payload + self.metadata = metadata + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift new file mode 100644 index 0000000..742cee0 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretMetadata: Equatable, Identifiable, Sendable { + public var id: UUID + public var metadataJSON: Data + public var schemaVersion: Int + + public init( + id: UUID, + metadataJSON: Data, + schemaVersion: Int + ) { + self.id = id + self.metadataJSON = metadataJSON + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Sources/Entity/SecretPayload.swift b/Projects/DVDomain/Sources/Entity/SecretPayload.swift new file mode 100644 index 0000000..c887d53 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretPayload.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretPayload: Equatable, Identifiable, Sendable { + public var id: UUID + public var encryptedData: Data + public var keyTag: String + public var schemaVersion: Int + + public init( + id: UUID, + encryptedData: Data, + keyTag: String, + schemaVersion: Int + ) { + self.id = id + self.encryptedData = encryptedData + self.keyTag = keyTag + self.schemaVersion = schemaVersion + } +} diff --git a/Projects/DVDomain/Tests/.gitkeep b/Projects/DVDomain/Tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Projects/DVDomain/Tests/ExampleTest.swift b/Projects/DVDomain/Tests/ExampleTest.swift deleted file mode 100644 index 31145ca..0000000 --- a/Projects/DVDomain/Tests/ExampleTest.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import DVDomain - -@Suite("Example") -struct ExampleTest { - @Test("조건을 만족해야 한다") - func example() { - #expect(1 + 1 == 2) - } -} From 35ee0fc70a2c2498229e63810965993329d089d2 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:15:39 +0900 Subject: [PATCH 34/60] =?UTF-8?q?[#12]=20delete:=20DVDomain=20project=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=82=B4=EC=9D=98=20test=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Project.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Projects/DVDomain/Project.swift b/Projects/DVDomain/Project.swift index 0e4008e..5ddeada 100644 --- a/Projects/DVDomain/Project.swift +++ b/Projects/DVDomain/Project.swift @@ -12,9 +12,5 @@ let project = Project.project( .core(), ] ), - .tests( - name: DVModule.DVDomain.name, - dependencies: [DVModule.DVDomain.dependency] - ), ] ) From 8b5f79f98e2d216ecef744ea68f7d9f2daba970c Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:17:54 +0900 Subject: [PATCH 35/60] =?UTF-8?q?[#12]=20feat:=20SecretRepository,=20Error?= =?UTF-8?q?=20case=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/Error/SecretRepositoryError.swift | 13 +++++++++++++ .../Repository/Interface/SecretRepository.swift | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift create mode 100644 Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift diff --git a/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift new file mode 100644 index 0000000..2b645bd --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretRepositoryError: Error, Equatable, Sendable { + case notFound(id: UUID) + case duplicateID(id: UUID) + case invalidQuery + case invalidPatch + case corruptedStorage + case storageUnavailable + case persistenceFailed +} diff --git a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift new file mode 100644 index 0000000..f3a5107 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SecretRepository: Sendable { + func create(_ secret: Secret) async throws -> Secret + func fetch(id: UUID) async throws -> Secret? + func fetch(_ query: SecretQuery) async throws -> [Secret] + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func delete(id: UUID) async throws +} From e79977c44afcc1336353884808458d4c5e6fb65e Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:20:37 +0900 Subject: [PATCH 36/60] =?UTF-8?q?[#12]=20feat:=20SecretPatch,=20SecretQuer?= =?UTF-8?q?y=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Repository/SecretPatch.swift | 52 +++++++++++++++++++ .../Sources/Repository/SecretQuery.swift | 47 +++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 Projects/DVDomain/Sources/Repository/SecretPatch.swift create mode 100644 Projects/DVDomain/Sources/Repository/SecretQuery.swift diff --git a/Projects/DVDomain/Sources/Repository/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift new file mode 100644 index 0000000..406bb8a --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretPatch.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret의 일부 필드 변경 요청을 표현합니다. +public struct SecretPatch: Equatable, Sendable { + public var name: PatchField + public var secretType: PatchField + public var subType: PatchField + public var service: PatchField + public var environment: PatchField + public var expiresAt: PatchField + public var memo: PatchField + public var liked: PatchField + public var deletedAt: PatchField + public var payload: PatchField + public var metadata: PatchField + public var updatedAt: PatchField + + public init( + name: PatchField = .unchanged, + secretType: PatchField = .unchanged, + subType: PatchField = .unchanged, + service: PatchField = .unchanged, + environment: PatchField = .unchanged, + expiresAt: PatchField = .unchanged, + memo: PatchField = .unchanged, + liked: PatchField = .unchanged, + deletedAt: PatchField = .unchanged, + payload: PatchField = .unchanged, + metadata: PatchField = .unchanged, + updatedAt: PatchField = .unchanged + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + self.deletedAt = deletedAt + self.payload = payload + self.metadata = metadata + self.updatedAt = updatedAt + } +} + +public enum PatchField: Equatable, Sendable { + case unchanged + case set(Value) +} diff --git a/Projects/DVDomain/Sources/Repository/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/SecretQuery.swift new file mode 100644 index 0000000..effe32b --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/SecretQuery.swift @@ -0,0 +1,47 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Secret 목록 조회 범위, 필터, 검색어, 정렬 조건을 표현합니다. +public struct SecretQuery: Equatable, Sendable { + public var collection: Collection // 어디 탭/사이드 바 범위에서 볼 것인가 + public var secretType: String? // 어떤 종류의 Secret인가 + public var service: String? // 어떤 서비스인가 + public var environment: String? // 어떤 실행 환경인가 + public var searchText: String? // 사용자가 입력한 텍스트 검색어 + public var sort: Sort // 어떤 순서로 볼 것인가 + + public init( + collection: Collection = .all, + secretType: String? = nil, + service: String? = nil, + environment: String? = nil, + searchText: String? = nil, + sort: Sort = .recentlyAdded + ) { + self.collection = collection + self.secretType = secretType + self.service = service + self.environment = environment + self.searchText = searchText + self.sort = sort + } +} + +public extension SecretQuery { + enum Collection: Equatable, Sendable { + case all + case liked + case expired(referenceDate: Date) + case deleted + case project(id: UUID) + } + + enum Sort: Equatable, Sendable { + case recentlyAdded + case oldestFirst + case expiringSoon + case nameAscending + case nameDescending + } +} From f478bcf322f0327f05d0b44e26afbb50ad096372 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 16:59:48 +0900 Subject: [PATCH 37/60] =?UTF-8?q?[#12]=20feat:=20SecretCryptoService=20?= =?UTF-8?q?=ED=8F=AC=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20Error=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Error/SecretCryptoError.swift | 9 ++++++++ .../Interface/SecretCryptoService.swift | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift create mode 100644 Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift diff --git a/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift new file mode 100644 index 0000000..10e28eb --- /dev/null +++ b/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum SecretCryptoError: Error, Equatable, Sendable { + case keyUnavailable + case encryptionFailed + case decryptionFailed + case encodingFailed + case decodingFailed +} diff --git a/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift new file mode 100644 index 0000000..228eebb --- /dev/null +++ b/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +/// Secret payload 암호화와 metadata 인코딩을 수행하는 서비스 프로토콜입니다. +public protocol SecretCryptoService: Sendable { + func encryptPayload( + _ payload: Payload + ) async throws -> SecretPayload + + func decryptPayload( + _ payload: SecretPayload, + as type: Payload.Type + ) async throws -> Payload + + func encodeMetadata( + _ metadata: Metadata + ) throws -> SecretMetadata + + func decodeMetadata( + _ metadata: SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata +} From bd76527c67d293e7963e2376074f9abbe657a140 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:00:25 +0900 Subject: [PATCH 38/60] =?UTF-8?q?[#12]=20fix:=20SecretMetadata,=20Payload?= =?UTF-8?q?=20Entity=EC=97=90=EC=84=9C=20id=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/Entity/SecretMetadata.swift | 5 +---- Projects/DVDomain/Sources/Entity/SecretPayload.swift | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift index 742cee0..97b229d 100644 --- a/Projects/DVDomain/Sources/Entity/SecretMetadata.swift +++ b/Projects/DVDomain/Sources/Entity/SecretMetadata.swift @@ -2,17 +2,14 @@ import Foundation -public struct SecretMetadata: Equatable, Identifiable, Sendable { - public var id: UUID +public struct SecretMetadata: Equatable, Sendable { public var metadataJSON: Data public var schemaVersion: Int public init( - id: UUID, metadataJSON: Data, schemaVersion: Int ) { - self.id = id self.metadataJSON = metadataJSON self.schemaVersion = schemaVersion } diff --git a/Projects/DVDomain/Sources/Entity/SecretPayload.swift b/Projects/DVDomain/Sources/Entity/SecretPayload.swift index c887d53..a20f4fc 100644 --- a/Projects/DVDomain/Sources/Entity/SecretPayload.swift +++ b/Projects/DVDomain/Sources/Entity/SecretPayload.swift @@ -2,19 +2,16 @@ import Foundation -public struct SecretPayload: Equatable, Identifiable, Sendable { - public var id: UUID +public struct SecretPayload: Equatable, Sendable { public var encryptedData: Data public var keyTag: String public var schemaVersion: Int public init( - id: UUID, encryptedData: Data, keyTag: String, schemaVersion: Int ) { - self.id = id self.encryptedData = encryptedData self.keyTag = keyTag self.schemaVersion = schemaVersion From b2dfd4122770526cccec63ebc8d590f0bfe25a16 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:01:33 +0900 Subject: [PATCH 39/60] =?UTF-8?q?[#12]=20feat:=20PayloadData=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20secret=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretContent/Payload/APIKeyPayload.swift | 11 +++++++++++ .../SecretContent/Payload/CustomPayload.swift | 11 +++++++++++ .../Payload/DatabasePayload.swift | 11 +++++++++++ .../SecretContent/Payload/EnvSetPayload.swift | 11 +++++++++++ .../Payload/Interface/SecretPayloadData.swift | 8 ++++++++ .../Payload/LicenseKeyPayload.swift | 11 +++++++++++ .../Payload/OAuthClientPayload.swift | 13 +++++++++++++ .../SecretContent/Payload/SSHKeyPayload.swift | 13 +++++++++++++ .../Payload/SSLCertPayload.swift | 19 +++++++++++++++++++ .../Payload/ServiceAccountPayload.swift | 11 +++++++++++ 10 files changed, 119 insertions(+) create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift new file mode 100644 index 0000000..997e972 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/APIKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift new file mode 100644 index 0000000..12d27a4 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/CustomPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct CustomPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var value: String + + public init(value: String) { + self.value = value + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift new file mode 100644 index 0000000..e1a0146 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/DatabasePayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabasePayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var linkString: String + + public init(linkString: String) { + self.linkString = linkString + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift new file mode 100644 index 0000000..3ea6d03 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/EnvSetPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct EnvSetPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var content: String + + public init(content: String) { + self.content = content + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift new file mode 100644 index 0000000..0b20e47 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/Interface/SecretPayloadData.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 암호화 대상 Secret payload content가 따라야 하는 Domain 계약입니다. +public protocol SecretPayloadData: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift new file mode 100644 index 0000000..baea0c1 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/LicenseKeyPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var licenseKey: String + + public init(licenseKey: String) { + self.licenseKey = licenseKey + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift new file mode 100644 index 0000000..99d2a20 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/OAuthClientPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var clientId: String + public var clientSecret: String + + public init(clientId: String, clientSecret: String) { + self.clientId = clientId + self.clientSecret = clientSecret + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift new file mode 100644 index 0000000..3cde697 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSHKeyPayload.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var privateKey: String + public var passphrase: String? + + public init(privateKey: String, passphrase: String? = nil) { + self.privateKey = privateKey + self.passphrase = passphrase + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift new file mode 100644 index 0000000..661af5d --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/SSLCertPayload.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var certificate: String + public var privateKey: String + public var certificateChain: String? + + public init( + certificate: String, + privateKey: String, + certificateChain: String? = nil + ) { + self.certificate = certificate + self.privateKey = privateKey + self.certificateChain = certificateChain + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift new file mode 100644 index 0000000..2399d6c --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Payload/ServiceAccountPayload.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountPayload: SecretPayloadData, Equatable { + public static let schemaVersion = 1 + + public var credentialJSON: String + + public init(credentialJSON: String) { + self.credentialJSON = credentialJSON + } +} From 2d3a7752205731b3f38adda7f11df6a08a4ed796 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:02:06 +0900 Subject: [PATCH 40/60] =?UTF-8?q?[#12]=20feat:=20Metadata=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20Secret=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EB=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Metadata/APIKeyMetadata.swift | 11 ++++++++ .../Metadata/DatabaseMetadata.swift | 25 +++++++++++++++++++ .../Interface/SecretMetadataContent.swift | 8 ++++++ .../Metadata/LicenseKeyMetadata.swift | 22 ++++++++++++++++ .../Metadata/OAuthClientMetadata.swift | 13 ++++++++++ .../Metadata/SSHKeyMetadata.swift | 22 ++++++++++++++++ .../Metadata/SSLCertMetadata.swift | 19 ++++++++++++++ .../Metadata/ServiceAccountMetadata.swift | 19 ++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift create mode 100644 Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift new file mode 100644 index 0000000..8ef8336 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/APIKeyMetadata.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct APIKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var scope: String? + + public init(scope: String? = nil) { + self.scope = scope + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift new file mode 100644 index 0000000..92623cd --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/DatabaseMetadata.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct DatabaseMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var host: String? + public var port: Int? + public var databaseName: String? + public var username: String? + public var sslRequired: Bool? + + public init( + host: String? = nil, + port: Int? = nil, + databaseName: String? = nil, + username: String? = nil, + sslRequired: Bool? = nil + ) { + self.host = host + self.port = port + self.databaseName = databaseName + self.username = username + self.sslRequired = sslRequired + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift new file mode 100644 index 0000000..7d24bbe --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/Interface/SecretMetadataContent.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 평문 JSON으로 저장 가능한 Secret metadata content가 따라야 하는 Domain 계약입니다. +public protocol SecretMetadataContent: Codable, Sendable { + static var schemaVersion: Int { get } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift new file mode 100644 index 0000000..97c10bc --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/LicenseKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct LicenseKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var licenseType: String? + public var registrationEmail: String? + public var orderNumber: String? + public var website: String? + + public init( + licenseType: String? = nil, + registrationEmail: String? = nil, + orderNumber: String? = nil, + website: String? = nil + ) { + self.licenseType = licenseType + self.registrationEmail = registrationEmail + self.orderNumber = orderNumber + self.website = website + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift new file mode 100644 index 0000000..c94ed36 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/OAuthClientMetadata.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct OAuthClientMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var redirectUri: String? + public var scopes: String? + + public init(redirectUri: String? = nil, scopes: String? = nil) { + self.redirectUri = redirectUri + self.scopes = scopes + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift new file mode 100644 index 0000000..cb82961 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSHKeyMetadata.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSHKeyMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var publicKey: String? + public var keyType: String? + public var host: String? + public var username: String? + + public init( + publicKey: String? = nil, + keyType: String? = nil, + host: String? = nil, + username: String? = nil + ) { + self.publicKey = publicKey + self.keyType = keyType + self.host = host + self.username = username + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift new file mode 100644 index 0000000..0a29ee0 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/SSLCertMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct SSLCertMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var domain: String? + public var issuer: String? + public var renewCommand: String? + + public init( + domain: String? = nil, + issuer: String? = nil, + renewCommand: String? = nil + ) { + self.domain = domain + self.issuer = issuer + self.renewCommand = renewCommand + } +} diff --git a/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift new file mode 100644 index 0000000..2658250 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/Metadata/ServiceAccountMetadata.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +public struct ServiceAccountMetadata: SecretMetadataContent, Equatable { + public static let schemaVersion = 1 + + public var projectId: String? + public var accountEmail: String? + public var authority: String? + + public init( + projectId: String? = nil, + accountEmail: String? = nil, + authority: String? = nil + ) { + self.projectId = projectId + self.accountEmail = accountEmail + self.authority = authority + } +} From 8601c2c5f41c4a5d36cc087ca2d286ed4f0f2062 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:45:47 +0900 Subject: [PATCH 41/60] =?UTF-8?q?[#12]=20feat:=20Secret=20(=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=82=AD=EC=A0=9C,=20=EC=A1=B0=ED=9A=8C,=20?= =?UTF-8?q?=EC=88=98=EC=A0=95)=20=EA=B4=80=EB=A0=A8=20UseCase=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/Secret/CreateSecretUseCase.swift | 14 ++++++++++++++ .../Interface/Secret/DeleteSecretUseCase.swift | 9 +++++++++ .../Interface/Secret/FetchSecretUseCase.swift | 12 ++++++++++++ .../Interface/Secret/PatchSecretUseCase.swift | 16 ++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift new file mode 100644 index 0000000..cc6357e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +public protocol CreateSecretUseCase: Sendable { + func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret + + func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift new file mode 100644 index 0000000..b57c4f1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol DeleteSecretUseCase: Sendable { + func softDelete(id: UUID) async throws -> Secret + func restore(id: UUID) async throws -> Secret + func permanentlyDelete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift new file mode 100644 index 0000000..eaf58d1 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -0,0 +1,12 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol FetchSecretUseCase: Sendable { + func fetch(id: UUID) async throws -> Secret? + func fetch(query: SecretQuery) async throws -> [Secret] + func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift new file mode 100644 index 0000000..b4201f2 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -0,0 +1,16 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol PatchSecretUseCase: Sendable { + func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret + func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret + func removeMetadata(id: UUID) async throws -> Secret +} From 0003d5fff64f7c4eaf4e672917f6926e46fcfd17 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:46:22 +0900 Subject: [PATCH 42/60] =?UTF-8?q?[#12]=20feat:=20Secret=20UseCase=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/CreateSecretUseCaseImpl.swift | 81 +++++++++++++++++++ .../Impl/Secret/DeleteSecretUseCaseImpl.swift | 50 ++++++++++++ .../Impl/Secret/FetchSecretUseCaseImpl.swift | 46 +++++++++++ .../Impl/Secret/PatchSecretUseCaseImpl.swift | 75 +++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift new file mode 100644 index 0000000..c8b5f46 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -0,0 +1,81 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct CreateSecretUseCaseImpl: CreateSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let idGenerator: @Sendable () -> UUID + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + idGenerator: @escaping @Sendable () -> UUID = { UUID() }, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.idGenerator = idGenerator + self.dateProvider = dateProvider + } + + public func execute( + draft: SecretDraft, + payload: Payload + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func execute( + draft: SecretDraft, + payload: Payload, + metadata: Metadata + ) async throws -> Secret { + do { + try SecretUseCaseHelper.validateDraft(draft) + let now = dateProvider() + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let secret = Secret( + id: idGenerator(), + name: draft.name, + secretType: draft.secretType, + subType: draft.subType, + service: draft.service, + environment: draft.environment, + expiresAt: draft.expiresAt, + memo: draft.memo, + liked: draft.liked, + createdAt: now, + updatedAt: now, + payload: encryptedPayload, + metadata: encodedMetadata + ) + return try await repository.create(secret) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift new file mode 100644 index 0000000..51c4112 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/DeleteSecretUseCaseImpl.swift @@ -0,0 +1,50 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct DeleteSecretUseCaseImpl: DeleteSecretUseCase { + private let repository: any SecretRepository + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.dateProvider = dateProvider + } + + public func softDelete(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(now), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func restore(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + deletedAt: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func permanentlyDelete(id: UUID) async throws { + do { + try await repository.delete(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift new file mode 100644 index 0000000..260f2bb --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -0,0 +1,46 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct FetchSecretUseCaseImpl: FetchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService + ) { + self.repository = repository + self.cryptoService = cryptoService + } + + public func fetch(id: UUID) async throws -> Secret? { + do { + return try await repository.fetch(id: id) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func fetch(query: SecretQuery) async throws -> [Secret] { + do { + return try await repository.fetch(query) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func revealPayload( + id: UUID, + as type: Payload.Type + ) async throws -> Payload { + do { + guard let secret = try await repository.fetch(id: id) else { + throw SecretUseCaseError.secretNotFound(id: id) + } + return try await cryptoService.decryptPayload(secret.payload, as: type) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift new file mode 100644 index 0000000..5a101b0 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift @@ -0,0 +1,75 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct PatchSecretUseCaseImpl: PatchSecretUseCase { + private let repository: any SecretRepository + private let cryptoService: any SecretCryptoService + private let dateProvider: @Sendable () -> Date + + public init( + repository: any SecretRepository, + cryptoService: any SecretCryptoService, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.cryptoService = cryptoService + self.dateProvider = dateProvider + } + + public func patch(id: UUID, with patch: SecretPatch) async throws -> Secret { + do { + let patch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(patch, now: dateProvider()) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updatePayload( + id: UUID, + payload: Payload + ) async throws -> Secret { + do { + let encryptedPayload = try await cryptoService.encryptPayload(payload) + let now = dateProvider() + let patch = SecretPatch( + payload: .set(encryptedPayload), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func updateMetadata( + id: UUID, + metadata: Metadata + ) async throws -> Secret { + do { + let encodedMetadata = try cryptoService.encodeMetadata(metadata) + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(encodedMetadata), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func removeMetadata(id: UUID) async throws -> Secret { + do { + let now = dateProvider() + let patch = SecretPatch( + metadata: .set(nil), + updatedAt: .set(now) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw SecretUseCaseError.map(error) + } + } +} From 6e51543270218610f8805e4772e87b6e111665a6 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:46:51 +0900 Subject: [PATCH 43/60] =?UTF-8?q?[#12]=20feat:=20Secret=20create=EC=97=90?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=9C=20input=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=91=9C=ED=98=84=20draft=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/Draft/SecretDraft.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift new file mode 100644 index 0000000..0682ad9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. +public struct SecretDraft: Equatable, Sendable { + public var name: String + public var secretType: String + public var subType: String? + public var service: String? + public var environment: String? + public var expiresAt: Date? + public var memo: String? + public var liked: Bool + + public init( + name: String, + secretType: String, + subType: String? = nil, + service: String? = nil, + environment: String? = nil, + expiresAt: Date? = nil, + memo: String? = nil, + liked: Bool = false + ) { + self.name = name + self.secretType = secretType + self.subType = subType + self.service = service + self.environment = environment + self.expiresAt = expiresAt + self.memo = memo + self.liked = liked + } +} From da4158fe22e615a00e1b04b8b8bc5676b1ada6e5 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:47:26 +0900 Subject: [PATCH 44/60] =?UTF-8?q?[#12]=20feat:=20SecretUseCaseError=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EB=B0=8F=20mapping=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/Error/SecretUseCaseError.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift diff --git a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift new file mode 100644 index 0000000..0dd79f8 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift @@ -0,0 +1,33 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum SecretUseCaseError: Error, Equatable, Sendable { + case invalidName + case invalidSecretType + case secretNotFound(id: UUID) + case repositoryFailure(SecretRepositoryError) + case cryptoFailure(SecretCryptoError) + case unexpected +} + +extension SecretUseCaseError { + static func map(_ error: Error) -> SecretUseCaseError { + if let error = error as? SecretUseCaseError { + return error + } + + if let error = error as? SecretRepositoryError { + if case let .notFound(id) = error { + return .secretNotFound(id: id) + } + return .repositoryFailure(error) + } + + if let error = error as? SecretCryptoError { + return .cryptoFailure(error) + } + + return .unexpected + } +} From 8aa720df88479cd180ac56740e1c05664463653c Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 17:49:16 +0900 Subject: [PATCH 45/60] =?UTF-8?q?[#12]=20feat:=20Secret=20UseCase=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?helper=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/SecretUseCaseHelper.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift new file mode 100644 index 0000000..10494f9 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift @@ -0,0 +1,25 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +enum SecretUseCaseHelper { + /// Secret 초안의 기본 필수값을 검증합니다. + static func validateDraft(_ draft: SecretDraft) throws { + guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidName + } + + guard !draft.secretType.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw SecretUseCaseError.invalidSecretType + } + } + + /// patch에 updatedAt이 없으면 전달받은 시각으로 채웁니다. + static func settingUpdatedAtIfNeeded(_ patch: SecretPatch, now: Date) -> SecretPatch { + var next = patch + if case .unchanged = next.updatedAt { + next.updatedAt = .set(now) + } + return next + } +} From aa24bf020809226eb551651eead190b89aeb2c2a Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 18:39:18 +0900 Subject: [PATCH 46/60] =?UTF-8?q?[#12]=20feat:=20Secret,=20SecretPayload,?= =?UTF-8?q?=20SecretMetadata=EC=97=90=20extension=EC=9C=BC=EB=A1=9C=20Enti?= =?UTF-8?q?ty=20=EB=A7=A4=ED=95=91=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/Local/Models/Secret.swift | 26 +++++++++++++++++++ .../Storage/Local/Models/SecretMetadata.swift | 10 +++++++ .../Storage/Local/Models/SecretPayload.swift | 11 ++++++++ 3 files changed, 47 insertions(+) diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 23325fc..0385f11 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import SwiftData import Foundation @@ -69,3 +70,28 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.Secret { + func toDomain() throws -> DVDomain.Secret { + guard let payload else { + throw SecretRepositoryError.corruptedStorage + } + + return DVDomain.Secret( + id: id, + name: name, + secretType: secretType, + subType: subType, + service: service, + environment: environment, + expiresAt: expiresAt, + memo: memo, + liked: liked, + deletedAt: deletedAt, + createdAt: createdAt, + updatedAt: updatedAt, + payload: payload.toDomain(), + metadata: metadata?.toDomain() + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift index 284b29f..2cfa7ab 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -25,3 +26,12 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretMetadata { + func toDomain() -> DVDomain.SecretMetadata { + DVDomain.SecretMetadata( + metadataJSON: metadataJSON, + schemaVersion: schemaVersion + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift index f503ce0..80813e7 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -28,3 +29,13 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.SecretPayload { + func toDomain() -> DVDomain.SecretPayload { + DVDomain.SecretPayload( + encryptedData: encryptedData, + keyTag: keyTag, + schemaVersion: schemaVersion + ) + } +} From 8b627c3f454f2eedf54f5b7aae3488cd4a84c4f6 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:13:39 +0900 Subject: [PATCH 47/60] =?UTF-8?q?[#12]=20feat:=20modelContainer=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=95=98=EB=8A=94=20LocalStorage=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/Local/LocalStorage.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Projects/DVData/Sources/Storage/Local/LocalStorage.swift diff --git a/Projects/DVData/Sources/Storage/Local/LocalStorage.swift b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift new file mode 100644 index 0000000..f8a300f --- /dev/null +++ b/Projects/DVData/Sources/Storage/Local/LocalStorage.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftData + +public final class LocalStorage { + public static let shared = LocalStorage() + + private init() { } + + public lazy var modelContainer: ModelContainer = { + let configuration = ModelConfiguration(isStoredInMemoryOnly: false) + + do { + return try ModelContainer( + for: Schema.appSchema, + configurations: configuration + ) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } + }() +} From c24fa5c0e643f079909a23a03c020717833eb5a0 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:14:18 +0900 Subject: [PATCH 48/60] =?UTF-8?q?[#12]=20feat:=20=EA=B8=B0=EB=B3=B8=20Secr?= =?UTF-8?q?et(payload,=20metadata=20=ED=8F=AC=ED=95=A8)=20CRUD=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=ED=95=9C=20SecretReposi?= =?UTF-8?q?toryImpl=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/SecretRepositoryImpl.swift | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift new file mode 100644 index 0000000..7cbbea0 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -0,0 +1,214 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +@ModelActor +public actor SecretRepositoryImpl: SecretRepository { + public init(modelContainer: ModelContainer) { + let modelContext = ModelContext(modelContainer) + self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) + self.modelContainer = modelContainer + } + + public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { + do { + let localSecret = SwiftDataModel.Secret( + id: secret.id, + name: secret.name, + secretType: secret.secretType, + subType: secret.subType, + service: secret.service, + environment: secret.environment, + expiresAt: secret.expiresAt, + memo: secret.memo, + liked: secret.liked, + deletedAt: secret.deletedAt, + createdAt: secret.createdAt, + updatedAt: secret.updatedAt + ) + + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: secret.payload.encryptedData, + keyTag: secret.payload.keyTag, + schemaVersion: secret.payload.schemaVersion, + secret: localSecret + ) + localSecret.payload = localPayload + + modelContext.insert(localSecret) + modelContext.insert(localPayload) + + if let metadata = secret.metadata { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: localSecret + ) + localSecret.metadata = localMetadata + modelContext.insert(localMetadata) + } + + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func fetch(id: UUID) async throws -> DVDomain.Secret? { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + return nil + } + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretFetchDescriptorBuilder로 원하는 조건으로 fetch 후 InMemorySecretQueryFilter로 searchText 보정 + public func fetch(_ query: SecretQuery) async throws -> [DVDomain.Secret] { + do { + let descriptor = SecretFetchDescriptorBuilder.make(from: query) + let localSecrets = try modelContext.fetch(descriptor) + let domainSecrets = try localSecrets.map { try $0.toDomain() } + return InMemorySecretQueryFilter.apply(query, to: domainSecrets) + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + /// SecretPatch 적용하여 update + public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + apply(patch, to: localSecret) + try modelContext.save() + + return try localSecret.toDomain() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func delete(id: UUID) async throws { + do { + guard let localSecret = try fetchLocalSecret(id: id) else { + throw SecretRepositoryError.notFound(id: id) + } + + modelContext.delete(localSecret) + try modelContext.save() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } +} + +extension SecretRepositoryImpl { + private func fetchLocalSecret(id: UUID) throws -> SwiftDataModel.Secret? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + /// SecretPatch를 SwiftData model에 반영 + private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) { + if case let .set(name) = patch.name { + secret.name = name + } + if case let .set(secretType) = patch.secretType { + secret.secretType = secretType + } + if case let .set(subType) = patch.subType { + secret.subType = subType + } + if case let .set(service) = patch.service { + secret.service = service + } + if case let .set(environment) = patch.environment { + secret.environment = environment + } + if case let .set(expiresAt) = patch.expiresAt { + secret.expiresAt = expiresAt + } + if case let .set(memo) = patch.memo { + secret.memo = memo + } + if case let .set(liked) = patch.liked { + secret.liked = liked + } + if case let .set(deletedAt) = patch.deletedAt { + secret.deletedAt = deletedAt + } + if case let .set(updatedAt) = patch.updatedAt { + secret.updatedAt = updatedAt + } + if case let .set(payload) = patch.payload { + apply(payload, to: secret) + } + if case let .set(metadata) = patch.metadata { + apply(metadata, to: secret) + } + } + + /// payload가 있으면 업데이트하고, 없으면 새 payload를 만든다. + private func apply(_ payload: DVDomain.SecretPayload, to secret: SwiftDataModel.Secret) { + if let localPayload = secret.payload { + localPayload.encryptedData = payload.encryptedData + localPayload.keyTag = payload.keyTag + localPayload.schemaVersion = payload.schemaVersion + } else { + let localPayload = SwiftDataModel.SecretPayload( + encryptedData: payload.encryptedData, + keyTag: payload.keyTag, + schemaVersion: payload.schemaVersion, + secret: secret + ) + secret.payload = localPayload + modelContext.insert(localPayload) + } + } + + /// metadata가 nil이면 기존 metadata를 삭제하고, 값이 있으면 업데이트 또는 생성한다. + private func apply(_ metadata: DVDomain.SecretMetadata?, to secret: SwiftDataModel.Secret) { + guard let metadata else { + if let localMetadata = secret.metadata { + modelContext.delete(localMetadata) + } + secret.metadata = nil + return + } + + if let localMetadata = secret.metadata { + localMetadata.metadataJSON = metadata.metadataJSON + localMetadata.schemaVersion = metadata.schemaVersion + } else { + let localMetadata = SwiftDataModel.SecretMetadata( + metadataJSON: metadata.metadataJSON, + schemaVersion: metadata.schemaVersion, + secret: secret + ) + secret.metadata = localMetadata + modelContext.insert(localMetadata) + } + } +} From 41141920c1e02a21433624ad3026ae6d44bcadc0 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 19:14:39 +0900 Subject: [PATCH 49/60] =?UTF-8?q?[#12]=20feat:=20SecretFetchDescriptorBuil?= =?UTF-8?q?der,=20InMemorySecretQueryFilter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/InMemorySecretQueryFilter.swift | 35 ++++++ .../Secret/SecretFetchDescriptorBuilder.swift | 102 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift create mode 100644 Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift new file mode 100644 index 0000000..2cbc73b --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +/// SwiftData FetchDescriptor로 처리하기 애매한 조건을 Domain Secret 배열에서 후처리하는 필터. 현재는 searchText만 담당 +enum InMemorySecretQueryFilter { + static func apply(_ query: SecretQuery, to secrets: [DVDomain.Secret]) -> [DVDomain.Secret] { + secrets.filter { matchesSearchText(query.searchText, secret: $0) } + } + + private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool { + guard let searchText else { + return true + } + + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { + return true + } + + let fields = [ + secret.name, + secret.secretType, + secret.subType, + secret.service, + secret.environment, + secret.memo, + ] + + return fields.contains { + $0?.localizedCaseInsensitiveContains(keyword) == true + } + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift new file mode 100644 index 0000000..24bf826 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretFetchDescriptorBuilder.swift @@ -0,0 +1,102 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +/// Domain의 SecretQuery를 SwiftData의 FetchDescriptor로 바꾸는 타입 +enum SecretFetchDescriptorBuilder { + static func make(from query: SecretQuery) -> FetchDescriptor { + var descriptor = FetchDescriptor( + predicate: predicate(from: query), + sortBy: sortDescriptors(from: query.sort) + ) + descriptor.includePendingChanges = true + return descriptor + } + + private static func predicate(from query: SecretQuery) -> Predicate { + let hasSecretType = !(query.secretType?.isEmpty ?? true) + let secretType = query.secretType ?? "" + let hasService = !(query.service?.isEmpty ?? true) + let service = query.service ?? "" + let hasEnvironment = !(query.environment?.isEmpty ?? true) + let environment = query.environment ?? "" + + switch query.collection { + case .all: + return #Predicate { secret in + secret.deletedAt == nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .liked: + return #Predicate { secret in + secret.deletedAt == nil && + secret.liked && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .expired(referenceDate): + return #Predicate { secret in + secret.deletedAt == nil && + secret.expiresAt != nil && + secret.expiresAt! < referenceDate && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case .deleted: + return #Predicate { secret in + secret.deletedAt != nil && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + case let .project(projectID): + return #Predicate { secret in + secret.deletedAt == nil && + secret.projectLinks.contains { link in + link.project.id == projectID + } && + (!hasSecretType || secret.secretType == secretType) && + (!hasService || secret.service == service) && + (!hasEnvironment || secret.environment == environment) + } + } + } + + private static func sortDescriptors( + from sort: SecretQuery.Sort + ) -> [SortDescriptor] { + switch sort { + case .recentlyAdded: + return [ + SortDescriptor(\.createdAt, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .oldestFirst: + return [ + SortDescriptor(\.createdAt, order: .forward), + SortDescriptor(\.updatedAt, order: .forward), + ] + case .expiringSoon: + return [ + SortDescriptor(\.expiresAt, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameAscending: + return [ + SortDescriptor(\.name, order: .forward), + SortDescriptor(\.updatedAt, order: .reverse), + ] + case .nameDescending: + return [ + SortDescriptor(\.name, order: .reverse), + SortDescriptor(\.updatedAt, order: .reverse), + ] + } + } +} From 21c8ad5b714a0d62604d95ba248a284147ac3d29 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:20:47 +0900 Subject: [PATCH 50/60] =?UTF-8?q?[#12]=20chore:=20Domain=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20Service=20=EC=9E=AC=ED=8F=B4=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/{Security => Service}/Error/SecretCryptoError.swift | 0 .../{Security => Service}/Interface/SecretCryptoService.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Projects/DVDomain/Sources/{Security => Service}/Error/SecretCryptoError.swift (100%) rename Projects/DVDomain/Sources/{Security => Service}/Interface/SecretCryptoService.swift (100%) diff --git a/Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift similarity index 100% rename from Projects/DVDomain/Sources/Security/Error/SecretCryptoError.swift rename to Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift diff --git a/Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift similarity index 100% rename from Projects/DVDomain/Sources/Security/Interface/SecretCryptoService.swift rename to Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift From fbdbcaddfd2403508e55fcaf0e2942cf5f5b90ab Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:21:11 +0900 Subject: [PATCH 51/60] =?UTF-8?q?[#12]=20feat:=20payload,=20metadata=20JSO?= =?UTF-8?q?NCoder=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JSONCoder/SecretMetadataJSONCoder.swift | 27 +++++++++++++++++++ .../JSONCoder/SecretPayloadJSONCoder.swift | 27 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift new file mode 100644 index 0000000..f8294a6 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretMetadataJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretMetadataJSONCoder: Sendable { + /// Metadata content를 JSON Data로 직렬화한다. + func encode(_ metadata: Metadata) throws -> Data { + do { + return try JSONEncoder().encode(metadata) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Metadata.Type + ) throws -> Metadata { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} diff --git a/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift new file mode 100644 index 0000000..90b978c --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/JSONCoder/SecretPayloadJSONCoder.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +struct SecretPayloadJSONCoder: Sendable { + /// Payload content를 JSON Data로 직렬화한다. + func encode(_ payload: Payload) throws -> Data { + do { + return try JSONEncoder().encode(payload) + } catch { + throw SecretCryptoError.encodingFailed + } + } + + /// JSON Data를 요청한 payload content 타입으로 역직렬화한다. + func decode( + _ data: Data, + as type: Payload.Type + ) throws -> Payload { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw SecretCryptoError.decodingFailed + } + } +} From 9950a0637ba2eaea7d969528eba86d116f17b2c7 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:21:23 +0900 Subject: [PATCH 52/60] =?UTF-8?q?[#12]=20feat:=20SecretCryptoServiceImpl?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/SecretCryptoServiceImpl.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift new file mode 100644 index 0000000..6c5f990 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/SecretCryptoServiceImpl.swift @@ -0,0 +1,88 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation + +public struct SecretCryptoServiceImpl: SecretCryptoService { + private let masterKeyTag: String + private let keyStore: KeychainKeyStore + private let payloadJSONCoder: SecretPayloadJSONCoder + private let metadataJSONCoder: SecretMetadataJSONCoder + + /// Keychain key tag와 service namespace를 설정해 SecretCryptoService 구현체를 생성한다. + public init( + masterKeyTag: String = "com.devault.masterKey.v1", + keychainService: String = "com.devault.secret" + ) { + self.masterKeyTag = masterKeyTag + self.keyStore = KeychainKeyStore(service: keychainService) + self.payloadJSONCoder = SecretPayloadJSONCoder() + self.metadataJSONCoder = SecretMetadataJSONCoder() + } + + /// Payload content를 JSON으로 직렬화한 뒤 AES-GCM으로 암호화해 저장용 SecretPayload를 만든다. + public func encryptPayload( + _ payload: Payload + ) async throws -> DVDomain.SecretPayload { + do { + let key = try keyStore.getOrCreateSymmetricKey(tag: masterKeyTag) + let json = try payloadJSONCoder.encode(payload) + // nonce + ciphertext + authentication tag 생성 + let sealedBox = try AES.GCM.seal(json, using: key) + + // 3가지를 하나의 Data로 합침 -> DB에 저장 + guard let combined = sealedBox.combined else { + throw SecretCryptoError.encryptionFailed + } + + return DVDomain.SecretPayload( + encryptedData: combined, + keyTag: masterKeyTag, + schemaVersion: Payload.schemaVersion + ) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.encryptionFailed + } + } + + /// 저장된 SecretPayload를 keyTag로 복호화한 뒤 요청한 payload content 타입으로 역직렬화한다. + public func decryptPayload( + _ payload: DVDomain.SecretPayload, + as type: Payload.Type + ) async throws -> Payload { + do { + // 새 key를 만들어도 기존 암호문은 열릴 수 없으므로 key가 없을 때 새 키를 만들지 않음 + let key = try keyStore.getSymmetricKey(tag: payload.keyTag) + let sealedBox = try AES.GCM.SealedBox(combined: payload.encryptedData) + // key가 맞는지, authentication tag가 유효한지 확인 + let json = try AES.GCM.open(sealedBox, using: key) + return try payloadJSONCoder.decode(json, as: type) + } catch let error as SecretCryptoError { + throw error + } catch { + throw SecretCryptoError.decryptionFailed + } + } + + /// Metadata content를 JSON Data로 직렬화해 저장용 SecretMetadata를 만든다. + public func encodeMetadata( + _ metadata: Metadata + ) throws -> DVDomain.SecretMetadata { + let json = try metadataJSONCoder.encode(metadata) + return DVDomain.SecretMetadata( + metadataJSON: json, + schemaVersion: Metadata.schemaVersion + ) + } + + /// 저장된 SecretMetadata의 JSON Data를 요청한 metadata content 타입으로 역직렬화한다. + public func decodeMetadata( + _ metadata: DVDomain.SecretMetadata, + as type: Metadata.Type + ) throws -> Metadata { + try metadataJSONCoder.decode(metadata.metadataJSON, as: type) + } +} From 49b154745c6c9ab7065fde3c54b47aa3cee73f8d Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:22:17 +0900 Subject: [PATCH 53/60] =?UTF-8?q?[#12]=20feat:=20=ED=82=A4=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20key=20=EC=A0=91=EA=B7=BC=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8B=B4=EC=9D=80=20KeychainKeyStore=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Keychain/KeychainKeyStore.swift | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift new file mode 100644 index 0000000..c065ea0 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift @@ -0,0 +1,108 @@ +// Copyright © 2026 Devault. All rights reserved + +import CryptoKit +import DVDomain +import Foundation +import Security + +struct KeychainKeyStore: Sendable { + private let service: String + + /// Keychain generic password item을 구분할 service namespace를 설정한다. + init(service: String) { + self.service = service + } + + /// tag에 해당하는 symmetric key를 조회하고, 없으면 새 key를 생성해 Keychain에 저장한다. + func getOrCreateSymmetricKey(tag: String) throws -> SymmetricKey { + if let data = try loadKeyData(tag: tag) { + return SymmetricKey(data: data) + } + + let data = try generateKeyData() + try saveKeyData(data, tag: tag) + return SymmetricKey(data: data) + } + + /// tag에 해당하는 symmetric key를 조회하고, 없으면 keyUnavailable 오류를 던진다. + func getSymmetricKey(tag: String) throws -> SymmetricKey { + guard let data = try loadKeyData(tag: tag) else { + throw SecretCryptoError.keyUnavailable + } + + return SymmetricKey(data: data) + } +} + +extension KeychainKeyStore { + /// Keychain에서 tag에 해당하는 raw key Data를 조회한다. + private func loadKeyData(tag: String) throws -> Data? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + // '키가 없음' -> 암호화 시점에서 key 새로 생성, 복호화 시점에서 keyUnavailable + if status == errSecItemNotFound { + return nil + } + + guard status == errSecSuccess, let data = result as? Data else { + throw SecretCryptoError.keyUnavailable + } + + return data + } + + /// raw key Data를 Keychain에 저장하고, 기존 item이 있으면 갱신한다. + private func saveKeyData(_ data: Data, tag: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + ] + + let attributes: [CFString: Any] = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + var addQuery = query + attributes.forEach { addQuery[$0.key] = $0.value } + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + return + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + } + + /// AES-256 symmetric key로 사용할 32바이트 랜덤 Data를 생성한다. + private func generateKeyData() throws -> Data { + var bytes = [UInt8](repeating: 0, count: 32) + let count = bytes.count + let status = bytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, count, $0.baseAddress!) + } + + guard status == errSecSuccess else { + throw SecretCryptoError.keyUnavailable + } + + return Data(bytes) + } +} From ae20f04ac2784ec1f45ef6f62f80f5317a6a9fe4 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:50:27 +0900 Subject: [PATCH 54/60] =?UTF-8?q?[#12]=20fix:=20SecretRepositoryImpl?= =?UTF-8?q?=EC=97=90=EC=84=9C=20public=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RepositoryImpl/Secret/SecretRepositoryImpl.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index 7cbbea0..d0bafb4 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -6,12 +6,6 @@ import SwiftData @ModelActor public actor SecretRepositoryImpl: SecretRepository { - public init(modelContainer: ModelContainer) { - let modelContext = ModelContext(modelContainer) - self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) - self.modelContainer = modelContainer - } - public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret { do { let localSecret = SwiftDataModel.Secret( From 5edb35fad6321bf6a98f99b2c0cf559347f323d0 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 22:51:02 +0900 Subject: [PATCH 55/60] =?UTF-8?q?[#12]=20feat:=20Secret=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EC=A1=B0=ED=9A=8C=20demo=20view=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/SecretUseCaseDemoView.swift | 231 ++++++++++++++++++ Projects/Devault/Sources/ContentView.swift | 24 +- 2 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift diff --git a/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift new file mode 100644 index 0000000..2416a31 --- /dev/null +++ b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift @@ -0,0 +1,231 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import SwiftUI + +public struct SecretUseCaseDemoView: View { + private let createSecretUseCase: any CreateSecretUseCase + private let fetchSecretUseCase: any FetchSecretUseCase + + @State private var selectedSecret: Secret? + @State private var revealedPayload: APIKeyPayload? + @State private var secrets: [Secret] = [] + @State private var statusMessage = "Ready" + @State private var isRunning = false + + public init( + createSecretUseCase: any CreateSecretUseCase, + fetchSecretUseCase: any FetchSecretUseCase + ) { + self.createSecretUseCase = createSecretUseCase + self.fetchSecretUseCase = fetchSecretUseCase + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + controls + status + content + } + .padding(24) + .frame(minWidth: 760, minHeight: 480) + .task { + await fetchAllSecrets() + } + } +} + +private extension SecretUseCaseDemoView { + var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Secret CRUD Demo") + .font(.title2) + .fontWeight(.semibold) + Text("UseCase 기반 생성, 목록 조회, 상세 조회, payload reveal 확인") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + var controls: some View { + HStack(spacing: 10) { + Button { + Task { await createDemoSecret() } + } label: { + Text(isRunning ? "Creating..." : "Demo Create") + } + .disabled(isRunning) + .keyboardShortcut(.defaultAction) + + Button("Refresh") { + Task { await fetchAllSecrets() } + } + .disabled(isRunning) + } + } + + var status: some View { + Text(statusMessage) + .font(.callout) + .foregroundStyle(isRunning ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + var content: some View { + HStack(alignment: .top, spacing: 20) { + list + .frame(minWidth: 260, idealWidth: 300, maxWidth: 340) + Divider() + detail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + var list: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Secrets") + .font(.headline) + Spacer() + Text("\(secrets.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + + List(secrets, selection: selectedSecretBinding) { secret in + VStack(alignment: .leading, spacing: 5) { + Text(secret.name) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + Text(secret.updatedAt.formatted(date: .numeric, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(secret.id) + } + } + } + + var detail: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Detail") + .font(.headline) + Spacer() + Button("Reveal Payload") { + Task { await revealSelectedPayload() } + } + .disabled(isRunning || selectedSecret == nil) + } + + if let selectedSecret { + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { + resultRow("ID", selectedSecret.id.uuidString) + resultRow("Name", selectedSecret.name) + resultRow("Type", selectedSecret.secretType) + resultRow("Service", selectedSecret.service ?? "-") + resultRow("Environment", selectedSecret.environment ?? "-") + resultRow("Liked", selectedSecret.liked ? "true" : "false") + resultRow("Created", selectedSecret.createdAt.formatted(date: .numeric, time: .standard)) + resultRow("Updated", selectedSecret.updatedAt.formatted(date: .numeric, time: .standard)) + resultRow("Memo", selectedSecret.memo ?? "-") + resultRow("Encrypted bytes", "\(selectedSecret.payload.encryptedData.count)") + resultRow("Payload", revealedPayload?.value ?? "Hidden") + } + .font(.callout) + } else { + Text("Select a secret from the list.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } + + var selectedSecretBinding: Binding { + Binding( + get: { selectedSecret?.id }, + set: { id in + selectedSecret = secrets.first { $0.id == id } + revealedPayload = nil + } + ) + } + + func resultRow(_ title: String, _ value: String) -> some View { + GridRow { + Text(title) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .leading) + Text(value) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + func createDemoSecret() async { + await run("Creating secret...") { + let timestamp = Int(Date().timeIntervalSince1970) + let draft = SecretDraft( + name: "Demo Secret \(timestamp)", + secretType: "apiKey", + service: "github", + environment: "development", + memo: "Created from temporary Presentation UseCase demo", + liked: true + ) + let payload = APIKeyPayload(value: "demo-api-key-\(timestamp)") + let metadata = APIKeyMetadata(scope: "repo") + + let created = try await createSecretUseCase.execute( + draft: draft, + payload: payload, + metadata: metadata + ) + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + selectedSecret = try await fetchSecretUseCase.fetch(id: created.id) + revealedPayload = nil + statusMessage = "Created demo secret" + } + } + + func revealSelectedPayload() async { + guard let id = selectedSecret?.id else { return } + + await run("Revealing payload...") { + revealedPayload = try await fetchSecretUseCase.revealPayload(id: id, as: APIKeyPayload.self) + statusMessage = "Payload revealed" + } + } + + func fetchAllSecrets() async { + await run("Fetching secrets...") { + secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + if let selectedID = selectedSecret?.id { + selectedSecret = secrets.first { $0.id == selectedID } + } else { + selectedSecret = secrets.first + } + revealedPayload = nil + statusMessage = "Fetched \(secrets.count) secret(s)" + } + } + + func run(_ message: String, operation: () async throws -> Void) async { + isRunning = true + statusMessage = message + + do { + try await operation() + } catch { + statusMessage = "Failed: \(error)" + } + + isRunning = false + } +} diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index 2072de6..b400b1a 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -1,12 +1,24 @@ +import DVData +import DVDomain +import DVPresentation import SwiftUI struct ContentView: View { + private let repository = SecretRepositoryImpl( + modelContainer: LocalStorage.shared.modelContainer + ) + private let cryptoService = SecretCryptoServiceImpl() + var body: some View { - VStack(spacing: 16) { - Text("Devault") - .font(.largeTitle) - .fontWeight(.bold) - } - .frame(width: 400, height: 300) + SecretUseCaseDemoView( + createSecretUseCase: CreateSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService + ), + fetchSecretUseCase: FetchSecretUseCaseImpl( + repository: repository, + cryptoService: cryptoService + ) + ) } } From ef5a45283064482e6e57a3153a879f2aee140c87 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:24:46 +0900 Subject: [PATCH 56/60] =?UTF-8?q?[#12]=20feat:=20UserAuthenticationService?= =?UTF-8?q?=20protocol,=20=EA=B5=AC=ED=98=84=EC=B2=B4,=20error=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocalUserAuthenticationServiceImpl.swift | 45 +++++++++++++++++++ .../Error/UserAuthenticationError.swift | 7 +++ .../Interface/UserAuthenticationService.swift | 6 +++ 3 files changed, 58 insertions(+) create mode 100644 Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift create mode 100644 Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift create mode 100644 Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift diff --git a/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift new file mode 100644 index 0000000..ed195c8 --- /dev/null +++ b/Projects/DVData/Sources/ServiceImpl/Authentication/LocalUserAuthenticationServiceImpl.swift @@ -0,0 +1,45 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import LocalAuthentication + +public struct LocalUserAuthenticationServiceImpl: UserAuthenticationService { + /// LocalAuthentication 기반 사용자 인증 구현체를 생성한다. + public init() {} + + /// Touch ID 또는 시스템 암호로 현재 사용자를 인증한다. + public func authenticate(reason: String) async throws { + let context = LAContext() + context.touchIDAuthenticationAllowableReuseDuration = 0 + + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + throw UserAuthenticationError.unavailable + } + + try await withCheckedThrowingContinuation { continuation in + context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) { success, error in + if success { + continuation.resume() + return + } + + if let error = error as? LAError { + switch error.code { + case .userCancel, .systemCancel, .appCancel: + continuation.resume(throwing: UserAuthenticationError.cancelled) + default: + continuation.resume(throwing: UserAuthenticationError.failed) + } + return + } + + continuation.resume(throwing: UserAuthenticationError.failed) + } + } + } +} diff --git a/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift new file mode 100644 index 0000000..eb3da8b --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Error/UserAuthenticationError.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum UserAuthenticationError: Error, Equatable, Sendable { + case unavailable + case cancelled + case failed +} diff --git a/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift new file mode 100644 index 0000000..dab2db4 --- /dev/null +++ b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift @@ -0,0 +1,6 @@ +// Copyright © 2026 Devault. All rights reserved + +/// 현재 사용자가 민감 작업을 수행할 수 있는지 로컬 인증으로 확인하는 서비스입니다. +public protocol UserAuthenticationService: Sendable { + func authenticate(reason: String) async throws +} From 5b800ba40498e45faa5b9b50319c0a5c8f52d213 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:25:40 +0900 Subject: [PATCH 57/60] =?UTF-8?q?[#12]=20feat:=20payload=20reveal=EC=8B=9C?= =?UTF-8?q?=20authenticate=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B1=B0?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift | 5 +++++ .../UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift index 0dd79f8..5a8a30f 100644 --- a/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift +++ b/Projects/DVDomain/Sources/UseCase/Error/SecretUseCaseError.swift @@ -8,6 +8,7 @@ public enum SecretUseCaseError: Error, Equatable, Sendable { case secretNotFound(id: UUID) case repositoryFailure(SecretRepositoryError) case cryptoFailure(SecretCryptoError) + case authenticationFailure(UserAuthenticationError) case unexpected } @@ -28,6 +29,10 @@ extension SecretUseCaseError { return .cryptoFailure(error) } + if let error = error as? UserAuthenticationError { + return .authenticationFailure(error) + } + return .unexpected } } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index 260f2bb..0675823 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -5,13 +5,16 @@ import Foundation public struct FetchSecretUseCaseImpl: FetchSecretUseCase { private let repository: any SecretRepository private let cryptoService: any SecretCryptoService + private let authenticationService: any UserAuthenticationService public init( repository: any SecretRepository, - cryptoService: any SecretCryptoService + cryptoService: any SecretCryptoService, + authenticationService: any UserAuthenticationService ) { self.repository = repository self.cryptoService = cryptoService + self.authenticationService = authenticationService } public func fetch(id: UUID) async throws -> Secret? { @@ -38,6 +41,7 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { guard let secret = try await repository.fetch(id: id) else { throw SecretUseCaseError.secretNotFound(id: id) } + try await authenticationService.authenticate(reason: "Reveal secret payload") return try await cryptoService.decryptPayload(secret.payload, as: type) } catch { throw SecretUseCaseError.map(error) From 7091064a4b7bdf8621ce32951e273db787f07814 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:26:23 +0900 Subject: [PATCH 58/60] =?UTF-8?q?[#12]=20refactor:=20KeychainKeyStore=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F.=20keychainFailure=20=EC=97=90=EB=9F=AC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Security/Keychain/KeychainKeyStore.swift | 32 +++++++++++-------- .../Service/Error/SecretCryptoError.swift | 1 + 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift index c065ea0..2354c04 100644 --- a/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift +++ b/Projects/DVData/Sources/ServiceImpl/Security/Keychain/KeychainKeyStore.swift @@ -23,7 +23,7 @@ struct KeychainKeyStore: Sendable { try saveKeyData(data, tag: tag) return SymmetricKey(data: data) } - + /// tag에 해당하는 symmetric key를 조회하고, 없으면 keyUnavailable 오류를 던진다. func getSymmetricKey(tag: String) throws -> SymmetricKey { guard let data = try loadKeyData(tag: tag) else { @@ -37,13 +37,12 @@ struct KeychainKeyStore: Sendable { extension KeychainKeyStore { /// Keychain에서 tag에 해당하는 raw key Data를 조회한다. private func loadKeyData(tag: String) throws -> Data? { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: tag, + var query = keyQuery(tag: tag) + let attributes: [CFString: Any] = [ kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] + attributes.forEach { query[$0.key] = $0.value } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -54,7 +53,7 @@ extension KeychainKeyStore { } guard status == errSecSuccess, let data = result as? Data else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: status) } return data @@ -62,15 +61,11 @@ extension KeychainKeyStore { /// raw key Data를 Keychain에 저장하고, 기존 item이 있으면 갱신한다. private func saveKeyData(_ data: Data, tag: String) throws { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: tag, - ] + let query = keyQuery(tag: tag) let attributes: [CFString: Any] = [ kSecValueData: data, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] var addQuery = query @@ -81,16 +76,25 @@ extension KeychainKeyStore { if status == errSecDuplicateItem { let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) guard updateStatus == errSecSuccess else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: updateStatus) } return } guard status == errSecSuccess else { - throw SecretCryptoError.keyUnavailable + throw SecretCryptoError.keychainFailure(status: status) } } + /// service와 tag로 Keychain key item을 식별하는 기본 query를 만든다. + private func keyQuery(tag: String) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: tag, + ] + } + /// AES-256 symmetric key로 사용할 32바이트 랜덤 Data를 생성한다. private func generateKeyData() throws -> Data { var bytes = [UInt8](repeating: 0, count: 32) diff --git a/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift index 10e28eb..fa5a179 100644 --- a/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift +++ b/Projects/DVDomain/Sources/Service/Error/SecretCryptoError.swift @@ -2,6 +2,7 @@ public enum SecretCryptoError: Error, Equatable, Sendable { case keyUnavailable + case keychainFailure(status: Int32) case encryptionFailed case decryptionFailed case encodingFailed From 5c87deaffa6402b17ac9bc787fd5d88a0f9f3640 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sat, 23 May 2026 23:26:59 +0900 Subject: [PATCH 59/60] =?UTF-8?q?[#12]=20feat:=20ContentView=EC=9D=98=20Se?= =?UTF-8?q?cretUseCaseDemoView=EC=97=90=EC=84=9C=20=EB=B0=9B=EB=8A=94=20us?= =?UTF-8?q?eCase=EC=97=90=20authenticationService=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Devault/Sources/ContentView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index b400b1a..847e9e4 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -8,6 +8,7 @@ struct ContentView: View { modelContainer: LocalStorage.shared.modelContainer ) private let cryptoService = SecretCryptoServiceImpl() + private let authenticationService = LocalUserAuthenticationServiceImpl() var body: some View { SecretUseCaseDemoView( @@ -17,7 +18,8 @@ struct ContentView: View { ), fetchSecretUseCase: FetchSecretUseCaseImpl( repository: repository, - cryptoService: cryptoService + cryptoService: cryptoService, + authenticationService: authenticationService ) ) } From 9faeab4de1219889cc463cb041756c731cea934b Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Sun, 24 May 2026 00:02:03 +0900 Subject: [PATCH 60/60] =?UTF-8?q?[#12]=20fix:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20=EC=A1=B0=ED=9A=8C/=EB=B3=B5=ED=98=B8=ED=99=94=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index 0675823..d08aead 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -38,10 +38,10 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { as type: Payload.Type ) async throws -> Payload { do { + try await authenticationService.authenticate(reason: "Reveal secret payload") guard let secret = try await repository.fetch(id: id) else { throw SecretUseCaseError.secretNotFound(id: id) } - try await authenticationService.authenticate(reason: "Reveal secret payload") return try await cryptoService.decryptPayload(secret.payload, as: type) } catch { throw SecretUseCaseError.map(error)