array( array('install_id'=>'char(32)','namespace'=>'char(32)','request_id'=>'bigint unsigned','attempt'=>'smallint unsigned','generation'=>'char(32)','operation_id'=>'char(64)','token'=>'char(64)','lease_until'=>'bigint unsigned','stage'=>'varchar(24)','body_hmac'=>'char(64)'), array('PRIMARY'=>array(0,array('install_id','namespace','request_id','attempt'))))); } public function installNativeAttemptSchema(): array { try { $this->connect(1);$this->validateSchema(); if($this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.'native_send_attempts_v4'))){throw new OG_Privacy_Case_Refusal('native_attempt_schema_exists');} $this->sql('CREATE TABLE '.$this->table('native_send_attempts_v4').' (install_id CHAR(32) NOT NULL,namespace CHAR(32) NOT NULL,request_id BIGINT UNSIGNED NOT NULL,attempt SMALLINT UNSIGNED NOT NULL,generation CHAR(32) NOT NULL,operation_id CHAR(64) NOT NULL,token CHAR(64) NOT NULL,lease_until BIGINT UNSIGNED NOT NULL,stage VARCHAR(24) NOT NULL,body_hmac CHAR(64) NOT NULL,PRIMARY KEY(install_id,namespace,request_id,attempt)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateTableSpecs(self::nativeAttemptSpecs()); return array('status'=>'native_attempt_schema_installed','erasure_complete'=>false); }catch(Throwable $e){return $this->failure($e,'attempt_install');}finally{$this->finish();} } private static function nativeAttemptMac(array $row,string $secret): string { unset($row['body_hmac']);return hash_hmac('sha256',"og-native-send-attempt-v4\n".json_encode($row,JSON_THROW_ON_ERROR),hex2bin($secret)); } private static function nativeAttemptInteger($value): int { if(!is_string($value) || !preg_match('/^[1-9][0-9]{0,18}$/D',$value) || (string)(int)$value!==$value){throw new OG_Privacy_Case_Refusal('native_attempt_damaged');}return (int)$value; } private function nativeAttemptRows(array $args,string $generation,string $operation,string $secret): array { $rows=$this->rows('SELECT CAST(attempt AS CHAR) AS attempt,generation,operation_id,token,CAST(lease_until AS CHAR) AS lease_until,stage,body_hmac FROM '.$this->table('native_send_attempts_v4').' WHERE install_id=? AND namespace=? AND request_id=? ORDER BY attempt LIMIT 4',$args); if(count($rows)>3){throw new OG_Privacy_Case_Refusal('native_attempt_damaged');} foreach($rows as $i=>$r){ if(self::nativeAttemptInteger($r['attempt'])!==$i+1 || self::nativeAttemptInteger($r['lease_until'])<1 || $r['generation']!==$generation || $r['operation_id']!==$operation || !preg_match('/^[a-f0-9]{64}$/D',$r['token']) || !in_array($r['stage'],array('claimed','dispatch_unknown','observed_unknown','observed_review','observed_committed'),true) || !hash_equals(self::nativeAttemptMac($r,$secret),$r['body_hmac'])){throw new OG_Privacy_Case_Refusal('native_attempt_damaged');} }return $rows; } /** Caller must authorize the native request. Keys/history are trusted configuration, not response data. */ public function nativeSendAttempt(array $context,$requestId,string $event,string $token='',array $keys=array(),string $environment='production',array $history=array(),array $revoked=array()): array { try { if(!in_array($event,array('claim','mark','settle'),true) || ($event!=='claim' && !preg_match('/^[a-f0-9]{64}$/D',$token)) || !in_array($environment,array('production','review'),true)){throw new OG_Privacy_Case_Refusal('native_attempt_input');} $keys=OG_Native_Receipt_Keys::active($keys,$revoked); $binding=$this->nativeScopeBinding($context,$requestId,array_merge(self::nativeRemoteSpecs(),self::nativeOutcomeSpecs(),self::nativeAttemptSpecs())); // Cutover marker: block both old claims and not-yet-dispatched old tokens. // Settlement remains available for a dispatch already issued before takeover. if($event!=='settle' && $this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.'native_scheduled_attempts_v1'))){throw new OG_Privacy_Case_Refusal('native_scheduled_takeover');} if($event!=='settle' && $this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.'native_remote_operations'))){throw new OG_Privacy_Case_Refusal('native_v1_reconciliation_required');} $secret=$this->nativeRemoteSecret();$generation=$context['generation'];$args=array($binding['install'],$binding['namespace'],$requestId); $operation=$this->nativeRemoteRow($binding,$generation,$secret); if($operation===null){throw new OG_Privacy_Case_Refusal('native_outcome_operation_missing');} $outcome=$this->nativeOutcomeRow($binding,$generation,$operation,$secret,$keys,$environment,$history,$revoked); $state=self::nativeOutcomeResult($outcome,$operation,$keys,$environment,$history,$revoked)['state']; if($event!=='settle' && in_array($state,array('review_required','server_committed'),true)){ $this->commit();return array('status'=>'native_worker_stopped','state'=>$state,'erasure_complete'=>false); } if($event!=='settle' && !$keys){throw new OG_Privacy_Case_Refusal('registry_no_active_keys');} $clock=$this->rows('SELECT CAST(UNIX_TIMESTAMP() AS CHAR) AS now_seconds'); $now=self::nativeAttemptInteger($clock[0]['now_seconds']??null); $rows=$this->nativeAttemptRows($args,$generation,$operation['operation_id'],$secret);$old=$rows?end($rows):null; if($event==='claim'){ if($old!==null && self::nativeAttemptInteger($old['lease_until'])>$now){throw new OG_Privacy_Case_Refusal('native_attempt_busy');} if(count($rows)>=3){$this->commit();return array('status'=>'native_worker_attention_required','reason'=>'attempt_budget_exhausted','state'=>$state,'erasure_complete'=>false);} $row=array('attempt'=>(string)(count($rows)+1),'generation'=>$generation,'operation_id'=>$operation['operation_id'],'token'=>bin2hex(random_bytes(32)),'lease_until'=>(string)($now+60),'stage'=>'claimed'); $row['body_hmac']=self::nativeAttemptMac($row,$secret); if($this->sql('INSERT INTO '.$this->table('native_send_attempts_v4').' (install_id,namespace,request_id,attempt,generation,operation_id,token,lease_until,stage,body_hmac) VALUES (?,?,?,?,?,?,?,?,?,?)',array_merge($args,array_values($row)))!==1){throw new OG_Privacy_Case_Refusal('native_attempt_conflict');} }else{ if($old===null || !hash_equals($old['token'],$token)){throw new OG_Privacy_Case_Refusal('native_attempt_stale');} if($event==='mark' && ($old['stage']!=='claimed' || self::nativeAttemptInteger($old['lease_until'])<=$now)){throw new OG_Privacy_Case_Refusal('native_attempt_stale');} if($event==='settle' && $old['stage']!=='dispatch_unknown'){throw new OG_Privacy_Case_Refusal('native_attempt_stale');} $row=$old;$row['stage']=$event==='mark'?'dispatch_unknown':($state==='server_committed'?'observed_committed':($state==='review_required'?'observed_review':'observed_unknown')); $row['body_hmac']=self::nativeAttemptMac($row,$secret); if($this->sql('UPDATE '.$this->table('native_send_attempts_v4').' SET stage=?,body_hmac=? WHERE install_id=? AND namespace=? AND request_id=? AND attempt=? AND token=? AND body_hmac=?',array_merge(array($row['stage'],$row['body_hmac']),$args,array($row['attempt'],$token,$old['body_hmac'])))!==1){throw new OG_Privacy_Case_Refusal('native_attempt_conflict');} } $saved=$this->nativeAttemptRows($args,$generation,$operation['operation_id'],$secret); if(!$saved || end($saved)!==$row){throw new OG_Privacy_Case_Refusal('native_attempt_damaged');} $this->commit();$result=array('status'=>'native_attempt_'.$event,'operation_id'=>$operation['operation_id'],'token'=>$row['token'],'stage'=>$row['stage'],'attempt'=>(int)$row['attempt'],'erasure_complete'=>false); if($event==='mark'){$result['payload']=$operation['payload'];} return $result; }catch(Throwable $e){return array_merge($this->failure($e,'native_attempt'),array('erasure_complete'=>false));}finally{$this->finish();} } }