diff --git a/Sources/Clavis/Components/LibrarySidebarComponents.swift b/Sources/Clavis/Components/LibrarySidebarComponents.swift index 9c9a343..92b33b8 100644 --- a/Sources/Clavis/Components/LibrarySidebarComponents.swift +++ b/Sources/Clavis/Components/LibrarySidebarComponents.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI import ClavisCore -/// Presentation derived from the existing purpose until capability metadata migrates. +/// Presentation mirrors key metadata; private records remain authoritative. /// This does not grant access or alter the daemon's authorization decisions. struct LibraryKeyPresentation: Equatable { let typeTitle: String @@ -10,9 +10,13 @@ struct LibraryKeyPresentation: Equatable { let capabilities: [String] init(purpose: KeyPurpose) { - typeTitle = purpose == .agent ? "Bot" : "Personal" - symbol = purpose == .agent ? "desktopcomputer" : "person" - capabilities = purpose == .gitSigningOnly ? ["SSHSIG"] : ["SSH", "SSHSIG"] + self.init(profile: KeyAccessProfile(legacyPurpose: purpose)) + } + + init(profile: KeyAccessProfile) { + typeTitle = profile.type.title + symbol = profile.type == .bot ? "desktopcomputer" : "person" + capabilities = profile.capabilities.titles } var accessibilitySummary: String { diff --git a/Sources/Clavis/Views/KeyListView.swift b/Sources/Clavis/Views/KeyListView.swift index 0522d39..5ea95b6 100644 --- a/Sources/Clavis/Views/KeyListView.swift +++ b/Sources/Clavis/Views/KeyListView.swift @@ -552,7 +552,7 @@ struct KeySidebarRowView: View { } var body: some View { - let presentation = LibraryKeyPresentation(purpose: key.purpose) + let presentation = LibraryKeyPresentation(profile: key.effectiveAccessProfile) HStack(spacing: 10) { Image(systemName: presentation.symbol) .font(.system(size: 17)) @@ -588,7 +588,7 @@ struct KeySidebarRowView: View { .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .accessibilityElement(children: .combine) .accessibilityLabel(key.label) - .accessibilityValue(LibraryKeyPresentation(purpose: key.purpose).accessibilitySummary) + .accessibilityValue(LibraryKeyPresentation(profile: key.effectiveAccessProfile).accessibilitySummary) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) .accessibilityAction { onSelect() diff --git a/Sources/ClavisCore/Crypto/KeychainManager.swift b/Sources/ClavisCore/Crypto/KeychainManager.swift index f68fa58..445866e 100644 --- a/Sources/ClavisCore/Crypto/KeychainManager.swift +++ b/Sources/ClavisCore/Crypto/KeychainManager.swift @@ -341,6 +341,10 @@ public class KeychainManager { ) } + guard record.accessProfile == key.effectiveAccessProfile else { + throw KeyAccessError.profileMismatch + } + // The authentication policy used to prompt the user was chosen from the public index, which is an // unauthenticated display cache. Refuse to proceed if the authoritative record disagrees, // so a tampered index can only cause a refusal, never a weaker effective policy. @@ -456,6 +460,11 @@ public class KeychainManager { if let keyPurposeError = validationError as? KeyPurposeError { throw keyPurposeError } + // Policy disagreement is not record corruption. A stale backup must + // never restore broader capabilities to satisfy the public cache. + if let accessError = validationError as? KeyAccessError { + throw accessError + } ClavisLogger.log( "SECURITY_ALERT", "Keychain record validation failed for '\(label)': \(validationError.localizedDescription). Attempting restore from encrypted shadow vault." @@ -647,6 +656,7 @@ public class KeychainManager { } let isGitSigningRequest = SSHSIGPayload.parse(from: data) != nil + let operation: KeyOperation = isGitSigningRequest ? .signature : .ssh if key.purpose == .gitSigningOnly && !isGitSigningRequest { throw PrivateKeyRecordError.purposeNotAllowed( purpose: key.purpose.rawValue, @@ -654,6 +664,7 @@ public class KeychainManager { ) } + try key.effectiveAccessProfile.require(operation) if existingContext == nil { ClavisLogger.promptDebug("clavis-code", "KeychainManager: prompting for SSH signature '\(key.label)' (storage: \(key.storageType.rawValue), policy: \(key.biometricPolicy?.rawValue ?? "default")): \"\(prompt)\"") } @@ -665,6 +676,7 @@ public class KeychainManager { invalidateContextOnMismatch: false, auditOnAuthFailure: false ) { context, record in + try record.accessProfile.require(operation) guard allowedPurposes.contains(record.purpose) else { throw KeyPurposeError.notAllowedOnThisPath(record.purpose) } @@ -814,12 +826,12 @@ public class KeychainManager { } var updatedRecord = StoredPrivateKeyRecord( - version: record.version, label: record.label, algorithm: record.algorithm, storageType: record.storageType, biometricPolicy: record.biometricPolicy, keyPurpose: newPurpose, + accessProfile: record.accessProfile.changingType(to: newPurpose == .agent ? .bot : .personal), keyData: record.keyData, createdAt: record.createdAt ) @@ -851,7 +863,8 @@ public class KeychainManager { algorithmName: key.algorithmName, storage: key.storageType, biometricPolicy: key.biometricPolicy, - keyPurpose: newPurpose + keyPurpose: newPurpose, + accessProfile: updatedRecord.accessProfile ) do { @@ -937,6 +950,7 @@ public class KeychainManager { storageType: record.storageType, biometricPolicy: record.biometricPolicy, keyPurpose: record.keyPurpose, + accessProfile: record.accessProfile, keyData: record.keyData, createdAt: record.createdAt ) @@ -977,7 +991,8 @@ public class KeychainManager { algorithmName: key.algorithmName, storage: key.storage, biometricPolicy: key.biometricPolicy, - keyPurpose: key.keyPurpose + keyPurpose: key.keyPurpose, + accessProfile: key.accessProfile ) do { @@ -1072,7 +1087,8 @@ public class KeychainManager { algorithmName: rec.algorithm.rawValue, storage: rec.storageType, biometricPolicy: rec.biometricPolicy ?? .userPresence, - keyPurpose: rec.keyPurpose + keyPurpose: rec.keyPurpose, + accessProfile: rec.accessProfile ) try PublicKeyStore.saveChecked(restoredInfo) repaired.append(.missingPublicCache(label: privLabel)) diff --git a/Sources/ClavisCore/Models/KeyAccessProfile.swift b/Sources/ClavisCore/Models/KeyAccessProfile.swift new file mode 100644 index 0000000..54dd7bb --- /dev/null +++ b/Sources/ClavisCore/Models/KeyAccessProfile.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Key ownership is independent of the protocols the key may sign for. +public enum KeyType: String, Codable, CaseIterable, Sendable { + case personal + case bot + + public var title: String { self == .personal ? "Personal" : "Bot" } +} + +public enum KeyOperation: String, Codable, CaseIterable, Sendable { + case ssh + case signature +} + +public struct KeyCapabilities: Codable, Equatable, Sendable { + public let ssh: Bool + public let signature: Bool + + public init(ssh: Bool, signature: Bool) { + self.ssh = ssh + self.signature = signature + } + + public func permits(_ operation: KeyOperation) -> Bool { + switch operation { + case .ssh: return ssh + case .signature: return signature + } + } + + public var titles: [String] { + (ssh ? ["SSH"] : []) + (signature ? ["SSHSIG"] : []) + } +} + +/// Persisted in the authenticated private record, mirrored in the display cache. +/// Legacy purpose checks remain an additional constraint during rollout. +public struct KeyAccessProfile: Codable, Equatable, Sendable { + public let type: KeyType + public let capabilities: KeyCapabilities + + public init(type: KeyType, capabilities: KeyCapabilities) { + self.type = type + self.capabilities = capabilities + } + + public init(legacyPurpose: KeyPurpose) { + type = legacyPurpose == .agent ? .bot : .personal + capabilities = KeyCapabilities(ssh: legacyPurpose != .gitSigningOnly, signature: legacyPurpose != .agent) + } + + public func changingType(to type: KeyType) -> KeyAccessProfile { + KeyAccessProfile(type: type, capabilities: capabilities) + } + + public func require(_ operation: KeyOperation) throws { + guard capabilities.permits(operation) else { + throw KeyAccessError.operationDisabled(operation) + } + } +} + +public enum KeyAccessError: LocalizedError, Equatable { + case operationDisabled(KeyOperation) + case profileMismatch + case legacyEncodingWouldLosePolicy + + public var errorDescription: String? { + switch self { + case .operationDisabled(let operation): + return "This key does not permit \(operation == .ssh ? "SSH" : "Signature") requests." + case .profileMismatch: + return "Key policy differs from its authenticated record. Operation refused." + case .legacyEncodingWouldLosePolicy: + return "This key policy requires the current private-record format." + } + } +} diff --git a/Sources/ClavisCore/Models/KeyModels.swift b/Sources/ClavisCore/Models/KeyModels.swift index 3b6fb51..2a5cae7 100644 --- a/Sources/ClavisCore/Models/KeyModels.swift +++ b/Sources/ClavisCore/Models/KeyModels.swift @@ -13,6 +13,11 @@ public struct SSHKeyInfo: Identifiable, Codable, Equatable { public let storage: KeyStorageType? public let biometricPolicy: BiometricPolicy? public let keyPurpose: KeyPurpose? + public let accessProfile: KeyAccessProfile? + + public var effectiveAccessProfile: KeyAccessProfile { + accessProfile ?? KeyAccessProfile(legacyPurpose: purpose) + } public var purpose: KeyPurpose { keyPurpose ?? .general @@ -59,7 +64,8 @@ public struct SSHKeyInfo: Identifiable, Codable, Equatable { algorithmName: String? = nil, storage: KeyStorageType? = nil, biometricPolicy: BiometricPolicy? = nil, - keyPurpose: KeyPurpose? = nil + keyPurpose: KeyPurpose? = nil, + accessProfile: KeyAccessProfile? = nil ) { self.label = label self.publicKeyOpenSSH = publicKeyOpenSSH @@ -70,6 +76,7 @@ public struct SSHKeyInfo: Identifiable, Codable, Equatable { self.storage = storage self.biometricPolicy = biometricPolicy self.keyPurpose = keyPurpose + self.accessProfile = accessProfile } } diff --git a/Sources/ClavisCore/Models/PrivateKeyRecord.swift b/Sources/ClavisCore/Models/PrivateKeyRecord.swift index 9072f2e..d54b74f 100644 --- a/Sources/ClavisCore/Models/PrivateKeyRecord.swift +++ b/Sources/ClavisCore/Models/PrivateKeyRecord.swift @@ -8,7 +8,7 @@ import Security /// biometric policy, and private key payload. The public index (`PublicKeyStore`, an in-memory /// copy of unauthenticated Keychain metadata items) is treated strictly as a display cache. public struct StoredPrivateKeyRecord: Codable, Equatable { - public static let currentVersion: Int = 2 + public static let currentVersion: Int = 3 private static let binaryMagic = Data("CLVPKR02".utf8) private static let maximumLabelBytes = 1_024 private static let maximumKeyBytes = 65_536 @@ -18,6 +18,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { public let algorithm: KeyAlgorithm public let storageType: KeyStorageType public let biometricPolicy: BiometricPolicy? + public let accessProfile: KeyAccessProfile public let keyPurpose: KeyPurpose public var keyData: Data public let createdAt: Date @@ -27,7 +28,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { } private enum CodingKeys: String, CodingKey { - case version, label, algorithm, storageType, biometricPolicy, keyPurpose, keyData, createdAt + case version, label, algorithm, storageType, biometricPolicy, keyPurpose, accessProfile, keyData, createdAt } public init( @@ -37,6 +38,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { storageType: KeyStorageType, biometricPolicy: BiometricPolicy? = nil, keyPurpose: KeyPurpose, + accessProfile: KeyAccessProfile? = nil, keyData: Data, createdAt: Date = Date() ) { @@ -46,6 +48,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { self.storageType = storageType self.biometricPolicy = biometricPolicy self.keyPurpose = keyPurpose + self.accessProfile = accessProfile ?? KeyAccessProfile(legacyPurpose: keyPurpose) self.keyData = keyData self.createdAt = createdAt } @@ -58,6 +61,11 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { self.storageType = try container.decode(KeyStorageType.self, forKey: .storageType) self.biometricPolicy = try container.decodeIfPresent(BiometricPolicy.self, forKey: .biometricPolicy) self.keyPurpose = try container.decodeIfPresent(KeyPurpose.self, forKey: .keyPurpose) ?? .general + if version >= 3 { + self.accessProfile = try container.decode(KeyAccessProfile.self, forKey: .accessProfile) + } else { + self.accessProfile = KeyAccessProfile(legacyPurpose: keyPurpose) + } self.keyData = try container.decode(Data.self, forKey: .keyData) self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } @@ -70,6 +78,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { try container.encode(storageType, forKey: .storageType) try container.encodeIfPresent(biometricPolicy, forKey: .biometricPolicy) try container.encode(keyPurpose, forKey: .keyPurpose) + try container.encode(accessProfile, forKey: .accessProfile) try container.encode(keyData, forKey: .keyData) try container.encode(createdAt, forKey: .createdAt) } @@ -85,9 +94,12 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { } public func encode() throws -> Data { - guard version == Self.currentVersion else { + guard version == 2 || version == Self.currentVersion else { throw PrivateKeyRecordError.unsupportedVersion(version) } + if version == 2 && accessProfile != KeyAccessProfile(legacyPurpose: purpose) { + throw KeyAccessError.legacyEncodingWouldLosePolicy + } guard algorithm == .ecdsaP256 else { throw PrivateKeyRecordError.corruptedRecord("Unsupported algorithm: only ECDSA P-256 is supported") } @@ -118,6 +130,10 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { case .userPresence: output.append(1) case .biometryCurrentSet: output.append(2) } + if version >= 3 { + output.append(accessProfile.type == .personal ? 1 : 2) + output.append((accessProfile.capabilities.ssh ? 1 : 0) | (accessProfile.capabilities.signature ? 2 : 0)) + } var timestamp = createdAt.timeIntervalSince1970.bitPattern.bigEndian Swift.withUnsafeBytes(of: ×tamp) { output.append(contentsOf: $0) } var labelLength = UInt16(labelData.count).bigEndian @@ -141,14 +157,29 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { guard let version = reader.readUInt8() else { throw PrivateKeyRecordError.corruptedRecord("Malformed binary private-key record") } - guard Int(version) == currentVersion else { + guard version == 2 || Int(version) == currentVersion else { throw PrivateKeyRecordError.unsupportedVersion(Int(version)) } guard let algorithmByte = reader.readUInt8(), let storageByte = reader.readUInt8(), let purposeByte = reader.readUInt8(), - let biometricByte = reader.readUInt8(), - let timestampBits = reader.readUInt64(), + let biometricByte = reader.readUInt8() else { + throw PrivateKeyRecordError.corruptedRecord("Malformed binary private-key record") + } + let profile: KeyAccessProfile? + if version >= 3 { + guard let typeByte = reader.readUInt8(), (1...2).contains(typeByte), + let bits = reader.readUInt8(), bits & ~3 == 0 else { + throw PrivateKeyRecordError.corruptedRecord("Unknown key type or capability identifier") + } + profile = KeyAccessProfile( + type: typeByte == 1 ? .personal : .bot, + capabilities: KeyCapabilities(ssh: bits & 1 != 0, signature: bits & 2 != 0) + ) + } else { + profile = nil + } + guard let timestampBits = reader.readUInt64(), let labelLength = reader.readUInt16(), Int(labelLength) <= maximumLabelBytes, let labelData = reader.readData(count: Int(labelLength)), @@ -193,6 +224,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable { storageType: storage, biometricPolicy: biometric, keyPurpose: purpose, + accessProfile: profile, keyData: keyData, createdAt: Date(timeIntervalSince1970: TimeInterval(bitPattern: timestampBits)) ) diff --git a/Tests/ClavisAppTests/LibrarySidebarTests.swift b/Tests/ClavisAppTests/LibrarySidebarTests.swift index 7c5f995..55d95ed 100644 --- a/Tests/ClavisAppTests/LibrarySidebarTests.swift +++ b/Tests/ClavisAppTests/LibrarySidebarTests.swift @@ -17,8 +17,9 @@ struct LibrarySidebarTests { let bot = LibraryKeyPresentation(purpose: .agent) #expect(personal.typeTitle == "Personal") #expect(bot.typeTitle == "Bot") - #expect(personal.capabilities == bot.capabilities) - #expect(bot.accessibilitySummary == "Bot, SSH, SSHSIG") + #expect(personal.capabilities == ["SSH", "SSHSIG"]) + #expect(bot.capabilities == ["SSH"]) + #expect(bot.accessibilitySummary == "Bot, SSH") } @Test func nativeSearchUpdatesBindingAndCancelClearsBeforeBlurring() { diff --git a/Tests/ClavisCoreTests/KeyAccessProfileTests.swift b/Tests/ClavisCoreTests/KeyAccessProfileTests.swift new file mode 100644 index 0000000..9d994ed --- /dev/null +++ b/Tests/ClavisCoreTests/KeyAccessProfileTests.swift @@ -0,0 +1,129 @@ +import Foundation +import LocalAuthentication +import Testing +@testable import ClavisCore +@testable import ClavisTestSupport + +@Suite struct KeyAccessProfileTests { + private func record( + version: Int = StoredPrivateKeyRecord.currentVersion, + purpose: KeyPurpose = .general, + profile: KeyAccessProfile? = nil + ) -> StoredPrivateKeyRecord { + StoredPrivateKeyRecord( + version: version, label: "migration", algorithm: .ecdsaP256, + storageType: .secureEnclave, biometricPolicy: .userPresence, + keyPurpose: purpose, accessProfile: profile, + keyData: Data([0x42, 0x24]), createdAt: Date(timeIntervalSince1970: 123) + ) + } + + @Test(arguments: KeyPurpose.allCases) + func legacyMigrationPreservesRestrictions(_ purpose: KeyPurpose) throws { + // A v2 envelope has no profile bytes. Decode without rewriting key material. + let legacy = record(version: 2, purpose: purpose) + let bytes = try legacy.encode() + #expect(bytes[8] == 2) + let decoded = try StoredPrivateKeyRecord.decode(from: bytes) + #expect(decoded.keyData == legacy.keyData) + #expect(decoded.createdAt == legacy.createdAt) + #expect(decoded.biometricPolicy == legacy.biometricPolicy) + #expect(decoded.accessProfile.type == (purpose == .agent ? .bot : .personal)) + #expect(decoded.accessProfile.capabilities.ssh == (purpose != .gitSigningOnly)) + #expect(decoded.accessProfile.capabilities.signature == (purpose != .agent)) + #expect(try decoded.encode() == bytes) + } + + @Test(arguments: [false, true], [false, true]) + func independentCapabilitiesRoundTrip(ssh: Bool, signature: Bool) throws { + for type in KeyType.allCases { + let profile = KeyAccessProfile(type: type, capabilities: .init(ssh: ssh, signature: signature)) + let original = record(profile: profile) + let decoded = try StoredPrivateKeyRecord.decode(from: original.encode()) + #expect(decoded == original) + #expect(decoded.accessProfile == profile) + #expect(profile.capabilities.permits(.ssh) == ssh) + #expect(profile.capabilities.permits(.signature) == signature) + #expect(try JSONDecoder().decode(StoredPrivateKeyRecord.self, from: JSONEncoder().encode(original)) == original) + } + } + + @Test func invalidProfileBytesAndTruncationFailClosed() throws { + let valid = try record().encode() + for offset in [13, 14] { + var invalid = valid + invalid[offset] = 0xff + #expect(throws: (any Error).self) { try StoredPrivateKeyRecord.decode(from: invalid) } + } + for end in 0..