~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
216 lines · 7.8 KB Raw History
import CryptoKit
import Foundation
import os
import Security

/// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256.
///
/// Keys are stored per-DID: an access token is bound to the thumbprint of the
/// key that requested it, so each signed-in account needs its own and signing
/// out of one must not disturb the others.
final class DPoP: Sendable {
    private let privateKey: P256.Signing.PrivateKey
    let publicJWK: [String: String]
    let thumbprint: String

    init(privateKey: P256.Signing.PrivateKey) {
        self.privateKey = privateKey
        let publicKey = privateKey.publicKey
        let rawRepresentation = publicKey.rawRepresentation
        let xCoord = rawRepresentation.prefix(32)
        let yCoord = rawRepresentation.suffix(32)

        publicJWK = [
            "kty": "EC",
            "crv": "P-256",
            "x": xCoord.base64URLEncoded(),
            "y": yCoord.base64URLEncoded(),
        ]

        // JWK thumbprint (RFC 7638): lexicographic JSON of required members
        let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(xCoord.base64URLEncoded())","y":"\#(yCoord.base64URLEncoded())"}"#
        let hash = SHA256.hash(data: Data(thumbprintInput.utf8))
        thumbprint = Data(hash).base64URLEncoded()
    }

    /// Create a DPoP proof JWT for one request: `htm` and `htu` (the URL
    /// without query or fragment), `ath` when it carries an access token, and
    /// the nonce the request's origin last sent.
    func createProof(
        httpMethod: String,
        url: URL,
        accessToken: String? = nil
    ) async throws -> String {
        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
        components.query = nil
        components.fragment = nil
        let htu = components.url!.absoluteString
        let nonce = DPoPNonces.shared.nonce(for: url)

        // Header
        let header: [String: Any] = [
            "typ": "dpop+jwt",
            "alg": "ES256",
            "jwk": publicJWK,
        ]

        // Payload
        var payload: [String: Any] = [
            "jti": UUID().uuidString,
            "htm": httpMethod.uppercased(),
            "htu": htu,
            "iat": Int(Date().timeIntervalSince1970),
        ]

        if let accessToken {
            let tokenHash = SHA256.hash(data: Data(accessToken.utf8))
            payload["ath"] = Data(tokenHash).base64URLEncoded()
        }

        if let nonce {
            payload["nonce"] = nonce
        }

        let headerData = try JSONSerialization.data(withJSONObject: header)
        let payloadData = try JSONSerialization.data(withJSONObject: payload)

        let signingInput = headerData.base64URLEncoded() + "." + payloadData.base64URLEncoded()
        let signature = try privateKey.signature(for: Data(signingInput.utf8))

        return signingInput + "." + signature.rawRepresentation.base64URLEncoded()
    }
}

// MARK: - Nonces

/// The DPoP nonce each server last sent, by origin. An account's
/// authorization server and its PDS are different origins with different
/// nonces, and a proof carries the one for the origin it is sent to. Held in
/// memory only: a server hands out a fresh one on the first refusal.
final class DPoPNonces: Sendable {
    static let shared = DPoPNonces()

    private let nonces = OSAllocatedUnfairLock<[String: String]>(initialState: [:])

    static func origin(of url: URL) -> String {
        let scheme = url.scheme?.lowercased() ?? ""
        let host = url.host?.lowercased() ?? ""
        let port = url.port.map { ":\($0)" } ?? ""
        return "\(scheme)://\(host)\(port)"
    }

    func nonce(for url: URL) -> String? {
        let origin = Self.origin(of: url)
        return nonces.withLock { $0[origin] }
    }

    /// Keep the nonce `response` to a request for `url` carries, if any.
    func keep(from response: HTTPURLResponse, for url: URL) {
        guard let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), !nonce.isEmpty else { return }
        let origin = Self.origin(of: url)
        nonces.withLock { $0[origin] = nonce }
    }

    func set(_ nonce: String?, for url: URL) {
        let origin = Self.origin(of: url)
        nonces.withLock { $0[origin] = nonce }
    }
}

// MARK: - Key Management

extension DPoP {
    private static var keychainService: String {
        StorageEnvironment.dpopService
    }

    private static let legacyKeychainAccount = "dpop-private-key"

    private static func account(for did: String) -> String {
        "dpop-private-key::\(did)"
    }

    /// Load the account's key from Keychain, or generate and store a new one.
    static func loadOrCreate(for did: String) throws -> DPoP {
        if let existingKey = loadKey(account: account(for: did)) {
            return DPoP(privateKey: existingKey)
        }
        let newKey = P256.Signing.PrivateKey()
        try saveToKeychain(newKey, account: account(for: did))
        return DPoP(privateKey: newKey)
    }

    /// A key held only in memory. Sign-in generates one before the DID is
    /// known, then calls `persist(for:)` once the token response names it.
    static func createEphemeral() -> DPoP {
        DPoP(privateKey: P256.Signing.PrivateKey())
    }

    /// Store this instance's key under `did`, replacing any existing key.
    func persist(for did: String) throws {
        try DPoP.clearKey(for: did)
        try DPoP.saveToKeychain(privateKey, account: DPoP.account(for: did))
    }

    /// Remove one account's stored key (for sign-out).
    static func clearKey(for did: String) throws {
        delete(account: account(for: did))
    }

    /// Move a pre-multi-account key under its DID. The tokens minted with it
    /// are bound to its thumbprint, so it has to survive the move intact.
    static func migrateLegacyKey(to did: String) {
        guard let legacyKey = loadKey(account: legacyKeychainAccount) else { return }
        if loadKey(account: account(for: did)) == nil {
            try? saveToKeychain(legacyKey, account: account(for: did))
        }
        delete(account: legacyKeychainAccount)
    }

    private static func delete(account: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: keychainService,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(query as CFDictionary)
    }

    private static func loadKey(account: String) -> P256.Signing.PrivateKey? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: keychainService,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else { return nil }
        return try? P256.Signing.PrivateKey(rawRepresentation: data)
    }

    private static func saveToKeychain(_ key: P256.Signing.PrivateKey, account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: keychainService,
            kSecAttrAccount as String: account,
            kSecValueData as String: key.rawRepresentation,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
        }
    }
}

// MARK: - Base64URL

extension Data {
    func base64URLEncoded() -> String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}