1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
//
// DPoP.swift
// AT-IRC
//
// ES256 DPoP key, JWK thumbprint, and proof generation (RFC 9449).
//
import Foundation
import CryptoKit
struct DPoPKey {
let privateKey: P256.Signing.PrivateKey
init() {
privateKey = P256.Signing.PrivateKey()
}
init(rawRepresentation: Data) throws {
privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation)
}
var rawRepresentation: Data {
privateKey.rawRepresentation
}
/// Public key as an EC P-256 JWK.
var publicJWK: [String: String] {
let x963 = privateKey.publicKey.x963Representation
let x = x963.subdata(in: 1..<33)
let y = x963.subdata(in: 33..<65)
return [
"crv": "P-256",
"kty": "EC",
"x": Base64URL.encode(x),
"y": Base64URL.encode(y),
]
}
/// RFC 7638 JWK thumbprint (SHA-256 over the canonical JWK, base64url).
var thumbprint: String {
let jwk = publicJWK
let canonical = "{\"crv\":\"\(jwk["crv"]!)\",\"kty\":\"\(jwk["kty"]!)\",\"x\":\"\(jwk["x"]!)\",\"y\":\"\(jwk["y"]!)\"}"
let digest = SHA256.hash(data: Data(canonical.utf8))
return Base64URL.encode(Data(digest))
}
/// Build a signed DPoP proof JWT for one request.
/// - Parameters:
/// - method: HTTP method (`GET`, `POST`, ...).
/// - url: Request URL; query and fragment are stripped for `htu`.
/// - nonce: Server-provided `DPoP-Nonce`, when available.
/// - accessToken: When calling a resource server, the token the proof is bound to (`ath`).
func proof(method: String, url: URL, nonce: String? = nil, accessToken: String? = nil) throws -> String {
let header: [String: Any] = [
"alg": "ES256",
"typ": "dpop+jwt",
"jwk": publicJWK,
]
var claims: [String: Any] = [
"jti": UUID().uuidString,
"htm": method.uppercased(),
"htu": Self.htu(url),
"iat": Int(Date().timeIntervalSince1970),
]
if let nonce, !nonce.isEmpty {
claims["nonce"] = nonce
}
if let accessToken {
let digest = SHA256.hash(data: Data(accessToken.utf8))
claims["ath"] = Base64URL.encode(Data(digest))
}
let headerData = try JSONSerialization.data(withJSONObject: header, options: [.sortedKeys])
let claimsData = try JSONSerialization.data(withJSONObject: claims, options: [.sortedKeys])
let signingInput = Base64URL.encode(headerData) + "." + Base64URL.encode(claimsData)
let signature = try privateKey.signature(for: Data(signingInput.utf8))
return signingInput + "." + Base64URL.encode(signature.rawRepresentation)
}
/// `htu` is the request URI without query or fragment (RFC 9449 §4.2).
static func htu(_ url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
components.query = nil
components.fragment = nil
return components.string ?? url.absoluteString
}
}
enum DPoPError: LocalizedError {
case signingFailed
var errorDescription: String? {
switch self {
case .signingFailed: "Could not sign DPoP proof"
}
}
}