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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Sources/Clavis/Components/LibrarySidebarComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ 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
let symbol: String
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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/Clavis/Views/KeyListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down
24 changes: 20 additions & 4 deletions Sources/ClavisCore/Crypto/KeychainManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -647,13 +656,15 @@ 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,
operation: "non-Git SSH signing"
)
}

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)\"")
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -851,7 +863,8 @@ public class KeychainManager {
algorithmName: key.algorithmName,
storage: key.storageType,
biometricPolicy: key.biometricPolicy,
keyPurpose: newPurpose
keyPurpose: newPurpose,
accessProfile: updatedRecord.accessProfile
)

do {
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
79 changes: 79 additions & 0 deletions Sources/ClavisCore/Models/KeyAccessProfile.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
9 changes: 8 additions & 1 deletion Sources/ClavisCore/Models/KeyModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -70,6 +76,7 @@ public struct SSHKeyInfo: Identifiable, Codable, Equatable {
self.storage = storage
self.biometricPolicy = biometricPolicy
self.keyPurpose = keyPurpose
self.accessProfile = accessProfile
}
}

Expand Down
44 changes: 38 additions & 6 deletions Sources/ClavisCore/Models/PrivateKeyRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -37,6 +38,7 @@ public struct StoredPrivateKeyRecord: Codable, Equatable {
storageType: KeyStorageType,
biometricPolicy: BiometricPolicy? = nil,
keyPurpose: KeyPurpose,
accessProfile: KeyAccessProfile? = nil,
keyData: Data,
createdAt: Date = Date()
) {
Expand All @@ -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
}
Expand All @@ -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()
}
Expand All @@ -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)
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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: &timestamp) { output.append(contentsOf: $0) }
var labelLength = UInt16(labelData.count).bigEndian
Expand All @@ -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)),
Expand Down Expand Up @@ -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))
)
Expand Down
5 changes: 3 additions & 2 deletions Tests/ClavisAppTests/LibrarySidebarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading