You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This document details Timor's security measures for credential storage, network communication, and data protection.
Security Overview
graph TB
subgraph "Credential Security"
KC[Keychain Storage]
PROT[Protection Levels]
MEM[Memory Handling]
end
subgraph "Network Security"
TLS[TLS 1.3]
CERT[Certificate Pinning]
RL[Rate Limiting]
end
subgraph "Data Security"
LOCAL[Local-Only Cache]
VALID[Input Validation]
EMPTY[Empty Data Protection]
end
subgraph "Token Security"
OAUTH[OAuth 2.0]
REFRESH[Proactive Refresh]
EXPIRE[Expiry Tracking]
end
Loading
Credential Storage
Keychain Architecture
All sensitive credentials are stored in macOS Keychain:
graph LR
subgraph "Keychain Items"
CID[Client ID]
CS[Client Secret]
AT[Access Token]
RT[Refresh Token]
EXP[Token Expiry]
end
subgraph "Protection"
STD["Standard - WhenUnlocked"]
HIGH["High - WhenUnlockedThisDeviceOnly"]
end
CID --> STD
AT --> STD
EXP --> STD
CS --> HIGH
RT --> HIGH
Loading
Protection Levels
enumProtectionLevel{case standard // kSecAttrAccessibleWhenUnlocked
case high // kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case sensitive // User Presence Required (biometrics)
}
Level
Accessibility
Use Case
Standard
When device unlocked
Non-sensitive data (Client ID)
High
When unlocked, this device only
Secrets (Client Secret, Refresh Token)
Sensitive
Requires biometrics
Future: optional for all tokens
KeychainManager Implementation
func save(_ value:String, for key:String, protection:ProtectionLevel)throws{guardlet data = value.data(using:.utf8)else{throwKeychainError.invalidData
}letaccessibility:CFStringswitch protection {case.standard:
accessibility = kSecAttrAccessibleWhenUnlocked
case.high:
accessibility = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case.sensitive:
// Create access control requiring user presence
varerror:Unmanaged<CFError>?letaccessControl=SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,.userPresence,&error
)
// ...
}letquery:[String:Any]=[
kSecClass asString: kSecClassGenericPassword,
kSecAttrService asString:Constants.Keychain.service,
kSecAttrAccount asString: key,
kSecValueData asString: data,
kSecAttrAccessible asString: accessibility
]
// Delete existing then add new
SecItemDelete(query asCFDictionary)letstatus=SecItemAdd(query asCFDictionary,nil)}
Timor implements certificate pinning to prevent MITM attacks:
sequenceDiagram
participant App as Timor
participant Delegate as PinnedURLSessionDelegate
participant Server as Spotify API
App->>Server: TLS Handshake
Server-->>Delegate: Server Certificate Chain
Delegate->>Delegate: Validate chain with system
Delegate->>Delegate: Extract public key hashes
Delegate->>Delegate: Compare against pinned hashes
alt Hash Match
Delegate-->>App: Accept connection
App->>Server: API Request
else No Match
Delegate-->>App: Cancel challenge
App->>App: Connection failed
end
Loading
Pinned Certificate Hashes
privatestaticletpinnedPublicKeyHashes:Set<String>=[
// Leaf certificate (rotates annually)
"88b56ec2e245e6042cff85bab64e91872a6d7d7caff3af38582334d44dcba3b7",
// Intermediate CA (more stable)
"ebf967039a1282fcd6aebe815e06e39f7b7cf05b3fc3768a7c24bc6fcb12a0cb",
// Root CA (rarely changes)
"93336939b223ecf6b3a33598be91ad79f8ab826693f8ac50cd827008eca78968",]
Hash Extraction Process
To update hashes when Spotify rotates certificates:
// ASWebAuthenticationSession provides:
// - Isolated browser context
// - No cookie sharing with Safari
// - System-managed credential handling
authSession =ASWebAuthenticationSession(
url: authURL,
callbackURLScheme:"timor"){ callbackURL, error in
// Handle securely
}
authSession?.prefersEphemeralWebBrowserSession =true // Optional: even more isolated
Data Validation
Input Sanitization
// Validate playlist ID format
func isValidPlaylistId(_ id:String)->Bool{
// Spotify IDs are 22 characters, alphanumeric
letregex=try!Regex("^[a-zA-Z0-9]{22}$")return id.contains(regex)}
// Validate track URI format
func isValidTrackUri(_ uri:String)->Bool{return uri.hasPrefix("spotify:track:")}
Empty Data Protection
func cachePlaylistTracks(_ playlistId:String, tracks:[Track]){
// CRITICAL: Never overwrite good cache with empty data
guard !tracks.isEmpty else{
logger.warning("Refusing to cache empty track list")return}
// Proceed with caching...
}