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:
Luke Symons
2026-03-10 03:48:04 +11:00
committed by GitHub
parent 033960c9c0
commit cb409416af
14 changed files with 916 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -18,6 +18,7 @@ $(OUT): $(SRCS) $(wildcard *.h)
-DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \
-o $@ $(SRCS) \
-larchive \
-lsqlite3 \
-framework Foundation \
-framework Security \
-framework CoreServices

View File

@@ -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;
}

View 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);

View 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;
}

View File

@@ -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
}

View File

@@ -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

View 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
}
}

View 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)
}
}

View 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)"
}
}

View 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
}
}
}
}

View File

@@ -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 {

View File

@@ -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?