using System.Security.Cryptography; using System.Text; using Foundation; using Security; using VoiceCat.Core; namespace VoiceCat.iOS; internal sealed class IosStorage { private readonly string directory; private readonly ServerProfileStore profiles; internal IosStorage() { NSUrl? group = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup); string root = group?.Path ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); directory = Path.Combine(root, "voicecat"); profiles = new(Path.Combine(directory, "servers.json")); } internal string TofuPath => Path.Combine(directory, "tofu_pins.txt"); internal IReadOnlyList LoadProfiles() { MigrateLegacyFiles(); return profiles.Load(); } internal void SaveProfiles(IEnumerable values) => profiles.Save(values); private void MigrateLegacyFiles() { Directory.CreateDirectory(directory); string legacy = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "voicecat"); if (Path.GetFullPath(legacy) == Path.GetFullPath(directory) || !Directory.Exists(legacy)) return; foreach (string name in new[] { "servers.json", "tofu_pins.txt" }) { string source = Path.Combine(legacy, name), destination = Path.Combine(directory, name); if (File.Exists(source) && !File.Exists(destination)) File.Copy(source, destination); } } internal string? LoadPassword(ServerProfile profile) { string? value = ReadPassword(IosConstants.PasswordService, profile.Id.ToString("D"), IosConstants.AppGroup); return value ?? (profile.LegacyKeychainTag is { Length: > 0 } tag ? ReadPassword("cat.voice.VoiceCatiOS", tag, IosConstants.AppGroup) ?? ReadPassword("cat.voice.VoiceCatiOS", tag, null) : null); } internal void SavePassword(Guid id, string password) { byte[] encoded = Encoding.UTF8.GetBytes(password); try { using var data = NSData.FromArray(encoded); using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); using var attributes = new SecRecord { ValueData = data, Label = "VoiceCat server password", Accessible = SecAccessible.AfterFirstUnlock }; SecStatusCode status = SecKeyChain.Update(query, attributes); if (status == SecStatusCode.ItemNotFound) { using var record = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); record.ValueData = data; record.Label = attributes.Label; record.Accessible = attributes.Accessible; status = SecKeyChain.Add(record); } if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status})."); } finally { CryptographicOperations.ZeroMemory(encoded); } } internal void RemovePassword(Guid id) { using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); SecStatusCode status = SecKeyChain.Remove(query); if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound)) throw new InvalidOperationException($"Keychain removal failed ({status})."); } private static string? ReadPassword(string service, string account, string? group) { using var query = PasswordQuery(service, account, group); using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status); if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null; byte[] bytes = data.ToArray(); try { return Encoding.UTF8.GetString(bytes); } finally { CryptographicOperations.ZeroMemory(bytes); } } private static SecRecord PasswordQuery(string service, string account, string? group) => new(SecKind.GenericPassword) { Service = service, Account = account, AccessGroup = group }; }