mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 04:59:05 +08:00
keychain: add remote keychain browser via vphoned (#169)
Co-authored-by: rezk <rezk> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -443,6 +443,7 @@ VPHONED_SRCS=(
|
||||
"$VPHONED_SRC/vphoned_devmode.m"
|
||||
"$VPHONED_SRC/vphoned_location.m"
|
||||
"$VPHONED_SRC/vphoned_files.m"
|
||||
"$VPHONED_SRC/vphoned_keychain.m"
|
||||
)
|
||||
needs_vphoned_build=0
|
||||
if [[ ! -f "$VPHONED_BIN" ]]; then
|
||||
@@ -462,6 +463,7 @@ if [[ "$needs_vphoned_build" == "1" ]]; then
|
||||
-I"$VPHONED_SRC/vendor/libarchive" \
|
||||
-o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \
|
||||
-larchive \
|
||||
-lsqlite3 \
|
||||
-framework Foundation \
|
||||
-framework Security \
|
||||
-framework CoreServices
|
||||
|
||||
@@ -445,6 +445,7 @@ VPHONED_SRCS=(
|
||||
"$VPHONED_SRC/vphoned_devmode.m"
|
||||
"$VPHONED_SRC/vphoned_location.m"
|
||||
"$VPHONED_SRC/vphoned_files.m"
|
||||
"$VPHONED_SRC/vphoned_keychain.m"
|
||||
)
|
||||
needs_vphoned_build=0
|
||||
if [[ ! -f "$VPHONED_BIN" ]]; then
|
||||
@@ -464,6 +465,7 @@ if [[ "$needs_vphoned_build" == "1" ]]; then
|
||||
-I"$VPHONED_SRC/vendor/libarchive" \
|
||||
-o "$VPHONED_BIN" "${VPHONED_SRCS[@]}" \
|
||||
-larchive \
|
||||
-lsqlite3 \
|
||||
-framework Foundation \
|
||||
-framework Security \
|
||||
-framework CoreServices
|
||||
|
||||
@@ -18,6 +18,7 @@ $(OUT): $(SRCS) $(wildcard *.h)
|
||||
-DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \
|
||||
-o $@ $(SRCS) \
|
||||
-larchive \
|
||||
-lsqlite3 \
|
||||
-framework Foundation \
|
||||
-framework Security \
|
||||
-framework CoreServices
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#import "vphoned_location.h"
|
||||
#import "vphoned_files.h"
|
||||
#import "vphoned_install.h"
|
||||
#import "vphoned_keychain.h"
|
||||
|
||||
#ifndef AF_VSOCK
|
||||
#define AF_VSOCK 40
|
||||
@@ -254,7 +255,7 @@ static BOOL handle_client(int fd) {
|
||||
}
|
||||
|
||||
// Build capabilities list
|
||||
NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", nil];
|
||||
NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil];
|
||||
if (vp_location_available()) [caps addObject:@"location"];
|
||||
if (vp_custom_installer_available()) [caps addObject:@"ipa_install"];
|
||||
|
||||
@@ -301,6 +302,13 @@ static BOOL handle_client(int fd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keychain operations
|
||||
if ([t hasPrefix:@"keychain_"]) {
|
||||
NSDictionary *resp = vp_handle_keychain_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
NSDictionary *resp = handle_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp)) break;
|
||||
}
|
||||
|
||||
12
scripts/vphoned/vphoned_keychain.h
Normal file
12
scripts/vphoned/vphoned_keychain.h
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* vphoned_keychain — Remote keychain enumeration over vsock.
|
||||
*
|
||||
* Handles keychain_list: queries SecItemCopyMatching for all keychain
|
||||
* classes and returns attributes as JSON.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Handle a keychain command. Returns a response dict.
|
||||
NSDictionary *vp_handle_keychain_command(NSDictionary *msg);
|
||||
254
scripts/vphoned/vphoned_keychain.m
Normal file
254
scripts/vphoned/vphoned_keychain.m
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* vphoned_keychain — Remote keychain enumeration over vsock.
|
||||
*
|
||||
* Uses both SecItemCopyMatching (for items we're entitled to) and direct
|
||||
* sqlite3 access to /var/Keychains/keychain-2.db (for everything else).
|
||||
* The sqlite approach bypasses access-group entitlement checks entirely.
|
||||
*/
|
||||
|
||||
#import "vphoned_keychain.h"
|
||||
#import "vphoned_protocol.h"
|
||||
#import <Security/Security.h>
|
||||
#import <sqlite3.h>
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Convert a CFType keychain attribute value to a JSON-safe NSObject.
|
||||
static id safe_value(id val) {
|
||||
if (!val || val == (id)kCFNull) return [NSNull null];
|
||||
if ([val isKindOfClass:[NSString class]]) return val;
|
||||
if ([val isKindOfClass:[NSNumber class]]) return val;
|
||||
if ([val isKindOfClass:[NSDate class]]) {
|
||||
return @([(NSDate *)val timeIntervalSince1970]);
|
||||
}
|
||||
if ([val isKindOfClass:[NSData class]]) {
|
||||
NSString *str = [[NSString alloc] initWithData:val encoding:NSUTF8StringEncoding];
|
||||
if (str) return str;
|
||||
return [(NSData *)val base64EncodedStringWithOptions:0];
|
||||
}
|
||||
return [val description];
|
||||
}
|
||||
|
||||
// MARK: - SQLite-based keychain reader
|
||||
|
||||
static NSString *KEYCHAIN_DB_PATH = @"/var/Keychains/keychain-2.db";
|
||||
|
||||
/// Map sqlite table name to our class abbreviation.
|
||||
static NSDictionary *tableToClass(void) {
|
||||
return @{
|
||||
@"genp": @"genp",
|
||||
@"inet": @"inet",
|
||||
@"cert": @"cert",
|
||||
@"keys": @"keys",
|
||||
};
|
||||
}
|
||||
|
||||
/// Read a text column, returning @"" if NULL.
|
||||
static NSString *col_text(sqlite3_stmt *stmt, int col) {
|
||||
const unsigned char *val = sqlite3_column_text(stmt, col);
|
||||
if (!val) return @"";
|
||||
return [NSString stringWithUTF8String:(const char *)val];
|
||||
}
|
||||
|
||||
/// Read a blob column as base64 string.
|
||||
static NSString *col_blob_base64(sqlite3_stmt *stmt, int col) {
|
||||
const void *blob = sqlite3_column_blob(stmt, col);
|
||||
int size = sqlite3_column_bytes(stmt, col);
|
||||
if (!blob || size <= 0) return @"";
|
||||
NSData *data = [NSData dataWithBytes:blob length:size];
|
||||
// Try UTF-8 first
|
||||
NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
if (str) return str;
|
||||
return [data base64EncodedStringWithOptions:0];
|
||||
}
|
||||
|
||||
/// Query one table from the keychain DB via sqlite3.
|
||||
static NSArray *query_db_table(sqlite3 *db, NSString *table, NSString *className, NSMutableArray *diag) {
|
||||
// Columns available in genp/inet tables:
|
||||
// rowid, acct, svce, agrp, labl, data, cdat, mdat, desc, icmt, type, crtr, pdmn
|
||||
// inet also has: srvr, ptcl, port, path
|
||||
NSString *sql;
|
||||
BOOL isInet = [table isEqualToString:@"inet"];
|
||||
BOOL isCert = [table isEqualToString:@"cert"];
|
||||
BOOL isKeys = [table isEqualToString:@"keys"];
|
||||
|
||||
if (isInet) {
|
||||
sql = [NSString stringWithFormat:
|
||||
@"SELECT rowid, acct, svce, agrp, labl, data, cdat, mdat, pdmn, srvr, ptcl, port, path FROM %@", table];
|
||||
} else if (isCert || isKeys) {
|
||||
sql = [NSString stringWithFormat:
|
||||
@"SELECT rowid, agrp, labl, data, cdat, mdat, pdmn FROM %@", table];
|
||||
} else {
|
||||
sql = [NSString stringWithFormat:
|
||||
@"SELECT rowid, acct, svce, agrp, labl, data, cdat, mdat, pdmn FROM %@", table];
|
||||
}
|
||||
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, sql.UTF8String, -1, &stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
[diag addObject:[NSString stringWithFormat:@"%@: sqlite error %d", className, rc]];
|
||||
return @[];
|
||||
}
|
||||
|
||||
NSMutableArray *output = [NSMutableArray array];
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
|
||||
entry[@"class"] = className;
|
||||
|
||||
int col = 0;
|
||||
int rowid = sqlite3_column_int(stmt, col++);
|
||||
|
||||
if (!isCert && !isKeys) {
|
||||
entry[@"account"] = col_text(stmt, col++);
|
||||
entry[@"service"] = col_text(stmt, col++);
|
||||
}
|
||||
entry[@"accessGroup"] = col_text(stmt, col++);
|
||||
entry[@"label"] = col_text(stmt, col++);
|
||||
|
||||
// Value data
|
||||
const void *blob = sqlite3_column_blob(stmt, col);
|
||||
int blobSize = sqlite3_column_bytes(stmt, col);
|
||||
if (blob && blobSize > 0) {
|
||||
NSData *data = [NSData dataWithBytes:blob length:blobSize];
|
||||
NSString *str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
if (str) {
|
||||
entry[@"value"] = str;
|
||||
entry[@"valueEncoding"] = @"utf8";
|
||||
} else {
|
||||
entry[@"value"] = [data base64EncodedStringWithOptions:0];
|
||||
entry[@"valueEncoding"] = @"base64";
|
||||
}
|
||||
entry[@"valueSize"] = @(blobSize);
|
||||
}
|
||||
col++;
|
||||
|
||||
// Dates (stored as text in sqlite, e.g. "2025-01-15 12:34:56")
|
||||
NSString *cdat = col_text(stmt, col++);
|
||||
NSString *mdat = col_text(stmt, col++);
|
||||
if (cdat.length > 0) entry[@"createdStr"] = cdat;
|
||||
if (mdat.length > 0) entry[@"modifiedStr"] = mdat;
|
||||
|
||||
// Protection class (pdmn)
|
||||
NSString *pdmn = col_text(stmt, col++);
|
||||
if (pdmn.length > 0) entry[@"protection"] = pdmn;
|
||||
|
||||
// inet-specific fields
|
||||
if (isInet) {
|
||||
NSString *server = col_text(stmt, col++);
|
||||
if (server.length > 0) entry[@"server"] = server;
|
||||
NSString *protocol = col_text(stmt, col++);
|
||||
if (protocol.length > 0) entry[@"protocol"] = protocol;
|
||||
int port = sqlite3_column_int(stmt, col++);
|
||||
if (port > 0) entry[@"port"] = @(port);
|
||||
NSString *path = col_text(stmt, col++);
|
||||
if (path.length > 0) entry[@"path"] = path;
|
||||
}
|
||||
|
||||
// Use rowid for unique ID generation
|
||||
entry[@"_rowid"] = @(rowid);
|
||||
[output addObject:entry];
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
NSUInteger count = output.count;
|
||||
if (count > 0) {
|
||||
[diag addObject:[NSString stringWithFormat:@"%@: %lu rows", className, (unsigned long)count]];
|
||||
} else {
|
||||
[diag addObject:[NSString stringWithFormat:@"%@: empty", className]];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/// Read all keychain items directly from the sqlite database.
|
||||
static NSDictionary *query_keychain_db(NSString *filterClass, NSMutableArray *diag) {
|
||||
sqlite3 *db = NULL;
|
||||
int rc = sqlite3_open_v2(KEYCHAIN_DB_PATH.UTF8String, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
[diag addObject:[NSString stringWithFormat:@"db open failed: %d", rc]];
|
||||
NSLog(@"vphoned: sqlite3_open(%@) failed: %d", KEYCHAIN_DB_PATH, rc);
|
||||
return @{@"items": @[], @"diag": diag};
|
||||
}
|
||||
|
||||
[diag addObject:[NSString stringWithFormat:@"opened %@", KEYCHAIN_DB_PATH]];
|
||||
|
||||
NSMutableArray *allItems = [NSMutableArray array];
|
||||
|
||||
struct { NSString *table; NSString *name; } tables[] = {
|
||||
{ @"genp", @"genp" },
|
||||
{ @"inet", @"inet" },
|
||||
{ @"cert", @"cert" },
|
||||
{ @"keys", @"keys" },
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) {
|
||||
if (filterClass && ![filterClass isEqualToString:tables[i].name]) continue;
|
||||
NSArray *items = query_db_table(db, tables[i].table, tables[i].name, diag);
|
||||
[allItems addObjectsFromArray:items];
|
||||
}
|
||||
|
||||
sqlite3_close(db);
|
||||
return @{@"items": allItems};
|
||||
}
|
||||
|
||||
// MARK: - Command Handler
|
||||
|
||||
NSDictionary *vp_handle_keychain_command(NSDictionary *msg) {
|
||||
id reqId = msg[@"id"];
|
||||
NSString *type = msg[@"t"];
|
||||
|
||||
// Add a test keychain item (for debugging)
|
||||
if ([type isEqualToString:@"keychain_add"]) {
|
||||
NSString *account = msg[@"account"] ?: @"vphone-test";
|
||||
NSString *service = msg[@"service"] ?: @"vphone";
|
||||
NSString *password = msg[@"password"] ?: @"testpass123";
|
||||
|
||||
NSDictionary *deleteQuery = @{
|
||||
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
|
||||
(__bridge id)kSecAttrAccount: account,
|
||||
(__bridge id)kSecAttrService: service,
|
||||
};
|
||||
SecItemDelete((__bridge CFDictionaryRef)deleteQuery);
|
||||
|
||||
NSDictionary *attrs = @{
|
||||
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
|
||||
(__bridge id)kSecAttrAccount: account,
|
||||
(__bridge id)kSecAttrService: service,
|
||||
(__bridge id)kSecAttrLabel: [NSString stringWithFormat:@"%@ (%@)", service, account],
|
||||
(__bridge id)kSecValueData: [password dataUsingEncoding:NSUTF8StringEncoding],
|
||||
};
|
||||
|
||||
OSStatus status = SecItemAdd((__bridge CFDictionaryRef)attrs, NULL);
|
||||
NSLog(@"vphoned: keychain_add: account=%@ service=%@ status=%d", account, service, (int)status);
|
||||
|
||||
NSMutableDictionary *resp = vp_make_response(@"keychain_add", reqId);
|
||||
resp[@"status"] = @(status);
|
||||
resp[@"ok"] = @(status == errSecSuccess);
|
||||
if (status != errSecSuccess) {
|
||||
resp[@"msg"] = [NSString stringWithFormat:@"SecItemAdd failed: %d", (int)status];
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"keychain_list"]) {
|
||||
NSString *filterClass = msg[@"class"];
|
||||
NSMutableArray *diag = [NSMutableArray array];
|
||||
|
||||
// Primary: read directly from sqlite DB (bypasses entitlement checks)
|
||||
NSDictionary *dbResult = query_keychain_db(filterClass, diag);
|
||||
NSArray *dbItems = dbResult[@"items"];
|
||||
|
||||
NSLog(@"vphoned: keychain_list: %lu items (sqlite), diag: %@",
|
||||
(unsigned long)dbItems.count, diag);
|
||||
|
||||
NSMutableDictionary *resp = vp_make_response(@"keychain_list", reqId);
|
||||
resp[@"items"] = dbItems;
|
||||
resp[@"count"] = @(dbItems.count);
|
||||
resp[@"diag"] = diag;
|
||||
return resp;
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"unknown keychain command: %@", type];
|
||||
return r;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var windowController: VPhoneWindowController?
|
||||
private var menuController: VPhoneMenuController?
|
||||
private var fileWindowController: VPhoneFileWindowController?
|
||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
|
||||
@@ -126,6 +127,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
let fileWC = VPhoneFileWindowController()
|
||||
fileWindowController = fileWC
|
||||
|
||||
let keychainWC = VPhoneKeychainWindowController()
|
||||
keychainWindowController = keychainWC
|
||||
|
||||
let mc = VPhoneMenuController(keyHelper: keyHelper, control: control)
|
||||
mc.vm = vm
|
||||
mc.captureView = wc.captureView
|
||||
@@ -133,6 +137,10 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
guard let fileWC, let control else { return }
|
||||
fileWC.showWindow(control: control)
|
||||
}
|
||||
mc.onKeychainPressed = { [weak keychainWC, weak control] in
|
||||
guard let keychainWC, let control else { return }
|
||||
keychainWC.showWindow(control: control)
|
||||
}
|
||||
if let provider = locationProvider {
|
||||
mc.locationProvider = provider
|
||||
}
|
||||
|
||||
@@ -467,6 +467,35 @@ class VPhoneControl {
|
||||
return "Installed \(localURL.lastPathComponent) through the built-in IPA installer."
|
||||
}
|
||||
|
||||
// MARK: - Keychain Operations
|
||||
|
||||
struct KeychainResult {
|
||||
let items: [[String: Any]]
|
||||
let diagnostics: [String]
|
||||
}
|
||||
|
||||
func listKeychainItems(filterClass: String? = nil) async throws -> KeychainResult {
|
||||
var req: [String: Any] = ["t": "keychain_list"]
|
||||
if let filterClass { req["class"] = filterClass }
|
||||
let (resp, _) = try await sendRequest(req)
|
||||
guard let items = resp["items"] as? [[String: Any]] else {
|
||||
throw ControlError.protocolError("missing items in keychain response")
|
||||
}
|
||||
let diag = resp["diag"] as? [String] ?? []
|
||||
return KeychainResult(items: items, diagnostics: diag)
|
||||
}
|
||||
|
||||
func addKeychainItem(account: String = "vphone-test", service: String = "vphone", password: String = "testpass123") async throws -> Bool {
|
||||
let req: [String: Any] = ["t": "keychain_add", "account": account, "service": service, "password": password]
|
||||
let (resp, _) = try await sendRequest(req)
|
||||
let ok = resp["ok"] as? Bool ?? false
|
||||
if !ok {
|
||||
let msg = resp["msg"] as? String ?? "unknown error"
|
||||
throw ControlError.protocolError("keychain_add: \(msg)")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Location
|
||||
|
||||
func sendLocation(
|
||||
@@ -656,7 +685,7 @@ class VPhoneControl {
|
||||
switch type {
|
||||
case "file_get", "file_put", "ipa_install":
|
||||
transferRequestTimeout
|
||||
case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir":
|
||||
case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir", "keychain_list":
|
||||
slowRequestTimeout
|
||||
default:
|
||||
defaultRequestTimeout
|
||||
|
||||
102
sources/vphone-cli/VPhoneKeychainBrowserModel.swift
Normal file
102
sources/vphone-cli/VPhoneKeychainBrowserModel.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
class VPhoneKeychainBrowserModel {
|
||||
let control: VPhoneControl
|
||||
|
||||
var items: [VPhoneKeychainItem] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var diagnostics: [String] = []
|
||||
var searchText = ""
|
||||
var selection = Set<VPhoneKeychainItem.ID>()
|
||||
var sortOrder = [KeyPathComparator(\VPhoneKeychainItem.displayName)]
|
||||
var filterClass: String?
|
||||
var showDiagnostics = false
|
||||
|
||||
init(control: VPhoneControl) {
|
||||
self.control = control
|
||||
}
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var filteredItems: [VPhoneKeychainItem] {
|
||||
var list = items
|
||||
if let filterClass {
|
||||
list = list.filter { $0.itemClass == filterClass }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
let query = searchText.lowercased()
|
||||
list = list.filter {
|
||||
$0.account.lowercased().contains(query)
|
||||
|| $0.service.lowercased().contains(query)
|
||||
|| $0.label.lowercased().contains(query)
|
||||
|| $0.accessGroup.lowercased().contains(query)
|
||||
|| $0.server.lowercased().contains(query)
|
||||
|| $0.protection.lowercased().contains(query)
|
||||
|| $0.value.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
return list.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
let count = filteredItems.count
|
||||
let total = items.count
|
||||
let suffix = count == 1 ? "item" : "items"
|
||||
if count != total {
|
||||
return "\(count)/\(total) \(suffix)"
|
||||
}
|
||||
if count == 0, !diagnostics.isEmpty {
|
||||
return diagnostics.joined(separator: " | ")
|
||||
}
|
||||
return "\(count) \(suffix)"
|
||||
}
|
||||
|
||||
static let classFilters: [(label: String, value: String?)] = [
|
||||
("All", nil),
|
||||
("Passwords", "genp"),
|
||||
("Internet", "inet"),
|
||||
("Certificates", "cert"),
|
||||
("Keys", "keys"),
|
||||
("Identities", "idnt"),
|
||||
]
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func addTestItem() async {
|
||||
do {
|
||||
_ = try await control.addKeychainItem()
|
||||
print("[keychain] test item added, refreshing...")
|
||||
await refresh()
|
||||
} catch {
|
||||
self.error = "Add failed: \(error)"
|
||||
print("[keychain] add failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
func refresh() async {
|
||||
guard control.isConnected else {
|
||||
error = "Waiting for vphoned connection…"
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await control.listKeychainItems()
|
||||
items = result.items.enumerated().compactMap { VPhoneKeychainItem(index: $0.offset, entry: $0.element) }
|
||||
diagnostics = result.diagnostics
|
||||
if items.isEmpty, !diagnostics.isEmpty {
|
||||
print("[keychain] 0 items, diag: \(diagnostics)")
|
||||
}
|
||||
} catch {
|
||||
self.error = "\(error)"
|
||||
items = []
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
297
sources/vphone-cli/VPhoneKeychainBrowserView.swift
Normal file
297
sources/vphone-cli/VPhoneKeychainBrowserView.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VPhoneKeychainBrowserView: View {
|
||||
@Bindable var model: VPhoneKeychainBrowserModel
|
||||
|
||||
private let controlBarHeight: CGFloat = 24
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tableView
|
||||
.padding(.bottom, controlBarHeight)
|
||||
.overlay(controlBar.frame(maxHeight: .infinity, alignment: .bottom))
|
||||
.searchable(text: $model.searchText, prompt: "Filter keychain items")
|
||||
.toolbar { toolbarContent }
|
||||
|
||||
if model.showDiagnostics {
|
||||
Divider()
|
||||
diagnosticsPanel
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task { await model.refresh() }
|
||||
.onChange(of: model.control.isConnected) { _, connected in
|
||||
if connected, model.items.isEmpty {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: .init(
|
||||
get: { model.error != nil },
|
||||
set: { if !$0 { model.error = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { model.error = nil }
|
||||
} message: {
|
||||
Text(model.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table
|
||||
|
||||
var tableView: some View {
|
||||
Table(of: VPhoneKeychainItem.self, selection: $model.selection, sortOrder: $model.sortOrder) {
|
||||
TableColumn("", value: \.itemClass) { item in
|
||||
Image(systemName: item.classIcon)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
.help(item.displayClass)
|
||||
}
|
||||
.width(28)
|
||||
|
||||
TableColumn("Class", value: \.itemClass) { item in
|
||||
Text(item.displayClass)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
.width(min: 60, ideal: 80, max: 100)
|
||||
|
||||
TableColumn("Account", value: \.account) { item in
|
||||
Text(item.account.isEmpty ? "—" : item.account)
|
||||
.lineLimit(1)
|
||||
.help(item.account)
|
||||
}
|
||||
.width(min: 80, ideal: 150, max: .infinity)
|
||||
|
||||
TableColumn("Service", value: \.service) { item in
|
||||
Text(item.service.isEmpty ? "—" : item.service)
|
||||
.lineLimit(1)
|
||||
.help(item.service)
|
||||
}
|
||||
.width(min: 80, ideal: 150, max: .infinity)
|
||||
|
||||
TableColumn("Access Group", value: \.accessGroup) { item in
|
||||
Text(item.accessGroup.isEmpty ? "—" : item.accessGroup)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.help(item.accessGroup)
|
||||
}
|
||||
.width(min: 80, ideal: 160, max: .infinity)
|
||||
|
||||
TableColumn("Protection", value: \.protection) { item in
|
||||
Text(item.protection.isEmpty ? "—" : item.protection)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.help(item.protectionDescription)
|
||||
}
|
||||
.width(min: 40, ideal: 60, max: 80)
|
||||
|
||||
TableColumn("Value", value: \.value) { item in
|
||||
Text(item.displayValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.help(item.displayValue)
|
||||
}
|
||||
.width(min: 60, ideal: 120, max: .infinity)
|
||||
|
||||
TableColumn("Modified", value: \.displayName) { item in
|
||||
Text(item.displayDate)
|
||||
}
|
||||
.width(min: 80, ideal: 120, max: .infinity)
|
||||
} rows: {
|
||||
ForEach(model.filteredItems) { item in
|
||||
TableRow(item)
|
||||
}
|
||||
}
|
||||
.contextMenu(forSelectionType: VPhoneKeychainItem.ID.self) { ids in
|
||||
contextMenu(for: ids)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Control Bar
|
||||
|
||||
var controlBar: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(model.control.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Divider()
|
||||
|
||||
Picker("Class", selection: $model.filterClass) {
|
||||
ForEach(VPhoneKeychainBrowserModel.classFilters, id: \.value) { filter in
|
||||
Text(filter.label).tag(filter.value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 120)
|
||||
|
||||
Divider()
|
||||
|
||||
Text(model.statusText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 60)
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: controlBarHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
// MARK: - Diagnostics Panel
|
||||
|
||||
var diagnosticsPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Diagnostics")
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(model.diagnostics.count) entries")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Button {
|
||||
let text = model.diagnostics.joined(separator: "\n")
|
||||
if !text.isEmpty {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Copy diagnostics to clipboard")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.bar)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 1) {
|
||||
ForEach(Array(model.diagnostics.enumerated()), id: \.offset) { index, line in
|
||||
Text(line)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 1)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: model.diagnostics.count) { _, newCount in
|
||||
if newCount > 0 {
|
||||
proxy.scrollTo(newCount - 1, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 140)
|
||||
.background(Color(nsColor: .textBackgroundColor).opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task { await model.refresh() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task { await model.addTestItem() }
|
||||
} label: {
|
||||
Label("Add Test", systemImage: "plus.circle")
|
||||
}
|
||||
.help("Add a test keychain item (debug)")
|
||||
}
|
||||
ToolbarItem {
|
||||
Button {
|
||||
copySelected()
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(model.selection.isEmpty)
|
||||
}
|
||||
ToolbarItem {
|
||||
Button {
|
||||
model.showDiagnostics.toggle()
|
||||
} label: {
|
||||
Label("Diagnostics", systemImage: model.showDiagnostics ? "ladybug.fill" : "ladybug")
|
||||
}
|
||||
.help("Toggle diagnostics log panel")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context Menu
|
||||
|
||||
@ViewBuilder
|
||||
func contextMenu(for ids: Set<VPhoneKeychainItem.ID>) -> some View {
|
||||
Button("Copy Account") { copyField(ids: ids, keyPath: \.account) }
|
||||
Button("Copy Service") { copyField(ids: ids, keyPath: \.service) }
|
||||
Button("Copy Value") { copyField(ids: ids, keyPath: \.value) }
|
||||
Button("Copy Access Group") { copyField(ids: ids, keyPath: \.accessGroup) }
|
||||
Button("Copy Protection") { copyField(ids: ids, keyPath: \.protection) }
|
||||
Divider()
|
||||
Button("Copy Row (TSV)") { copyRows(ids: ids) }
|
||||
Divider()
|
||||
Button("Refresh") { Task { await model.refresh() } }
|
||||
}
|
||||
|
||||
// MARK: - Copy Actions
|
||||
|
||||
func copySelected() {
|
||||
let selected = model.filteredItems.filter { model.selection.contains($0.id) }
|
||||
guard !selected.isEmpty else { return }
|
||||
let header = "Class\tAccount\tService\tAccess Group\tProtection\tValue"
|
||||
let rows = selected.map { item in
|
||||
"\(item.displayClass)\t\(item.account)\t\(item.service)\t\(item.accessGroup)\t\(item.protection)\t\(item.displayValue)"
|
||||
}
|
||||
let text = ([header] + rows).joined(separator: "\n")
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
func copyRows(ids: Set<VPhoneKeychainItem.ID>) {
|
||||
let selected = model.filteredItems.filter { ids.contains($0.id) }
|
||||
guard !selected.isEmpty else { return }
|
||||
let header = "Class\tAccount\tService\tAccess Group\tProtection\tValue"
|
||||
let rows = selected.map { item in
|
||||
"\(item.displayClass)\t\(item.account)\t\(item.service)\t\(item.accessGroup)\t\(item.protection)\t\(item.displayValue)"
|
||||
}
|
||||
let text = ([header] + rows).joined(separator: "\n")
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
func copyField(ids: Set<VPhoneKeychainItem.ID>, keyPath: KeyPath<VPhoneKeychainItem, String>) {
|
||||
let values = model.filteredItems
|
||||
.filter { ids.contains($0.id) }
|
||||
.map { $0[keyPath: keyPath] }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
guard !values.isEmpty else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(values, forType: .string)
|
||||
}
|
||||
}
|
||||
133
sources/vphone-cli/VPhoneKeychainItem.swift
Normal file
133
sources/vphone-cli/VPhoneKeychainItem.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
struct VPhoneKeychainItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let itemClass: String
|
||||
let account: String
|
||||
let service: String
|
||||
let label: String
|
||||
let accessGroup: String
|
||||
let protection: String
|
||||
let server: String
|
||||
let value: String
|
||||
let valueEncoding: String
|
||||
let valueSize: Int
|
||||
let created: Date?
|
||||
let modified: Date?
|
||||
|
||||
var displayClass: String {
|
||||
switch itemClass {
|
||||
case "genp": "Password"
|
||||
case "inet": "Internet"
|
||||
case "cert": "Certificate"
|
||||
case "keys": "Key"
|
||||
case "idnt": "Identity"
|
||||
default: itemClass
|
||||
}
|
||||
}
|
||||
|
||||
var classIcon: String {
|
||||
switch itemClass {
|
||||
case "genp": "key.fill"
|
||||
case "inet": "globe"
|
||||
case "cert": "checkmark.seal.fill"
|
||||
case "keys": "lock.fill"
|
||||
case "idnt": "person.badge.key.fill"
|
||||
default: "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var displayValue: String {
|
||||
if value.isEmpty { return "—" }
|
||||
if valueEncoding == "base64" {
|
||||
return "[\(ByteCountFormatter.string(fromByteCount: Int64(valueSize), countStyle: .file)) binary]"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
if !label.isEmpty { return label }
|
||||
if !account.isEmpty { return account }
|
||||
if !service.isEmpty { return service }
|
||||
if !server.isEmpty { return server }
|
||||
return "(unnamed)"
|
||||
}
|
||||
|
||||
var protectionDescription: String {
|
||||
switch protection {
|
||||
case "ak": "WhenUnlocked"
|
||||
case "ck": "AfterFirstUnlock"
|
||||
case "dk": "Always"
|
||||
case "aku": "WhenUnlocked (ThisDevice)"
|
||||
case "cku": "AfterFirstUnlock (ThisDevice)"
|
||||
case "dku": "Always (ThisDevice)"
|
||||
case "akpu": "WhenPasscodeSet (ThisDevice)"
|
||||
default: protection
|
||||
}
|
||||
}
|
||||
|
||||
var displayDate: String {
|
||||
if let modified {
|
||||
return Self.dateFormatter.string(from: modified)
|
||||
}
|
||||
if let created {
|
||||
return Self.dateFormatter.string(from: created)
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .short
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let sqliteDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
extension VPhoneKeychainItem {
|
||||
init?(index: Int, entry: [String: Any]) {
|
||||
guard let cls = entry["class"] as? String else { return nil }
|
||||
|
||||
itemClass = cls
|
||||
account = entry["account"] as? String ?? ""
|
||||
service = entry["service"] as? String ?? ""
|
||||
label = entry["label"] as? String ?? ""
|
||||
accessGroup = entry["accessGroup"] as? String ?? ""
|
||||
protection = entry["protection"] as? String ?? ""
|
||||
server = entry["server"] as? String ?? ""
|
||||
value = entry["value"] as? String ?? ""
|
||||
valueEncoding = entry["valueEncoding"] as? String ?? ""
|
||||
valueSize = (entry["valueSize"] as? NSNumber)?.intValue ?? 0
|
||||
|
||||
if let ts = entry["created"] as? Double {
|
||||
created = Date(timeIntervalSince1970: ts)
|
||||
} else if let ts = entry["created"] as? NSNumber {
|
||||
created = Date(timeIntervalSince1970: ts.doubleValue)
|
||||
} else if let str = entry["createdStr"] as? String {
|
||||
created = Self.sqliteDateFormatter.date(from: str)
|
||||
} else {
|
||||
created = nil
|
||||
}
|
||||
|
||||
if let ts = entry["modified"] as? Double {
|
||||
modified = Date(timeIntervalSince1970: ts)
|
||||
} else if let ts = entry["modified"] as? NSNumber {
|
||||
modified = Date(timeIntervalSince1970: ts.doubleValue)
|
||||
} else if let str = entry["modifiedStr"] as? String {
|
||||
modified = Self.sqliteDateFormatter.date(from: str)
|
||||
} else {
|
||||
modified = nil
|
||||
}
|
||||
|
||||
let rowid = (entry["_rowid"] as? NSNumber)?.intValue ?? index
|
||||
id = "\(cls)-\(rowid)"
|
||||
}
|
||||
}
|
||||
53
sources/vphone-cli/VPhoneKeychainWindowController.swift
Normal file
53
sources/vphone-cli/VPhoneKeychainWindowController.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class VPhoneKeychainWindowController {
|
||||
private var window: NSWindow?
|
||||
private var model: VPhoneKeychainBrowserModel?
|
||||
|
||||
func showWindow(control: VPhoneControl) {
|
||||
if let window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let model = VPhoneKeychainBrowserModel(control: control)
|
||||
self.model = model
|
||||
|
||||
let view = VPhoneKeychainBrowserView(model: model)
|
||||
let hostingView = NSHostingView(rootView: view)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 900, height: 500),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Keychain"
|
||||
window.subtitle = "vphone"
|
||||
window.contentView = hostingView
|
||||
window.contentMinSize = NSSize(width: 600, height: 300)
|
||||
window.center()
|
||||
window.toolbarStyle = .unified
|
||||
window.isReleasedWhenClosed = false
|
||||
|
||||
let toolbar = NSToolbar(identifier: "vphone-keychain-toolbar")
|
||||
toolbar.displayMode = .iconOnly
|
||||
window.toolbar = toolbar
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.window = nil
|
||||
self?.model = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,18 @@ extension VPhoneMenuController {
|
||||
func buildConnectMenu() -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
let menu = NSMenu(title: "Connect")
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let fileBrowser = makeItem("File Browser", action: #selector(openFiles))
|
||||
fileBrowser.isEnabled = false
|
||||
connectFileBrowserItem = fileBrowser
|
||||
menu.addItem(fileBrowser)
|
||||
|
||||
let keychainBrowser = makeItem("Keychain Browser", action: #selector(openKeychain))
|
||||
keychainBrowser.isEnabled = false
|
||||
connectKeychainBrowserItem = keychainBrowser
|
||||
menu.addItem(keychainBrowser)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let devModeStatus = makeItem("Developer Mode Status", action: #selector(devModeStatus))
|
||||
@@ -37,6 +43,7 @@ extension VPhoneMenuController {
|
||||
|
||||
func updateConnectAvailability(available: Bool) {
|
||||
connectFileBrowserItem?.isEnabled = available
|
||||
connectKeychainBrowserItem?.isEnabled = available
|
||||
connectDevModeStatusItem?.isEnabled = available
|
||||
connectPingItem?.isEnabled = available
|
||||
connectGuestVersionItem?.isEnabled = available
|
||||
@@ -46,6 +53,10 @@ extension VPhoneMenuController {
|
||||
onFilesPressed?()
|
||||
}
|
||||
|
||||
@objc func openKeychain() {
|
||||
onKeychainPressed?()
|
||||
}
|
||||
|
||||
@objc func devModeStatus() {
|
||||
Task {
|
||||
do {
|
||||
|
||||
@@ -10,7 +10,9 @@ class VPhoneMenuController {
|
||||
weak var vm: VPhoneVirtualMachine?
|
||||
|
||||
var onFilesPressed: (() -> Void)?
|
||||
var onKeychainPressed: (() -> Void)?
|
||||
var connectFileBrowserItem: NSMenuItem?
|
||||
var connectKeychainBrowserItem: NSMenuItem?
|
||||
var connectDevModeStatusItem: NSMenuItem?
|
||||
var connectPingItem: NSMenuItem?
|
||||
var connectGuestVersionItem: NSMenuItem?
|
||||
|
||||
Reference in New Issue
Block a user