[3733] | 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 | } |
---|