1 | <?PHP |
---|
2 | /** |
---|
3 | * Functions that are needed for iScheduling requests |
---|
4 | * |
---|
5 | * - verifying Domain Key signatures |
---|
6 | * - delivering remote scheduling requests to local users inboxes |
---|
7 | * - Utility functions which we can use to decide whether this |
---|
8 | * is a permitted activity for this user. |
---|
9 | * |
---|
10 | * @package davical |
---|
11 | * @subpackage iSchedule |
---|
12 | * @author Rob Ostensen <rob@boxacle.net> |
---|
13 | * @copyright Rob Ostensen |
---|
14 | * @license http://gnu.org/copyleft/gpl.html GNU GPL v3 or later |
---|
15 | */ |
---|
16 | |
---|
17 | require_once("XMLDocument.php"); |
---|
18 | |
---|
19 | /** |
---|
20 | * A class for handling iScheduling requests. |
---|
21 | * |
---|
22 | * @package davical |
---|
23 | * @subpackage iSchedule |
---|
24 | */ |
---|
25 | class iSchedule |
---|
26 | { |
---|
27 | public $parsed; |
---|
28 | public $selector; |
---|
29 | public $domain; |
---|
30 | private $dk; |
---|
31 | private $DKSig; |
---|
32 | private $try_anyway = false; |
---|
33 | private $failed = false; |
---|
34 | private $failOnError = true; |
---|
35 | private $subdomainsOK = true; |
---|
36 | private $remote_public_key ; |
---|
37 | |
---|
38 | function __construct ( ) |
---|
39 | { |
---|
40 | $this->selector = 'cal'; |
---|
41 | if ( is_object ( $c ) && isset ( $c->scheduling_dkim_selector ) ) |
---|
42 | $this->scheduling_dkim_selector = $c->scheduling_dkim_selector ; |
---|
43 | } |
---|
44 | |
---|
45 | /** |
---|
46 | * gets the domainkey TXT record from DNS |
---|
47 | */ |
---|
48 | function getTxt () |
---|
49 | { |
---|
50 | // TODO handle parents of subdomains and procuration records |
---|
51 | $dkim = dns_get_record ( $this->remote_selector . '._domainkey.' . $this->remote_server , DNS_TXT ); |
---|
52 | if ( count ( $dkim ) > 0 ) |
---|
53 | $this->dk = $dkim [ 0 ] [ 'txt' ]; |
---|
54 | else |
---|
55 | { |
---|
56 | $this->failed = true; |
---|
57 | return false; |
---|
58 | } |
---|
59 | return true; |
---|
60 | } |
---|
61 | |
---|
62 | /** |
---|
63 | * parses DNS TXT record from domainkey lookup |
---|
64 | */ |
---|
65 | function parseTxt ( ) |
---|
66 | { |
---|
67 | if ( $this->failed == true ) |
---|
68 | return false; |
---|
69 | $clean = preg_replace ( '/[\s\t]*([;=])[\s\t]*/', '$1', $this->dk ); |
---|
70 | $pairs = preg_split ( '/;/', $clean ); |
---|
71 | $this->parsed = array(); |
---|
72 | foreach ( $pairs as $v ) |
---|
73 | { |
---|
74 | list($key,$value) = preg_split ( '/=/', $v, 2 ); |
---|
75 | if ( preg_match ( '/(g|k|n|p|s|t|v)/', $key ) ) |
---|
76 | $this->parsed [ $key ] = $value; |
---|
77 | else |
---|
78 | $this->parsed_ignored [ $key ] = $value; |
---|
79 | } |
---|
80 | return true; |
---|
81 | } |
---|
82 | |
---|
83 | /** |
---|
84 | * validates that domainkey is acceptable for the current request |
---|
85 | */ |
---|
86 | function validateKey ( ) |
---|
87 | { |
---|
88 | $this->failed = true; |
---|
89 | if ( isset ( $this->parsed [ 's' ] ) ) |
---|
90 | { |
---|
91 | if ( ! preg_match ( '/(\*|calendar)/', $this->parsed [ 's' ] ) ) |
---|
92 | return 'foo'; |
---|
93 | } |
---|
94 | if ( isset ( $this->parsed [ 'k' ] ) && $this->parsed [ 'k' ] != 'rsa' ) |
---|
95 | return false; |
---|
96 | if ( isset ( $this->parsed [ 't' ] ) && ! preg_match ( '/^[y:s]+$/', $this->parsed [ 't' ] ) ) |
---|
97 | return false; |
---|
98 | else |
---|
99 | { |
---|
100 | if ( preg_match ( '/y/', $this->parsed [ 't' ] ) ) |
---|
101 | $this->failOnError = false; |
---|
102 | if ( preg_match ( '/s/', $this->parsed [ 't' ] ) ) |
---|
103 | $this->subdomainsOK = false; |
---|
104 | } |
---|
105 | if ( isset ( $this->parsed [ 'g' ] ) ) |
---|
106 | $this->remote_user_rule = $this->parsed [ 'g' ]; |
---|
107 | if ( isset ( $this->parsed [ 'p' ] ) ) |
---|
108 | { |
---|
109 | $data = "-----BEGIN PUBLIC KEY-----\n" . implode ("\n",str_split ( preg_replace ( '/_/', '', $this->parsed [ 'p' ] ), 64 )) . "\n-----END PUBLIC KEY-----"; |
---|
110 | if ( $data === false ) |
---|
111 | return false; |
---|
112 | $this->remote_public_key = $data; |
---|
113 | } |
---|
114 | else |
---|
115 | return false; |
---|
116 | $this->failed = false; |
---|
117 | return true; |
---|
118 | } |
---|
119 | |
---|
120 | /** |
---|
121 | * finds a remote calender server via DNS SRV records |
---|
122 | */ |
---|
123 | function getServer ( ) |
---|
124 | { |
---|
125 | $this->remote_ssl = false; |
---|
126 | $r = dns_get_record ( '_ischedules._tcp.' . $this->domain , DNS_SRV ); |
---|
127 | if ( 0 < count ( $r ) ) |
---|
128 | { |
---|
129 | $remote_server = $r [ 0 ] [ 'target' ]; |
---|
130 | $remote_port = $r [ 0 ] [ 'port' ]; |
---|
131 | $this->remote_ssl = true; |
---|
132 | } |
---|
133 | if ( ! isset ( $remote_server ) ) |
---|
134 | { |
---|
135 | $r = dns_get_record ( '_ischedule._tcp.' . $this->domain , DNS_SRV ); |
---|
136 | if ( 0 < count ( $r ) ) |
---|
137 | { |
---|
138 | $remote_server = $r [ 0 ] [ 'target' ]; |
---|
139 | $remote_port = $r [ 0 ] [ 'port' ]; |
---|
140 | } |
---|
141 | } |
---|
142 | elseif ( $this->try_anyway == true ) |
---|
143 | { |
---|
144 | if ( ! isset ( $remote_server ) ) |
---|
145 | $remote_server = $this->domain; |
---|
146 | if ( ! isset ( $remote_port ) ) |
---|
147 | $remote_port = 80; |
---|
148 | } |
---|
149 | if ( ! isset ( $remote_server ) ) |
---|
150 | return false; |
---|
151 | $this->remote_server = $remote_server; |
---|
152 | $this->remote_port = $remote_port; |
---|
153 | } |
---|
154 | |
---|
155 | /** |
---|
156 | * get capabilities from remote server |
---|
157 | */ |
---|
158 | function getCapabilities ( ) |
---|
159 | { |
---|
160 | $remote_capabilities = file_get_contents ( 'http'. ( $this->remote_ssl ? 's' : '' ) . '://' . |
---|
161 | $this->remote_server . ':' . $this->remote_port . |
---|
162 | '/.well-known/ischedule?query=capabilities' ); |
---|
163 | $xmltree = BuildXMLTree( $request->xml_tags, $position); |
---|
164 | if ( !is_object($xmltree) ) { |
---|
165 | $request->DoResponse( 406, translate("REPORT body is not valid XML data!") ); |
---|
166 | } |
---|
167 | } |
---|
168 | |
---|
169 | |
---|
170 | /** |
---|
171 | * signs a POST body and headers |
---|
172 | * |
---|
173 | * @param string $body the body of the POST |
---|
174 | * @param array $headers the headers to sign as passed to header (); |
---|
175 | */ |
---|
176 | function signDKIM ( $body, $headers ) |
---|
177 | { |
---|
178 | $b = ''; |
---|
179 | if ( ! is_array ( $headers ) ) |
---|
180 | return false; |
---|
181 | foreach ( $headers as $v ) |
---|
182 | $b .= $v . "\n"; |
---|
183 | $dk['s'] = $this->selector; |
---|
184 | $dk['d'] = $this->domain; |
---|
185 | $dk['c'] = 'simple-http'; |
---|
186 | $dk['q'] = 'dns/txt'; |
---|
187 | $dk['bh'] = base64_encode ( hash ( 'sha256', $body , true ) ); |
---|
188 | //a=rsa-sha1; d=caveman.name; s=cal; c=simple-http; q=dns/txt; h=Originator:Recipient:Host:Content-Type; b |
---|
189 | // XXX finish me |
---|
190 | } |
---|
191 | |
---|
192 | /** |
---|
193 | * parses and validates DK header |
---|
194 | * |
---|
195 | * @param string $sig the value of the DKIM-Signature header |
---|
196 | */ |
---|
197 | function parseDKIM ( $sig ) |
---|
198 | { |
---|
199 | |
---|
200 | $this->failed = true; |
---|
201 | $tags = preg_split ( '/;[\s\t]/', $sig ); |
---|
202 | foreach ( $tags as $v ) |
---|
203 | { |
---|
204 | list($key,$value) = preg_split ( '/=/', $v, 2 ); |
---|
205 | $dkim[$key] = $value; |
---|
206 | } |
---|
207 | // the canonicalization method is currently undefined as of draft-01 of the iSchedule spec |
---|
208 | // but it does define the value, it should be simple-http. RFC4871 also defines two methods |
---|
209 | // simple and relaxed, simple is probably the same as simple http |
---|
210 | // relaxed allows for header case folding and whitespace folding, see section 3.4.4 or RFC4871 |
---|
211 | if ( ! preg_match ( '{(simple|simple-http|relaxed)(/(simple|simple-http|relaxed))?}', $dkim['c'], $matches ) ) // canonicalization method |
---|
212 | return 'bad canonicalization:' . $dkim['c'] ; |
---|
213 | if ( count ( $matches ) > 2 ) |
---|
214 | $this->body_cannon = $matches[2]; |
---|
215 | else |
---|
216 | $this->body_cannon = $matches[1]; |
---|
217 | $this->header_cannon = $matches[1]; |
---|
218 | // signing algorythm REQUIRED |
---|
219 | if ( $dkim['a'] != 'rsa-sha1' && $dkim['a'] != 'rsa-sha256' ) |
---|
220 | return 'bad signing algorythm:' . $dkim['a'] ; |
---|
221 | // query method to retrieve public key, could/should we add https to the spec? REQUIRED |
---|
222 | if ( $dkim['q'] != 'dns/txt' ) |
---|
223 | return 'bad query method'; |
---|
224 | // domain of the signing entity REQUIRED |
---|
225 | if ( ! isset ( $dkim['d'] ) ) |
---|
226 | return 'missing signing domain'; |
---|
227 | $this->remote_server = $dkim['d']; |
---|
228 | // identity of signing agent, OPTIONAL |
---|
229 | if ( isset ( $dkim['i'] ) ) |
---|
230 | // if present, domain of the signing agent must be a match or a subdomain of the signing domain |
---|
231 | if ( ! stristr ( $dkim['i'], $dkim['d'] ) ) // RFC4871 does not specify a case match requirement |
---|
232 | return 'signing domain mismatch'; |
---|
233 | // grab the local part of the signing agent if it's an email address |
---|
234 | if ( strstr ( $dkim [ 'i' ], '@' ) ) |
---|
235 | $this->remote_user = substr ( $dkim [ 'i' ], 0, strpos ( $dkim [ 'i' ], '@' ) - 1 ); |
---|
236 | // selector used to retrieve public key REQUIRED |
---|
237 | if ( ! isset ( $dkim['s'] ) ) |
---|
238 | return 'missing selector'; |
---|
239 | $this->remote_selector = $dkim['s']; |
---|
240 | // signed header fields, colon seperated REQUIRED |
---|
241 | if ( ! isset ( $dkim['h'] ) ) |
---|
242 | return 'missing list of signed headers'; |
---|
243 | $this->signed_headers = preg_split ( '/:/', $dkim['h'] ); |
---|
244 | foreach ( $this->signed_headers as $h ) |
---|
245 | // signed header fields MUST actually be present in the request |
---|
246 | // DKIM Signature is NOT allowed in signed header fields per RFC4871 |
---|
247 | if ( ( ! isset ( $_SERVER['HTTP_' . strtr ( strtoupper ( $h ), '-', '_' ) ] ) && |
---|
248 | ! isset ( $_SERVER[ strtr ( strtoupper ( $h ), '-', '_' ) ] ) ) |
---|
249 | || strtolower ( $h ) == 'dkim-signature' ) |
---|
250 | return "header $h is signed but missing from request"; |
---|
251 | // body hash REQUIRED |
---|
252 | if ( ! isset ( $dkim['bh'] ) ) |
---|
253 | return 'missing body signature'; |
---|
254 | // signed header hash REQUIRED |
---|
255 | if ( ! isset ( $dkim['b'] ) ) |
---|
256 | return 'missing signature in b field'; |
---|
257 | // length of body used for signing |
---|
258 | if ( isset ( $dkim['l'] ) ) |
---|
259 | $this->signed_length = $dkim['l']; |
---|
260 | $this->failed = false; |
---|
261 | $this->DKSig = $dkim; |
---|
262 | return true; |
---|
263 | } |
---|
264 | |
---|
265 | /** |
---|
266 | * split up a mailto uri into domain and user components |
---|
267 | */ |
---|
268 | function parseURI ( $uri ) |
---|
269 | { |
---|
270 | if ( preg_match ( '/^mailto:([^@]+)@([^\s\t\n]+)/', $uri, $matches ) ) |
---|
271 | { |
---|
272 | $this->remote_user = $matches[1]; |
---|
273 | $this->domain = $matches[2]; |
---|
274 | } |
---|
275 | else |
---|
276 | return false; |
---|
277 | } |
---|
278 | |
---|
279 | /** |
---|
280 | * verifies parsed DKIM header is valid for current message with a signature from the public key in DNS |
---|
281 | */ |
---|
282 | function verifySignature ( ) |
---|
283 | { |
---|
284 | global $request,$c; |
---|
285 | $this->failed = true; |
---|
286 | $signed = ''; |
---|
287 | foreach ( $this->signed_headers as $h ) |
---|
288 | if ( isset ( $_SERVER['HTTP_' . strtoupper ( strtr ( $h, '-', '_' ) ) ] ) ) |
---|
289 | $signed .= "$h: " . $_SERVER['HTTP_' . strtoupper ( strtr ( $h, '-', '_' ) ) ] . "\n"; |
---|
290 | else |
---|
291 | $signed .= "$h: " . $_SERVER[ strtoupper ( strtr ( $h, '-', '_' ) ) ] . "\n"; |
---|
292 | $body = $request->raw_post; |
---|
293 | if ( ! isset ( $this->signed_length ) ) |
---|
294 | $this->signed_length = strlen ( $body ); |
---|
295 | else |
---|
296 | $body = substr ( $body, 0, $this->signed_length ); |
---|
297 | if ( isset ( $this->remote_user_rule ) ) |
---|
298 | if ( $this->remote_user_rule != '*' && ! stristr ( $this->remote_user, $this->remote_user_rule ) ) |
---|
299 | return false; |
---|
300 | $body_hash = base64_encode ( hash ( preg_replace ( '/^.*(sha[1256]+).*/','$1', $this->DKSig['a'] ), $body , true ) ); |
---|
301 | if ( $this->DKSig['bh'] != $body_hash ) |
---|
302 | return false; |
---|
303 | $sig = $_SERVER['HTTP_DKIM_SIGNATURE']; |
---|
304 | $sig = preg_replace ( '/ b=[^;\s\n\t]+/', ' b=', $sig ); |
---|
305 | $sig = preg_replace ( '/[\r\n]*$/', '', $sig ); |
---|
306 | $signed .= 'DKIM-Signature: ' . $sig; |
---|
307 | $verify = openssl_verify ( $signed, base64_decode ( $this->DKSig['b'] ), $this->remote_public_key ); |
---|
308 | if ( $verify != 1 ) |
---|
309 | return false; |
---|
310 | $this->failed = false; |
---|
311 | return true; |
---|
312 | } |
---|
313 | |
---|
314 | /** |
---|
315 | * checks that current request has a valid DKIM signature signed by a currently valid key from DNS |
---|
316 | */ |
---|
317 | function validateRequest ( ) |
---|
318 | { |
---|
319 | global $request; |
---|
320 | if ( isset ( $_SERVER['HTTP_DKIM_SIGNATURE'] ) ) |
---|
321 | $sig = $_SERVER['HTTP_DKIM_SIGNATURE']; |
---|
322 | else |
---|
323 | $request->DoResponse( 403, translate('DKIM signature missing') ); |
---|
324 | |
---|
325 | $err = $this->parseDKIM ( $sig ); |
---|
326 | if ( $err !== true || $this->failed ) |
---|
327 | $request->DoResponse( 403, translate('DKIM signature invalid ' ) . "\n" . $err . "\n" . $sig ); |
---|
328 | if ( ! $this->getTxt () || $this->failed ) |
---|
329 | $request->DoResponse( 403, translate('DKIM signature validation failed(DNS ERROR)') ); |
---|
330 | if ( ! $this->parseTxt () || $this->failed ) |
---|
331 | $request->DoResponse( 403, translate('DKIM signature validation failed(KEY Parse ERROR)') ); |
---|
332 | if ( ! $this->validateKey () || $this->failed ) |
---|
333 | $request->DoResponse( 403, translate('DKIM signature validation failed(KEY Validation ERROR)') ); |
---|
334 | if ( ! $this->verifySignature () || $this->failed ) |
---|
335 | $request->DoResponse( 403, translate('DKIM signature validation failed(Signature verification ERROR)') . $this->verifySignature() ); |
---|
336 | return true; |
---|
337 | } |
---|
338 | } |
---|
339 | |
---|
340 | $d = new iSchedule (); |
---|
341 | if ( $d->validateRequest ( ) ) |
---|
342 | { |
---|
343 | include ( 'caldav-POST.php' ); |
---|
344 | // TODO |
---|
345 | // handle request. |
---|
346 | } |
---|