AICOM_Base_Signer::site_key_id( $pub ), 'public_key' => $pub, 'secret_enc' => self::encrypt( $sec ), 'created_at' => time(), ]; self::safe_memzero( $sec ); return $rec; } /** Load the active identity, creating one on first use. */ public static function ensure(): array { $all = self::load(); if ( empty( $all['active'] ) ) { $all['active'] = self::generate(); self::save( $all ); } return $all['active']; } /** Public view of the active identity (never contains the secret). */ public static function public_info(): array { $a = self::load()['active'] ?? null; return $a ? [ 'key_id' => $a['key_id'], 'public_key' => $a['public_key'], 'created_at' => $a['created_at'] ] : []; } public static function has_identity(): bool { return ! empty( self::load()['active'] ); } /** Discard every key (disconnect / revoke). installation_id is kept. */ public static function wipe(): void { delete_option( self::OPT_IDENTITY ); } /** Fresh keypair, discarding the old one (used when starting a NEW pairing after a disconnect). */ public static function regenerate(): array { self::wipe(); return self::ensure(); } /** * Sign a message with the active (or pending) key. * * @param string $which 'active' | 'pending' * @return array{key_id:string,signature:string}|null */ public static function sign( string $message, string $which = 'active' ): ?array { $rec = self::load()[ $which ] ?? null; if ( ! $rec ) { return null; } $sec = self::decrypt( (string) $rec['secret_enc'] ); if ( $sec === null ) { return null; } $sig = AICOM_Base_Signer::sign( $message, $sec ); self::safe_memzero( $sec ); return [ 'key_id' => $rec['key_id'], 'signature' => $sig ]; } // ── rotation (PROTOCOL §8) ──────────────────────────────────────────── /** Create (or return the existing) pending keypair for a rotation. */ public static function begin_rotation( string $rotation_id ): array { $all = self::load(); if ( ! empty( $all['pending'] ) && ( $all['pending']['rotation_id'] ?? '' ) === $rotation_id ) { return $all['pending']; } $rec = self::generate(); $rec['rotation_id'] = $rotation_id; $all['pending'] = $rec; self::save( $all ); return $rec; } public static function pending(): ?array { $p = self::load()['pending'] ?? null; return $p ?: null; } /** Promote pending → active after AICOMBase confirmed. */ public static function commit_rotation(): bool { $all = self::load(); if ( empty( $all['pending'] ) ) { return false; } $new = $all['pending']; unset( $new['rotation_id'] ); $all['active'] = $new; unset( $all['pending'] ); self::save( $all ); return true; } public static function abort_rotation(): void { $all = self::load(); unset( $all['pending'] ); self::save( $all ); } // ── storage ─────────────────────────────────────────────────────────── private static function load(): array { $v = get_option( self::OPT_IDENTITY, [] ); return is_array( $v ) ? $v : []; } private static function save( array $all ): void { update_option( self::OPT_IDENTITY, $all, false ); } // ── at-rest encryption ──────────────────────────────────────────────── private static function key(): string { return hash( 'sha256', wp_salt( 'secure_auth' ) . '|aicombase-identity', true ); } private static function encrypt( string $plain ): string { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); return base64_encode( $nonce . sodium_crypto_secretbox( $plain, $nonce, self::key() ) ); } private static function decrypt( string $encoded ): ?string { $bin = base64_decode( $encoded, true ); if ( ! is_string( $bin ) || strlen( $bin ) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + 16 ) { return null; } $plain = sodium_crypto_secretbox_open( substr( $bin, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $bin, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), self::key() ); return $plain === false ? null : $plain; } }