2500) { throw new InvalidArgumentException('added_scope_capacity'); } $previous=null; foreach($refs as $ref) { if(!is_string($ref) || $ref==='' || preg_match_all('/./us',$ref)>191 || preg_match('//u',$ref)!==1 || ($previous!==null && strcmp($previous,$ref)>=0)) { throw new InvalidArgumentException('added_ref_invalid'); } $previous=$ref; } return $refs; } public static function addedDigest(array $refs): string { return hash('sha256',"og-native-added-scope-v4\n".self::canonical(self::addedScope($refs))); } /** Transport failure classification only; never permission to complete/reissue. */ public static function transportFailure(int $httpStatus): array { return array('status'=>$httpStatus===429?'retry_later':'remote_unknown', 'retry_same_operation'=>true,'erasure_complete'=>false); } public static function canonical(array $value): string { // Python ensure_ascii escapes DEL; PHP leaves it literal by default. return str_replace("\x7f",'\\u007f',json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); } /** Canonical-y and small-order admission, not proof of possession or provenance. * Constants: libsodium 1.0.18 ge25519_has_small_order; ignore the x-sign bit. * Does not replace the crypto library's signature verification. */ public static function validateRegistry(array $keys): array { if (count($keys) > 16) { throw new InvalidArgumentException('registry_invalid'); } $small = array( str_repeat('00',32), '01'.str_repeat('00',31), '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a', 'ec'.str_repeat('ff',30).'7f', 'ed'.str_repeat('ff',30).'7f', 'ee'.str_repeat('ff',30).'7f' ); foreach ($keys as $id=>$public) { if (!is_string($id) || !preg_match('/^(prod|test)\-[a-z0-9-]{1,48}$/D', $id)) { throw new InvalidArgumentException('registry_invalid'); } $encoded = hex2bin(self::hex($public,64)); $encoded[31] = chr(ord($encoded[31]) & 127); if (strcmp(strrev($encoded), strrev(hex2bin('ed'.str_repeat('ff',30).'7f'))) >= 0) { throw new InvalidArgumentException('registry_noncanonical'); } if (in_array(bin2hex($encoded), $small, true)) { throw new InvalidArgumentException('registry_small_order'); } } return $keys; } private static function keys(array $value, array $expected): void { $keys = array_keys($value); sort($keys, SORT_STRING); sort($expected, SORT_STRING); if ($keys !== $expected) { throw new InvalidArgumentException('wire_shape'); } } private static function hex($value, int $length): string { if (!is_string($value) || !preg_match('/^[a-f0-9]{'.$length.'}$/D', $value)) { throw new InvalidArgumentException('wire_hex'); } return $value; } private static function decimal($value): string { if (!is_string($value) || !preg_match('/^[1-9][0-9]{0,18}$/D', $value) || (strlen($value) === 19 && strcmp($value, '9223372036854775807') > 0)) { throw new InvalidArgumentException('wire_decimal'); } return $value; } /** * Only the request email is identity-wide. Order contacts are deliberately NOT sent. * Order keys bind the requested scope but are not proof of server-side ownership. * The caller must derive this from validated, terminal paged evidence, not POST data. */ public static function request(array $input): array { self::keys($input, array('version','install','namespace','request_id','operation_id', 'cutoff_seconds','requester_email_hash','evidence_digest','orders')); if ($input['version'] !== self::VERSION || !is_array($input['orders']) || !array_is_list($input['orders']) || count($input['orders']) > 2500) { throw new InvalidArgumentException('wire_scope'); } $orders = array(); $previous = null; foreach ($input['orders'] as $order) { if (!is_array($order)) { throw new InvalidArgumentException('wire_order'); } self::keys($order, array('ref','order_key_hash')); $ref = self::decimal($order['ref']); // Numeric order, without float conversion or platform-dependent integer coercion. if ($previous !== null && (strlen($ref) < strlen($previous) || (strlen($ref) === strlen($previous) && strcmp($ref, $previous) <= 0))) { throw new InvalidArgumentException('wire_order_sequence'); } $orders[] = array('ref'=>$ref, 'order_key_hash'=>self::hex($order['order_key_hash'],64)); $previous = $ref; } return array('version'=>self::VERSION, 'install'=>self::hex($input['install'],32), 'namespace'=>self::hex($input['namespace'],32), 'request_id'=>self::decimal($input['request_id']), 'operation_id'=>self::hex($input['operation_id'],64), 'cutoff_seconds'=>self::decimal($input['cutoff_seconds']), 'requester_email_hash'=>self::hex($input['requester_email_hash'],64), 'evidence_digest'=>self::hex($input['evidence_digest'],64), 'orders'=>$orders); } public static function encode(array $request): string { $body = self::canonical(self::request($request)); if (strlen($body) > self::MAX_BYTES) { throw new InvalidArgumentException('wire_capacity'); } return $body; } public static function digest(array $request): string { return hash('sha256', "og-native-erasure-request-v4\n".self::encode($request)); } /** * Bound response consistency only: authenticated HTTPS plus server-side committed * operation lookup are separate obligations. This never returns erasure_complete=true. * Requiring canonical bytes also rejects duplicate JSON members and trailing data. */ private static function boundReceipt(array $request, $body, array $added): array { $unknown = array('status'=>'remote_unknown','erasure_complete'=>false); try { $request = self::request($request); $added=self::addedScope($added); if(count($added)+count($request['orders'])>2500 || array_intersect($added,array_column($request['orders'],'ref'))) { return $unknown; } if (!is_string($body) || strlen($body) > 4096) { return $unknown; } $value = json_decode($body, true, 8, JSON_THROW_ON_ERROR); if (!is_array($value)) { return $unknown; } if(($value['status']??null)==='operator_review_required') { $case=self::hex($value['case_id']??null,64); $reason=$value['reason']??null; if(!in_array($reason,array('scope_too_large','receipt_expired','restart_approval_required'),true)){return $unknown;} $expected=array('version'=>self::VERSION,'status'=>'operator_review_required','install'=>$request['install'],'namespace'=>$request['namespace'],'request_id'=>$request['request_id'],'operation_id'=>$request['operation_id'],'request_digest'=>self::digest($request),'case_id'=>$case,'reason'=>$reason,'automatic_retry'=>false); if($added!==array() || $value!==$expected || self::canonical($value)!==$body){return $unknown;} return array('status'=>'bound_remote_review','case_id'=>$case,'reason'=>$reason,'request_digest'=>self::digest($request)); } $lc=$value['legacy_ref_count']??null;$ld=$value['legacy_ref_digest']??null; if(!is_int($lc) || $lc<0 || $lc>2500 || count($added)+count($request['orders'])+$lc>2500){return $unknown;} self::hex($ld,64); if($lc===0 && $ld!==hash('sha256',"og-native-legacy-refs-v4\n[]")){return $unknown;} $expected = array('version'=>self::VERSION, 'status'=>'committed', 'install'=>$request['install'], 'namespace'=>$request['namespace'], 'request_id'=>$request['request_id'], 'operation_id'=>$request['operation_id'], 'request_digest'=>self::digest($request), 'added_count'=>count($added), 'added_digest'=>self::addedDigest($added), 'legacy_ref_count'=>$lc,'legacy_ref_digest'=>$ld,'legacy_scope'=>'requester_identity_rows_only'); if ($value !== $expected || self::canonical($value) !== $body) { return $unknown; } return array('status'=>'bound_remote_receipt','request_digest'=>$expected['request_digest'], 'operation_id'=>$request['operation_id'],'legacy_ref_count'=>$lc,'legacy_ref_digest'=>$ld,'erasure_complete'=>false); } catch (Throwable $e) { return $unknown; } } /** * Keys must come from the shipped/configured trust registry, NEVER the response. * Default registry is deliberately empty: no production key has been provisioned. * No unsigned fallback, key download, algorithm negotiation or HTTP occurs here. */ public static function receipt(array $request, $body, array $trustedKeys = array(), string $environment = 'production'): array { $unknown = array('status'=>'remote_unknown','erasure_complete'=>false); try { $trustedKeys = self::validateRegistry($trustedKeys); if (!function_exists('sodium_crypto_sign_verify_detached') || !is_string($body) || strlen($body) > self::MAX_RECEIPT_BYTES || !in_array($environment, array('production','review'), true)) { return $unknown; } $value = json_decode($body, true, 12, JSON_THROW_ON_ERROR); if (!is_array($value) || array_keys($value) !== array('format','algorithm','environment','key_id','receipt','added_refs','signature') || self::canonical($value) !== $body || $value['format'] !== 'og-native-erasure-signed-v4' || $value['algorithm'] !== 'Ed25519' || $value['environment'] !== $environment || !is_string($value['key_id']) || !preg_match('/^'.($environment === 'production' ? 'prod' : 'test').'\-[a-z0-9-]{1,48}$/D', $value['key_id']) || !is_string($value['receipt']) || strlen($value['receipt']) > 4096) { return $unknown; } $public = self::hex($trustedKeys[$value['key_id']] ?? null, 64); $signature = self::hex($value['signature'], 128); $signed = $value; unset($signed['signature'],$signed['added_refs']); $message = "og-native-erasure-signature-v4\n".self::canonical($signed); if (!sodium_crypto_sign_verify_detached(hex2bin($signature), $message, hex2bin($public))) { return $unknown; } $bound = self::boundReceipt($request, $value['receipt'], $value['added_refs']); if($bound['status']==='bound_remote_review'){ return array('status'=>'authenticated_remote_review','authentication'=>'Ed25519','key_id'=>$value['key_id'],'environment'=>$environment,'request_digest'=>$bound['request_digest'],'operation_id'=>$request['operation_id'],'case_id'=>$bound['case_id'],'reason'=>$bound['reason'],'automatic_retry'=>false,'erasure_complete'=>false); } if ($bound['status'] !== 'bound_remote_receipt') { return $unknown; } return array('status'=>'authenticated_remote_receipt','authentication'=>'Ed25519', 'key_id'=>$value['key_id'],'environment'=>$environment, 'request_digest'=>$bound['request_digest'],'operation_id'=>$bound['operation_id'], 'added_refs'=>$value['added_refs'],'added_digest'=>self::addedDigest($value['added_refs']),'legacy_ref_count'=>$bound['legacy_ref_count'],'legacy_ref_digest'=>$bound['legacy_ref_digest'],'legacy_scope'=>'requester_identity_rows_only','erasure_complete'=>false); } catch (Throwable $e) { return $unknown; } } }