keys = $keys; $this->replay = $replay; $this->jobs = $jobs; $this->ops = $ops; $this->lock = $lock; } /** * @param array $headers lower-case names with dashes * @return array{status: int, body: string, signature: ?string} */ /** * The REST route's permission check: null when the request is well formed and signed with a known access key's * secret, else the unsigned refusal to send (the same answer handle() gives, which the other site reads). * * @param array $headers lower-case names with dashes * @return array{status: int, body: string, signature: ?string}|null */ public function authenticate(array $headers, string $body): ?array { try { $this->verify($headers, $body); return null; } catch (RelayError $e) { return self::refusal($e, null, (string) ($headers['x-relay-nonce'] ?? '')); } } /** * Well formed, a known key, a correct signature: checked before anything else, and again by handle(). * * @param array $headers * @return array{op: string, key_id: string, job_id: string, timestamp: string, key: array, secret: string} */ private function verify(array $headers, string $body): array { $nonce = (string) ($headers['x-relay-nonce'] ?? ''); $op = (string) ($headers['x-relay-op'] ?? ''); $key_id = (string) ($headers['x-relay-key'] ?? ''); $job_id = (string) ($headers['x-relay-job'] ?? ''); $timestamp = (string) ($headers['x-relay-timestamp'] ?? ''); $signature = (string) ($headers['x-relay-signature'] ?? ''); if (!Permissions::known_op($op) || !preg_match('/^[a-z0-9]{12}$/', $key_id) || !preg_match('/^[0-9a-f]{32}$/', $nonce) || !preg_match('/^[0-9a-f]{64}$/', $signature) || !ctype_digit($timestamp) || ($job_id !== '' && !preg_match('/^[0-9a-f-]{36}$/', $job_id))) { throw self::error('remote.bad_request', esc_html__('The request is not a valid SiteRelay request.', 'siterelay'), 400); } $key = $this->keys->find($key_id); if ($key === null) { throw self::error('remote.unknown_key', esc_html__('This site does not know that access key. It may have been deleted.', 'siterelay'), 401); } $secret = $this->keys->secret($key); if (!hash_equals(Signer::request($secret, $op, $job_id, $key_id, (int) $timestamp, $nonce, $body), $signature)) { throw self::error('remote.bad_signature', esc_html__('The request signature is wrong. The connection string may be outdated: create a new access key.', 'siterelay'), 401); } return ['op' => $op, 'key_id' => $key_id, 'job_id' => $job_id, 'timestamp' => $timestamp, 'key' => $key, 'secret' => $secret]; } public function handle(array $headers, string $body): array { $nonce = (string) ($headers['x-relay-nonce'] ?? ''); $secret = null; try { ['op' => $op, 'key_id' => $key_id, 'job_id' => $job_id, 'timestamp' => $timestamp, 'key' => $key, 'secret' => $candidate] = $this->verify($headers, $body); $secret = $candidate; // authenticated from here on: answers are signed if (abs(time() - (int) $timestamp) > self::CLOCK_SKEW) { throw self::error('remote.clock', esc_html__('The two sites\' clocks differ by more than five minutes.', 'siterelay'), 401); } if (!$this->replay->remember($nonce, $key_id)) { throw self::error('remote.replay', esc_html__('This request was already received once.', 'siterelay'), 401); } $job = in_array($op, self::JOB_OPS, true) ? $this->bound_job($key, $job_id, $op) : null; $this->check_key($key, $op, $job_id); $frame = Frame::decode($body); $direction = $job !== null ? $job->direction : ($op === 'job.open' ? (string) ($frame['meta']['direction'] ?? '') : null); if ($op === 'job.cancel' && $job !== null) { $allowed = true; // cleaning up is always allowed } else { $allowed = Permissions::allows($op, (string) $key['permissions'], SiteMode::current(), $direction ?: null); } if (!$allowed) { throw $this->forbidden((string) $key['permissions'], $direction ?: (in_array($op, ['db.write', 'files.plan', 'files.write', 'job.restore'], true) ? 'push' : 'pull')); } $locked = in_array($op, self::LOCKED_OPS, true); if ($locked && !$this->lock->acquire()) { throw self::error('job.busy', esc_html__('This site is busy with another SiteRelay request. Trying again shortly.', 'siterelay'), 409, true); } try { [$meta, $payload] = $this->dispatch($op, $key, $job, $frame['meta'], $frame['payload']); } finally { if ($locked) { $this->lock->release(); } } return self::answer(200, Frame::encode($meta, $payload), $secret, $nonce); } catch (RelayError $e) { return self::refusal($e, $secret, $nonce); } } /** @return array{status: int, body: string, signature: ?string} */ private static function refusal(RelayError $e, ?string $secret, string $nonce): array { $status = (int) ($e->context()['status'] ?? self::status_for($e)); $error = ['code' => $e->error_code(), 'message' => $e->text(), 'hint' => $e->hint(), 'retryable' => $e->retryable()]; return self::answer($status, Frame::encode(['error' => $error]), $secret, $nonce); } /** @return array{0: array, 1: string} */ private function dispatch(string $op, array $key, ?Job $job, array $meta, string $payload): array { switch ($op) { case 'hello': return [$this->ops->hello($key), '']; case 'db.describe': return [$this->ops->describe($meta), '']; case 'subsites.list': return [$this->ops->subsites(), '']; case 'users.list': return [$this->ops->users_list($meta), '']; case 'users.preview': return [$this->ops->users_preview($meta), '']; case 'job.open': return [$this->ops->open($key, $meta), '']; case 'db.read': return $this->ops->read($job, $meta); case 'db.write': return $this->ops->write($job, $meta, $payload); case 'files.manifest': return $this->ops->files_manifest($job, $meta); case 'files.read': return $this->ops->files_read($job, $meta); case 'files.plan': return $this->ops->files_plan($job, $meta, $payload); case 'files.write': return $this->ops->files_write($job, $payload); case 'job.finalize': return [$this->ops->finalize($job, $meta), '']; case 'job.cancel': return [$this->ops->cancel($job), '']; default: return [$this->ops->restore($key, $meta), '']; } } private function bound_job(array $key, string $job_id, string $op): Job { $job = $job_id !== '' ? $this->jobs->find($job_id) : null; if ($job === null || $job->role !== 'receiver' || $job->key_id !== $key['id']) { throw self::error('remote.job', esc_html__('That migration is not open on this site.', 'siterelay'), 409); } if ($job->status !== Job::RUNNING && $op !== 'job.cancel') { /* translators: %s: job status */ throw self::error('remote.job', esc_html(sprintf(__('That migration is no longer open on this site (it is %s).', 'siterelay'), $job->status)), 409); } return $job; } /** Revoked and expired keys stop at once; a one-time key only serves the job it opened, for at most a day. */ private function check_key(array $key, string $op, string $job_id): void { $status = KeyRepository::status($key); if ($status === 'revoked' || $status === 'expired') { throw $status === 'revoked' ? self::error('remote.key_revoked', esc_html__('That access key was revoked on this site.', 'siterelay'), 403) : self::error('remote.key_expired', esc_html__('That access key has expired.', 'siterelay'), 403); } if ($status === 'used') { $bound = $this->jobs->find((string) $key['bound_job']); $fresh = $bound !== null && strtotime($bound->created_at . ' UTC') > time() - KeyRepository::ONE_TIME_WINDOW; if ($job_id === '' || $job_id !== $key['bound_job'] || !$fresh) { throw self::error('remote.key_used', esc_html__('That one-time access key was already used.', 'siterelay'), 403); } } } private function forbidden(string $permission, string $direction): RelayError { $mode = SiteMode::current(); $site = home_url(); if ($mode === 'locked') { /* translators: %s: site URL */ $message = sprintf(__('%s is locked: it does not accept pulls or pushes. Change its site mode in SiteRelay → Settings.', 'siterelay'), $site); } elseif ($direction === 'push' && $mode === 'pull-only') { /* translators: %s: site URL */ $message = sprintf(__('%s is a pull-only site (a blueprint): it never accepts a push.', 'siterelay'), $site); } elseif ($direction === 'pull' && $mode === 'push-only') { /* translators: %s: site URL */ $message = sprintf(__('%s only accepts pushes, so it cannot be pulled from.', 'siterelay'), $site); } else { $message = $permission === 'pull' ? __('That access key only allows pulling.', 'siterelay') : __('That access key only allows pushing.', 'siterelay'); } return self::error('remote.forbidden', $message, 403); } private static function status_for(RelayError $e): int { $code = $e->error_code(); if ($code === 'job.active_exists' || $code === 'job.busy') { return 409; } if (strpos($code, 'db.') === 0 || $code === 'keys.unreadable') { return 500; } return 400; } private static function error(string $code, string $message, int $status, bool $retryable = false): RelayError { return new RelayError($code, esc_html($message), '', $retryable, ['status' => $status]); } /** @return array{status: int, body: string, signature: ?string} */ private static function answer(int $status, string $body, ?string $secret, string $nonce): array { return ['status' => $status, 'body' => $body, 'signature' => $secret !== null ? Signer::response($secret, $nonce, $status, $body) : null]; } }